diff --git a/Cargo.lock b/Cargo.lock index 267130fc41..887b10ab11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/brush/src/stored_art.rs b/crates/brush/src/stored_art.rs index a386e2e181..e54fdd3121 100644 --- a/crates/brush/src/stored_art.rs +++ b/crates/brush/src/stored_art.rs @@ -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 diff --git a/crates/color/src/cms.rs b/crates/color/src/cms.rs index 7fd8707a99..3ad4820ce1 100644 --- a/crates/color/src/cms.rs +++ b/crates/color/src/cms.rs @@ -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 { + [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); diff --git a/crates/color/src/cms/icc.rs b/crates/color/src/cms/icc.rs index f6a2316b89..ffddd6c955 100644 --- a/crates/color/src/cms/icc.rs +++ b/crates/color/src/cms/icc.rs @@ -34,6 +34,17 @@ type Xf = Option>; /// 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, + out_of: Arc, + /// 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>)>>, + /// Transforms into other profiles of the same model ([`Self::to_same_model_of`]), keyed as + /// [`Self::direct`] is. + same_model: Mutex>)>>, } /// 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> { + 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 { + 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| -> Option { + 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() diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 7b22163525..95e465b623 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -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); + } +} diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index b2920c7c7b..bee0f0f9fa 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -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:?}"); +} diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 71c6642846..6339f0a4ca 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -525,8 +525,8 @@ fn planar(d: &mut tiff::decoder::Decoder) - } /// 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(d: &mut tiff::decoder::Decoder) - pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { 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>) -> Option> { - 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> { let (_, mut d) = tiff(bytes)?; tiff_icc(&mut d) @@ -638,6 +650,61 @@ fn jpeg_icc(bytes: &[u8]) -> Option> { 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> { + // 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>) -> Option> { 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 { + 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 = (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(); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 5885a0ff54..d49776b2cb 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -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 { + 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) -> Vec { + 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 Vec { + 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 { + 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> { - struct Find<'a> { - art: &'a InstanceArt<'a>, - p: Point, - opt: HitOptions, - symbols: &'a SymbolBoxes<'a>, - } - impl Find<'_> { - fn hit(&self, n: &Node, whole: Option, 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, whole: Option, 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, opt: HitOptions) -> Option { + 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, whole: Option, outline: bool) -> Option { + 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 { + 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 = 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> = 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> = const { std::cell::Cell::new(None) }; + /// How many symbol instances' art clicks and marquees walked on this thread. + static ARTS_WALKED: std::cell::Cell = 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 { /// 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, leaves: bool, whole_compounds: bool, outline: bool) -> Vec { + let _walk = ArtWalk::open(); let mut out = Vec::new(); // The objects the marquee may touch ([`Top`]). let tops: Vec = match scope.and_then(|s| doc.node(s)) { @@ -1573,14 +1734,45 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, 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, whole_compounds: bool, outline: bool, out: &mut Vec) { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index dde7365424..74bd959a7f 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -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>) { + 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 { + 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> { + (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(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. diff --git a/crates/doc/src/inks.rs b/crates/doc/src/inks.rs index a9fa5f3f80..ed920fbd41 100644 --- a/crates/doc/src/inks.rs +++ b/crates/doc/src/inks.rs @@ -80,16 +80,22 @@ pub fn spot_color(doc: &Document, name: &str) -> Option { /// 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)); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b32a4485f4..7b7ef4fee7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index 7ff6cdeea2..68aaedd68e 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -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::() + }; vec_bytes(c) - + c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::()).sum::() + + c.iter() + .map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::() + custom(f)) + .sum::() }); 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::() @@ -276,7 +281,13 @@ fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet) -> 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) -> 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::() }).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 = + 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::>() + 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::()) + .sum(); + if table == 0 { own } else { ALLOC + table + own } + }) + .sum(); anchors + if p.subpaths.capacity() == 0 { 0 } else { ALLOC + p.subpaths.capacity() * size_of::() } } diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index e774a24972..cd4798c76c 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -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 { 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), } } diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 4ef6446247..541e7801c2 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -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, + /// 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 { + 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) { 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) { @@ -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) { @@ -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. diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 944accfe4b..99a65ae1f7 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -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, } /// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`], @@ -562,7 +567,18 @@ fn art_box(art: &Arc, symbols: &SymbolBoxes) -> Option { 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)] diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 06b71f8fc8..d165a086fb 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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, diff --git a/crates/doc/src/text/composite.rs b/crates/doc/src/text/composite.rs index e95428c2f5..93a7e27808 100644 --- a/crates/doc/src/text/composite.rs +++ b/crates/doc/src/text/composite.rs @@ -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): -//! . -//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own. +//! , +//! custom character sets: +//! . +//! 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, 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 { + 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, +} + +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, } /// The size a part can take, in percent of the type's size. @@ -197,11 +266,40 @@ pub const COMPOSITE_SCALE: std::ops::RangeInclusive = 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, font: impl Into, style: impl Into) -> 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| 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 = 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 { + 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| 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::(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:?}"); + } + } } diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index 45cf181f18..c3db242ed0 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -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 diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 62739d3688..8e0d63e9d0 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -210,22 +210,22 @@ pub fn effect_catalog() -> Vec { 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 { crate::SVG_FILTER, "SVG Filter", &["Effect", "SVG Filters"], - "{name: string (the filter's id), xml: string (one SVG 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 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 diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 1b64971a3a..d2a6687dd7 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -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>, 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>, 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 { 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); diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 519323e5f0..50fbf8f759 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -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::>(); 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>, (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>, (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::>(); + 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::>(); + 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] diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 9c0c318725..e3915797f2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -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, diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index b59d61beb2..f24a5dd2ee 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -606,9 +606,62 @@ fn transform_step(e: &Effect) -> Option { (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> { + 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 { + 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 { + 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 } diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 47b1c83206..a49e817ef2 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -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, 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> = 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)] diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs index 1881381567..8dc013e9dc 100644 --- a/crates/effects/src/revolve/classic.rs +++ b/crates/effects/src/revolve/classic.rs @@ -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, ¶ms).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"), diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 953768d298..cdc2376724 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1474,8 +1474,8 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result = 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 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, String>> = if id == INFLATE { let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect(); - let casts: Vec = inks.iter().map(|i| i.opacity > 0.0).collect(); + let casts: Vec = 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 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(id: &str, p: &Value, shapes: &'a [Profile], built: Vec = 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 { diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 29c05406a2..f48b94bce9 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -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::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, seam: f64) -> Vec { + 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, 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, 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))] diff --git a/crates/effects/src/solids/shadow.rs b/crates/effects/src/solids/shadow.rs index 3906ac2d3c..81542a3722 100644 --- a/crates/effects/src/solids/shadow.rs +++ b/crates/effects/src/solids/shadow.rs @@ -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. diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 945eb639aa..de1ec75de3 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -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> { + 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`"), diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 4d62961612..7dfd78f28d 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -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, 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}"); + } + } + } +} diff --git a/crates/effects/src/svg_filter.rs b/crates/effects/src/svg_filter.rs index 0c4bff0f1d..67826614e1 100644 --- a/crates/effects/src/svg_filter.rs +++ b/crates/effects/src/svg_filter.rs @@ -1,7 +1,8 @@ //! Effect › SVG Filters (`svg.filter`): an SVG `` element applied as a live raster effect. //! The filter runs on the object's pixels as SVG 1.1 describes it //! (), 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, 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 { + 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 { + let n: Vec = v.as_array()?.iter().map(Value::as_f64).collect::>()?; + 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 { + 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 { + 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 `` 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 `` 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 { + 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 { - 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 { + 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!( "{drawn}", 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, 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> { use image::ImageEncoder; diff --git a/crates/effects/src/svg_filter/tests.rs b/crates/effects/src/svg_filter/tests.rs index f93cb53124..4ab6328359 100644 --- a/crates/effects/src/svg_filter/tests.rs +++ b/crates/effects/src/svg_filter/tests.rs @@ -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 { + 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#""#; + +/// 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#""#; + 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##""##; + 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##""##; + let primitive = r##""##; + 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#""#; + 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 [ + ("", 1.5, MAX_WINDOW_PX), + ("", 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": "", "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#""#; 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#""#) ); let plain = filter(json!({ "xml": RED_FLOOD })); diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index 1ebcab785a..9fbb37a39d 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -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, Option>)> = 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. diff --git a/crates/engine/src/cmd/actionset.rs b/crates/engine/src/cmd/actionset.rs index b6a9118380..ad23db1c64 100644 --- a/crates/engine/src/cmd/actionset.rs +++ b/crates/engine/src/cmd/actionset.rs @@ -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 { "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, /// 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, /// Its `/parameterCount`, if it has one. pub declared: Option, /// 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, + /// Its `/type`, read as UTF-8 with replacement characters, if it has one. + pub kind: Option, + /// Its `/value`, if it is a number that fits an `i64` or text. + pub value: Option, +} + +/// 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), +} + /// 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> { 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>, 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 { 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 { 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 { 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 { .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 = 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 }) diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index cba512cd18..d5f241b4ee 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -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 { "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 { "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 { "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 { 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 { +/// 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(d: &Document, items: &mut Vec, 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 { 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 { } }); } - 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 { 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, 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, 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, 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) { + let locks = d.locked_artboards(); + fn visit(n: &Node, clips: bool, locks: &LockedArtboards, out: &mut Vec) { + 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 { 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 = { - 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 { } // 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 { Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::>(), "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 { + fn visit(n: &Node, above: (bool, bool), out: &mut Vec) { + 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 { 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 = 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 = 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 = 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 = 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 = kept.iter().map(|k| k.0).collect(); @@ -369,7 +440,9 @@ fn rejoin(s: &mut Session, p: &Value) -> Result { 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(()) })?; } diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 4d4ed6461b..e1a50b928b 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -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::>())); + assert_eq!(r["before"], 16 * 6); + assert_eq!(r["after"].as_u64(), Some(all.iter().map(|id| anchors(&s, *id) as u64).sum::())); + 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>) -> 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>) -> NodeKind { + NodeKind::Group { children: c, clip: false } +} + +fn sublayer(c: Vec>) -> 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()); +} diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs index afa232a7ce..7d11eedd1a 100644 --- a/crates/engine/src/cmd/addons/pathtools/tests.rs +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -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:?}"); diff --git a/crates/engine/src/cmd/addons/select.rs b/crates/engine/src/cmd/addons/select.rs index b2ab59d851..c9ae29a303 100644 --- a/crates/engine/src/cmd/addons/select.rs +++ b/crates/engine/src/cmd/addons/select.rs @@ -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 { "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 { 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::>>().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 = selectable(&st.doc, st.isolation) .into_iter() diff --git a/crates/engine/src/cmd/addons/select/tests.rs b/crates/engine/src/cmd/addons/select/tests.rs index c97b278258..09106a3652 100644 --- a/crates/engine/src/cmd/addons/select/tests.rs +++ b/crates/engine/src/cmd/addons/select/tests.rs @@ -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 diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 1046cada1c..db6e4e28a1 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -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 { "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 ), diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index 7fd8c9aa9a..89f3893b24 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -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 { 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 { 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 { 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()) } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index f694a2b144..66c9ec8fc4 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -117,7 +117,7 @@ pub fn specs() -> Vec { "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 { "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 { "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) ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 8dcd453650..6784f36294 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -525,16 +525,17 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { 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 = targets.into_iter().filter(|t| d.node(*t).is_none_or(|f| f.appearance.fill_paint() != paint)).collect(); + let unpainted: Vec = + 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 { 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 { .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 = 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 = + 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 { } 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 { 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 diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index a117bd453e..2eb81eaef5 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -12,6 +12,7 @@ use super::{ thread_local! { static BOX_TESTS: std::cell::Cell = const { std::cell::Cell::new(0) }; static CANDIDATE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static TRACES_ON_THIS_THREAD: std::cell::Cell = 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| { + 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 { /// (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>) -> Result>> { 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> = 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>) -> Result> = 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, /// The settings traced with, as JSON. @@ -604,21 +631,25 @@ fn prepare(s: &Session, c: &str, p: &Value) -> Result { let bytes = Arc::clone(&blob.bytes); let library = trace_library(c, s, &mut params)?; let traced_with = serde_json::to_value(¶ms).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, } 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 { + #[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 { + 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 { 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 { @@ -716,7 +773,7 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result traced, _ => trace_now(&inputs, &trace, &|_, _| true)?, }; @@ -729,6 +786,13 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Result> = 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 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, ¶ms); + let all = tr::seam_fills(&res.paths, ¶ms); + 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, ¶ms, 2 * anchors), all); + // One anchor short of that: none. + assert_eq!(seams_within(&res, ¶ms, 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() }; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 5922787723..d863edb96d 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -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 { "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> { 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> { + 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::>(), }) } @@ -205,6 +219,12 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { 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, 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, working: &str, policy: Policy, kind: cms::ProfileKind| -> Option> { + 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::(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); diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs index 3925612a14..131695e33e 100644 --- a/crates/engine/src/cmd/compositefonts.rs +++ b/crates/engine/src/cmd/compositefonts.rs @@ -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): -//! . +//! , +//! custom character sets: +//! . 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 { vec![ cmd!( @@ -33,7 +40,7 @@ pub fn specs() -> Vec { "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 { "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 { "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 { /// copy of the Kanji part, and keys or values a part can't take refused. fn parse_font(v: &Value, c: &str) -> Result { 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 { 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::>>()? + } + 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 { 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 { + 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 { 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())?; diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index da310a89b9..751cb49ef0 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -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 = 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 = (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 = (0..64).map(|i| json!({"name": format!("S{i}"), "characters": full})).collect(); + let big: Vec = (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}"); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index ebef729fd3..08944940a2 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "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 { "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 { 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| { let mut g = GradientPaint::new(Gradient::new(GradientKind::Radial, stops)); @@ -495,6 +510,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { // 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; diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index 48f2c31e62..43f3aec881 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -356,7 +356,8 @@ fn mirror_open(sp: &SubPath, axis: &Axis, m: Affine) -> Vec { 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)); diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index ae48ba02e6..3b1e8a7666 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -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 { "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 { fn guide_add(s: &mut Session, p: &Value) -> Result { 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")), diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index d469c7a613..9055e83844 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -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; diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 401bdd2977..977f3f921a 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -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, 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, Vec) = 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, within: f64) - } pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { + 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 { 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 { 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 { 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 { 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 { // 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 { 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 { { 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 { // 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 { 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). diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index be7db9a1ae..883c9ba5ed 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -274,7 +274,7 @@ fn refit_run(anchors: &[Anchor], tol: f64, sigma: f64, free: [bool; 2]) -> Vec Result { 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()) } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 28cd71804a..fc5be365d5 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -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; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 27024ef8eb..4a3ca6265a 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -1,6 +1,6 @@ use super::access::file_list; use super::formats::formats; -use super::{CommandSpec, action_batch, always, batch, cmd, export, has_doc, has_selection, load, pdfimport, save, screens}; +use super::{CommandSpec, action_batch, always, batch, cmd, export, has_doc, has_selection, load, pdfimport, psdlayers, save, screens}; pub fn specs() -> Vec { vec![ @@ -9,7 +9,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row 36 pt apart (on trim boxes, plus the bleed on both sides), and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", + "{path | name | dataBase64 (a file's path, or its name with its bytes), pages?: \"2-3, 5\" (PDF/.ai: 1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds; trim: the bleed box round it becomes the document's bleed; a PDF-compatible .ai or a PDF saved with its editing data opens on its trim boxes for crop), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page; a PSD file's layers become layers holding their pixels, with their names, visibility, opacity and blend modes, groups as layers of sublayers, layer masks as opacity masks, clipped layers and layers in blend modes VectorCraft doesn't have merged with the layers below) | false (one layer per page, without the art that is off; Save then asks for a name; a PSD file opens as its merged image), hiddenLayers?: false (a PSD file's hidden layers come in hidden instead of being left out), importSlices?: true (a PSD file's slices become user slices over its canvas, with its layers or its merged image; slices the file generated automatically are left out, and slices that can't be read are left out with a warning), editingData?: true (default: an EPS, .ai or PDF that carries its editor's own copy of the art opens from it, with its layers, hidden objects, guides, artboards and the art outside them; a .ai or PDF whose type shows opens from its PDF part with textAs: outlines) | \"always\" (as true, and a .ai's editing copy is used even where it draws differently from its PDF part, which can be older; the warnings say so) | false (only what its page or PDF part prints, for print pipelines; a .ai saved without PDF compatibility then can't be opened), legacyNameEncoding?: windows-1252 (default) | macintosh | windows-949 (also euc-kr) | shift_jis | gbk | big5 (the code page names in the editing data of a .ai or an EPS are read in when they aren't UTF-8: layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets, and data sets' text values; warnings list the first three such names as read, and count the rest), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, through the profiles the document keeps after the color management policies of edit.colorSettings, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), grays?: as file.documentColorMode, dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} (DXF), crop?: as cropTo (its older name), linkPages?: false (true: each page `pages` picks of a PDF or PDF-compatible .ai opens as a placed document linked to that page of the file, as file.place with link places one, on an artboard of its cropTo box (bounding: its art's bounds; a blank page: its crop box; a PDF-compatible .ai: its trim box for crop; on its trim box a page shows its bleed box round the artboard and gives the document its bleed), in a row 36 pt apart (on trim boxes, plus the bleed on both sides), and a layer named after the page; links.embed makes a page editable art; refused for any other file, without a path, with a password or with textAs: outlines; Save then asks for a name), importer?: \"plugin.\" (an importer plug-in's format id, see document.formats: it opens the file, which must have an extension it lists, also one a built-in format reads), params?: {…} (the importer's own parameters, see plugin.info)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; the color management policies of edit.colorSettings keep, take off or convert from the profiles the file is tagged with, their notes go to warnings, and a document they changed opens with unsaved changes; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its size at the resolution it declares, as file.place sizes it; 72 ppi, 1 px = 1 pt, when it declares none), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), PICT .pct/.pic/.pict (QuickDraw versions 1 and 2, with or without the file's 512-byte header: one artboard, the picture's frame; shapes, lines and regions as paths, text as point type, bitmaps, pixel maps and QuickTime JPEG images as images; what isn't read is listed in warnings), HP-GL plot files .plt/.hpgl/.hgl/.hpg (one artboard round the art; what isn't drawn is listed in warnings), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again) and opens on the artboard its editing copy names as active. A file that no built-in format reads, by its content or its extension, opens with the installed importer plug-in that lists its extension (the one whose id sorts first, when several do): it is given the file, at most 128 MiB, and its objects become a new document whose artboard is their bounds; Save then asks for a name, and the warnings are the importer's. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks). An SVG's variable data (SVG Options › Include Variable Data, a variableSets element) gives the document its text variables and data sets, each text variable bound to the type object its docRef names, unless the SVG restores the native document; variable data variable.loadLibrary would refuse is left out with a warning", always, load::open ), @@ -139,6 +139,15 @@ pub fn specs() -> Vec { always, pdfimport::pdf_info ), + cmd!( + query "document.psdInfo", + "PSD Info", + [], + None, + "{path} or {name?, dataBase64} what a PSD or PSB file holds, read from its header, slices and layer records without decoding pixels → {format: psd|psb, width, height (the canvas, px), layers (group markers not counted), hiddenLayers (the hidden ones among them), groups, slices (those importSlices brings in; 0 when the file has none or they can't be read)}; a file without layer records, or whose layer records can't be read, has layers: 0 and opens and places as its merged image; a file that is not a PSD or PSB file is an error", + always, + psdlayers::psd_info + ), cmd!( "document.exportForOffice", "Save for Office Documents…", diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 2ca51b3e00..52ed7f0e7f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -528,17 +528,26 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } // 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, /// 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, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { 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 { - 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, opts: &LoadOptions, as_template: bool) -> Result { +/// 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, + opts: &LoadOptions, + as_template: bool, + policy_changed: bool, +) -> Result { 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 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 } diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 00eef89a11..189274a31f 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -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 { @@ -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, diff --git a/crates/engine/src/cmd/fileio/psdlayers.rs b/crates/engine/src/cmd/fileio/psdlayers.rs index c3c45029a0..ea5cf63aa2 100644 --- a/crates/engine/src/cmd/fileio/psdlayers.rs +++ b/crates/engine/src/cmd/fileio/psdlayers.rs @@ -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)>, 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 { + 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 { + 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, + })) +} diff --git a/crates/engine/src/cmd/fileio/psdread.rs b/crates/engine/src/cmd/fileio/psdread.rs index 28b44d6197..ed9f08a731 100644 --- a/crates/engine/src/cmd/fileio/psdread.rs +++ b/crates/engine/src/cmd/fileio/psdread.rs @@ -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 { +pub(super) fn resources(resources: &[u8], signed: bool) -> impl Iterator { let mut r = Reader::new(resources); std::iter::from_fn(move || { if r.rest().is_empty() { diff --git a/crates/engine/src/cmd/fileio/psdslices.rs b/crates/engine/src/cmd/fileio/psdslices.rs new file mode 100644 index 0000000000..ea87f70797 --- /dev/null +++ b/crates/engine/src/cmd/fileio/psdslices.rs @@ -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 +//! (). 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 +//! (, `src/imageResources.ts` and `src/descriptor.ts`), +//! which agrees with 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, + /// 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, 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, 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, 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, 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('&', "&").replace('<', "<").replace('>', ">") } +} + +/// 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 { + 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 { + let n = text_len(r.u32()? as usize)?; + let bytes = r.take(n.checked_mul(2).ok_or(TRUNCATED)?)?; + let units: Vec = 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), + /// 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 { + 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 { + 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, 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 { + *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) { + 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) { + 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, + } +} diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 2799d34b7a..867b9f9f87 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1239,14 +1239,23 @@ fn revert(s: &mut Session, _: &Value) -> Result { 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 { "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 { "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 ), diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index b66b22d1a8..bbf6883834 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -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 = [[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. diff --git a/crates/engine/src/cmd/fileio/tests_psdlayers.rs b/crates/engine/src/cmd/fileio/tests_psdlayers.rs index 31f670d7ff..298f3d22dc 100644 --- a/crates/engine/src/cmd/fileio/tests_psdlayers.rs +++ b/crates/engine/src/cmd/fileio/tests_psdlayers.rs @@ -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) { + 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::>(), ["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::>(), ["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::>(), ["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::>(), [("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")); +} diff --git a/crates/engine/src/cmd/fileio/tests_psdslices.rs b/crates/engine/src/cmd/fileio/tests_psdslices.rs new file mode 100644 index 0000000000..42dc33c600 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_psdslices.rs @@ -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 { + let units: Vec = 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 { + 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), +} + +/// A key or class ID: a four-character one with length 0, else its length and characters. +fn key(k: &str) -> Vec { + 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 { + 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 { + 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) -> Vec { + 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 { + 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 { + 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 { + 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: "Hi", 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 < b & c", "")); + assert_eq!((got[2].1.text.as_str(), got[2].1.background.as_str(), got[2].1.name.as_str()), ("Hi", "#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 = 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 = (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>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, &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 = 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)})); + } + } +} diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index 02b4097684..cc803c59b9 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -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}; diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index 37adcf438d..4525a3aa5c 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -56,7 +56,7 @@ pub(crate) fn outlined_text(n: &Node) -> Option { } /// `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; diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 7c8f664cf3..28662f013c 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -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 { vec![ @@ -39,7 +40,7 @@ pub fn specs() -> Vec { "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::>().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] diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 118c4cd04f..7be0a122df 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -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)] diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index 0d914ad8ff..378a8e3745 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -88,6 +88,23 @@ pub(crate) fn keep_placed(d: &mut Document, graphs: &[(NodeId, Option, 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<()> { diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 247dc92346..f589ed9ae5 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -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 diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 681c1a6a4a..ee33f23517 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -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 { 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 = - 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 = 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 = own_labels(n, g).into_iter().collect(); + struct Plain<'a> { + roles: HashMap, + 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) { + 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]) -> 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 { + 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]) -> Vec { + 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> { + let mut names: HashMap> = 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> { + 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> = 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::>>() + }; + 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> = 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> = 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> = + 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 { + 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 = 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 { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let mut out = HashMap::new(); + if legend.is_empty() { + return out; + } + let mut by_series: HashMap> = 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, 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 = 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 { diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index c3a2898571..d55fdda7b2 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -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 { + 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 { 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) -> Result { +/// 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, key: Option<(NodeId, (usize, usize))>) -> Result { 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 = - 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 { 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 { 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)?; diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index b8c4199623..3ea501c775 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -260,7 +260,7 @@ pub fn specs() -> Vec { "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 { "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 { "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 ), diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 8f4e3c71f4..20905ffdae 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options, as text.setRangeStyle: the characters drawn in one OpenType form of the font, or its default glyphs; a form other than \"default\" takes away their glyph choice), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\", listLevel?: 1..9 (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"yakumonoHankaku\"|\"gyomatsuYakumonoHankaku\"|\"gyomatsuYakumonoZenkaku\"|\"yakumonoZenkaku\"|\"lineEndHalf\" (Mojikumi Set: Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), bunriKinshi?: bool (Paragraph panel menu › Bunri-Kinshi: a line never breaks inside an inseparable pair, two em dashes, two horizontal ellipses, two two-dot leaders or the two halves of a vertical kana repeat mark, and justification adds no space inside one; kept but without effect with kinsoku \"none\"; off by default), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), warichu?: bool, warichuLines?: 1..6, warichuGap?: pt 0..100, warichuScale?: % 10..100, warichuAlign?: \"auto\"|\"left\"|\"center\"|\"right\"|\"justifyLeft\"|\"justifyCenter\"|\"justifyRight\"|\"justifyAll\" (Character panel menu › Warichu and Warichu Settings, as text.setRangeStyle: the characters set smaller and stacked on lines inside their line), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options, as text.setRangeStyle: space before and after each character in reading order), tsume?: % 0..100 (Character panel › Tsume, with Show East Asian Options, as text.setRangeStyle: the share of the blank space beside each character's ink taken away in horizontal type), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), glyphForm?: \"traditional\"|\"expert\"|\"jis04\"|\"jis90\"|\"jis78\"|\"jis83\"|\"fullWidth\"|\"halfWidth\"|\"thirdWidth\"|\"quarterWidth\"|\"default\" (Glyphs panel menu, with Show East Asian Options, as text.setRangeStyle: the characters drawn in one OpenType form of the font, or its default glyphs; a form other than \"default\" takes away their glyph choice), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\"|\"standardSingleLine\"|\"standardEveryLine\" (line breaking, Every-line by default; singleLine and everyLine are the Middle Eastern & South Asian composers, which shape Indic, Arabic and Hebrew type; the standard ones set it glyph by glyph as the font maps its characters, for legacy fonts that keep a script on Latin character codes), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -255,7 +255,7 @@ pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &s /// list of artboard indices", and a list with an entry that is not a whole number is "`{key}` must /// contain whole numbers"; a whole number past the last artboard is `no such artboard`. Every entry /// is checked for its form before any is checked against the artboards. -fn listed_artboards(d: &vectorcraft_doc::Document, v: &Value, key: &str, cmd: &str) -> Result> { +pub(crate) fn listed_artboards(d: &vectorcraft_doc::Document, v: &Value, key: &str, cmd: &str) -> Result> { let entries = v.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be a list of artboard indices")))?; if entries.is_empty() { return Err(bad(cmd, format!("`{key}` must be a non-empty list of artboard indices"))); @@ -645,7 +645,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { Some(v) => Some(match v.as_str() { Some("singleLine") => Composer::SingleLine, Some("everyLine") => Composer::EveryLine, - _ => return Err(bad(C, "composer must be \"singleLine\" or \"everyLine\"")), + Some("standardSingleLine") => Composer::StandardSingleLine, + Some("standardEveryLine") => Composer::StandardEveryLine, + _ => return Err(bad(C, "composer must be \"singleLine\", \"everyLine\", \"standardSingleLine\" or \"standardEveryLine\"")), }), }; let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?; diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index db1edc98e9..fdf7dd4b19 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -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", diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 0fb450fa43..4d160c7c49 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -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, next: Option, smooth: bool) { +fn join_point_between(anchors: &mut Anchors, ai: usize, prev: Option, next: Option, smooth: bool) { let at = |j: Option| 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); diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 46231dcd0d..83216b2f43 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -4,6 +4,10 @@ //! - A raster image becomes one image at 100% of its physical size: its pixels at the resolution //! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with //! `link` ([`super::links`]). +//! - A PSD or PSB file placed with `layers` becomes one group of its layers as objects, embedded +//! ([`fileio::psd_layers`]); otherwise it is an image like any other, its merged image. An +//! embedded one brings its slices in as user slices, placed with its art (`importSlices`, +//! [`fileio::psd_slices`]). //! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options //! (`dxf`, [`fileio::dxfimport`]), and an HP-GL plot file (`vectorcraft_cad::hpgl`). //! - An EMF, WMF or PICT picture becomes one group, clipped to its frame (`crop: "crop"`) or @@ -45,7 +49,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, Scaling, SymbolBoxes}; +use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, Scaling, SliceOptions, SymbolBoxes}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_pdf::CropTo; @@ -75,7 +79,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at), showImportOptions?: true (in the app, a text file without text, a DXF drawing without dxf and a PDF with several pages or a password and no page ask for those options first; false places them with the options last confirmed, a PDF on page 1 (or page), and asks only an encrypted PDF's password)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file or PSD/PSB file placed in a clip group, which the Links commands take}. A PSD or PSB file that names a clipping path places in a clip group of that path (even-odd), as does its merged image in document.open. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), layers?: false (a PSD or PSB file: true embeds its layers as one group named after the file, read as document.open reads them: an image for each layer and a group for each layer group, with the layer's name, visibility, opacity, blend mode and opacity mask, at the place its merged image would take and clipped to the canvas when layers, hidden ones included, reach past it; never linked: link is then false unless given, and link: true is refused; a file without layers is embedded as its merged image, and so are one whose layers are all left out and one whose layers can't be read, with a warning), hiddenLayers?: false (with layers: true, the hidden layers come in hidden instead of being left out; refused without it), importSlices?: bool (a PSD or PSB file: its slices become user slices, moved and scaled with the placed art, in the same undo step; default true when the file is embedded, false when it is linked, and true with link is refused; slices the file generated automatically are left out), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at), showImportOptions?: true (in the app, a text file without text, a DXF drawing without dxf, a PSD or PSB file embedded without layers (with two layers or more, or with slices) and a PDF with several pages or a password and no page ask for those options first; false places them with the options last confirmed, a PDF on page 1 (or page), and asks only an encrypted PDF's password)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file or PSD/PSB file placed in a clip group, which the Links commands take, slices?: [the ids of the user slices a PSD file brought in]}. A PSD or PSB file that names a clipping path places in a clip group of that path (even-odd), as does its merged image in document.open. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), @@ -84,7 +88,7 @@ pub fn specs() -> Vec { "Placed File Info", [], None, - "{path | name | dataBase64 (a file's path, or its name with its bytes), page?, crop?, password?, thumbnail?: px, link?: bool, dxf?: {…} (both as file.place)} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), plates?: [ink names] (a DCS file), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}", + "{path | name | dataBase64 (a file's path, or its name with its bytes), page?, crop?, password?, thumbnail?: px, link?: bool, dxf?: {…}, layers?, hiddenLayers?, importSlices? (all as file.place)} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), plates?: [ink names] (a DCS file), slices?: the user slices a PSD or PSB file would bring in, warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}", always, info ), @@ -102,7 +106,7 @@ pub fn specs() -> Vec { "Load Place Cursor", [], None, - "{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, dxf?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}", + "{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, dxf?, layers?, hiddenLayers?, importSlices?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}", has_doc, queue ), @@ -148,16 +152,33 @@ struct Loaded { board: Option, } +/// A PSD or PSB file's slices, embedded with `importSlices`: their rectangles over the natural box +/// of its art, and their options. +type Slices = Vec<(Rect, SliceOptions)>; + /// Points per pixel of a raster image at the resolution it declares (72 ppi when none). pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) { let (x, y) = ppi.unwrap_or((72.0, 72.0)); (72.0 / x, 72.0 / y) } +/// [`load_sliced`] without the slices. +fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result { + load_into(p, cmd, board, link_pdf, &mut vec![]) +} + +/// [`load_into`], with the slices of a PSD or PSB file that `importSlices` brings in. +fn load_sliced(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result<(Loaded, Slices)> { + let mut slices = vec![]; + let l = load_into(p, cmd, board, link_pdf, &mut slices)?; + Ok((l, slices)) +} + /// The file `p` names, read into placeable art; a DXF drawing's Fit to Artboard fills `board` /// (unless the params name their own box). A PDF/.ai page read from a path is placed linked with -/// Link when `link_pdf` (false for the queries, which read it embedded). -fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result { +/// Link when `link_pdf` (false for the queries, which read it embedded). A PSD or PSB file's slices +/// go to `slices`. +fn load_into(p: &Value, cmd: &str, board: Option, link_pdf: bool, slices_out: &mut Slices) -> Result { let src = fileio::source(p, cmd)?; let name = fileio::file_name(src.name); // An Affinity file is recognised by its signature, whatever its name says. @@ -237,12 +258,52 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result match psd_groups(doc) { + Some((art, natural)) => { + *slices_out = psd_slices(slices_from, natural, &mut warnings); + return Ok(Loaded { name, format, art, natural, warnings, link: None, board: None }); + } + // No layer has art: the merged image, embedded. + None => unread = warnings, + }, + // No layers: the merged image, embedded. + Ok(None) => {} + Err(e) => unread.push(format!("the layers can't be read ({e}): the file was placed as its merged image")), + } + } + } if format.raster { let img = fileio::raster_image(&src.bytes)?; let (sx, sy) = pt_per_px(img.ppi); let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy); - let link = src.path.map(|path| super::links::link_info(path, &src.bytes)); - let warnings = fileio::size_note(&img.blob.bytes).into_iter().collect(); + let link = src.path.filter(|_| !embed).map(|path| super::links::link_info(path, &src.bytes)); + let mut warnings: Vec = unread.into_iter().chain(fileio::size_note(&img.blob.bytes)).collect(); + *slices_out = psd_slices(slices_from, natural, &mut warnings); let clip = fileio::psdread::clipping_path(&src.bytes); return Ok(Loaded { name, format, art: Art::Image(img, clip), natural, warnings, link, board: None }); } @@ -303,6 +364,56 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result Option<(Art, Rect)> { + let canvas = doc.artboards.first()?.rect; + let nodes = psd_objects(&std::mem::take(&mut doc.layers)); + // Hidden layers count too: showing one later must not draw past the canvas. + let symbols = doc.symbol_boxes(); + let reached = nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, reach(&symbols, n)))?; + let past = reached.union(canvas) != canvas; + Some((Art::Vector { src: Box::new(doc), nodes, clip: past.then_some(canvas) }, canvas)) +} + +/// The slices of the PSD or PSB file `bytes` (none without it) laid over `canvas`, the natural box +/// of its art; their warnings go to `warnings`. +fn psd_slices(bytes: Option<&[u8]>, canvas: Rect, warnings: &mut Vec) -> Slices { + let Some(bytes) = bytes else { return vec![] }; + let (slices, notes) = fileio::psd_slices(bytes, canvas); + warnings.extend(notes); + slices +} + +/// The bounds of `n` and every object inside it, hidden ones included. +fn reach(symbols: &SymbolBoxes, n: &Node) -> Option { + match n.children() { + Some(kids) => kids.iter().fold(None, |acc, k| vectorcraft_geom::union_opt(acc, reach(symbols, k))), + None => n.geometric_bounds_with(symbols), + } +} + +/// The layers a PSD import made as objects: a layer of sublayers as a group of them, and a layer +/// holding one image as that image, each with the layer's name, visibility, opacity, blend mode, +/// isolation and opacity mask. +fn psd_objects(layers: &[Arc]) -> Vec { + layers + .iter() + .map(|l| { + let mut n = (**l).clone(); + let NodeKind::Layer { children, .. } = &l.kind else { return n }; + n.kind = match &children[..] { + [c] if matches!(c.kind, NodeKind::Image(_)) => c.kind.clone(), + _ => NodeKind::Group { children: psd_objects(children).into_iter().map(Arc::new).collect(), clip: false }, + }; + n + }) + .collect() +} + /// Page `page` (1-based) of the PDF/.ai/.ait file `src` (`bytes`, read from `path` when there is /// one) as art to place: clipped to the box `opts.crop` names, or (`bounding`) the bounds of its /// art; read with `opts.password`. Only the page placed is read. A .ai saved without PDF @@ -548,6 +659,7 @@ pub struct PlaceJob { pub struct PlaceRead { job: PlaceJob, loaded: Loaded, + slices: Slices, } impl PlaceJob { @@ -562,7 +674,7 @@ impl PlaceJob { /// the renderers' image store for the first frame that draws them /// ([`super::links::readable_and_kept`]). pub fn read(self) -> Result { - let mut loaded = load(&self.params, PLACE, self.target, true)?; + let (mut loaded, slices) = load_sliced(&self.params, PLACE, self.target, true)?; let linked = loaded.link.is_some() && bool_or(&self.params, "link", true); match &mut loaded.art { Art::Image(img, _) => { @@ -572,7 +684,7 @@ impl PlaceJob { Art::Dcs(a) => a.img.blob = super::links::readable(a.img.blob.clone(), false, true)?, _ => {} } - Ok(PlaceRead { job: self, loaded }) + Ok(PlaceRead { job: self, loaded, slices }) } } @@ -653,7 +765,7 @@ fn prepare(s: &Session, p: &Value) -> Result { /// Place the file `read` holds in the active document. fn apply(s: &mut Session, read: PlaceRead) -> Result { - let PlaceRead { job: PlaceJob { params, old, target: read_for, .. }, mut loaded } = read; + let PlaceRead { job: PlaceJob { params, old, target: read_for, .. }, mut loaded, slices: read_slices } = read; let p = ¶ms; let template = bool_or(p, "template", false); let at = at_param(p)?; @@ -699,7 +811,22 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { if let Art::Dcs(a) = &loaded.art { warnings.extend(dcs::swatch_notes(&st.doc, &a.spots)); } - let (id, size) = s.edit("Place", |d, sel| { + // A PSD file's slices move and scale with its art; one that would leave the canvas is left out. + let read_count = read_slices.len(); + let slices: Slices = read_slices + .into_iter() + .filter_map(|(r, o)| { + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| m * c); + let placed = corners.iter().skip(1).fold(Rect::from_points(corners[0], corners[0]), |acc, c| acc.union_pt(*c)); + super::slices::slice_rect_ok(placed).then_some((placed, o)) + }) + .collect(); + match read_count - slices.len() { + 0 => {} + 1 => warnings.push("1 slice would lie outside the canvas and was left out".into()), + n => warnings.push(format!("{n} slices would lie outside the canvas and were left out")), + } + let (id, size, slice_ids) = s.edit("Place", |d, sel| { let node = transformed(build(d, loaded, link), m); let size = node.geometric_bounds_with(&d.symbol_boxes()).unwrap_or_default(); let (into, index) = match old { @@ -712,15 +839,19 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { None => (parent, usize::MAX), }; let id = d.insert(into, index, node)?; + let slice_ids: Vec = slices.into_iter().map(|(r, o)| super::slices::add_slice(d, r, o).0).collect(); // Template art is locked: nothing to select. if template { sel.clear() } else { sel.set([id]) } - Ok((id, size)) + Ok((id, size, slice_ids)) })?; let mut out = json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings }); + if !slice_ids.is_empty() { + out["slices"] = json!(slice_ids); + } // A DCS, PSD or PSB file clipped by its clipping path: its image, which the Links commands // take. if file_clipped { @@ -824,7 +955,7 @@ fn read_queue(doc: u64, p: Value, queued: Vec) -> Result { let thumbnail = p.get("thumbnail").and_then(Value::as_f64); let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]); for mut q in queued { - for k in ["link", "template", "page", "crop", "text", "dxf"] { + for k in ["link", "template", "page", "crop", "text", "dxf", "layers", "hiddenLayers", "importSlices"] { if let Some(v) = p.get(k) { q[k] = v.clone(); } @@ -870,7 +1001,7 @@ impl Session { /// desktop the Place dialog's OK with several files does this off the UI thread). pub fn prepare_place_queue(&self, params: &Value) -> Result { has_doc(self).map_err(|why| EngineError::Disabled(QUEUE.into(), why))?; - check_switches(params, &["link", "template"], QUEUE)?; + check_switches(params, &["link", "template", "layers", "hiddenLayers", "importSlices"], QUEUE)?; check_numbers(params, &["thumbnail"], QUEUE)?; let files = queued_files(params)?; Ok(QueueJob { doc: self.doc()?.uid, params: params.clone(), files }) @@ -948,7 +1079,7 @@ pub fn read_info(p: &Value) -> Result { check_switches(p, &["link"], "file.place.info")?; check_numbers(p, &["thumbnail"], "file.place.info")?; // A PDF/.ai page linked or not has the same size and art. - let mut l = load(p, "file.place.info", None, false)?; + let (mut l, slices) = load_sliced(p, "file.place.info", None, false)?; let natural = l.natural; let mut out = json!({ "name": l.name, @@ -957,6 +1088,9 @@ pub fn read_info(p: &Value) -> Result { "height": natural.height(), "warnings": std::mem::take(&mut l.warnings), }); + if matches!(l.format.id, "psd" | "psb") { + out["slices"] = json!(slices.len()); + } if let Art::Image(img, _) = &l.art { let (x, y) = img.ppi.unwrap_or((72.0, 72.0)); out["pixelWidth"] = json!(img.width); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 8f5f532c51..5f412e080d 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -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 { 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 { // 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], 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 { + 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 { + 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], report: &mut FlattenReport) -> Option>> { + 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 { 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); diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index e5107c247b..193e86b600 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -999,8 +999,9 @@ impl RecoveryJob { pub fn write(&self) -> std::result::Result { 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())?; diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 03bbd11ab6..e5fa5f75b3 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -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 { "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 { "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 { "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 { fn all_on_artboard(s: &mut Session, p: &Value) -> Result { 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 = @@ -670,6 +679,9 @@ fn key(s: &mut Session, p: &Value) -> Result { } 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 { } 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 { 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, guide: Option, v: &Value) -> Result { + 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 { 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 { 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 { 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()), diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 3388b8344b..8868143ef3 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "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 { *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 { const C: &str = "swatch.ungroup"; let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index 7ddc6e4bb2..63096c9d98 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -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 { diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index c8da4b578c..b2ceb94508 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -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::>(), [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. diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 418e465350..cb1366ed40 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "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 { 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) { diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 01615e5f4f..0701f6eeca 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec { "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() { diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index cba6199e97..8e58132ce0 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -245,39 +245,92 @@ fn define(s: &mut Session, p: &Value) -> Result { 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 { 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 = + 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::>(), "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 { "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 ), diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 146583778f..323d5d0918 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -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::>(), "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), "objects": d.node_count(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8b62c8c8e3..aeb723c963 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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>, + /// 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, diff --git a/crates/engine/src/sanity.rs b/crates/engine/src/sanity.rs index f1b6fd55bb..4737246c9d 100644 --- a/crates/engine/src/sanity.rs +++ b/crates/engine/src/sanity.rs @@ -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 = 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`] diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b54333206f..b2836d159c 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -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, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f89cc98ff8..1b420d7805 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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| { + 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::>() + }; + 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 { - 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. diff --git a/crates/engine/src/tests/actionset.rs b/crates/engine/src/tests/actionset.rs index d80e649ec7..49e5685bdc 100644 --- a/crates/engine/src/tests/actionset.rs +++ b/crates/engine/src/tests/actionset.rs @@ -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::(), 150_000); + assert_eq!(steps().map(|s| s.values.len()).sum::(), 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(" ")); diff --git a/crates/engine/src/tests/anchor_edit_work.rs b/crates/engine/src/tests/anchor_edit_work.rs index 1055156167..5401bc0bf8 100644 --- a/crates/engine/src/tests/anchor_edit_work.rs +++ b/crates/engine/src/tests/anchor_edit_work.rs @@ -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] diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index bc4ce76c83..a2e9eb3012 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -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", ¶ms).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] diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index 243dce52e8..205f922078 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -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 = 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> { - 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 = 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 = 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> = 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 = node(&s, g).children().unwrap().iter().filter(|c| !matches!(c.kind, NodeKind::Image(_))).map(|c| c.id.0).collect(); + let paths: Vec = 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::>(), [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::>(), [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()); } diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index be79004be2..368bc6de10 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -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::>(), ["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)> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) -> Vec { + 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, Vec) { + 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(©); + 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] diff --git a/crates/engine/src/tests/constrain_new.rs b/crates/engine/src/tests/constrain_new.rs index 382e3af31c..49fd29e94b 100644 --- a/crates/engine/src/tests/constrain_new.rs +++ b/crates/engine/src/tests/constrain_new.rs @@ -40,7 +40,7 @@ fn close(a: f64, b: f64) -> bool { fn segment_angles(p: &PathData) -> Vec { 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() diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index a0129c1cd1..45ff1f84c1 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -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); diff --git a/crates/engine/src/tests/fileinfo.rs b/crates/engine/src/tests/fileinfo.rs index 4d86a84b3c..8a152cd44e 100644 --- a/crates/engine/src/tests/fileinfo.rs +++ b/crates/engine/src/tests/fileinfo.rs @@ -81,6 +81,29 @@ fn junk_is_rejected() { assert_eq!(s.doc().unwrap().doc.metadata, DocMetadata { created: s.doc().unwrap().doc.metadata.created, ..Default::default() }); } +/// A control character inside a value is refused with the field's rule: Description and Copyright +/// Notice take line breaks and tabs, the other text fields and each keyword in a list take none. +#[test] +fn control_characters_are_refused_with_the_field_rule() { + let mut s = session(); + for (params, msg) in [ + (json!({"description": "a\u{7}b"}), "description has control characters"), + (json!({"copyrightNotice": "a\u{0}b"}), "copyrightNotice has control characters"), + (json!({"title": "a\nb"}), "title has control characters or line breaks"), + (json!({"author": "a\rb"}), "author has control characters or line breaks"), + (json!({"authorTitle": "a\tb"}), "authorTitle has control characters or line breaks"), + (json!({"copyrightUrl": "a\u{7}b"}), "copyrightUrl has control characters or line breaks"), + (json!({"keywords": ["ok", "a\nb"]}), "keywords has control characters or line breaks"), + ] { + match s.execute("file.info", ¶ms) { + Err(EngineError::BadParams { cmd, msg: got }) => assert_eq!((cmd.as_str(), got.as_str()), ("file.info", msg), "{params}"), + other => panic!("{params}: {other:?}"), + } + } + s.execute("file.info", &json!({"description": "two\nlines\tand a tab", "copyrightNotice": "a\r\nb"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.metadata.description, "two\nlines\tand a tab"); +} + #[test] fn file_info_round_trips_and_old_files_load() { let mut s = session(); diff --git a/crates/engine/src/tests/keyanchor.rs b/crates/engine/src/tests/keyanchor.rs new file mode 100644 index 0000000000..0dc6675f2a --- /dev/null +++ b/crates/engine/src/tests/keyanchor.rs @@ -0,0 +1,162 @@ +//! #1636: the key anchor. With Direct Selection, the point a Shift-click adds to two or more +//! selected anchor points is the key anchor; Align moves the other points to it, Distribute with a +//! spacing places them from it, and it stays in place. + +use serde_json::json; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn path(s: &mut Session, d: &str) -> NodeId { + NodeId(s.execute("path.create", &json!({"d": d})).unwrap()["id"].as_u64().unwrap()) +} + +fn pts(s: &Session, id: NodeId) -> Vec<(f64, f64)> { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() +} + +fn key_anchor(s: &Session) -> Option<(NodeId, (usize, usize))> { + s.doc().unwrap().selection.key_anchor +} + +/// A Direct Selection click at (`x`, `y`), with Shift when `shift`. +fn click(s: &mut Session, x: f64, y: f64, shift: bool) { + let (v, mods) = (ViewInfo::default(), Mods { shift, ..Default::default() }); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), v).unwrap(); + } +} + +#[test] +fn a_shift_click_makes_the_key_anchor_and_a_plain_click_narrows_the_selection() { + let mut s = session(); + let id = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.select_tool("directSelection", ViewInfo::default()).unwrap(); + click(&mut s, 100.0, 40.0, false); + assert_eq!(key_anchor(&s), None, "one point: no key anchor"); + click(&mut s, 150.0, 160.0, true); + assert_eq!(key_anchor(&s), Some((id, (0, 2))), "the point the Shift-click added"); + click(&mut s, 200.0, 60.0, true); + assert_eq!(key_anchor(&s), Some((id, (0, 1))), "the last point added"); + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspect["keyAnchor"], json!({"id": id.0, "anchor": [0, 1]})); + // A Shift-click on the key anchor deselects it, and there is no key anchor. + click(&mut s, 200.0, 60.0, true); + assert_eq!(key_anchor(&s), None); + click(&mut s, 200.0, 60.0, true); + // A plain click on one of the selected points selects that point alone. + click(&mut s, 100.0, 40.0, false); + let sel = &s.doc().unwrap().selection; + assert_eq!(sel.anchors.get(&id).map(|a| a.iter().copied().collect::>()), Some(vec![(0, 0)])); + assert_eq!(sel.key_anchor, None); +} + +#[test] +fn align_moves_the_other_points_to_the_key_anchor() { + let mut s = session(); + let id = path(&mut s, "M100 40 L200 60 L150 160 L60 120 Z"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + assert_eq!(key_anchor(&s), None, "a set selects no key anchor"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((id, (0, 2)))); + s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); + assert_eq!(pts(&s, id), [(150.0, 40.0), (150.0, 60.0), (150.0, 160.0), (60.0, 120.0)], "to the key anchor's x"); + s.execute("object.align", &json!({"vertical": "bottom"})).unwrap(); + assert_eq!(pts(&s, id), [(150.0, 160.0), (150.0, 160.0), (150.0, 160.0), (60.0, 120.0)], "the key anchor stays"); + let steps = super::history(&s); + s.execute("object.align", &json!({"vertical": "top"})).unwrap(); + assert_eq!(super::history(&s), steps, "nothing moved: no undo step"); + // `to: "selection"` aligns to the points' bounds instead. + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 3]], "mode": "add"})).unwrap(); + s.execute("object.align", &json!({"horizontal": "left", "to": "selection"})).unwrap(); + assert_eq!(pts(&s, id).iter().map(|p| p.0).collect::>(), [60.0; 4]); +} + +#[test] +fn distribute_with_a_spacing_places_the_points_from_the_key_anchor() { + let mut s = session(); + let id = path(&mut s, "M10 10 L20 50 L100 30 L60 90"); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "toggle"})).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 20})).unwrap(); + assert_eq!(pts(&s, id), [(60.0, 10.0), (80.0, 50.0), (100.0, 30.0), (60.0, 90.0)], "20 apart, outward from the key anchor"); + s.execute("object.distribute", &json!({"vertical": "center", "spacing": 5})).unwrap(); + assert_eq!(pts(&s, id), [(60.0, 25.0), (80.0, 35.0), (100.0, 30.0), (60.0, 90.0)], "in their order along y, from y 30"); + // Without a spacing, Distribute spaces the points between the first and the last. + s.execute("object.distribute", &json!({"horizontal": "center"})).unwrap(); + assert_eq!(pts(&s, id)[1].0, 80.0); +} + +#[test] +fn the_key_anchor_goes_when_it_can_no_longer_be_one() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + let b = path(&mut s, "M300 300 L400 300"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": b.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((b, (0, 1))), "a point of another path"); + assert!(s.doc().unwrap().selection.has_key()); + let r = s.execute("select.key", &json!({})).unwrap(); + assert_eq!((r["key"].clone(), r.get("keyAnchor")), (json!(null), None), "select.key {{}} cancels it"); + assert_eq!(key_anchor(&s), None); + let r = s.execute("select.key", &json!({"id": b.0, "anchor": [0, 1]})).unwrap(); + assert_eq!(r["keyAnchor"], json!({"id": b.0, "anchor": [0, 1]})); + // Deleting the key anchor's path leaves no key anchor. + s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(key_anchor(&s), None); + // A key object replaces a key anchor. + let c = path(&mut s, "M500 500 L600 500"); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((a, (0, 1))), "beside a path selected whole"); + s.execute("select.key", &json!({"id": c.0})).unwrap(); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.key, sel.key_anchor), (Some(c), None)); + // Type selected whole has no points to align, so one anchor beside it is no key anchor. + let t = NodeId(s.execute("text.create", &json!({"x": 40, "y": 400, "size": 12, "text": "Key"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), None); +} + +#[test] +fn select_key_rejects_an_anchor_that_cannot_be_the_key() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + let err = |s: &mut Session, p: serde_json::Value| s.execute("select.key", &p).expect_err(&p.to_string()).to_string(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("select another anchor point"), "the only point"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 9]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), None, "a point the path does not have"); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("select another anchor point"), "the only point on the path"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0], [0, 1]], "mode": "set"})).unwrap(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 2]})).contains("is not a selected anchor point")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 9]})).contains("is not a selected anchor point")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0]})).contains("expected [subpath, anchor]")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, -1]})).contains("non-negative integer")); + assert!(err(&mut s, json!({"anchor": [0, 1]})).contains("needs `id`")); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 1], "guide": 0})).contains("not both")); + // A selected segment's ends are not selected anchor points. + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 0]], "mode": "set"})).unwrap(); + assert!(err(&mut s, json!({"id": a.0, "anchor": [0, 0]})).contains("is not a selected anchor point")); + assert_eq!(key_anchor(&s), None, "the key stays as it was"); +} + +#[test] +fn a_rejected_select_key_keeps_the_key_anchor() { + let mut s = session(); + let a = path(&mut s, "M100 40 L200 60 L150 160 Z"); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0], [0, 9]], "mode": "set"})).unwrap(); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]], "mode": "add"})).unwrap(); + assert_eq!(key_anchor(&s), Some((a, (0, 1)))); + // [0, 9] is in the selection but not on the path. + let err = s.execute("select.key", &json!({"id": a.0, "anchor": [0, 9]})).expect_err("a point the path does not have").to_string(); + assert!(err.contains("is not a selected anchor point"), "{err}"); + assert_eq!(key_anchor(&s), Some((a, (0, 1))), "the key stays as it was"); +} diff --git a/crates/engine/src/tests/linked_stops.rs b/crates/engine/src/tests/linked_stops.rs index 62955e6065..a0f001afe6 100644 --- a/crates/engine/src/tests/linked_stops.rs +++ b/crates/engine/src/tests/linked_stops.rs @@ -231,6 +231,75 @@ fn deleting_a_spot_swatch_takes_the_process_plates_of_the_stops_it_unlinks() { assert_eq!(gradient(&s, id).gradient.stops[1], linked, "undo brings back the link and the plates"); } +/// The stops of the gradients the merge test puts in place: the art's fill, gradient swatch "Glow", +/// graphic style "Style", the art of brush "Brush" and the fill new art takes. +fn merge_test_stops(s: &Session, id: NodeId) -> Vec<(&'static str, Vec)> { + let d = doc(s); + let stops = |p: Paint| match p { + Paint::Gradient(g) => g.gradient.stops, + p => panic!("not a gradient: {p:?}"), + }; + let style = d.graphic_style("Style").unwrap().appearance.items.iter().find_map(|it| match it { + vectorcraft_doc::AppearanceItem::Fill(f) => Some(f.paint.clone()), + vectorcraft_doc::AppearanceItem::Stroke(_) => None, + }); + let mut brush = None; + for n in vectorcraft_brush::find(d, "Brush").unwrap().art() { + n.walk(&mut |m| { + if brush.is_none() && matches!(m.appearance.fill_paint(), Paint::Gradient(_)) { + brush = Some(m.appearance.fill_paint()); + } + }); + } + vec![ + ("art", stops(d.node(id).unwrap().appearance.fill_paint())), + ("gradient swatch", stops(d.swatch("Glow").unwrap().paint.clone())), + ("graphic style", stops(style.unwrap())), + ("brush art", stops(brush.unwrap())), + ("new art", stops(s.paint.fill.clone())), + ] +} + +#[test] +fn merging_swatches_takes_the_process_plates_of_the_stops_it_relinks() { + // Kept swatch "Keep": spot, global process, and process that isn't global. + for (spot, global) in [(true, true), (false, true), (false, false)] { + let (mut s, id) = setup(); + run(&mut s, "swatch.new", json!({"name": "Keep", "color": "#00ff00", "spot": spot, "global": global})); + run(&mut s, "swatch.new", json!({"name": "Other", "color": {"c": 1, "m": 0, "y": 0, "k": 0}, "spot": true})); + // Ink at 50 % over a 30 % cyan plate, and Other over its own 20 % magenta plate, which the + // merge leaves alone. + let plated = json!({"stops": [ + {"offset": 0, "swatch": "Ink", "tint": 50, "plates": [0.3, 0, 0, 0]}, + {"offset": 1, "swatch": "Other", "plates": [0, 0.2, 0, 0]}, + ]}); + run(&mut s, "paint.editGradient", plated.clone()); + run(&mut s, "swatch.new", json!({"name": "Glow", "gradient": plated})); + run(&mut s, "graphicStyle.new", json!({"name": "Style"})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Brush"})); + s.paint.fill = doc(&s).node(id).unwrap().appearance.fill_paint(); + let before = merge_test_stops(&s, id); + let kept = doc(&s).linked_color(doc(&s).swatch("Keep").unwrap().paint.color().unwrap(), spot); + assert!(before.iter().all(|(_, st)| st[0].plates == Some([0.3, 0.0, 0.0, 0.0])), "{before:?}"); + + run(&mut s, "swatch.merge", json!({"names": ["Keep", "Ink"]})); + let after = merge_test_stops(&s, id); + // The stop shows its tint of the kept color, unlinked when the kept swatch isn't global. + let (link, tint, color) = if global { (Some("Keep"), 0.5, kept.tinted(0.5)) } else { (None, 1.0, kept.tinted(0.5)) }; + for (place, st) in &after { + let what = format!("spot {spot}, global {global}, {place}: {st:?}"); + assert_eq!((st[0].swatch.as_deref(), st[0].tint, st[0].plates), (link, tint, None), "{what}"); + assert_eq!(st[0].color, color, "{what}"); + assert_eq!((st[1].swatch.as_deref(), st[1].plates), (Some("Other"), Some([0.0, 0.2, 0.0, 0.0])), "{what}"); + } + + run(&mut s, "edit.undo", json!({})); + assert_eq!(merge_test_stops(&s, id), before, "undo brings back the links and the plates"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(merge_test_stops(&s, id), after, "redo merges again"); + } +} + #[test] fn radial_spot_gradients_mixed_with_other_inks_export_as_device_n_shadings() { let (mut s, _) = setup(); diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 4afab29a91..1db3ffa34e 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -3299,6 +3299,32 @@ fn select_all_on_artboard_refuses_an_artboard_past_u32() { assert!(selected(&s).is_empty()); } +/// All on Active Artboard refuses an `artboard` that is not a whole number, naming the parameter, +/// and leaves the selection as it was; a whole number past the last artboard is `no such +/// artboard`. Left out or null, it takes the first artboard. +#[test] +fn select_all_on_artboard_refuses_an_artboard_that_is_not_a_whole_number() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2, "artboardLayout": {"layout": "row", "spacing": 0}})).unwrap(); + let (first, second) = (rect(&mut s, 10.0, 10.0, 20.0, 20.0), rect(&mut s, 110.0, 10.0, 20.0, 20.0)); + for bad in [json!("1"), json!(-1), json!(1.5), json!(true), json!([1]), json!({})] { + s.execute("select.none", &json!({})).unwrap(); + let err = s.execute("select.allOnArtboard", &json!({"artboard": bad})).unwrap_err().to_string(); + assert_eq!(err, "invalid parameters for `select.allOnArtboard`: `artboard` must be a whole number", "{bad}"); + assert!(selected(&s).is_empty(), "{bad}"); + } + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.execute("select.allOnArtboard", &json!({"artboard": 9})).unwrap_err().to_string(), "no such artboard"); + assert!(selected(&s).is_empty()); + for p in [json!({}), json!({"artboard": null}), json!({"artboard": 0})] { + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.allOnArtboard", &p).unwrap(); + assert_eq!(selected(&s), vec![first], "{p}"); + } + s.execute("select.allOnArtboard", &json!({"artboard": 1})).unwrap(); + assert_eq!(selected(&s), vec![second]); +} + #[test] fn save_and_recall_selection() { let mut s = session(); @@ -4605,6 +4631,27 @@ fn artboard_guides_go_with_their_artboard() { assert_eq!((guides(&s).len(), selected_guides(&s)), (3, vec![0, 1])); } +/// `guide.add` refuses an `artboard` that is not a whole number, naming the parameter, and adds no +/// guide; a whole number past the last artboard is `no such artboard`. Null makes a canvas guide, +/// and a whole number an artboard guide. +#[test] +fn guide_add_refuses_an_artboard_that_is_not_a_whole_number() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 900, "y": 0, "width": 400, "height": 300})).unwrap(); + let add = |s: &mut Session, artboard: Value| s.execute("guide.add", &json!({"vertical": true, "pos": 10, "artboard": artboard})); + for bad in [json!("1"), json!(-1), json!(1.5), json!(true), json!([1]), json!({})] { + let err = add(&mut s, bad.clone()).unwrap_err().to_string(); + assert_eq!(err, "invalid parameters for `guide.add`: `artboard` must be a whole number", "{bad}"); + } + assert_eq!(add(&mut s, json!(9)).unwrap_err().to_string(), "no such artboard"); + assert!(s.doc().unwrap().doc.ruler_guides().is_empty()); + add(&mut s, Value::Null).unwrap(); + add(&mut s, json!(1)).unwrap(); + let d = &s.doc().unwrap().doc; + let boards: Vec<_> = d.ruler_guides().iter().map(|g| g.artboard).collect(); + assert_eq!(boards, [None, Some(d.artboards[1].id)]); +} + /// #451: a guide dragged out of a ruler snaps (to the art's corners, side midpoints and anchors, /// the artboards' edges and centres) and is made in one undo step where it is released over the /// canvas; with the Artboard tool it is an artboard guide of the active artboard. diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 7d1c38b5e5..1bac01d48d 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -798,6 +798,44 @@ fn create_outlines_draws_the_type_objects_own_paint() { assert!(differing(&before, &render(&session()), 24) > 1000, "the type paints something"); } +/// Create Outlines draws what the type drew with effects of its own: a drop shadow of the whole +/// type and a Transform effect that scales and turns it, over fills and strokes of the object's +/// own around its Characters row. +#[test] +fn create_outlines_draws_the_type_objects_own_effects() { + let mut s = session(); + type_with_own_paint(&mut s, &[red_blue(), blue_stroke()], 1); + let plain = render(&s); + let shadow = json!({"x": 6, "y": 6, "blur": 0, "opacity": 100, "mode": "normal", "color": "#00ff00"}); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": shadow, "item": null})); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 80, "scaleV": 80, "rotate": 10}, "item": null})); + let before = render(&s); + assert!(differing(&plain, &before, 24) > 500, "the effects change what the type draws"); + run(&mut s, "type.createOutlines", json!({})); + let after = render(&s); + let n = differing(&before, &after, 24); + assert!(n < 40, "{n} pixels differ"); +} + +/// A Warp effect of the type object's own bends one gradient across the whole type on the canvas, +/// and the group Create Outlines makes keeps it across the whole outline rather than one per +/// glyph, so the outlines draw what the type drew. +#[test] +fn create_outlines_keeps_a_warped_character_gradient_across_the_type() { + let mut s = session(); + let t = run(&mut s, "text.create", json!({"x": 20, "y": 110, "text": "HIH", "size": 60}))["id"].as_u64().unwrap(); + run(&mut s, "select.set", json!({"ids": [t]})); + let down = json!({"kind": "linear", "angle": 90, "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}); + run(&mut s, "paint.setFill", json!({"gradient": down})); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "effect.apply", json!({"effect": "warp.arc", "params": {"bend": 50}, "item": null})); + let before = render(&s); + run(&mut s, "type.createOutlines", json!({})); + let after = render(&s); + let n = differing(&before, &after, 24); + assert!(n < 40, "{n} pixels differ"); +} + /// Selected type "HIH" at 40 pt with ruby, underlined and struck through, its characters filled /// with `fill` (a `paint.setFill` param) and stroked 2 pt wide, then scaled to 150% and rotated 20 /// degrees (the stroke keeps its 2 pt, which is 4/3 pt in text space). diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 715bf2b8ce..7d2675e938 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -1722,6 +1722,35 @@ fn text_set_format() { assert_eq!(t.para.composer, vectorcraft_doc::Composer::EveryLine); } +/// `text.setFormat {composer}` takes the two Middle Eastern & South Asian and the two Standard +/// composers, each per paragraph and one undo step; any other value is refused and names them all. +#[test] +fn text_set_format_takes_the_four_composers() { + use vectorcraft_doc::Composer; + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "One\nTwo"})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let composers = |s: &Session| { + let NodeKind::Text(t) = node(s, id).kind else { panic!() }; + [t.para_at(0).composer, t.para_at(1).composer] + }; + assert_eq!(composers(&s), [Composer::EveryLine; 2], "new type is ME&SA Every-line"); + for (value, want) in [ + ("singleLine", Composer::SingleLine), + ("standardSingleLine", Composer::StandardSingleLine), + ("standardEveryLine", Composer::StandardEveryLine), + ("everyLine", Composer::EveryLine), + ] { + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": value, "start": 4, "end": 4})).unwrap(); + assert_eq!(composers(&s), [Composer::EveryLine, want], "{value}: only the second paragraph"); + } + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "standardEveryLine"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(composers(&s), [Composer::EveryLine; 2], "one undo step"); + let e = s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "standard"})).unwrap_err().to_string(); + assert!(e.contains("standardSingleLine") && e.contains("standardEveryLine"), "{e}"); +} + #[test] fn stroke_advanced_scale_swap_flip() { let mut s = session(); diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index 52cb314de0..708b602d1f 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -704,10 +704,11 @@ fn effect_params_are_the_effects_own() { fn an_effect_parameter_without_a_default_is_held_to_its_type() { use crate::cmd::params::undefaulted_type; use vectorcraft_render::effects::effect_catalog; - // Read by checks of their own: a stored texture or a texture to load, an SVG filter, a Warp's + // Read by checks of their own: a stored texture or a texture to load, an SVG filter and its axes, a Warp's // map and the style of Crop Marks. let own_check = |effect: &str, key: &str| match key { "textureImage" | "name" | "xml" => true, + "axes" => effect == "svg.filter", "version" => effect.starts_with("warp."), "style" => effect == "cropMarks", _ => false, diff --git a/crates/engine/src/tests/pathops.rs b/crates/engine/src/tests/pathops.rs index 0564589d2d..e7da630b81 100644 --- a/crates/engine/src/tests/pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -1088,6 +1088,62 @@ fn create_outlines_keeps_the_objects_own_fills_and_strokes() { assert_eq!(node(&s, t).appearance.items, own, "one undo step brings the type back"); } +/// The type object's own effects go on the group once, in their order, with their settings and a +/// hidden one still hidden; a fill's own effect stays on its path of the whole outline, and no +/// glyph or whole-outline path takes the object's effects. Undo brings the type back with them. +#[test] +fn create_outlines_keeps_the_objects_own_effects_on_the_group() { + let mut s = session(); + let t = text(&mut s, "Hi", 64.0); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + for (cmd, p) in [ + ("appearance.addFill", json!({"target": "object"})), + ("appearance.setItem", json!({"index": 0, "color": "#ff0000"})), + ("effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 2}, "item": 0})), + ("effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 7, "y": 3, "blur": 0, "opacity": 60}, "item": null})), + ("effect.apply", json!({"effect": "distort.transform", "params": {"rotate": 15}, "item": null})), + ("effect.setParams", json!({"index": 1, "visible": false, "item": null})), + ] { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + } + let own: Appearance = (*node(&s, t).appearance).clone(); + let ids_of = |fx: &[vectorcraft_doc::Effect]| fx.iter().map(|e| (e.id.clone(), e.visible)).collect::>(); + assert_eq!(ids_of(&own.effects), [("stylize.dropShadow".to_string(), true), ("distort.transform".to_string(), false)]); + assert_eq!(ids_of(own.items[0].effects()), [("path.offsetPath".to_string(), true)]); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let g = node(&s, r[0]); + assert_eq!(g.appearance.effects, own.effects, "the group holds the object's effects as they were"); + let ch = g.children().unwrap(); + assert!(ch.iter().all(|c| c.appearance.effects.is_empty()), "no piece takes the object's effects"); + let rows: Vec<_> = ch.iter().filter(|c| c.appearance.items == own.items).collect(); + assert_eq!(rows.len(), 1, "one path of the whole outline paints the object's fill"); + assert_eq!(rows[0].appearance.items[0].effects(), own.items[0].effects(), "the fill keeps its own effect"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*node(&s, t).appearance, own, "one undo step brings the type back with its effects"); +} + +/// The group takes the type object's opacity, blend mode, Isolate Blending, Opacity & Mask Define +/// Knockout Shape and opacity mask. +#[test] +fn create_outlines_keeps_the_objects_transparency_and_opacity_mask() { + let mut s = session(); + let t = text(&mut s, "Hi", 64.0); + let m = rect(&mut s, 0.0, 0.0, 40.0, 80.0); + s.execute("select.set", &json!({"ids": [t.0, m.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let p = json!({"ids": [t.0], "opacity": 70, "blend": "multiply", "isolate": true, "knockoutShape": true}); + s.execute("transparency.set", &p).unwrap(); + let before = node(&s, t); + assert!(before.extras().mask.is_some(), "the type has a mask"); + let look = |n: &vectorcraft_doc::Node| (n.opacity, n.blend, n.isolate, n.knockout_shape); + assert_eq!(look(&before), (0.7, vectorcraft_color::BlendMode::Multiply, true, true)); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let g = node(&s, r[0]); + assert_eq!(look(&g), look(&before)); + assert_eq!(g.extras().mask, before.extras().mask, "the group keeps the type's opacity mask"); +} + /// Colour bitmap glyphs (emoji) become embedded images where they sat; glyphs showing one image /// share its pixels. #[test] diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 662e301caa..64926b7cfb 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -1023,6 +1023,78 @@ fn saved_for_older_apps_with_a_precision_the_placed_art_is_rounded_too() { assert_ne!(preview, crate::cmd::fileio::preview_png(&s.doc().unwrap().doc).unwrap()); } +/// Saved for an older version, a placed document held only in stored brushes' art (a scatter +/// brush's art and a pattern brush's tile) is saved as the art it shows, with the resources that art +/// uses, and a precision save's preview shows that art as the file holds it, rounded. The current +/// version keeps it placed. +#[test] +fn saved_for_older_apps_placed_documents_in_brush_art_become_their_art() { + let dir = Folder::new("placed-brush-older"); + let src = dir.file("badge.vectorcraft"); + // A symbol `Star` and a triangle off the whole-point grid. + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let star = Node::path(vectorcraft_doc::NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look(BLUE)); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Star".into(), art: std::sync::Arc::new(star) }); + let id = d.alloc_id(); + d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Star".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap(); + let points = [(3.4876474632797176, 10.5), (96.5, 2.4876), (40.4876, 97.5)].map(|(x, y)| vectorcraft_geom::Point::new(x, y)); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::path::SubPath::polyline(&points, true)); + let id = d.alloc_id(); + d.insert(Some(layer), 1, Node::path(id, path, look(RED))).unwrap(); + write(&src, &vectorcraft_format::save_file(&d)); + let mut s = session(); + let id = place(&mut s, &src); + let art = s.doc().unwrap().doc.node(id).unwrap().clone(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let scatter = vectorcraft_brush::Scatter { art: art.clone(), ..Default::default() }; + let pattern = vectorcraft_brush::PatternBrush { side: art, ..Default::default() }; + vectorcraft_brush::store( + d, + &[ + vectorcraft_brush::Brush { name: "Badge Dots".into(), kind: vectorcraft_brush::BrushKind::Scatter(scatter) }, + vectorcraft_brush::Brush { name: "Badge Band".into(), kind: vectorcraft_brush::BrushKind::Pattern(pattern) }, + ], + ); + assert!(!d.has_placed(), "only the brushes hold it"); + } + let line = s.execute("shape.line", &json!({"x1": 20, "y1": 150, "x2": 380, "y2": 150})).unwrap()["id"].clone(); + s.execute("brush.apply", &json!({"name": "Badge Dots", "ids": [line]})).unwrap(); + // The placed documents in each brush's art. + let placed_in_brushes = |d: &Document| { + let mut n = 0; + for b in vectorcraft_brush::library(d) { + for art in b.art() { + art.walk(&mut |c| n += usize::from(matches!(c.kind, NodeKind::PlacedDocument(_)))); + } + } + n + }; + let prefix = vectorcraft_doc::placed_document::RESOURCE_PREFIX; + let older = vectorcraft_format::VERSION - 1; + let old = + vectorcraft_format::load(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "version": older})).unwrap())).unwrap(); + assert_eq!(placed_in_brushes(&old), 0, "{}", old.unknown[vectorcraft_brush::DOC_KEY]); + assert_eq!(vectorcraft_brush::library(&old).len(), 2); + for b in vectorcraft_brush::library(&old) { + assert!(matches!(b.art()[0].kind, NodeKind::Group { .. }), "{}: {}", b.name, b.art()[0].kind_label()); + } + assert!(old.symbols.iter().any(|s| s.name.starts_with(prefix)), "the art's symbol is saved with it"); + let current = vectorcraft_format::load(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap())).unwrap(); + assert_eq!(placed_in_brushes(¤t), 2, "the current version keeps them placed"); + assert!(current.symbols.iter().all(|s| !s.name.starts_with(prefix))); + // With a precision, the preview shows the brushes' art as the file holds it, rounded. + let file = decode(&s.execute("file.saveCopy", &json!({"version": older, "precision": 0, "preview": true})).unwrap()); + let saved = vectorcraft_format::load(&file).unwrap(); + assert_eq!(placed_in_brushes(&saved), 0); + let preview = vectorcraft_format::preview(&file); + assert!(preview.is_some()); + assert_eq!(preview, crate::cmd::fileio::preview_png(&saved).unwrap()); + assert_ne!(preview, crate::cmd::fileio::preview_png(&s.doc().unwrap().doc).unwrap()); +} + #[test] fn break_link_gives_the_editable_copy_placing_without_link_gives() { let dir = Folder::new("placed-break"); diff --git a/crates/engine/src/tests/rastersettings.rs b/crates/engine/src/tests/rastersettings.rs index 75659faf32..320ecb6317 100644 --- a/crates/engine/src/tests/rastersettings.rs +++ b/crates/engine/src/tests/rastersettings.rs @@ -292,6 +292,58 @@ fn rasterize_measures_the_art_its_geometry_effects_draw() { assert_eq!((xf.translation().x, width), (150.0, 41)); } +/// A Transform effect after Rasterize moves the image the Rasterize effect makes: Expand Appearance +/// and PDF export make the image of the art before the Transform effect, as Object › Rasterize +/// makes it, and place it through the effect's map, as the canvas draws it. +#[test] +fn expand_rasterize_then_transform_matches_canvas() { + let mut o = session(); + red_rectangle(&mut o); + let made = id(&o.execute("object.rasterize", &json!({"ppi": 72, "background": "white", "addAround": 2})).unwrap()); + let od = &o.doc().unwrap().doc; + let (w, h, xf, png, _) = rasterized(od, od.node(made).unwrap()); + // About the center of the rectangle's path. + let about = + |m: vectorcraft_geom::Affine| vectorcraft_geom::Affine::translate((70.5, 65.25)) * m * vectorcraft_geom::Affine::translate((-70.5, -65.25)); + let cases = [ + (json!({"scaleH": 200, "scaleV": 200}), about(vectorcraft_geom::Affine::scale(2.0))), + (json!({"rotate": 30}), about(vectorcraft_geom::Affine::rotate(-30f64.to_radians()))), + ( + json!({"rotate": 30, "moveH": 10, "strokes": true}), + vectorcraft_geom::Affine::translate((10.0, 0.0)) * about(vectorcraft_geom::Affine::rotate(-30f64.to_radians())), + ), + ]; + for (params, map) in cases { + let mut s = session(); + let r = red_rectangle(&mut s); + let raster = json!({"useDocumentResolution": false, "resolution": 72, "background": "white", "addAround": 2}); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": raster, "ids": [r.0]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": params.clone(), "ids": [r.0]})).unwrap(); + let check = |d: &Document, n: &vectorcraft_doc::Node, what: &str| { + let (w2, h2, xf2, png2, _) = rasterized(d, n); + assert_eq!((w2, h2), (w, h), "{what} {params}"); + assert!(png2 == png, "{what} {params}: the same pixels"); + let want = map * xf; + assert!(xf2.as_coeffs().iter().zip(want.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{what} {params}: {xf2:?} {want:?}"); + }; + let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap(); + check(&flat, &flat.layers[0].children().unwrap()[0], "PDF export"); + s.execute("effect.expandAppearance", &json!({"ids": [r.0]})).unwrap(); + let d = &s.doc().unwrap().doc; + check(d, d.node(r).unwrap(), "Expand Appearance"); + } + // A Transform effect that scales the image to nothing leaves nothing in PDF export, as the + // canvas draws nothing. + let mut s = session(); + let r = red_rectangle(&mut s); + let raster = json!({"useDocumentResolution": false, "resolution": 72, "background": "white", "addAround": 2}); + s.execute("effect.apply", &json!({"effect": "rasterize", "params": raster, "ids": [r.0]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"scaleH": 0}, "ids": [r.0]})).unwrap(); + let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap(); + let n = &flat.layers[0].children().unwrap()[0]; + assert!(matches!(&n.kind, vectorcraft_doc::NodeKind::Group { children, .. } if children.is_empty()), "{:?}", n.kind); +} + #[test] fn the_clipping_mask_follows_the_art_its_geometry_effects_draw() { let clip = |s: &Session, n: NodeId| match &s.doc().unwrap().doc.node(n).unwrap().kind { diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index a51f73b222..eed8801efc 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1532,6 +1532,47 @@ fn images_sharing_an_entry_are_written_once() { assert_eq!(store.images_written().len(), 1, "restored into this session's area once"); } +/// A linked image shown only in a stored brush's art is kept as its preview, as a save writes it; +/// an image key that a brush's art embeds keeps its full bytes though the layers show it linked. +#[test] +fn recovery_brush_links_keep_proxy_and_embedded_full_bytes() { + let (full, proxy) = (vec![1u8; 40], vec![2u8; 10]); + for brush_linked in [true, false] { + let store = Arc::new(MemoryStore::default()); + let mut s = session_in(&store); + { + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let blob = + vectorcraft_doc::ImageBlob { proxy: Some(Arc::new(proxy.clone())), ..vectorcraft_doc::ImageBlob::new("image/jpeg", full.clone()) }; + d.images.insert("k".into(), blob); + let image = |link: Option| { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::IDENTITY, + link, + placement: Default::default(), + pixels: None, + }; + vectorcraft_doc::Node::new(NodeId(0), vectorcraft_doc::NodeKind::Image(Box::new(im))) + }; + let link = Some(vectorcraft_doc::LinkInfo::new("/work/k.jpg")); + if !brush_linked { + let (mut n, layer) = (image(link.clone()), d.layers[0].id); + n.id = d.alloc_id(); + d.insert(Some(layer), 0, n).unwrap(); + } + let art = image(if brush_linked { link } else { None }); + let scatter = vectorcraft_brush::Scatter { art, ..Default::default() }; + vectorcraft_brush::store(d, &[vectorcraft_brush::Brush { name: "Dots".into(), kind: vectorcraft_brush::BrushKind::Scatter(scatter) }]); + } + let r = s.execute("file.recovery.save", &json!({})).unwrap(); + let want = if brush_linked { proxy.len() } else { full.len() }; + assert_eq!(r["saved"][0]["imageBytes"], want, "linked in the brush: {brush_linked}: {r}"); + } +} + #[test] fn copies_from_before_images_were_kept_apart_still_restore() { let store = Arc::new(MemoryStore::default()); diff --git a/crates/engine/src/tests/simplify_part.rs b/crates/engine/src/tests/simplify_part.rs index 12f3330595..f3ab93f274 100644 --- a/crates/engine/src/tests/simplify_part.rs +++ b/crates/engine/src/tests/simplify_part.rs @@ -73,8 +73,8 @@ fn simplify_refits_only_the_run_between_direct_selected_anchors() { assert_eq!(out["after"].as_u64(), Some(len as u64)); assert!(len < 101 - 30, "{len}"); let end = len - 41; - assert_eq!(sp.anchors[..20], original.anchors[..20], "the anchors before the run"); - assert_eq!(sp.anchors[end + 1..], original.anchors[61..], "the anchors after the run"); + assert_eq!(sp.anchors.to_vec()[..20], original.anchors.to_vec()[..20], "the anchors before the run"); + assert_eq!(sp.anchors.to_vec()[end + 1..], original.anchors.to_vec()[61..], "the anchors after the run"); assert_eq!((sp.anchors[20].p, sp.anchors[20].h_in), (original.anchors[20].p, original.anchors[20].h_in)); assert_eq!((sp.anchors[end].p, sp.anchors[end].h_out), (original.anchors[60].p, original.anchors[60].h_out)); for i in 0..20 { @@ -100,7 +100,7 @@ fn separate_runs_on_one_path_simplify_independently_in_one_step() { let id = wave(&mut s); select_anchors(&mut s, id, ranges); s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); - path_of(&s, id).subpaths[0].anchors.clone() + path_of(&s, id).subpaths[0].anchors.to_vec() }; let (first, second) = (alone(&[(10, 30)]), alone(&[(60, 80)])); let mut s = session(); @@ -110,13 +110,13 @@ fn separate_runs_on_one_path_simplify_independently_in_one_step() { let undo = undo_len(&s); s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); assert_eq!(undo_len(&s), undo + 1, "one undo step"); - let both = path_of(&s, id).subpaths[0].anchors.clone(); + let both = path_of(&s, id).subpaths[0].anchors.to_vec(); // Where old anchor 30 is in the first result, and old anchor 80 in the second. let (a30, a80) = (first.len() - 71, second.len() - 21); assert!(a30 < 30 && a80 < 80, "both runs lose anchors"); let mid = a30 + 30; assert_eq!(both[..=a30], first[..=a30]); - assert_eq!(both[a30 + 1..mid], original.anchors[31..60], "the anchors between the runs"); + assert_eq!(both[a30 + 1..mid], original.anchors.to_vec()[31..60], "the anchors between the runs"); assert_eq!(both[mid..], second[60..]); let sel: BTreeSet<_> = (10..=a30).chain(mid..=mid + a80 - 60).map(|i| (0, i)).collect(); assert_eq!(s.doc().unwrap().selection.anchors.get(&id), Some(&sel)); @@ -138,7 +138,7 @@ fn a_run_across_the_start_of_a_closed_path_simplifies_as_one() { assert!(sp.closed); assert!(sp.anchors.len() < 80 - 10, "{}", sp.anchors.len()); assert!(!sp.anchors.iter().any(|a| a.p == original.anchors[0].p), "anchor 0 was inside the run"); - assert_eq!(sp.anchors[1..60], original.anchors[11..70]); + assert_eq!(sp.anchors.to_vec()[1..60], original.anchors.to_vec()[11..70]); assert_eq!((sp.anchors[0].p, sp.anchors[0].h_out), (original.anchors[10].p, original.anchors[10].h_out)); assert_eq!((sp.anchors[60].p, sp.anchors[60].h_in), (original.anchors[70].p, original.anchors[70].h_in)); for i in 0..60 { @@ -162,8 +162,8 @@ fn picked_segments_simplify_and_stay_picked() { s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); let sp = path_of(&s, id).subpaths[0].clone(); let end = sp.anchors.len() - 41; - assert_eq!(sp.anchors[..20], original.anchors[..20]); - assert_eq!(sp.anchors[end + 1..], original.anchors[61..]); + assert_eq!(sp.anchors.to_vec()[..20], original.anchors.to_vec()[..20]); + assert_eq!(sp.anchors.to_vec()[end + 1..], original.anchors.to_vec()[61..]); let sel = &s.doc().unwrap().selection; assert_eq!(sel.segments.get(&id), Some(&(20..end).map(|i| (0, i)).collect())); assert_eq!(sel.anchors.get(&id), Some(&(20..=end).map(|i| (0, i)).collect())); @@ -200,8 +200,8 @@ fn slider_preview_and_auto_simplify_only_the_run() { let previewed = path_of(&s, id); let sp = &previewed.subpaths[0]; assert!(sp.anchors.len() < 101, "{p}"); - assert_eq!(sp.anchors[..20], original.anchors[..20], "{p}"); - assert_eq!(sp.anchors[sp.anchors.len() - 40..], original.anchors[61..], "{p}"); + assert_eq!(sp.anchors.to_vec()[..20], original.anchors.to_vec()[..20], "{p}"); + assert_eq!(sp.anchors.to_vec()[sp.anchors.len() - 40..], original.anchors.to_vec()[61..], "{p}"); s.execute("edit.undo", &json!({})).unwrap(); s.execute("object.path.simplify", &p).unwrap(); assert_eq!(path_of(&s, id), previewed, "{p}"); diff --git a/crates/engine/src/tests/smooth_path.rs b/crates/engine/src/tests/smooth_path.rs index 9ce6a21044..1ca7a6d5e0 100644 --- a/crates/engine/src/tests/smooth_path.rs +++ b/crates/engine/src/tests/smooth_path.rs @@ -93,7 +93,7 @@ fn more_smoothing_removes_the_jags_and_keeps_the_shape() { // Within the tolerance (2.5 % of 300 pt at most) plus the jag the refit smooths away. assert!(farthest(&before, &after) <= 7.5 + 1.5, "level {level}: {}", farthest(&before, &after)); // The end points of an open path stay put. - let (a, b) = (&before.subpaths[0].anchors, &after.subpaths[0].anchors); + let (a, b) = (&before.subpaths[0].anchors.to_vec(), &after.subpaths[0].anchors.to_vec()); assert_eq!((a[0].p, a[a.len() - 1].p), (b[0].p, b[b.len() - 1].p)); assert!(!after.subpaths[0].closed); s.execute("edit.undo", &json!({})).unwrap(); @@ -206,7 +206,7 @@ fn direct_selected_anchors_smooth_only_their_section() { s.execute("select.anchors", &json!({"id": id.0, "anchors": anchors})).unwrap(); s.execute("object.path.smooth", &json!({"level": 100})).unwrap(); let after = path_of(&s, id); - let (a, b) = (&before.subpaths[0].anchors, &after.subpaths[0].anchors); + let (a, b) = (&before.subpaths[0].anchors.to_vec(), &after.subpaths[0].anchors.to_vec()); assert!(b.len() < a.len(), "{} → {}", a.len(), b.len()); // Anchors 0..=10 and 30..=40 are untouched, the section between them refitted. assert_eq!(a[..=10].iter().map(|x| x.p).collect::>(), b[..=10].iter().map(|x| x.p).collect::>()); diff --git a/crates/engine/src/tests/styles.rs b/crates/engine/src/tests/styles.rs index 6f77b58971..e460aeb890 100644 --- a/crates/engine/src/tests/styles.rs +++ b/crates/engine/src/tests/styles.rs @@ -325,6 +325,26 @@ fn select_same_appearance_attribute() { run(&mut s, "select.set", json!({"ids": [a.0]})); run(&mut s, "select.same.appearanceAttribute", json!({"item": (1u64 << 32) + 1})); assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + // Null is the default: the shared effect. + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "select.same.appearanceAttribute", json!({"item": null})); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); +} + +/// Select > Same > Appearance Attribute refuses an `item` that is not a whole number and leaves +/// the selection as it was. +#[test] +fn select_same_appearance_attribute_refuses_an_item_that_is_not_a_whole_number() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "effect.apply", json!({"effect": "distort.roughen"})); + rect(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [a.0]})); + for item in [json!("1"), json!(-1), json!(1.5), json!(true), json!([1])] { + let e = s.execute("select.same.appearanceAttribute", &json!({"item": item})).unwrap_err().to_string(); + assert_eq!(e, "invalid parameters for `select.same.appearanceAttribute`: `item` must be a whole number", "{item}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{item}"); + } } /// With a member path of a compound path selected first, Select > Same > Graphic Style and diff --git a/crates/engine/src/tests/svgfilter.rs b/crates/engine/src/tests/svgfilter.rs index e42d7f9736..611c9bfd1f 100644 --- a/crates/engine/src/tests/svgfilter.rs +++ b/crates/engine/src/tests/svgfilter.rs @@ -551,6 +551,26 @@ fn svg_export_keeps_a_measured_region_only_on_the_object_alone() { assert!(after.contains(""#; + for (scale, axes, live) in [(2.0, json!([2, 0, 0, 2]), true), (1000.0, json!([2, 0, 0, 2]), false)] { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [a.0], "params": {"xml": hue, "scale": scale, "axes": axes}})); + let out = svg(&mut s); + assert_eq!((out.contains(" std::collections::BTreeSet { + pdf.split(" scn").filter_map(|s| s.rsplit("cs ").next().filter(|t| t.parse::().is_ok()).map(str::to_string)).collect() + }; + let kept = pdf(true); + assert!(kept.contains("/Separation/Ink/DeviceCMYK"), "the faces print on the Ink plate"); + assert!(tints(&kept).len() > 1, "the highlight prints lighter tints of the ink: {:?}", tints(&kept)); + assert!(kept.contains("/Multiply"), "the shading is black multiplied over the ink"); + // Off, only a face left unshaded keeps the swatch, at its full tint. + assert!(tints(&pdf(false)).len() <= 1, "without the option shaded faces are process mixes"); +} + #[test] fn global_mode_shifts_tints_of_linked_colours_only() { let mut s = inks(); diff --git a/crates/engine/src/tests/toolsettings.rs b/crates/engine/src/tests/toolsettings.rs index 2ec7667bde..a9d9e3f5bd 100644 --- a/crates/engine/src/tests/toolsettings.rs +++ b/crates/engine/src/tests/toolsettings.rs @@ -286,7 +286,7 @@ fn legacy_spiral_decay_preferences_report_the_ratio_drawn_after_reopening() { assert_eq!(d.selection.objects.len(), 1); let id = d.selection.objects[0]; let path = d.doc.node(id).unwrap().path_data().unwrap().clone(); - let anchors = &path.subpaths[0].anchors; + let anchors = &path.subpaths[0].anchors.to_vec(); assert_eq!(anchors.len(), 5); assert!(!path.is_closed()); let center = Point::new(200.0, 200.0); diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index 7893b3d27e..9119c3e151 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -1063,6 +1063,110 @@ fn renaming_a_missing_name_onto_a_taken_one_reports_the_missing_name() { assert!(e.contains("no dataset named `Nope`"), "{e}"); } +/// Variable Options changes a variable's kind: the objects that can't hold the new kind are +/// unbound, the ones that can (locked ones too) stay bound, every value of the old kind goes, and +/// the change is one undo step. +#[test] +fn variable_options_changes_the_kind_and_unbinds_what_cant_hold_it() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + let other = text(&mut s, "Held"); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Title", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [t.0, r.0, other.0]})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Title", "ids": [other.0]})).unwrap(); + s.execute("dataset.new", &json!({"name": "A", "values": {"Show": false, "Title": "Bob"}})).unwrap(); + s.execute("dataset.new", &json!({"name": "B", "values": {"Show": true}})).unwrap(); + s.execute("select.set", &json!({"ids": [t.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).unwrap(); + // The rectangle isn't type, and the other type object already has the text variable Title. + assert_eq!(v["unbound"], json!([r.0, other.0])); + assert_eq!((v["name"].clone(), v["kind"].clone(), v["droppedValues"].clone()), (json!("Show"), json!("text"), json!(2))); + assert_eq!(steps(&s), before + 1, "one undo step"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!(vars.objects_of("Show"), [t], "the locked type object stays bound"); + assert_eq!(vars.objects_of("Title"), [other], "the other variable keeps its binding"); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!((sets["datasets"][0]["values"].clone(), sets["datasets"][1]["values"].clone()), (json!({"Title": {"text": "Bob"}}), json!({}))); + + s.execute("edit.undo", &json!({})).unwrap(); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!(vars.variable("Show").map(|v| v.kind), Some(vectorcraft_doc::VariableKind::Visibility)); + assert_eq!(vars.objects_of("Show"), [t, r, other]); + assert_eq!(vars.dataset("A").and_then(|d| d.values.get("Show")), Some(&vectorcraft_doc::DataValue::Visible(false))); + + // A new name and a new kind together are one undo step, and every visibility binding stays. + s.execute("variable.rename", &json!({"name": "Title", "newName": "Heading", "kind": "visibility"})).unwrap(); + assert_eq!(steps(&s), before + 1, "one step after the undo"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!( + (vars.variable("Heading").map(|v| v.kind), vars.objects_of("Heading")), + (Some(vectorcraft_doc::VariableKind::Visibility), vec![other]) + ); + // An image variable keeps only image objects. + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "image"})).unwrap(); + assert_eq!(v["unbound"], json!([t.0, r.0, other.0])); +} + +/// Variable Options with the variable's own name and kind changes nothing and adds no undo step; +/// a bad kind, a taken name, no change asked for and locked variables are refused. +#[test] +fn variable_options_refuses_bad_params_and_skips_a_change_that_changes_nothing() { + let mut s = session(); + let r = rect(&mut s); + s.execute("variable.define", &json!({"name": "Show", "kind": "visibility"})).unwrap(); + s.execute("variable.define", &json!({"name": "Other", "kind": "text"})).unwrap(); + s.execute("variable.bind", &json!({"variable": "Show", "ids": [r.0]})).unwrap(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + for p in [ + json!({"name": "Show", "newName": "Show"}), + json!({"name": "Show", "kind": "visibility"}), + json!({"name": "Show", "newName": "Show", "kind": "visible"}), + ] { + let v = s.execute("variable.rename", &p).unwrap(); + assert_eq!((v["unbound"].clone(), v["droppedValues"].clone()), (json!([]), json!(0)), "{p}"); + assert_eq!(steps(&s), before, "{p}: no undo step"); + } + for (p, want) in [ + (json!({"name": "Show", "kind": "unknown"}), "`kind` must be"), + (json!({"name": "Show", "kind": "number"}), "`kind` must be"), + (json!({"name": "Show", "kind": 1}), "`kind` must be"), + (json!({"name": "Show", "newName": "New", "kind": null}), "`kind` must be"), + (json!({"name": "Show", "newName": null, "kind": "text"}), "missing `newName`"), + (json!({"name": "Show", "newName": " ", "kind": "text"}), "missing `newName`"), + (json!({"name": "Show"}), "missing `newName` or `kind`"), + (json!({"name": "Show", "newName": "Other", "kind": "text"}), "already exists"), + (json!({"name": "Ghost", "kind": "text"}), "no variable named `Ghost`"), + ] { + let e = s.execute("variable.rename", &p).unwrap_err().to_string(); + assert!(e.contains(want), "{p}: {e}"); + } + // A rename alone carries a value that doesn't fit the kind, as a file may hold one. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc) + .variables + .datasets + .push(vectorcraft_doc::DataSet { name: "Odd".into(), values: [("Show".to_string(), vectorcraft_doc::DataValue::Text("x".into()))].into() }); + s.execute("variable.rename", &json!({"name": "Show", "newName": "Shown"})).unwrap(); + let odd = s.doc().unwrap().doc.variables.dataset("Odd").and_then(|d| d.values.get("Shown")).cloned(); + assert_eq!(odd, Some(vectorcraft_doc::DataValue::Text("x".into()))); + s.execute("edit.undo", &json!({})).unwrap(); + // A kind change drops it, though it fits the new kind: nobody gave it for that kind. + let v = s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).unwrap(); + assert_eq!(v["droppedValues"], 1); + assert!(s.doc().unwrap().doc.variables.dataset("Odd").is_some_and(|d| d.values.is_empty())); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("variable.lock", &json!({"locked": true})).unwrap(); + assert!(s.execute("variable.rename", &json!({"name": "Show", "kind": "text"})).is_err(), "refused while the variables are locked"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!((vars.variable("Show").map(|v| v.kind), vars.objects_of("Show")), (Some(vectorcraft_doc::VariableKind::Visibility), vec![r])); +} + /// `dataset.set` is how a value changes after the row exists: replacing it must not need the /// dataset to be deleted and rebuilt (which loses its place in the list and the active one). #[test] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 662af8c248..66ed0a41e3 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1262,6 +1262,14 @@ impl Session { /// is placed under ([`Session::place_queue_roots`]). fn note_place_queue_roots(&mut self) { self.place_queue_roots = crate::file_access::current(); + self.place_queue_loads = self.place_queue_loads.wrapping_add(1); + } + + /// How many times the place cursor has been loaded (`file.place.queue`, or a `queue` set on + /// the Place tool): a frontend that keeps its own state about the loaded cursor compares it to + /// tell a newer load. + pub fn place_queue_loads(&self) -> u64 { + self.place_queue_loads } /// The automation roots the place cursor's files are placed under: those in force when it diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index b02e34690b..8ea057cd12 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -823,7 +823,13 @@ fn color_settings_come_back_in_the_next_session() { a.execute("edit.colorSettings", &json!({})).unwrap(); assert!(!file.exists(), "nothing to keep before the settings change"); a.execute("edit.colorSettings", &json!({"rgb": loaded, "cmyk": cms::DEVICE_CMYK, "intent": "perceptual", "bpc": false})).unwrap(); - let chosen = cms::ColorSettings { rgb: loaded.into(), cmyk: cms::DEVICE_CMYK.into(), intent: cms::Intent::Perceptual, bpc: false }; + let chosen = cms::ColorSettings { + rgb: loaded.into(), + cmyk: cms::DEVICE_CMYK.into(), + intent: cms::Intent::Perceptual, + bpc: false, + ..cms::ColorSettings::default() + }; assert_eq!(cms::active_settings(), chosen); // The next start begins with the defaults and without the loaded profile, which the profile @@ -865,3 +871,163 @@ fn saved_color_settings_that_cannot_be_used_leave_the_defaults() { assert!(!saved.bpc && saved.cmyk == cms::GENERIC_CMYK, "written over by the next change: {saved:?}"); } } + +/// Edit › Color Settings' color management policies decide what opening a document tagged with +/// profiles other than the working spaces does with them: Preserve Embedded Profiles keeps them, +/// Off and Preserve Numbers take them off and keep the color numbers, and Convert to Working Space +/// converts the colors into the working spaces, keeping how they look, and takes them off. A +/// document a policy changed opens with unsaved changes, and the open reply names each profile. +/// A profile that is the working space stays, and File › Revert and File › New from Template apply +/// the policies too. +#[test] +fn color_management_policies_keep_take_off_or_convert_a_tagged_profile() { + let _g = settings(); + let dir = fresh_dir("policies"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + std::fs::create_dir_all(&dir).unwrap(); + let tagged = dir.join("tagged.vectorcraft"); + let mut s = session(); + let (blue, ink) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 0.0, 10.0, 10.0)); + exec(&mut s, "paint.setFill", json!({"ids": [blue.0], "color": "#3366cc"})); + exec(&mut s, "paint.setFill", json!({"ids": [ink.0], "color": {"c": 20, "m": 40, "y": 60, "k": 10}})); + exec(&mut s, "edit.assignProfile", json!({"rgb": cms::WIDE_GAMUT_RGB, "cmyk": cms::DEVICE_CMYK})); + // An artboard background, a character style's fill and a freeform gradient swatch, colors + // outside the art. + let paper = vectorcraft_color::Color::Rgb { r: 0.9, g: 0.8, b: 0.6 }; + let free = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + select(&mut s, &[free]); + exec(&mut s, "paint.setFill", json!({"gradient": {"kind": "freeform"}})); + exec(&mut s, "paint.freeform.setPoint", json!({"index": 0, "color": "#e6cc99", "stroke": false})); + { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + d.artboards[0].color = Some(paper); + let mut style = vectorcraft_doc::TextStyleDef { name: "Inked".into(), attrs: Default::default() }; + style.attrs.insert("fill".into(), serde_json::to_value(vectorcraft_color::Paint::solid(paper)).unwrap()); + d.char_styles.push(style); + let paint = d.node(free).unwrap().appearance.fill().unwrap().paint.clone(); + d.swatches.push(vectorcraft_color::Swatch { name: "Free".into(), paint, global: false, spot: false }); + } + exec(&mut s, "file.saveAs", json!({"path": tagged.to_str().unwrap()})); + let settings_colors = |s: &Session| { + let d = &s.doc().unwrap().doc; + let style = serde_json::from_value::(d.char_styles[0].attrs["fill"].clone()).unwrap(); + (d.artboards[0].color.unwrap(), style.color().unwrap()) + }; + // The first point of the freeform swatch and of the freeform fill. + let free_points = |s: &Session| { + let d = &s.doc().unwrap().doc; + let first = |p: &vectorcraft_color::Paint| match p { + vectorcraft_color::Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points[0].color), + _ => None, + }; + let swatch = d.swatches.iter().find(|w| w.name == "Free").and_then(|w| first(&w.paint)).unwrap(); + (swatch, first(&d.node(free).unwrap().appearance.fill().unwrap().paint).unwrap()) + }; + let (free0, _) = free_points(&s); + let fill = |s: &Session, id| s.doc().unwrap().doc.node(id).and_then(|n| n.appearance.fill()).and_then(|f| f.paint.color()).unwrap(); + let (blue0, ink0) = (fill(&s, blue), fill(&s, ink)); + let source = cms::for_document(Some(cms::WIDE_GAMUT_RGB), Some(cms::DEVICE_CMYK)); + let notes = |r: &serde_json::Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("profile")).count(); + // A session that opened the file under the policies, and the open reply. + let open = |rgb: &str, cmyk: &str| { + let mut s = session(); + exec(&mut s, "edit.colorSettings", json!({"rgbPolicy": rgb, "cmykPolicy": cmyk})); + let r = exec(&mut s, "document.open", json!({"path": tagged.to_str().unwrap()})); + (r, s) + }; + + let (r, kept) = open("preserveEmbedded", "preserveEmbedded"); + assert_eq!(doc_profiles(&kept.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), Some(cms::DEVICE_CMYK.into()))); + assert_eq!((fill(&kept, blue), fill(&kept, ink), notes(&r)), (blue0, ink0, 0)); + assert!(!kept.doc().unwrap().is_dirty()); + + for (rgb, cmyk) in [("off", "off"), ("off", "preserveNumbers")] { + let (r, off) = open(rgb, cmyk); + assert_eq!(doc_profiles(&off.doc().unwrap().doc), (None, None), "{cmyk}"); + assert_eq!((fill(&off, blue), fill(&off, ink), notes(&r)), (blue0, ink0, 2), "{cmyk}: the numbers stay"); + assert!(off.doc().unwrap().is_dirty(), "{cmyk}: the document changed"); + } + + let (r, converted) = open("convert", "convert"); + assert_eq!(doc_profiles(&converted.doc().unwrap().doc), (None, None)); + assert_eq!(notes(&r), 2); + let working = cms::working(); + let (blue1, ink1) = (fill(&converted, blue), fill(&converted, ink)); + assert!(blue1 != blue0 && ink1 != ink0, "the numbers change: {blue1:?} {ink1:?}"); + assert!(cms::delta_e2000(source.lab(&blue0), working.lab(&blue1)) < 1.0, "{blue0:?} → {blue1:?}"); + assert!(cms::delta_e2000(source.lab(&ink0), working.lab(&ink1)) < 3.0, "{ink0:?} → {ink1:?}"); + assert!(converted.doc().unwrap().is_dirty()); + let (board, styled) = settings_colors(&converted); + assert_eq!(board, styled, "the artboard and the style convert alike"); + assert!(board != paper && cms::delta_e2000(source.lab(&paper), working.lab(&board)) < 1.0, "{paper:?} → {board:?}"); + assert_eq!(settings_colors(&kept), (paper, paper), "kept with the profile"); + let (swatch_point, fill_point) = free_points(&converted); + assert_eq!(swatch_point, fill_point, "the freeform swatch and fill convert alike"); + assert!(swatch_point != free0 && cms::delta_e2000(source.lab(&free0), working.lab(&swatch_point)) < 1.0, "{free0:?} → {swatch_point:?}"); + + // A color mode asked for on open converts the colors the policy converted, through the working + // spaces, also when the document in front draws through another profile. + let mut s2 = session(); + exec(&mut s2, "edit.assignProfile", json!({"rgb": cms::DISPLAY_P3})); + exec(&mut s2, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "convert"})); + exec(&mut s2, "document.open", json!({"path": tagged.to_str().unwrap(), "colorMode": "cmyk"})); + let intent = working.settings().intent; + let want = working.convert(&blue1, cms::Model::Cmyk, intent); + let got = fill(&s2, blue); + assert!(cms::delta_e2000(working.lab(&want), working.lab(&got)) < 0.01, "{want:?} vs {got:?}"); + + // A profile that is the working space is kept whatever the policy. + exec(&mut s, "edit.colorSettings", json!({"rgb": cms::WIDE_GAMUT_RGB})); + let (r, same) = open("off", "off"); + assert_eq!(doc_profiles(&same.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), None)); + assert_eq!(notes(&r), 1); + exec(&mut s, "edit.colorSettings", json!({"rgb": cms::SRGB})); + + // File › Revert reads the file again under the policies. + let (_, mut reverted) = open("preserveEmbedded", "preserveEmbedded"); + exec(&mut reverted, "paint.setFill", json!({"ids": [blue.0], "color": "#ff0000"})); + exec(&mut reverted, "edit.colorSettings", json!({"cmykPolicy": "preserveNumbers"})); + let r = exec(&mut reverted, "file.revert", json!({"confirmed": true})); + assert_eq!(doc_profiles(&reverted.doc().unwrap().doc), (Some(cms::WIDE_GAMUT_RGB.into()), None)); + assert_eq!((fill(&reverted, blue), notes(&r)), (blue0, 1)); + + // File › New from Template applies the policies as opening does. + let mut from_template = session(); + exec(&mut from_template, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "off"})); + let r = exec(&mut from_template, "file.newFromTemplate", json!({"path": tagged.to_str().unwrap()})); + assert_eq!(doc_profiles(&from_template.doc().unwrap().doc), (None, None)); + assert_eq!(notes(&r), 2); + assert_eq!(fill(&from_template, ink), ink0, "Off keeps the CMYK numbers"); + let blue2 = fill(&from_template, blue); + assert!(blue2 != blue0 && cms::delta_e2000(source.lab(&blue0), working.lab(&blue2)) < 1.0, "{blue0:?} → {blue2:?}"); + assert!(from_template.doc().unwrap().is_dirty()); +} + +/// The policies are checked, come back in the reply and in `Color Settings.json`, and a saved file +/// that gives the RGB policy Preserve Numbers, a CMYK policy only, leaves the defaults. +#[test] +fn color_management_policies_are_checked_and_kept() { + let _g = settings(); + let dir = fresh_dir("policies-kept"); + let _reset = ColorSettingsReset { profile: None, paths: vec![dir.clone()] }; + let file = dir.join("Color Settings.json"); + let mut s = session(); + s.attach_color_settings(file.clone()); + for bad in [json!({"rgbPolicy": "preserveNumbers"}), json!({"cmykPolicy": "keep"}), json!({"rgbPolicy": 1})] { + assert!(s.execute("edit.colorSettings", &bad).is_err(), "{bad}"); + } + assert!(!file.exists(), "a refused change keeps nothing"); + let r = exec(&mut s, "edit.colorSettings", json!({"rgbPolicy": "convert", "cmykPolicy": "preserveNumbers"})); + assert_eq!((r["rgbPolicy"].as_str(), r["cmykPolicy"].as_str()), (Some("convert"), Some("preserveNumbers"))); + let saved: cms::ColorSettings = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap(); + assert_eq!((saved.rgb_policy, saved.cmyk_policy), (cms::Policy::Convert, cms::Policy::PreserveNumbers)); + cms::set_active(&cms::ColorSettings::default()).unwrap(); + std::fs::write( + &file, + json!({"rgb": cms::SRGB, "cmyk": cms::GENERIC_CMYK, "intent": "relative", "bpc": true, "rgbPolicy": "preserveNumbers"}).to_string(), + ) + .unwrap(); + let mut next = Session::new(); + next.attach_color_settings(file); + assert_eq!(cms::active_settings(), cms::ColorSettings::default()); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 46e8561f4c..8de519fa4e 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1672,6 +1672,9 @@ fn action_set(bytes: &[u8]) -> Result<(), TestCaseError> { prop_assert!(actions.iter().all(|a| a["steps"].as_array().map_or(0, Vec::len) <= 10_000)); prop_assert!(actions.iter().map(|a| a["steps"].as_array().map_or(0, Vec::len)).sum::() <= 100_000); prop_assert!(r["unread"].as_array().map_or(0, Vec::len) <= 1000); + let steps = || actions.iter().flat_map(|a| a["steps"].as_array().cloned().unwrap_or_default()); + prop_assert!(steps().all(|s| s["values"].as_array().map_or(0, Vec::len) as u64 <= s["params"].as_u64().unwrap_or(0))); + prop_assert!(steps().map(|s| s["values"].as_array().map_or(0, Vec::len)).sum::() <= 100_000); } Ok(()) } @@ -1690,6 +1693,8 @@ fn arb_aia_token() -> impl Strategy> { "/localizedName", "/isOn", "/value", + "/key", + "/type", "/x", "/version", ]) @@ -1699,6 +1704,7 @@ fn arb_aia_token() -> impl Strategy> { "[a-z ()\\\\]{0,12}".prop_map(|t| format!("({t})").into_bytes()), prop::sample::select(vec!["(", ")", "\\", "[ 2 4142 ]", "[ 3 41 4", "[ 1 zz ]", "[", "]", "< 2 0a0b >", "<", ">", "2.5"]) .prop_map(|t| t.as_bytes().to_vec()), + prop::sample::select(vec!["[ 2 c328 ]", "(ustring)", "(boolean)"]).prop_map(|t| t.as_bytes().to_vec()), prop::sample::select(vec!["{", "}", "\r\n", "\r", "\n"]).prop_map(|t| t.as_bytes().to_vec()), prop::sample::select(vec![vec![0u8], vec![0xFF, 0xFE], vec![0xC3]]), ] @@ -1835,6 +1841,7 @@ proptest! { /// Mutated copies of the PSD and PSB samples never crash opening them with their layers, hidden /// ones too: the PSD's layer records, channel lengths and data, and the PSB's layer count. /// (Masks, clipping and groups are covered by `tests_psdlayers::damaged_layers_never_panic`.) + /// Neither does `document.psdInfo`, nor placing them with their layers as objects. #[test] fn mutated_layered_psds_open_without_panics(which in 6usize..8, cut in 0usize..1_000_000, edits in prop::collection::vec((0usize..1_000_000, any::()), 0..12)) { let (name, mut bytes) = raster_samples().swap_remove(which); @@ -1849,6 +1856,10 @@ proptest! { let mut s = vectorcraft_engine::Session::new(); let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "hiddenLayers": true}); let _ = no_panic(s.execute("document.open", &p)); + let _ = no_panic(s.execute("document.psdInfo", &p)); + let _ = no_panic(s.execute("file.new", &json!({}))); + let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes), "layers": true, "hiddenLayers": true}); + let _ = no_panic(s.execute("file.place", &p)); }); prop_assert!(r.is_ok(), "{name}: panicked: {:?}", r.err()); } diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 609e0aedee..f27e646791 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -274,6 +274,7 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re // The objects that may keep their effects in an art style, as they are before baking (not on // a large canvas, whose scale the styles' strokes and effects would need). let styled = writer::styled_objects(doc); + let (symbol_styled, pattern_styled) = writer::styled_definitions(doc); let own_styles = doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles; let links = writer::style_links(doc); // Live geometry effects as their result, as the PDF part draws them. @@ -311,16 +312,14 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re w.reserve_swatch_gradients(); if scale == 1.0 { w.styled = styled; + (w.symbol_styled, w.pattern_styled) = (symbol_styled, pattern_styled); w.panel_styles(); - } else if !styled.is_empty() || own_styles || links > 0 { + } else if !styled.is_empty() || !symbol_styled.is_empty() || !pattern_styled.is_empty() || own_styles || links > 0 { w.warnings.push(writer::LARGE_CANVAS_STYLES.to_string()); } for layer in &doc.layers { w.layer(layer); } - if scale == 1.0 { - w.lost_links(links); - } // The symbols no instance uses too; their art adds definitions. w.symbols(); // The brushes, once the layers have said whether an object is written with one. @@ -328,6 +327,10 @@ fn export_within(doc: &Document, o: &AiEditingOptions, max_objects: usize) -> Re // The palette first: its gradient and pattern swatches add definitions. let mut palette = String::new(); w.palette(&mut palette); + // Once every definition is written: their opacity masks' art keeps links too. + if scale == 1.0 { + w.lost_links(links); + } let library = w.variable_library(); let first = to_art.transform_rect_bbox(board); let art = doc diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 1323ea950b..671f639327 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -1038,7 +1038,8 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { /// The documents [`the_writer_counts_objects_as_the_reader_reads_them`] writes: groups, compound /// paths, guides, sublayers, clipping groups and layers, painted clipping paths, opacity masks, /// gradients, pattern definitions and tiles, images, type, symbol definitions and instances, -/// objects with art styles, objects bound to variables and live envelopes. +/// objects with art styles, objects bound to variables, live envelopes, and the rectangles of the +/// bounds of wrapped objects written as their art. fn counted() -> Vec<(&'static str, Document)> { vec![ ("sample", sample()), @@ -1054,9 +1055,28 @@ fn counted() -> Vec<(&'static str, Document)> { ("blend", blend_doc()), ("brushes", brushes_document()), ("envelopes", envelopes_document()), + ("wrap bounds", wrap_bounds_doc()), ] } +/// Area type below a wrapped symbol instance written as its art and wrapped type written as its +/// outlines, each with the rectangle of its bounds. +fn wrap_bounds_doc() -> Document { + let mut doc = over_area_type(|doc| { + two_leaves(doc); + let mut n = instance(doc, "Leaf", Affine::new([3.0, 0.0, 0.0, 3.0, 150.0, 100.0])); + n.isolate = true; + wrapped(n, 4.5, false) + }); + let mut label = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Ill", style(60.0, Color::BLACK)); + // Mirrored, so written as its outlines. + label.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 150.0, 120.0]); + let label = wrapped(text_node(&mut doc, label), -2.0, true); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, label).unwrap(); + doc +} + /// A blend of two squares 20 pt apart with a step every 10 pt, written as its steps. fn blend_doc() -> Document { let mut doc = Document::new(200.0, 100.0); @@ -6761,6 +6781,159 @@ fn hidden_and_nested_mask_art_keep_live_offset_path() { } } +/// An image 60 × 40 points at (10, 10) whose opacity mask, of `art`, doesn't clip. +fn masked_image_node(doc: &mut Document, art: Node) -> Node { + let im = image_node(doc, ImageBlob::png(rgb_png()), (3, 2), Affine::new([60.0, 0.0, 0.0, 40.0, 10.0, 10.0]), None); + with_mask(im, art) +} + +/// A red square from (10, 10) to (70, 50) whose opacity mask, of `art`, doesn't clip. +fn masked_square(doc: &mut Document, art: Node) -> Node { + let sq = square(doc, Rect::new(10.0, 10.0, 70.0, 50.0), Color::rgb(1.0, 0.0, 0.0)); + with_mask(sq, art) +} + +/// `n` with an opacity mask of `art` that doesn't clip. +fn with_mask(mut n: Node, art: Node) -> Node { + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true }.into()); + n +} + +/// The opacity mask art of the first object with one in `n`, its masks' art included. +fn mask_art_in(n: &Node) -> Option> { + let mut art = None; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + if art.is_none() + && let Some(mask) = m.extras().mask.as_ref() + { + art = Some(mask.art.clone()); + } + }); + art +} + +/// The Offset Path distance of `n`'s one live effect, when that is all it has. +fn offset_path_of(n: &Node) -> Option { + match n.appearance.effects.as_slice() { + [e] if e.id == "path.offsetPath" => e.params.get("offset").and_then(serde_json::Value::as_f64), + _ => None, + } +} + +/// The opacity mask art of an image in a symbol's art keeps its live effects as an art style: a +/// black path with Offset Path reads back with the effect live on the definition's mask art, when +/// the mask's art root is hidden and when the symbol is used inside another symbol's art too, and +/// both instances draw as they did. +#[test] +fn a_symbols_mask_art_keeps_live_offset_path() { + let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0})); + let made = |effects: Vec, case: &str| { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, effects); + grown.visible = case != "hidden"; + let im = masked_image_node(&mut doc, grown); + doc.symbols.push(Symbol { dynamic: false, name: "Picture".into(), art: Arc::new(im) }); + let mut used = "Picture"; + if case == "nested" { + let inner = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Picture".into(), xf: Affine::translate((5.0, 0.0)) }); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::rgb(1.0, 0.0, 0.0)); + let frame = group(&mut doc, vec![inner, dot], false); + doc.symbols.push(Symbol { dynamic: false, name: "Frame".into(), art: Arc::new(frame) }); + used = "Frame"; + } + for x in [0.0, 100.0] { + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: used.into(), xf: Affine::translate((x, 20.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + } + doc + }; + for case in ["shown", "hidden", "nested"] { + let doc = made(vec![offset.clone()], case); + assert!(pixels_apart(&doc, &made(vec![], case)) > 0, "{case}: the effect draws nothing"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{case}: {:?}", out.warnings); + let back = read_back(&doc); + let picture = back.symbols.iter().find(|s| s.name == "Picture").unwrap_or_else(|| panic!("{case}: {:?}", back.symbols)); + let art = mask_art_in(&picture.art).unwrap_or_else(|| panic!("{case}: no masked image in {:?}", picture.art.kind)); + assert_eq!(offset_path_of(&art), Some(6.0), "{case}: {:?}", art.appearance.effects); + assert_eq!(pixels_apart(&doc, &back), 0, "{case}"); + } +} + +/// The opacity mask art of a square in a pattern's tile keeps its live effects as an art style: the +/// tile's black path with Offset Path reads back with the effect live, and the pattern fill draws as +/// it did. +#[test] +fn a_pattern_tiles_mask_art_keeps_live_offset_path() { + let offset = fx("path.offsetPath", serde_json::json!({"offset": 6.0})); + let made = |effects: Vec| { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, effects); + let sq = masked_square(&mut doc, grown); + let tile = Rect::new(0.0, 0.0, 80.0, 60.0); + doc.patterns.push(vectorcraft_doc::PatternDef { tile, ..vectorcraft_doc::PatternDef::new("Masked", vec![Arc::new(sq)]) }); + let fill = Paint::Pattern { pattern: "Masked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + doc + }; + let doc = made(vec![offset]); + assert!(pixels_apart(&doc, &made(vec![])) > 0, "the effect draws nothing"); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + let def = back.pattern("Masked").unwrap_or_else(|| panic!("{:?}", back.patterns.iter().map(|p| &p.name).collect::>())); + let art = def.art.iter().find_map(|a| mask_art_in(a)).expect("a masked image in the tile"); + assert_eq!(offset_path_of(&art), Some(6.0), "{:?}", art.appearance.effects); + assert_eq!(pixels_apart(&doc, &back), 0); +} + +/// Each opacity mask's art keeps its own art style: masks on the layers, in a symbol's art and in a +/// pattern's tile with different Offset Path distances read back with their own distances, and a +/// symbol's mask art with an effect no art style holds (Roughen) reads back without a live effect. +#[test] +fn mask_art_styles_stay_with_their_own_art() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let masked = |doc: &mut Document, effect: vectorcraft_doc::Effect| { + let grown = styled_rect(doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, vec![effect]); + masked_image_node(doc, grown) + }; + let by = |d: f64| fx("path.offsetPath", serde_json::json!({"offset": d})); + let on_layer = masked(&mut doc, by(3.0)); + doc.insert(Some(layer), usize::MAX, on_layer).unwrap(); + let picture = masked(&mut doc, by(6.0)); + doc.symbols.push(Symbol { dynamic: false, name: "Picture".into(), art: Arc::new(picture) }); + let rough = masked(&mut doc, fx("distort.roughen", serde_json::json!({"size": 6.0, "relative": false, "points": "corner"}))); + doc.symbols.push(Symbol { dynamic: false, name: "Rough".into(), art: Arc::new(rough) }); + for (symbol, x) in [("Picture", 100.0), ("Rough", 120.0)] { + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf: Affine::translate((x, 40.0)) }); + doc.insert(Some(layer), usize::MAX, instance).unwrap(); + } + let grown = styled_rect(&mut doc, "Grown", Rect::new(25.0, 20.0, 45.0, 40.0), solid(Color::BLACK), Paint::None, vec![by(9.0)]); + let tile = masked_square(&mut doc, grown); + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Masked", vec![Arc::new(tile)]) + }); + let fill = Paint::Pattern { pattern: "Masked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 60.0, 80.0, 100.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let back = read_back(&doc); + let symbol_art = |name: &str| { + let s = back.symbols.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("{name}: {:?}", back.symbols)); + mask_art_in(&s.art).unwrap_or_else(|| panic!("{name}: no masked image")) + }; + assert_eq!(offset_path_of(&image_mask_art(&back)), Some(3.0)); + assert_eq!(offset_path_of(&symbol_art("Picture")), Some(6.0)); + let tile = back.pattern("Masked").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("a masked image in the tile"); + assert_eq!(offset_path_of(&tile), Some(9.0)); + let rough = symbol_art("Rough"); + assert!(rough.appearance.effects.is_empty(), "{:?}", rough.appearance.effects); +} + /// A geometry effect on an opacity mask's art that no art style holds (Roughen) is applied to the /// art written, so the mask reads back drawing what it drew, but for pixels on its edge that the /// written points' rounding moves. @@ -6900,6 +7073,142 @@ fn a_filled_even_odd_object_is_written_as_its_drawn_look() { assert_eq!(n.extras().graphic_style, None); } +/// Links to graphic styles in pattern tiles count toward the warning about lost links: a tile's +/// object linked to a style, which is written without its link, warns; and so does an object on the +/// layers that loses its link while a tile's opacity mask art keeps its own. +#[test] +fn pattern_tile_style_links_count_toward_lost_links() { + let made = |linked_tile: bool| { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap.clone()); + linked.extras_mut().graphic_style = Some(7); + let art = if linked_tile { + linked + } else { + // An even-odd path is written without its link (see the test above). + let mut lost = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 140.0, 40.0)), ap); + lost.extras_mut().graphic_style = Some(7); + if let NodeKind::Path { rule, .. } = &mut lost.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, lost); + masked_square(&mut doc, linked) + }; + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Linked", vec![Arc::new(art)]) + }); + let fill = Paint::Pattern { pattern: "Linked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 80.0, 100.0)), Appearance::basic(fill, Paint::None, 0.0)); + put(&mut doc, filled); + doc + }; + for linked_tile in [true, false] { + let doc = made(linked_tile); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{linked_tile}: {:?}", out.warnings); + } + let back = read_back(&made(false)); + let tile = back.pattern("Linked").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("a masked square in the tile"); + assert!(tile.extras().graphic_style.is_some(), "the tile's mask art keeps its link"); +} + +/// An opacity mask's art linked to a graphic style reads back linked to it. +#[test] +fn opacity_mask_art_keeps_its_graphic_style_link() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap); + linked.extras_mut().graphic_style = Some(7); + let sq = masked_square(&mut doc, linked); + put(&mut doc, sq); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let art = mask_art_in(&back.layers[0]).expect("a masked square"); + let id = art.extras().graphic_style.expect("the mask's art keeps its link"); + assert_eq!(back.graphic_style_by_id(id).map(|g| g.name.as_str()), Some("Solid")); +} + +/// A link written in a pattern tile's opacity mask art that the object limit then leaves out with +/// the rest of the tile is not counted as kept: at every limit where the style isn't written, the +/// export warns that the link is lost. +#[test] +fn mask_art_links_left_out_at_the_object_limit_are_reported_lost() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let mut linked = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap); + linked.extras_mut().graphic_style = Some(7); + let mut art = vec![Arc::new(masked_square(&mut doc, linked))]; + for i in 0..6 { + let x = 2.0 + 12.0 * f64::from(i); + art.push(Arc::new(square(&mut doc, Rect::new(x, 52.0, x + 8.0, 58.0), Color::rgb(0.0, 0.0, 1.0)))); + } + doc.patterns.push(vectorcraft_doc::PatternDef { tile: Rect::new(0.0, 0.0, 80.0, 60.0), ..vectorcraft_doc::PatternDef::new("Linked", art) }); + let fill = Paint::Pattern { pattern: "Linked".into(), xf: Affine::IDENTITY }; + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 60.0)), Appearance::basic(fill, Paint::None, 0.0)); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let mut left_out = 0; + for max in 1..40 { + let out = export_within(&doc, &opts(), max).unwrap(); + let text = String::from_utf8_lossy(&out.program).into_owned(); + if !text.contains("(Solid) XW") { + left_out += 1; + assert!(out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{max}: {:?}", out.warnings); + } + } + assert!(left_out > 0 && left_out < 39, "{left_out}"); +} + +/// The links of opacity masks' art in a symbol no instance uses and in a pattern only the Swatches +/// panel holds are written with those definitions, after the layers, and count as kept: no warning +/// about lost links. +#[test] +fn mask_art_links_in_unused_definitions_are_not_reported_lost() { + let mut doc = Document::new(200.0, 100.0); + let ap = Appearance::basic(black_fill(), Paint::None, 0.0); + let mut g = vectorcraft_doc::GraphicStyle::new("Solid", ap.clone()); + g.id = 7; + doc.graphic_styles.push(g); + let linked = |doc: &mut Document| { + let mut n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(25.0, 20.0, 45.0, 40.0)), ap.clone()); + n.extras_mut().graphic_style = Some(7); + masked_square(doc, n) + }; + let unused = linked(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "Unused".into(), art: Arc::new(unused) }); + let tile = linked(&mut doc); + doc.patterns.push(vectorcraft_doc::PatternDef { + tile: Rect::new(0.0, 0.0, 80.0, 60.0), + ..vectorcraft_doc::PatternDef::new("Swatch only", vec![Arc::new(tile)]) + }); + let swatch = Swatch { + name: "Swatch only".into(), + paint: Paint::Pattern { pattern: "Swatch only".into(), xf: Affine::IDENTITY }, + global: false, + spot: false, + }; + swatches_plus(&mut doc, vec![swatch]); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::STYLE_LINKS), "{:?}", out.warnings); + let back = read_back(&doc); + let symbol = back.symbols.iter().find(|s| s.name == "Unused").and_then(|s| mask_art_in(&s.art)).expect("the unused symbol's mask art"); + assert!(symbol.extras().graphic_style.is_some(), "the symbol's mask art keeps its link"); + let tile = back.pattern("Swatch only").and_then(|p| p.art.iter().find_map(|a| mask_art_in(a))).expect("the swatch's mask art"); + assert!(tile.extras().graphic_style.is_some(), "the pattern's mask art keeps its link"); +} + /// A compound path filled by the non-zero rule keeps its effects, its gradient fill and its link to /// a graphic style. #[test] @@ -11085,6 +11394,42 @@ fn text_wrap_options_of_type_and_envelopes_are_read_back() { same_wraps(&wraps(top), &[(Some("Bent".into()), 2.5, true)]); } +/// The wrap shapes that `doc`'s first area type on its first layer takes from the objects above it, +/// in the type's own space: the bounds of each one's subpaths, and its options. +fn area_wrap_region(doc: &Document) -> Vec<(Vec<[f64; 4]>, f64, bool)> { + use vectorcraft_doc::TextKind; + let mut doc = doc.clone(); + vectorcraft_doc::text::resolve_wrap(None, &mut doc); + let top = doc.layers[0].children().unwrap(); + let Some(NodeKind::Text(t)) = top.iter().map(|n| &n.kind).find(|k| matches!(k, NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }))) + else { + panic!("no area type"); + }; + t.wrap + .iter() + .map(|s| { + let parts = s + .path + .subpaths + .iter() + .map(|sp| { + let b = PathData::new(vec![sp.clone()]).bounds().unwrap_or_default(); + [b.x0, b.y0, b.x1, b.y1] + }) + .collect(); + (parts, s.wrap.offset, s.wrap.invert) + }) + .collect() +} + +/// `before` and `after` hold as many wrap shapes, each with as many subpaths, with bounds within +/// `1e-3`, and the same options. +fn same_region(before: &[(Vec<[f64; 4]>, f64, bool)], after: &[(Vec<[f64; 4]>, f64, bool)]) -> bool { + let close = + |b: &[[f64; 4]], a: &[[f64; 4]]| b.len() == a.len() && b.iter().zip(a).all(|(b, a)| b.iter().zip(a).all(|(x, y)| (x - y).abs() < 1e-3)); + before.len() == after.len() && before.iter().zip(after).all(|(b, a)| close(&b.0, &a.0) && (b.1 - a.1).abs() < 1e-6 && b.2 == a.2) +} + /// Type written as its outlines wraps area type below it by its bounds, as it did before saving, /// not by its glyphs: its text wrap options go on an unpainted rectangle of its bounds, the last /// member of the outlines' group, which keeps the type's name and has no options of its own. @@ -11175,6 +11520,276 @@ fn type_written_as_outlines_keeps_wrapping_by_its_bounds() { assert_eq!(top[1].name.as_deref(), Some("Label"), "{text_out}"); } +/// A 300 × 200 document of area type "Body" filling the page, with `top` above it. +fn over_area_type(make: impl FnOnce(&mut Document) -> Node) -> Document { + use vectorcraft_doc::{TextKind, TextObject, TextRun}; + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut body = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK)); + body.runs = vec![TextRun::new("one two three four five six seven eight nine ten eleven twelve", style(10.0, Color::BLACK))]; + body.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 290.0, 190.0)) }; + body.xf = Affine::IDENTITY; + let body = named(text_node(&mut doc, body), "Body"); + let top = make(&mut doc); + for n in [body, top] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + doc +} + +/// Symbol "Leaf": two squares in opposite corners of the 20 × 20 square around the origin, apart. +fn two_leaves(doc: &mut Document) { + let a = square(doc, Rect::new(-10.0, -10.0, -6.0, -6.0), LEAF); + let b = square(doc, Rect::new(6.0, 6.0, 10.0, 10.0), LEAF); + let art = group(doc, vec![a, b], false); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); +} + +/// A symbol instance written as its art (its isolation is a group's), a blend and an intertwine, +/// each written as a group of its art, wrap area type below them by their bounds after reopening, +/// as they did before saving, not by the members of that art: the text wrap options go on an +/// unpainted rectangle of the bounds, the group's first member, and the group, which keeps the +/// object's name, isolation, lock and visibility, has none. A positive offset, a negative offset +/// with Invert Wrap, and hidden and locked. +#[test] +fn live_art_written_as_a_group_keeps_wrapping_by_its_bounds() { + let instance_of = |offset: f64, invert: bool, visible: bool, locked: bool| { + move |doc: &mut Document| { + two_leaves(doc); + let mut n = instance(doc, "Leaf", Affine::new([3.0, 0.0, 0.0, 3.0, 150.0, 100.0])); + n.isolate = true; + (n.visible, n.locked) = (visible, locked); + wrapped(named(n, "Thing"), offset, invert) + } + }; + let blend = |doc: &mut Document| { + let keys = vec![ + Arc::new(square(doc, Rect::new(60.0, 80.0, 70.0, 90.0), Color::gray(0.5))), + Arc::new(square(doc, Rect::new(220.0, 110.0, 230.0, 120.0), Color::gray(0.5))), + ]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(60.0), ..Default::default() }; + wrapped(named(Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }), "Thing"), 4.5, false) + }; + let intertwine = |doc: &mut Document| { + let a = Arc::new(square(doc, Rect::new(60.0, 60.0, 80.0, 80.0), Color::gray(0.5))); + let b = Arc::new(square(doc, Rect::new(200.0, 120.0, 220.0, 140.0), Color::gray(0.5))); + wrapped(named(Node::new(doc.alloc_id(), NodeKind::Intertwine { children: vec![a, b] }), "Thing"), 2.0, true) + }; + let repeat = |doc: &mut Document| { + let a = Arc::new(square(doc, Rect::new(100.0, 80.0, 110.0, 90.0), Color::gray(0.5))); + let spec = vectorcraft_doc::RepeatSpec::grid(vec![a], 8.0, 12.0); + wrapped(named(Node::new(doc.alloc_id(), NodeKind::Repeat(spec)), "Thing"), 1.5, false) + }; + // A Fidelity that isn't a whole number: written as its distorted art. + let envelope = |doc: &mut Document| { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + wrapped(named(envelope_node(doc, kind, 50.5, false, EnvelopeOptions::NEW), "Thing"), -3.0, true) + }; + let docs = [ + ("instance", over_area_type(instance_of(4.5, false, true, false)), 4.5, false, true, false), + ("instance, inverted", over_area_type(instance_of(-2.0, true, true, false)), -2.0, true, true, false), + ("instance, hidden and locked", over_area_type(instance_of(3.0, false, false, true)), 3.0, false, false, true), + ("blend", over_area_type(blend), 4.5, false, true, false), + ("intertwine", over_area_type(intertwine), 2.0, true, true, false), + ("repeat", over_area_type(repeat), 1.5, false, true, false), + ("envelope", over_area_type(envelope), -3.0, true, true, false), + ]; + for (name, doc, offset, invert, visible, locked) in docs { + let thing = &doc.layers[0].children().unwrap()[1]; + let want = thing.geometric_bounds().unwrap(); + let text_out = program(&doc); + let back = read_back(&doc); + let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back)); + assert_eq!(before.len(), usize::from(visible), "{name}"); + assert!(same_region(&before, &after), "{name}\n{before:?}\n{after:?}\n{text_out}"); + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{name}: {text_out}"); + let top = back.layers[0].children().unwrap(); + same_wraps(&wraps(top), &[(None, offset, invert)]); + let g = &top[1]; + assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{name}: {text_out}"); + assert_eq!( + (g.name.as_deref(), g.extras().wrap, g.visible, g.locked, g.isolate), + (Some("Thing"), None, visible, locked, thing.isolate), + "{name}" + ); + let first = &g.children().unwrap()[0]; + assert!(matches!(first.kind, NodeKind::Path { guide: false, .. }) && first.appearance.items.is_empty(), "{name}: {text_out}"); + assert_eq!((first.visible, first.locked), (visible, locked), "{name}"); + let b = first.geometric_bounds().unwrap(); + assert!([b.x0 - want.x0, b.y0 - want.y0, b.x1 - want.x1, b.y1 - want.y1].iter().all(|d| d.abs() < 1e-3), "{name}: {b:?} {want:?}"); + } + + // An envelope written live opens live and wraps by its bounds, its options its own. + let live = |doc: &mut Document| { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + wrapped(named(envelope_node(doc, kind, 50.0, false, EnvelopeOptions::NEW), "Thing"), -3.0, true) + }; + let doc = over_area_type(live); + let text_out = program(&doc); + let back = read_back(&doc); + let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back)); + assert_eq!(before.len(), 1); + assert!(same_region(&before, &after), "{before:?}\n{after:?}\n{text_out}"); + let top = back.layers[0].children().unwrap(); + assert!(matches!(top[1].kind, NodeKind::Envelope { .. }), "{text_out}"); + same_wraps(&wraps(top), &[(Some("Thing".into()), -3.0, true)]); +} + +/// The rectangle that keeps a blend written as its art wrapping by its bounds counts toward the +/// object limit, ahead of the art: one object short, the last step is left out with the limit's +/// warning; without room for the rectangle, the options are left out with the warning, and the +/// group does not take them. In an instance whose art holds area type, shown, hidden, in a hidden +/// group or written as its outlines, the rectangle is the group's first member, below that type: +/// area type below the instance wraps by its bounds after reopening, and the type in its art is not +/// wrapped by them. +#[test] +fn wrap_bounds_of_live_art_fit_the_object_limit_and_leave_area_type_in_the_art_unwrapped() { + use vectorcraft_doc::{TextKind, TextObject, TextRun}; + // A blend alone, without a symbol definition, so the objects are the group, the rectangle and + // the steps. + let mut doc = Document::new(300.0, 200.0); + let keys = vec![ + Arc::new(square(&mut doc, Rect::new(60.0, 80.0, 70.0, 90.0), Color::gray(0.5))), + Arc::new(square(&mut doc, Rect::new(220.0, 110.0, 230.0, 120.0), Color::gray(0.5))), + ]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(60.0), ..Default::default() }; + let thing = wrapped(named(Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }), "Thing"), 4.5, false); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, thing).unwrap(); + let steps = export_ai_editing(&doc, &opts()).unwrap(); + let steps = crate::ai_alone(&steps.program).unwrap().0.layers[0].children().unwrap()[0].children().unwrap().len() - 1; + let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap(); + let all = export_within(&doc, &opts(), read).unwrap(); + assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings); + let out = export_within(&doc, &opts(), read - 1).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + let text_out = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}"); + let (back, _) = crate::ai_alone(&out.program).unwrap(); + let top = back.layers[0].children().unwrap(); + same_wraps(&wraps(top), &[(None, 4.5, false)]); + assert_eq!((top[0].name.as_deref(), top[0].extras().wrap), (Some("Thing"), None), "{text_out}"); + let members = top[0].children().unwrap(); + assert_eq!(members.len(), steps, "the rectangle and all but the last step: {text_out}"); + assert!(matches!(members[0].kind, NodeKind::Path { guide: false, .. }) && members[0].appearance.items.is_empty(), "{text_out}"); + let out = export_within(&doc, &opts(), read - steps - 1).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings); + let text_out = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + assert!(!text_out.contains("(AI11TextWrapOffset)"), "{text_out}"); + let (back, _) = crate::ai_alone(&out.program).unwrap(); + assert!(wraps(back.layers[0].children().unwrap()).is_empty(), "{text_out}"); + + // Area type in the symbol's art, shown or hidden, of `words`, in a hidden group with `in_hidden`. + let make = |shown: bool, words: &'static str, in_hidden: bool| { + move |doc: &mut Document| { + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(2.0, Color::BLACK)); + t.runs = vec![TextRun::new(words, style(2.0, Color::BLACK))]; + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(-10.0, -10.0, -6.0, -6.0)) }; + t.xf = Affine::IDENTITY; + let mut t = text_node(doc, t); + t.visible = shown; + if in_hidden { + t = group(doc, vec![t], false); + t.visible = false; + } + let s = square(doc, Rect::new(6.0, 6.0, 10.0, 10.0), LEAF); + let art = group(doc, vec![t, s], false); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); + let mut n = instance(doc, "Leaf", Affine::new([3.0, 0.0, 0.0, 3.0, 150.0, 100.0])); + n.isolate = true; + wrapped(named(n, "Thing"), 4.5, false) + } + }; + // Type of spaces alone is written as its outlines, which are not type. + for (what, shown, words, in_hidden) in + [("shown", true, "leaf", false), ("hidden", false, "leaf", false), ("in a hidden group", true, "leaf", true), ("spaces", true, " ", false)] + { + let doc = over_area_type(make(shown, words, in_hidden)); + let text_out = program(&doc); + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{what}: {text_out}"); + let back = read_back(&doc); + let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back)); + assert_eq!(before.len(), 1, "{what}"); + assert!(same_region(&before, &after), "{what}\n{before:?}\n{after:?}\n{text_out}"); + let g = &back.layers[0].children().unwrap()[1]; + assert!(matches!(g.kind, NodeKind::Group { clip: false, .. }), "{what}: {text_out}"); + assert_eq!(g.extras().wrap, None, "{what}: {text_out}"); + let first = &g.children().unwrap()[0]; + assert!(matches!(first.kind, NodeKind::Path { guide: false, .. }) && first.appearance.items.is_empty(), "{what}: {text_out}"); + let mut resolved = back.clone(); + vectorcraft_doc::text::resolve_wrap(None, &mut resolved); + let mut inner = vec![]; + resolved.layers[0].children().unwrap()[1].walk(&mut |m| { + if let NodeKind::Text(t) = &m.kind { + inner.push((matches!(t.kind, TextKind::Area { .. }), m.visible, t.wrap.len())); + } + }); + let want: &[(bool, bool, usize)] = match what { + "shown" | "in a hidden group" => &[(true, true, 0)], + "hidden" => &[(true, false, 0)], + _ => &[], + }; + assert_eq!(inner, want, "{what}: {text_out}"); + } +} + +/// A blend with no keys, written as an empty group, has no bounds for a rectangle: its group keeps +/// the text wrap options, and they read back. +#[test] +fn live_art_without_bounds_keeps_its_text_wrap_on_the_group() { + let mut doc = Document::new(300.0, 200.0); + let spec = vectorcraft_doc::BlendSpec::default(); + let thing = wrapped(named(Node::new(doc.alloc_id(), NodeKind::Blend { children: vec![], spec: Box::new(spec) }), "Thing"), 4.5, true); + assert_eq!(thing.geometric_bounds(), None); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, thing).unwrap(); + let text_out = program(&doc); + assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}"); + let back = read_back(&doc); + same_wraps(&wraps(back.layers[0].children().unwrap()), &[(Some("Thing".into()), 4.5, true)]); +} + +/// A symbol instance written as its art, whose art holds an envelope of area type beside a square, +/// wraps area type below it by its bounds after reopening. The envelope there is written as its art, +/// after the type written for it as a live envelope is taken back: the group has no text wrap +/// options, and its first member is the rectangle of the bounds. +#[test] +fn an_envelope_of_area_type_in_written_art_leaves_the_wrap_on_the_bounds() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + use vectorcraft_doc::{TextKind, TextObject, TextRun}; + let doc = over_area_type(|doc| { + let kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: envelope_points(2, 3), handles: vec![] }; + let mut envelope = envelope_node(doc, kind, 50.0, false, EnvelopeOptions::NEW); + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK)); + t.runs = vec![TextRun::new("bent", style(10.0, Color::BLACK))]; + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(60.0, 30.0, 140.0, 70.0)) }; + t.xf = Affine::IDENTITY; + let t = text_node(doc, t); + let NodeKind::Envelope { content, .. } = &mut envelope.kind else { unreachable_envelope() }; + content.push(Arc::new(t)); + let s = square(doc, Rect::new(250.0, 150.0, 260.0, 160.0), LEAF); + let art = group(doc, vec![envelope, s], false); + doc.symbols.push(Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) }); + let mut n = instance(doc, "Bent", Affine::IDENTITY); + n.isolate = true; + wrapped(named(n, "Thing"), 4.5, false) + }); + let text_out = program(&doc); + let back = read_back(&doc); + let (before, after) = (area_wrap_region(&doc), area_wrap_region(&back)); + assert_eq!(before.len(), 1); + assert!(same_region(&before, &after), "{before:?}\n{after:?}\n{text_out}"); + let g = &back.layers[0].children().unwrap()[1]; + assert_eq!(g.extras().wrap, None, "{text_out}"); + let mut live = 0; + g.walk(&mut |m| live += usize::from(matches!(m.kind, NodeKind::Envelope { .. }))); + assert_eq!(live, 0, "{text_out}"); + let first = &g.children().unwrap()[0]; + assert!(matches!(first.kind, NodeKind::Path { guide: false, .. }) && first.appearance.items.is_empty(), "{text_out}"); +} + /// A clipping path inside art cut to a clipping object is written as its fills and its strokes, /// two objects: its text wrap options are written once, whether it has both or only strokes, and /// with its strokes when the region outside leaves nothing of its fills. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 5866bd5e44..1b48697e32 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -28,7 +28,7 @@ mod variables; pub(super) use brushes::BRUSHES; #[cfg(test)] pub(super) use envelopes::ENVELOPES; -pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_objects}; +pub(super) use styles::{LARGE_CANVAS_STYLES, style_links, styled_definitions, styled_objects}; #[cfg(test)] pub(super) use styles::{PANEL_EMPTY, PANEL_STYLES, STYLE_LINKS, WARPED_IMAGES, same_appearance}; @@ -133,6 +133,10 @@ struct Charges { kept_objects: usize, kept_symbol_nodes: u64, left_out: usize, + /// The objects written with the graphic style they are linked to, and those of them in the + /// definitions written. + links: usize, + kept_links: usize, } pub(super) struct Writer<'a> { @@ -171,6 +175,8 @@ pub(super) struct Writer<'a> { definition_objects: usize, /// The objects of symbol art in those definitions ([`Self::symbol_nodes`]). definition_symbol_nodes: u64, + /// The objects in those definitions written with the graphic style they are linked to. + definition_links: usize, /// The most objects written ([`Self::take_objects`]): the most the reader reads, /// [`crate::import::MAX_EDITING_OBJECTS`]. pub(super) max_objects: usize, @@ -268,6 +274,12 @@ pub(super) struct Writer<'a> { /// The objects that may be written with an art style, as they were before baking, by id /// ([`styles::styled_objects`]). pub(super) styled: HashMap>, + /// The objects of the opacity masks' art in each symbol's art and each pattern's tile art that + /// may be written with an art style, by the symbol's or pattern's name + /// ([`styles::styled_definitions`]). [`Self::define_symbol`] and [`Self::define_pattern`] write + /// a definition's art with its own as [`Self::styled`]. + pub(super) symbol_styled: HashMap, + pub(super) pattern_styled: HashMap, /// The art styles written ([`styles`]). styles: styles::Styles, /// An object's drawn look is being written: no object in it is written with a style, and its @@ -277,7 +289,8 @@ pub(super) struct Writer<'a> { /// brushed stroke's pieces) the art being written is in. Text wrap options are written only /// outside them, since only the document's own objects wrap type. drawn: u32, - /// The type just written as outlines has its text wrap options on the rectangle of its bounds + /// The type just written as outlines, or the symbol instance or live object just written as a + /// group of its art, has its text wrap options on the rectangle of its bounds /// ([`Self::wrap_bounds`]), not in the group's dictionary. wrap_moved: bool, /// The brushes written ([`brushes`]). @@ -365,6 +378,8 @@ impl<'a> Writer<'a> { symbol_scales: HashMap::new(), root_entries: None, styled: HashMap::new(), + symbol_styled: HashMap::new(), + pattern_styled: HashMap::new(), styles: styles::Styles::default(), in_look: false, drawn: 0, @@ -376,6 +391,7 @@ impl<'a> Writer<'a> { objects: 0, left_out: 0, definition_objects: 0, + definition_links: 0, definition_symbol_nodes: 0, max_objects: crate::import::MAX_EDITING_OBJECTS, last_samples: None, @@ -434,6 +450,8 @@ impl<'a> Writer<'a> { kept_objects: self.definition_objects, kept_symbol_nodes: self.definition_symbol_nodes, left_out: self.left_out, + links: self.styles.links_written, + kept_links: self.definition_links, } } @@ -447,15 +465,18 @@ impl<'a> Writer<'a> { fn keep_since(&mut self, c: Charges) { self.definition_objects = c.kept_objects + (self.objects - c.objects); self.definition_symbol_nodes = c.kept_symbol_nodes + (self.symbol_nodes - c.symbol_nodes); + self.definition_links = c.kept_links + (self.styles.links_written - c.links); } - /// Art written since `c` as `lines` is not used: the objects and symbol art charged since are - /// given back, except those of the definitions written since, and the images `lines` marks + /// Art written since `c` as `lines` is not used: the objects and symbol art charged since, and + /// the objects counted as written with their graphic style, are given back, except those of the + /// definitions written since, and the images `lines` marks /// are emptied (the others keep their indices). An image whose whole copy is among them is no /// longer written whole ([`Self::whole_images`]). fn give_back(&mut self, c: Charges, lines: &str) { self.objects = c.objects + (self.definition_objects - c.kept_objects); self.symbol_nodes = c.symbol_nodes + (self.definition_symbol_nodes - c.kept_symbol_nodes); + self.styles.links_written = c.links + (self.definition_links - c.kept_links); let mut emptied = HashSet::new(); for index in lines.split(PAYLOAD).skip(1).step_by(2) { if let Some((i, p)) = index.parse::().ok().and_then(|i| Some((i, self.payloads.get_mut(i)?))) { @@ -675,12 +696,11 @@ impl<'a> Writer<'a> { if n.extras().graph.is_some() && !self.in_look && graph.is_none() { self.warn(GRAPHS); } - // On the layers or in an opacity mask's art, outside art cut to a clipping object: with its - // art style, when it has one. + // On the layers or in an opacity mask's art (a symbol's and a pattern tile's included), + // outside art cut to a clipping object: with its art style, when it has one. if !self.in_look - && ((self.hidden == 0 && self.nest == 0) || self.mask_level == Some((self.hidden, self.nest))) + && ((self.hidden == 0 && self.nest == 0 && !self.in_symbol) || self.mask_level == Some((self.hidden, self.nest))) && self.clip.is_none() - && !self.in_symbol && let Some(orig) = self.styled.get(&n.id).cloned() && self.check_left(&orig) && let Some(name) = self.object_style(n, &orig) @@ -754,6 +774,8 @@ impl<'a> Writer<'a> { if !self.open_group("u") { return; } + let state = self.state; + let moved = self.wrap_on_bounds(n, state); // The canvas places an instance inside a symbol's art where the outer instance puts // it (9-slice guides, stains and patterns included), and this art doesn't move with it. self.wrote_fixed |= self.in_symbol; @@ -798,12 +820,13 @@ impl<'a> Writer<'a> { self.warn(SYMBOL_LIMIT); } } + self.wrap_moved = moved; self.end_group("U", xy); } NodeKind::Envelope { .. } if self.envelope(n, xy) => {} - NodeKind::Envelope { .. } => self.drawn_art(n, xy, Some(envelopes::ENVELOPES)), + NodeKind::Envelope { .. } => self.drawn_art(n, xy, Some(envelopes::ENVELOPES), true), NodeKind::Blend { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) | NodeKind::Intertwine { .. } => { - self.drawn_art(n, xy, Some(LIVE)); + self.drawn_art(n, xy, Some(LIVE), true); } // Ruler guides are kept in the document's native copy only. NodeKind::RulerGuide { .. } => {} @@ -870,13 +893,17 @@ impl<'a> Writer<'a> { } /// Live object `n`, of transparency `xy`, as a group of its art, with `warning` when it is given. - fn drawn_art(&mut self, n: &Node, xy: Xy, warning: Option<&str>) { + /// With `bounds`, `n`'s text wrap options go on the rectangle of its bounds, the group's first + /// member ([`Self::wrap_on_bounds`]); without it, in the group's dictionary. + fn drawn_art(&mut self, n: &Node, xy: Xy, warning: Option<&str>, bounds: bool) { if !self.open_group("u") { return; } + let state = self.state; if let Some(w) = warning { self.warn(w); } + let moved = bounds && self.wrap_on_bounds(n, state); if self.nest < MAX_NEST { let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); self.nest += 1; @@ -887,9 +914,29 @@ impl<'a> Writer<'a> { self.drawn -= 1; self.nest -= 1; } + self.wrap_moved = moved; self.end_group("U", xy); } + /// In the group that object `n` is written as, when the group's dictionary would hold `n`'s text + /// wrap options: the rectangle of `n`'s bounds with them ([`Self::wrap_bounds`]), then lock and + /// hidden state `state` again. True when the group's dictionary is to leave the options out + /// ([`Self::wrap_moved`]), so the group wraps type by `n`'s bounds, as `n` does + /// ([`vectorcraft_doc::text::resolve_wrap`]), not by its members. Written before the group's + /// members, the rectangle is below any area type among them and does not wrap it. False, + /// writing nothing, when `n` has no finite bounds, and the group's dictionary keeps the options. + fn wrap_on_bounds(&mut self, n: &Node, state: Option<(bool, bool)>) -> bool { + let Some(w) = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look) else { return false }; + if finite_bounds(n).is_none() { + return false; + } + self.wrap_bounds(n, w); + if let Some((locked, hidden)) = state { + self.state(locked, hidden); + } + true + } + /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]), /// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an /// empty `AIArtName`), has text wrap options ([`Self::text_wrap`]), has an opacity mask @@ -1536,23 +1583,20 @@ impl<'a> Writer<'a> { for c in over { self.node(c); } - // Type wraps by its bounds: its text wrap options go on a rectangle of its bounds, the - // group's last member, and the group's dictionary leaves them out. - if let Some(w) = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look) { - self.wrap_bounds(n, w); - self.wrap_moved = true; - } + // Type wraps by its bounds. + self.wrap_moved = self.wrap_on_bounds(n, state); if let Some((locked, hidden)) = state { self.state(locked, hidden); } self.end_group("U", xy); } - /// An unpainted closed rectangle at the geometric bounds of type `n`, the region its text wraps - /// around, with `n`'s lock and hidden state and text wrap options `w` in its dictionary. Nothing + /// An unpainted closed rectangle at the geometric bounds of type, a symbol instance or a live + /// object `n`, the region its text wraps around ([`vectorcraft_doc::text::resolve_wrap`]), with + /// `n`'s lock and hidden state and text wrap options `w` in its dictionary. Nothing /// when `n` has no finite bounds, or without room for it ([`Self::take_objects`]). fn wrap_bounds(&mut self, n: &Node, w: TextWrap) { - let Some(b) = n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return }; + let Some(b) = finite_bounds(n) else { return }; self.state(n.locked, !n.visible); let objects = self.objects; self.plain_object(&shapes::rectangle(b), FillRule::NonZero, None, None, Xy::PLAIN); @@ -1741,6 +1785,11 @@ impl<'a> Writer<'a> { } } +/// `n`'s geometric bounds, when they are finite. +fn finite_bounds(n: &Node) -> Option { + n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) +} + /// The gradient of paint `p`, when it has one. fn gradient(p: Option<&Paint>) -> Option<&vectorcraft_color::GradientPaint> { match p { diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs index dbd906e7cd..09c6a33550 100644 --- a/crates/eps/src/ai_editing/writer/envelopes.rs +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -411,9 +411,10 @@ impl Writer<'_> { self.hidden -= 1; self.nest -= 1; // The distorted art, which takes the envelope's lock, visibility, transparency, name and - // opacity mask. + // opacity mask. The reader opens the envelope live, wrapping type by its bounds, so its text + // wrap options stay in the art's dictionary. self.state(n.locked, !n.visible); - self.drawn_art(n, xy, None); + self.drawn_art(n, xy, None, false); if !opened || outlines || self.left_out_since(charges) { let lines = self.body.split_off(start); self.give_back(charges, &lines); diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index d0a90ce86a..e2702b5714 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -483,6 +483,8 @@ impl Writer<'_> { self.gs.replace(Xy::OPAQUE), (self.clip.take(), std::mem::take(&mut self.shown)), self.block_end, + // The tile's masks' objects are written with their art styles, those of no other art. + (std::mem::replace(&mut self.styled, self.pattern_styled.get(name).cloned().unwrap_or_default()), self.mask_level.take()), ); // `Bg` places a gradient in a pattern's tile as on the canvas, mirrored top to bottom: its // placement is worked out in the tile's space turned y up. @@ -505,7 +507,15 @@ impl Writer<'_> { self.hidden -= 1; self.nest -= 1; let art; - (art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, (self.clip, self.shown), self.block_end) = saved; + ( + art, + (self.to_art, self.grad_space, self.canvas), + self.state, + self.gs, + (self.clip, self.shown), + self.block_end, + (self.styled, self.mask_level), + ) = saved; let art = std::mem::replace(&mut self.body, art); self.defining.pop(); if self.left_out_since(before) { diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index a8a2da677c..50aa4df931 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -26,7 +26,8 @@ pub(in crate::ai_editing) const PANEL_EMPTY: &str = pub(in crate::ai_editing) const WARPED_IMAGES: &str = "images with a Warp after the first 256 envelopes and images with a Warp are written as their drawn look, without their Warp, in the editing data for other apps"; pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are left out of the editing data for other apps on type, images, symbol instances, knockout shapes and other objects that aren't written with their graphic style"; -/// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. +/// How many objects of `doc`'s layers, opacity masks, symbols and pattern tiles are linked to a +/// graphic style. pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { fn count(n: &Node) -> usize { // Ruler guides are not written as objects. @@ -37,7 +38,9 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) } - doc.layers.iter().map(|l| count(l)).sum::() + doc.symbols.iter().map(|s| count(&s.art)).sum::() + doc.layers.iter().map(|l| count(l)).sum::() + + doc.symbols.iter().map(|s| count(&s.art)).sum::() + + doc.patterns.iter().flat_map(|p| p.art.iter()).map(|a| count(a)).sum::() } /// The objects of `doc`'s layers and of their opacity masks' art that may be written with an art @@ -48,10 +51,30 @@ pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { /// Knockout Shape (the reader takes an object's transparency /// from its look, which doesn't carry it). A group's members are paths, compound paths or such /// groups, none with effects or anything baking changes. -pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { - fn walk(nodes: &[Arc], out: &mut HashMap>) { +pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> StyledMap { + styled_in(&doc.layers, true) +} + +/// The objects that may be written with an art style, by id, as they are before baking. +pub(in crate::ai_editing) type StyledMap = HashMap>; + +/// The objects of the opacity masks' art in each of `doc`'s symbols' art, by symbol name, and in +/// each of its patterns' tile art, by pattern name, that may be written with an art style, as +/// [`styled_objects`] finds them on the layers. A definition's own objects outside its masks' art +/// are not among them, and definitions without such objects are left out. +pub(in crate::ai_editing) fn styled_definitions(doc: &Document) -> (HashMap, HashMap) { + let symbols = doc.symbols.iter().map(|s| (s.name.clone(), styled_in(std::slice::from_ref(&s.art), false))); + let patterns = doc.patterns.iter().map(|p| (p.name.clone(), styled_in(&p.art, false))); + let kept = |(_, m): &(String, StyledMap)| !m.is_empty(); + (symbols.filter(kept).collect(), patterns.filter(kept).collect()) +} + +/// The objects of `nodes` that may be written with an art style ([`styled_objects`]): with `own`, +/// `nodes` and their members as well as their opacity masks' art, else their masks' art only. +fn styled_in(nodes: &[Arc], own: bool) -> StyledMap { + fn walk(nodes: &[Arc], own: bool, out: &mut StyledMap) { for n in nodes { - if candidate(n) { + if own && candidate(n) { out.insert(n.id, n.clone()); } if let Some(m) = n.extras().mask.as_ref() { @@ -59,7 +82,7 @@ pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap HashMap { let doc = self.doc; // A new document's styles are what the reader gives a file without a style list; they are // still written when an object is linked to one of them, so that it keeps its link. - let linked = self.styled.values().any(|n| n.extras().graphic_style.is_some()); + let definitions = self.symbol_styled.values().chain(self.pattern_styled.values()).flat_map(HashMap::values); + let linked = self.styled.values().chain(definitions).any(|n| n.extras().graphic_style.is_some()); if !linked && doc.graphic_styles == Document::new(1.0, 1.0).graphic_styles { return; } diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs index 0c8d2c7d3b..4d4bb2acba 100644 --- a/crates/eps/src/ai_editing/writer/symbols.rs +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -252,6 +252,8 @@ impl Writer<'_> { self.root_entries.take(), std::mem::take(&mut self.wrote_pattern), (std::mem::take(&mut self.wrote_fixed), std::mem::replace(&mut self.inner_scale, 1.0)), + // The art's masks' objects are written with their art styles, those of no other art. + (std::mem::replace(&mut self.styled, self.symbol_styled.get(name).cloned().unwrap_or_default()), self.mask_level.take()), ); // Gradients' matrices and instances' matrices are written about the canvas point of art // space's origin, y up; `Bg` places a gradient on the art as it lies in the symbol's space. @@ -309,6 +311,7 @@ impl Writer<'_> { self.root_entries, self.wrote_pattern, (self.wrote_fixed, self.inner_scale), + (self.styled, self.mask_level), ) = saved; self.doc = page_doc; (self.in_look, self.drawn) = (in_look, drawn); diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 6b1374cd83..aa819a77ad 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -36,10 +36,8 @@ pub(crate) fn subpath(s: &mut String, xf: Affine, sp: &SubPath) { s.push_str(if smooth { "c\n" } else { "C\n" }); } }; - for pair in sp.anchors.windows(2) { - if let [a, b] = pair { - segment(s, a, b); - } + for (a, b) in sp.anchors.iter().zip(sp.anchors.iter().skip(1)) { + segment(s, a, b); } if sp.closed && sp.anchors.len() > 1 diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 091b440758..312b7cc275 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1399,8 +1399,9 @@ impl<'a> Reader<'a> { } /// Link the objects of `links` among `nodes` to their graphic style's id, each only when it has its -/// style's appearance and transparency (`styles`, whose ids are 1, 2, … in order). A path, which -/// has no contents, takes the style's contents slot. +/// style's appearance and transparency (`styles`, whose ids are 1, 2, … in order), their members +/// and their opacity masks' art included. A path, which has no contents, takes the style's contents +/// slot. fn link_styles(nodes: &mut [Arc], links: &HashMap, styles: &[GraphicStyle], depth: usize) { if depth > MAX_DEPTH { return; @@ -1419,6 +1420,11 @@ fn link_styles(nodes: &mut [Arc], links: &HashMap, styles: &[ n.extras_mut().graphic_style = Some(*id); } } + if n.extras().mask.is_some() + && let Some(m) = Arc::make_mut(n).extras_mut().mask.as_mut() + { + link_styles(std::slice::from_mut(&mut m.art), links, styles, depth + 1); + } if n.children().is_some_and(|c| !c.is_empty()) && let Some(c) = Arc::make_mut(n).children_mut() { diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index e07f119215..12a11a78f4 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -790,6 +790,41 @@ fn colorized_images_keep_their_color() { assert_eq!(out.warnings, [IMAGE_ALPHA]); } +/// The pieces of a colorized warped image work out the image's coverage once, and an image with +/// other data works out its own: a black and white image's pieces are written as image masks +/// in its color, and a gray image's pieces with its color in their pixels. +#[test] +fn the_pieces_of_a_colorized_warped_image_work_out_its_coverage_once() { + // GUARDS: each piece of a colorized warped image decoded the image again for its coverage. + let mut doc = Document::new(400.0, 200.0); + doc.layers.clear(); + let layer = doc.add_layer(Some("Art")); + let paint = Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)); + let checks: Vec = (0..16 * 16).map(|i| if (i % 16 + i / 16) % 2 == 0 { 0 } else { 255 }).collect(); + let ramp: Vec = (0..16 * 16).map(|i| (i % 16 * 16) as u8).collect(); + for (gray, x) in [(checks, 10.0), (ramp, 210.0)] { + let photo = colorized_image(&mut doc, (16, 16), gray, &paint, (x, 10.0)); + let NodeKind::Image(im) = &photo.kind else { panic!("{:?}", photo.kind) }; + let bend = move |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(p.x, p.y + 8.0 * ((p.x - x) / 160.0 * std::f64::consts::PI).sin()); + let warped = vectorcraft_doc::live::mesh_warp_image(&photo, im, &bend, 4, false, false); + doc.insert(Some(layer), usize::MAX, warped).unwrap(); + } + super::writer::COVERAGE_RUNS.with(|n| n.set(0)); + let out = write(&doc); + assert_eq!(super::writer::COVERAGE_RUNS.with(std::cell::Cell::get), 2, "once for each image"); + let text = String::from_utf8(out.bytes).unwrap(); + let images = rasters(&text); + assert_eq!(images.len(), 64, "32 pieces of each image"); + let xi = |piece: &Vec<&str>| piece.iter().find(|l| l.ends_with(" XI")).map(|l| l.to_string()).unwrap_or_default(); + assert!(images[..32].iter().all(|p| xi(p).ends_with(" 1 1 0 0 0 1 XI")), "{:?}", images[..32].iter().map(xi).collect::>()); + assert!(images[32..].iter().all(|p| xi(p).ends_with(" 8 3 0 0 0 0 XI")), "{:?}", images[32..].iter().map(xi).collect::>()); + // The first piece holds the whole image: each row of 16 pixels in two bytes, its black pixels 0 bits. + assert!(xi(&images[0]).ends_with(" ] 0 0 16 16 16 16 1 1 0 0 0 1 XI"), "{}", xi(&images[0])); + let data: Vec<&str> = images[0].iter().filter_map(|l| l.strip_prefix('%')).filter(|l| l.bytes().all(|b| b.is_ascii_hexdigit())).collect(); + assert_eq!(data.concat(), "5555AAAA".repeat(8)); + assert_eq!(out.warnings, [IMAGE_ALPHA]); +} + /// An image mask is only for a colorized image the writer draws as the document does: not one /// with an opacity mask turned on, and not one with more pixels than the writer writes. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index d8f88e0d0b..c8bce40227 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -11,6 +11,15 @@ use super::{ }; use super::{CONIC, CONIC_WEDGES, DIAMOND, GRAPHS, HUE_STOPS, SPREAD, SPREAD_CAPPED}; +#[cfg(test)] +thread_local! { + /// How many times [`Writer::coverage`] has worked out an image's coverage on this thread. + pub(super) static COVERAGE_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// An image's coverage ([`ImageBlob::coverage`]): its width, its height and one byte a pixel. +type Coverage = (u32, u32, Arc>); + pub(super) struct Writer<'a> { doc: &'a Document, /// The document → art space. @@ -50,6 +59,9 @@ pub(super) struct Writer<'a> { /// The image decoded last (`None` when it can't be): the pieces of a warped image, which follow /// one another, decode it once. last_image: Option<(ImageBlob, Option>)>, + /// The coverage of [`Self::last_image`] once a colorized piece of it asked for it (`Some(None)` + /// when it can't be worked out), so the pieces of a colorized warped image work it out once. + last_coverage: Option>, /// The images written whole so far, by the address of their data (which each entry keeps). whole_images: std::collections::HashMap>>, /// Writing a graph's art with its records: a graph inside it is written as its art. @@ -79,6 +91,7 @@ impl<'a> Writer<'a> { kumi: false, clips: vec![], last_image: None, + last_coverage: None, whole_images: std::collections::HashMap::new(), in_graph: false, } @@ -1104,7 +1117,7 @@ impl<'a> Writer<'a> { let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h)); let coverage = match colorize { Some(paint) => { - let Some((_, _, coverage)) = ImageBlob::coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { + let Some((_, _, coverage)) = self.coverage(blob).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else { self.warn(UNDECODED); return; }; @@ -1172,9 +1185,26 @@ impl<'a> Writer<'a> { } let img = image::load_from_memory(&blob.bytes).ok().map(Arc::new); self.last_image = Some((blob.clone(), img.clone())); + self.last_coverage = None; img } + /// The coverage of `blob` ([`ImageBlob::coverage`]), worked out once for images that follow one + /// another with the same data, as [`Self::decoded`] decodes them. + fn coverage(&mut self, blob: &ImageBlob) -> Option { + let last = self.last_image.as_ref().is_some_and(|(last, _)| Arc::ptr_eq(&last.bytes, &blob.bytes) && last.mime == blob.mime); + if last && let Some(coverage) = &self.last_coverage { + return coverage.clone(); + } + #[cfg(test)] + COVERAGE_RUNS.with(|n| n.set(n.get() + 1)); + let coverage = ImageBlob::coverage(blob).map(|(w, h, c)| (w, h, Arc::new(c))); + if last { + self.last_coverage = Some(coverage.clone()); + } + coverage + } + /// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]` /// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`] /// digits, `%AI5_EndRaster`, then `F`. diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 8d147cb2d1..17608c7346 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -266,6 +266,14 @@ pub(crate) struct Scene<'a> { images: HashMap>, /// The source key and solid paint together identify a colorized encoding. colorized_images: HashMap<(String, String), Option>, + /// Image keys whose encoding didn't fit under the page limit → how much its first placement + /// adds to the body. The image is encoded again only once the page has room for that. + too_large_images: HashMap, + /// The same for colorized encodings, by `colorized_images`' keys. + too_large_colorized: HashMap<(String, String), usize>, + /// How many times an image or a colorized image was encoded. + #[cfg(test)] + encodes: usize, /// Freeform gradients' images by [`ImageCode::content_key`] → their indices in `codes`: a field /// encoded the same as one already kept draws from it. fields: HashMap>, @@ -297,6 +305,10 @@ impl<'a> Scene<'a> { brushes: None, images: HashMap::new(), colorized_images: HashMap::new(), + too_large_images: HashMap::new(), + too_large_colorized: HashMap::new(), + #[cfg(test)] + encodes: 0, fields: HashMap::new(), codes: vec![], placements: vec![], @@ -313,6 +325,30 @@ impl<'a> Scene<'a> { self } + /// [`Self::run`], with how many placements draw each encoded image the scene kept, and the + /// indices in `codes` that its image, colorized-image and field caches name. + #[cfg(test)] + pub fn run_with_codes(mut self) -> (Page, Vec, Vec) { + let doc = self.doc; + for l in &doc.layers { + self.node(l, false); + } + let uses = self.codes.iter().map(|c| c.uses).collect(); + let cached = self.images.values().chain(self.colorized_images.values()).flatten().chain(self.fields.values().flatten()).copied().collect(); + (self.page(), uses, cached) + } + + /// [`Self::run`], with how many times the scene encoded an image or a colorized image. + #[cfg(test)] + pub fn run_counting_encodes(mut self) -> (Page, usize) { + let doc = self.doc; + for l in &doc.layers { + self.node(l, false); + } + let encodes = self.encodes; + (self.page(), encodes) + } + pub fn run(mut self) -> Page { let doc = self.doc; for l in &doc.layers { @@ -556,7 +592,7 @@ impl<'a> Scene<'a> { if self.size() > self.max_body { self.body.truncate(was); self.unplace(placed); - self.forget_fields(codes); + self.forget_codes(codes); self.warn(TOO_LARGE); } } @@ -1439,24 +1475,21 @@ impl<'a> Scene<'a> { self.place_image(i, place); } - /// Forget the field images after the first `n` codes that no placement draws any longer, last - /// first, up to the first code that is drawn or isn't a field image. - fn forget_fields(&mut self, n: usize) { - while self.codes.len() > n { - let i = self.codes.len() - 1; - let Some(code) = self.codes.get(i) else { break }; - if code.uses > 0 { - break; - } - let key = code.content_key(); - let Some(bucket) = self.fields.get_mut(&key) else { break }; - let Some(at) = bucket.iter().position(|&j| j == i) else { break }; - bucket.swap_remove(at); - if bucket.is_empty() { - self.fields.remove(&key); - } - self.codes.pop(); + /// Forget the encoded images after the first `n` codes that no placement draws any longer, and + /// the cache entries that name them: placed images, colorized images and fields. A code that a + /// placement still draws keeps itself and every code before it. + fn forget_codes(&mut self, n: usize) { + let keep = self.codes.iter().rposition(|c| c.uses > 0).map_or(n, |last| n.max(last + 1)); + if keep >= self.codes.len() { + return; } + self.codes.truncate(keep); + self.images.retain(|_, e| e.is_none_or(|i| i < keep)); + self.colorized_images.retain(|_, e| e.is_none_or(|i| i < keep)); + self.fields.retain(|_, bucket| { + bucket.retain(|&i| i < keep); + !bucket.is_empty() + }); } /// A placed image (`blob`, cached under `key`): its pixel grid of `width` × `height` placed @@ -1469,7 +1502,28 @@ impl<'a> Scene<'a> { let encoded = match self.images.get(key) { Some(e) => *e, None => { - let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(|c| self.add_code(c)); + // An image turned away at the page limit is encoded again only once it fits. + if let Some(&len) = self.too_large_images.get(key) { + if self.size().saturating_add(len).saturating_add(64) > self.max_body { + self.warn(TOO_LARGE); + return; + } + self.too_large_images.remove(key); + } + #[cfg(test)] + { + self.encodes += 1; + } + let code = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())); + // An image that can't be placed isn't kept. + if let Some(c) = &code + && self.size().saturating_add(c.inline_len).saturating_add(64) > self.max_body + { + self.too_large_images.insert(key.to_string(), c.inline_len); + self.warn(TOO_LARGE); + return; + } + let e = code.map(|c| self.add_code(c)); self.images.insert(key.to_string(), e); e } @@ -1501,7 +1555,28 @@ impl<'a> Scene<'a> { let encoded = match self.colorized_images.get(&cache) { Some(e) => *e, None => { - let e = self.encode_colorized(blob, paint).map(|c| self.add_code(c)); + // An image turned away at the page limit is encoded again only once it fits. + if let Some(&len) = self.too_large_colorized.get(&cache) { + if self.size().saturating_add(len).saturating_add(64) > self.max_body { + self.warn(TOO_LARGE); + return; + } + self.too_large_colorized.remove(&cache); + } + #[cfg(test)] + { + self.encodes += 1; + } + let code = self.encode_colorized(blob, paint); + // An image that can't be placed isn't kept. + if let Some(c) = &code + && self.size().saturating_add(c.inline_len).saturating_add(64) > self.max_body + { + self.too_large_colorized.insert(cache, c.inline_len); + self.warn(TOO_LARGE); + return; + } + let e = code.map(|c| self.add_code(c)); self.colorized_images.insert(cache, e); e } diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 963e78c9ef..60b5832859 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -1495,6 +1495,228 @@ fn a_field_taken_back_at_the_page_limit_leaves_no_image_behind() { assert!(page.setup.contains("/VCim0 VCsrc") && !page.setup.contains("VCim1"), "{}", page.setup); } +/// A 200 × 200 pt document holding 8 × 8 pixels of noise as image `pic` and a three-pixel gray ramp +/// as image `gray`, its first layer holding what `nodes` makes. +fn image_doc_with(nodes: impl FnOnce(&mut Document) -> Vec) -> Document { + let mut d = Document::new(200.0, 200.0); + let mut png = vec![]; + noise(8, 8).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("pic".into(), ImageBlob::new("image/png", png)); + let mut gray = vec![]; + image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(3, 1, vec![0, 128, 255]).unwrap()) + .write_to(&mut std::io::Cursor::new(&mut gray), image::ImageFormat::Png) + .unwrap(); + d.images.insert("gray".into(), ImageBlob::png(gray)); + let nodes = nodes(&mut d); + let layer = d.layers[0].id; + for n in nodes { + d.insert(Some(layer), usize::MAX, n).unwrap(); + } + d +} + +/// One of [`image_doc_with`]'s images: `pic`, or `gray` colorized in process red. +#[derive(Clone, Copy, Debug)] +enum Pic { + Placed, + Colorized, +} + +/// An image of `pic` at (`x`, `y`), its pixels `scale` points wide. +fn pic_node(d: &mut Document, pic: Pic, x: f64, y: f64, scale: f64) -> Node { + let (key, width, height) = match pic { + Pic::Placed => ("pic", 8, 8), + Pic::Colorized => ("gray", 3, 1), + }; + let im = ImageObject { + key: key.into(), + width, + height, + xf: Affine::translate((x, y)) * Affine::scale(scale), + link: None, + placement: Default::default(), + pixels: None, + }; + let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + if let Pic::Colorized = pic { + n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); + } + n +} + +/// A pattern whose tile is an image of `pic` about 60 points wide. +fn pic_pattern(d: &mut Document, pic: Pic) -> Paint { + let scale = match pic { + Pic::Placed => 8.0, + Pic::Colorized => 20.0, + }; + let tile = pic_node(d, pic, 0.0, 0.0, scale); + d.patterns.push(vectorcraft_doc::PatternDef::new("tile", vec![Arc::new(tile)])); + Paint::Pattern { pattern: "tile".into(), xf: Affine::IDENTITY } +} + +/// A 2000-sided shape filled with each of `fills` in turn, and stroked in black when `stroked`. +fn many_sided(d: &mut Document, fills: &[Paint], stroked: bool) -> Node { + let stroke = if stroked { Paint::solid(Color::rgb(0.0, 0.0, 0.0)) } else { Paint::None }; + let mut n = path(d, shapes::polygon(Point::new(100.0, 100.0), 90.0, 2000, 0.0), fills[0].clone(), stroke, 1.0); + let Some(AppearanceItem::Fill(first)) = n.appearance.items.first().cloned() else { panic!("a filled shape") }; + for (k, paint) in fills.iter().enumerate().skip(1) { + let mut fill = first.clone(); + fill.paint = paint.clone(); + n.appearance.items.insert(k, AppearanceItem::Fill(fill)); + } + n +} + +/// A page limit that the many-sided shape of `build` (`true`: stroked) passes with its stroke and +/// not without it. +fn stroke_passes_the_limit(build: impl Fn(bool) -> Document, o: &EpsOptions) -> usize { + let fill = crate::scene::Scene::new(&build(false), o).run().body.len(); + let both = crate::scene::Scene::new(&build(true), o).run().body.len(); + assert!(both > fill + 20_000, "{fill} {both}"); + fill + (both - fill) / 2 +} + +/// Art taken back for passing the page limit after it placed an image (a placed or a colorized +/// one) keeps no encoding of that image, and no cache entry names one. A later placement of the +/// image is encoded again and written where it is drawn, at both levels. +#[test] +fn a_rolled_back_node_frees_its_image_codes() { + for pic in [Pic::Placed, Pic::Colorized] { + for level in Level::ALL { + let case = format!("{pic:?}, {level:?}"); + let build = |stroked: bool, after: bool| { + image_doc_with(|d| { + let fill = pic_pattern(d, pic); + let mut nodes = vec![many_sided(d, &[fill], stroked)]; + if after { + nodes.push(pic_node(d, pic, 20.0, 20.0, 4.0)); + } + nodes + }) + }; + let o = EpsOptions { level, ..opts(&build(false, false)) }; + let limit = stroke_passes_the_limit(|stroked| build(stroked, false), &o); + let (page, uses, cached) = crate::scene::Scene::new(&build(true, false), &o).with_max_body(limit).run_with_codes(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + assert_eq!((uses, cached), (vec![], vec![]), "{case}"); + assert!(page.body.is_empty() && page.setup.is_empty(), "{case}: {} {}", page.setup, page.body); + let (page, uses, cached) = crate::scene::Scene::new(&build(true, true), &o).with_max_body(limit).run_with_codes(); + assert_eq!((uses, cached), (vec![1], vec![0]), "{case}"); + let alone = crate::scene::Scene::new(&image_doc_with(|d| vec![pic_node(d, pic, 20.0, 20.0, 4.0)]), &o).run(); + assert_eq!(alone.body.matches("} exec\n").count(), 1, "{case}: {}", alone.body); + assert_eq!((&page.setup, &page.body), (&alone.setup, &alone.body), "{case}"); + } + } +} + +/// A field encoded before an image in art taken back for passing the page limit is forgotten with +/// it. Two later fields sampled the same share one image, the first one the page keeps. +#[test] +fn a_rolled_back_image_does_not_pin_an_earlier_field_code() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + for pic in [Pic::Placed, Pic::Colorized] { + for level in Level::ALL { + let case = format!("{pic:?}, {level:?}"); + let build = |stroked: bool, after: bool| { + image_doc_with(|d| { + let field = freeform(&[(60.0, 100.0, Color::rgb(0.0, 1.0, 0.0)), (140.0, 100.0, red)]); + let fills = [field, pic_pattern(d, pic)]; + let mut nodes = vec![many_sided(d, &fills, stroked)]; + if after { + nodes.extend([freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]); + } + nodes + }) + }; + let o = EpsOptions { level, ..opts(&build(false, false)) }; + let limit = stroke_passes_the_limit(|stroked| build(stroked, false), &o); + let (page, uses, cached) = crate::scene::Scene::new(&build(true, false), &o).with_max_body(limit).run_with_codes(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + assert_eq!((uses, cached), (vec![], vec![]), "{case}"); + let (page, uses, cached) = crate::scene::Scene::new(&build(true, true), &o).with_max_body(limit).run_with_codes(); + assert_eq!((uses, cached), (vec![2], vec![0]), "{case}"); + assert_eq!(reuses(&page.body, 0, level), 2, "{case}: {}", page.body); + let alone = doc_with(|d| vec![freeform_square(d, 0.0, 0.0, 64.0, red, blue), freeform_square(d, 0.0, 0.0, 64.0, red, blue)]); + let alone = crate::scene::Scene::new(&alone, &o).run(); + assert_eq!((&page.setup, &page.body), (&alone.setup, &alone.body), "{case}"); + } + } +} + +/// An image whose first placement doesn't fit under the page limit is left out with a warning and +/// keeps no encoding, though the object that draws it fits. The art before it is written. +#[test] +fn an_image_rejected_by_the_page_limit_keeps_no_unused_code() { + for pic in [Pic::Placed, Pic::Colorized] { + for level in Level::ALL { + let case = format!("{pic:?}, {level:?}"); + let d = image_doc_with(|d| vec![red_rect(d), pic_node(d, pic, 20.0, 20.0, 4.0)]); + let o = EpsOptions { level, ..opts(&d) }; + let rect = crate::scene::Scene::new(&image_doc_with(|d| vec![red_rect(d)]), &o).run(); + let full = crate::scene::Scene::new(&d, &o).run().body.len(); + let (page, uses, cached) = crate::scene::Scene::new(&d, &o).with_max_body(full - 1).run_with_codes(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + assert_eq!((uses, cached), (vec![], vec![]), "{case}"); + assert_eq!((&page.setup, &page.body), (&rect.setup, &rect.body), "{case}"); + } + } +} + +/// An image turned away at the page limit is encoded once, not again at each later placement +/// that doesn't fit either. +#[test] +fn an_image_rejected_by_the_page_limit_is_encoded_once() { + for pic in [Pic::Placed, Pic::Colorized] { + for level in Level::ALL { + let case = format!("{pic:?}, {level:?}"); + let d = image_doc_with(|d| { + let mut nodes = vec![red_rect(d)]; + nodes.extend((0..50).map(|k| pic_node(d, pic, f64::from(k), 20.0, 4.0))); + nodes + }); + let o = EpsOptions { level, ..opts(&d) }; + let rect = crate::scene::Scene::new(&image_doc_with(|d| vec![red_rect(d)]), &o).run(); + let (page, encodes) = crate::scene::Scene::new(&d, &o).with_max_body(rect.body.len() + 64).run_counting_encodes(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + assert_eq!(encodes, 1, "{case}"); + assert_eq!((&page.setup, &page.body), (&rect.setup, &rect.body), "{case}"); + } + } +} + +/// An image turned away at the page limit inside art that is then taken back is encoded again +/// once the page has room for it, and written where it is drawn. +#[test] +fn an_image_rejected_in_art_taken_back_is_encoded_again_when_it_fits() { + for pic in [Pic::Placed, Pic::Colorized] { + for level in Level::ALL { + let case = format!("{pic:?}, {level:?}"); + let build = |stroked: bool, after: bool| { + image_doc_with(|d| { + let fill = pic_pattern(d, pic); + let mut nodes = vec![many_sided(d, &[fill], stroked)]; + if after { + nodes.push(pic_node(d, pic, 20.0, 20.0, 4.0)); + } + nodes + }) + }; + let o = EpsOptions { level, ..opts(&build(false, false)) }; + let solid = image_doc_with(|d| vec![many_sided(d, &[Paint::solid(Color::rgb(1.0, 0.0, 0.0))], false)]); + // The shape's outline fits under this limit, and its first tile of the image doesn't. + let limit = crate::scene::Scene::new(&solid, &o).run().body.len() + 64; + let (page, encodes) = crate::scene::Scene::new(&build(false, false), &o).with_max_body(limit).run_counting_encodes(); + assert!(page.warnings.iter().any(|w| w.contains("too large")), "{case}: {:?}", page.warnings); + assert_eq!((encodes, page.body.matches("} exec\n").count()), (1, 0), "{case}: {}", page.body); + let (page, encodes) = crate::scene::Scene::new(&build(true, true), &o).with_max_body(limit).run_counting_encodes(); + assert_eq!(encodes, 2, "{case}"); + let alone = crate::scene::Scene::new(&image_doc_with(|d| vec![pic_node(d, pic, 20.0, 20.0, 4.0)]), &o).run(); + assert_eq!((&page.setup, &page.body), (&alone.setup, &alone.body), "{case}"); + } + } +} + #[test] fn run_length_and_ascii85_round_trip() { let cycle: Vec = (0..=255u8).cycle().take(1000).collect(); diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index fc6a060a5a..bd004d2d1e 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] vectorcraft-doc = { workspace = true } +vectorcraft-brush = { workspace = true } craft-vectorcraft-format = { workspace = true } serde ={ workspace = true } serde_json = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 968cd720a0..9add0798c7 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -52,6 +52,7 @@ use std::sync::Arc; use craft_vectorcraft_format::{Contents, Head, Image, ReadError, Tail}; use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor}; use serde::{Deserializer, Serialize}; +use vectorcraft_brush::stored_art::StoredArt; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] @@ -243,18 +244,20 @@ fn body(doc: &Document, o: &SaveOptions) -> Result { // version reads them. d.guides_to_saved(); // Older apps don't know placed documents: they get the art the objects show, with its - // resources. Otherwise resources only exporting adds are never saved. - if version < VERSION && d.has_placed() { - d = d.with_placed_art().into_owned(); - d.placed_as_groups(); - } else { - d.drop_placed_resources(); - } + // resources. Otherwise resources only exporting adds are never saved. The images and placed + // documents in the stored brushes' art count as those in the layers do. + let linked = with_brush_art(&mut d, |d| { + if version < VERSION { + placed_as_groups(d); + } else { + d.drop_placed_resources(); + } + d.linked_only_images() + }); // The art placed documents became for older versions is rounded too. if let Some(decimals) = o.precision { d.round_geometry(decimals.min(MAX_PRECISION)); } - let linked = d.linked_only_images(); // The blobs go in the file's `images` (only those the document uses). let blobs = std::mem::take(&mut d.images); if version < 3 { @@ -277,6 +280,39 @@ fn body(doc: &Document, o: &SaveOptions) -> Result { Ok(Body { d, v1, blobs, linked }) } +/// `f` run on `d` with the art of its stored brushes set on it with the ids it is stored with +/// ([`StoredArt::mount_as_stored`]); the brushes whose art `f` changed are then written back into +/// the library, and the art is taken off `d` again. `f` runs on `d` as it is when no stored brush's +/// art holds an image or a placed document, or when art is set on `d` already. +pub fn with_brush_art(d: &mut Document, f: impl FnOnce(&mut Document) -> T) -> T { + if !d.mounted_art.is_empty() { + return f(d); + } + let art = StoredArt::read(d); + if art.is_empty() { + return f(d); + } + art.mount_as_stored(d); + let out = f(d); + art.write_back(d); + out +} + +/// Every placed document in `d`'s art, the art of its stored brushes included, replaced by a group +/// of the art it shows, with the resources that art uses, as a save for a version older than +/// [`VERSION`] writes it. Without a placed document, the resources only exporting adds are dropped +/// ([`Document::drop_placed_resources`]). +pub fn placed_as_groups(d: &mut Document) { + with_brush_art(d, |d| { + if d.has_placed() { + *d = d.with_placed_art().into_owned(); + d.placed_as_groups(); + } else { + d.drop_placed_resources(); + } + }); +} + /// Serialize a document (pretty = human-diffable) in the current version. Editing-mode working /// copies are left out ([`Document::without_edit_modes`]). pub fn save(doc: &Document, pretty: bool) -> Vec { diff --git a/crates/format/tests/it/links.rs b/crates/format/tests/it/links.rs index e5a789f0d1..557218a706 100644 --- a/crates/format/tests/it/links.rs +++ b/crates/format/tests/it/links.rs @@ -4,8 +4,9 @@ use std::sync::Arc; use serde_json::Value; -use vectorcraft_doc::{Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind}; -use vectorcraft_format::{base64_decode, load, save}; +use vectorcraft_brush::{Brush, BrushKind, PatternBrush, Scatter}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; +use vectorcraft_format::{SaveOptions, base64_decode, load, save, save_with}; use vectorcraft_geom::Affine; fn image(d: &mut Document, key: &str, link: Option) { @@ -59,3 +60,94 @@ fn link_details_round_trip_and_linked_only_images_save_their_preview() { let again: Value = serde_json::from_slice(&save(&back, false)).unwrap(); assert_eq!(again["images"]["linked"], v["images"]["linked"]); } + +/// An image object for a stored brush's art. +fn brush_image(key: &str, link: Option) -> Node { + let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default(), pixels: None }; + Node::new(NodeId(0), NodeKind::Image(Box::new(im))) +} + +/// Stores a scatter brush with `scatter` as its art and a pattern brush with `tile` as its side tile. +fn brushes(d: &mut Document, scatter: Node, tile: Node) { + vectorcraft_brush::store( + d, + &[ + Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: scatter, ..Default::default() }) }, + Brush { name: "Frame".into(), kind: BrushKind::Pattern(PatternBrush { side: tile, ..Default::default() }) }, + ], + ); +} + +/// `key`'s entry in the saved file `v`: its bytes and its `proxy` flag. +fn saved_image(v: &Value, key: &str) -> (Vec, Value) { + (base64_decode(v["images"][key]["data"].as_str().unwrap()).unwrap(), v["images"][key]["proxy"].clone()) +} + +/// A linked image shown only in a stored brush's art (a scatter brush's art, a pattern brush's tile) +/// is saved as its preview, as one in the layers is, and with Include Linked Files as its file's +/// bytes; the brushes are saved as they were, their links kept. +#[test] +fn brush_only_link_saves_preview_unless_included() { + let mut d = Document::new(100.0, 100.0); + d.images.insert("dot".into(), blob(&[1, 2, 3], Some(&[9]))); + d.images.insert("tile".into(), blob(&[4, 5, 6], Some(&[8]))); + brushes(&mut d, brush_image("dot", Some(LinkInfo::new("/work/dot.jpg"))), brush_image("tile", Some(LinkInfo::new("/work/tile.jpg")))); + let stored = d.unknown[vectorcraft_brush::DOC_KEY].clone(); + + let bytes = save(&d, false); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(saved_image(&v, "dot"), (vec![9], Value::Bool(true))); + assert_eq!(saved_image(&v, "tile"), (vec![8], Value::Bool(true))); + let back = load(&bytes).unwrap(); + assert_eq!(back.unknown[vectorcraft_brush::DOC_KEY], stored, "the brushes are saved as they were"); + assert!(back.mounted_art.is_empty()); + let mut links = vec![]; + for b in vectorcraft_brush::library(&back) { + for art in b.art() { + art.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + links.extend(im.link.as_ref().map(|l| l.path.clone())); + } + }); + } + } + links.sort(); + assert_eq!(links, ["/work/dot.jpg", "/work/tile.jpg"]); + + let included = save_with(&d, &SaveOptions { include_linked: true, ..SaveOptions::default() }).unwrap(); + let v: Value = serde_json::from_slice(&included).unwrap(); + assert_eq!(saved_image(&v, "dot"), (vec![1, 2, 3], Value::Null)); + assert_eq!(saved_image(&v, "tile"), (vec![4, 5, 6], Value::Null)); +} + +/// An image key shown linked in one place and embedded in another keeps its full bytes, whichever +/// of the layers and a stored brush's art embeds it; the links stay as they were. +#[test] +fn shared_brush_image_key_keeps_full_bytes_for_embedded_use() { + for layer_linked in [true, false] { + let mut d = Document::new(100.0, 100.0); + d.images.insert("k".into(), blob(&[1, 2, 3], Some(&[9]))); + let link = Some(LinkInfo::new("/work/k.jpg")); + let (in_layer, in_brush) = if layer_linked { (link, None) } else { (None, link) }; + image(&mut d, "k", in_layer.clone()); + brushes(&mut d, brush_image("k", in_brush.clone()), brush_image("k", in_brush.clone())); + let bytes = save(&d, false); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(saved_image(&v, "k"), (vec![1, 2, 3], Value::Null), "linked in the layer: {layer_linked}"); + let back = load(&bytes).unwrap(); + let mut layer_links = vec![]; + for l in &back.layers { + l.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + layer_links.push(im.link.clone()); + } + }); + } + assert_eq!(layer_links, [in_layer]); + let brush_links: Vec> = vectorcraft_brush::library(&back) + .iter() + .flat_map(|b| b.art().into_iter().map(|n| if let NodeKind::Image(im) = &n.kind { im.link.clone() } else { None }).collect::>()) + .collect(); + assert_eq!(brush_links, [in_brush.clone(), in_brush]); + } +} diff --git a/crates/format/tests/it/native_v3.rs b/crates/format/tests/it/native_v3.rs index 512025375e..afc3bea9d2 100644 --- a/crates/format/tests/it/native_v3.rs +++ b/crates/format/tests/it/native_v3.rs @@ -212,6 +212,44 @@ fn text_composer_round_trips() { assert!(!String::from_utf8_lossy(&save(&d2, false)).contains("composer")); } +/// Every composer reads back as it was saved (the two Standard ones as their own values), and the +/// ME&SA names are the ones files have always used: `everyLine` stays the unwritten default and +/// `singleLine` keeps its spelling, so a document that never picks a Standard composer is unchanged. +#[test] +fn all_four_composers_round_trip() { + use vectorcraft_doc::{TextKind, TextObject}; + let one = |c: Composer| { + let mut d = Document::new(200.0, 200.0); + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "ક\u{ACD}ક", Default::default()); + t.para.composer = c; + t.kind = TextKind::Point; + let id = d.alloc_id(); + d.insert(None, usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + d + }; + for (c, written) in [ + (Composer::SingleLine, Some("singleLine")), + (Composer::EveryLine, None), + (Composer::StandardSingleLine, Some("standardSingleLine")), + (Composer::StandardEveryLine, Some("standardEveryLine")), + ] { + let bytes = save(&one(c), false); + let text = String::from_utf8_lossy(&bytes); + match written { + Some(w) => assert!(text.contains(&format!("\"composer\":\"{w}\"")), "{c:?}"), + None => assert!(!text.contains("\"composer\""), "{c:?}"), + } + let back = load(&bytes).unwrap(); + let mut got = None; + back.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + got = Some(t.para.composer); + } + }); + assert_eq!(got, Some(c)); + } +} + /// A document as the first version wrote it (format name `drawcraft`, anchors as maps). const V1_FILE: &str = r#"{"format":"drawcraft","version":1,"generator":"DrawCraft 0.1.0","document":{"version":1,"title":"Old","artboards":[{"id":1,"name":"Artboard 1","rect":{"x0":0.0,"y0":0.0,"x1":100.0,"y1":80.0}}], "layers":[{"id":1,"name":"Layer 1","visible":true,"locked":false,"opacity":1.0,"kind":{"type":"layer","children":[ diff --git a/crates/geom/src/anchors.rs b/crates/geom/src/anchors.rs new file mode 100644 index 0000000000..57b8081686 --- /dev/null +++ b/crates/geom/src/anchors.rs @@ -0,0 +1,790 @@ +//! The anchors of a subpath, held in shared chunks. +//! +//! [`Anchors`] keeps its anchors in chunks of at most [`CHUNK`] anchors, each behind an `Arc`. +//! Copying the list copies the table of chunks and shares the chunks themselves; changing an +//! anchor copies only the chunk that holds it when another copy still shares that chunk. An undo +//! step that keeps the version of a path before a one-anchor edit then keeps one chunk of its +//! own, however many anchors the path has. Inserting or removing an anchor copies the chunk it is +//! in and rebuilds the table, which has one entry per chunk. +//! +//! The list reads and writes as one flat sequence of anchors, as a `Vec` does. + +use std::borrow::Cow; +use std::fmt; +use std::ops::{Index, IndexMut, RangeBounds}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::path::Anchor; + +/// An [`Anchors`] list of the anchors given, as `vec!` makes a `Vec`. +#[macro_export] +macro_rules! anchors { + ($($t:tt)*) => { + $crate::Anchors::from(vec![$($t)*]) + }; +} + +/// Most anchors one chunk holds. +pub const CHUNK: usize = 256; + +/// The anchors of a subpath: a sequence with the reads of a slice and the edits of a `Vec`, whose +/// copies share the chunks they have not changed. +#[derive(Clone, Default)] +pub struct Anchors { + /// The chunks in order, none of them empty. + chunks: Vec>>, + /// Empty while every chunk but the last holds [`CHUNK`] anchors (anchor `i` is then in chunk + /// `i / CHUNK`). Otherwise `ends[c]` is the number of anchors in chunks `0..=c`. + ends: Vec, + /// The number of anchors. + len: usize, +} + +impl Anchors { + /// An empty list. + pub const fn new() -> Self { + Self { chunks: Vec::new(), ends: Vec::new(), len: 0 } + } + + /// The number of anchors. + pub fn len(&self) -> usize { + self.len + } + + /// Whether the list has no anchors. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// The chunk that holds anchor `i` and its place in that chunk. For `i` at or past the end the + /// chunk or the place is out of range. + fn locate(&self, i: usize) -> (usize, usize) { + if self.ends.is_empty() { + (i / CHUNK, i % CHUNK) + } else { + let c = self.ends.partition_point(|&e| e <= i); + let start = c.checked_sub(1).and_then(|p| self.ends.get(p)).copied().unwrap_or(0); + (c, i.saturating_sub(start)) + } + } + + /// Anchor `i`, or `None` past the end. + pub fn get(&self, i: usize) -> Option<&Anchor> { + if i >= self.len { + return None; + } + let (c, o) = self.locate(i); + self.chunks.get(c)?.get(o) + } + + /// Anchor `i` to change, or `None` past the end. Copies the chunk that holds it when another + /// list shares that chunk. + pub fn get_mut(&mut self, i: usize) -> Option<&mut Anchor> { + if i >= self.len { + return None; + } + let (c, o) = self.locate(i); + Arc::make_mut(self.chunks.get_mut(c)?).get_mut(o) + } + + /// The first anchor. + pub fn first(&self) -> Option<&Anchor> { + self.chunks.first()?.first() + } + + /// The last anchor. + pub fn last(&self) -> Option<&Anchor> { + self.chunks.last()?.last() + } + + /// The first anchor to change. + pub fn first_mut(&mut self) -> Option<&mut Anchor> { + Arc::make_mut(self.chunks.first_mut()?).first_mut() + } + + /// The last anchor to change. + pub fn last_mut(&mut self) -> Option<&mut Anchor> { + Arc::make_mut(self.chunks.last_mut()?).last_mut() + } + + /// The anchors in order. + pub fn iter(&self) -> Iter<'_> { + let (front, back): (&[Anchor], &[Anchor]) = (&[], &[]); + Iter { chunks: self.chunks.iter(), front: front.iter(), back: back.iter(), left: self.len } + } + + /// The anchors in order, to change. Each chunk is copied, when another list shares it, as the + /// iterator reaches it. + pub fn iter_mut(&mut self) -> IterMut<'_> { + let chunk: fn(&mut Arc>) -> std::slice::IterMut<'_, Anchor> = |c| Arc::make_mut(c).iter_mut(); + self.chunks.iter_mut().flat_map(chunk) + } + + /// The anchors as one slice: borrowed when they are in one chunk, copied otherwise. + pub fn contiguous(&self) -> Cow<'_, [Anchor]> { + match self.chunks.as_slice() { + [] => Cow::Borrowed(&[]), + [one] => Cow::Borrowed(one.as_slice()), + _ => Cow::Owned(self.to_vec()), + } + } + + /// The anchors in `range` copied into a `Vec`, or `None` when the range is out of bounds or + /// starts after it ends (as `slice::get` with a range). + pub fn get_range(&self, range: impl RangeBounds) -> Option> { + use std::ops::Bound; + let start = match range.start_bound() { + Bound::Included(&s) => s, + Bound::Excluded(&s) => s.checked_add(1)?, + Bound::Unbounded => 0, + }; + let end = match range.end_bound() { + Bound::Included(&e) => e.checked_add(1)?, + Bound::Excluded(&e) => e, + Bound::Unbounded => self.len, + }; + (start <= end && end <= self.len).then(|| self.iter().skip(start).take(end - start).copied().collect()) + } + + /// The anchors copied into a `Vec`. + pub fn to_vec(&self) -> Vec { + let mut v = Vec::with_capacity(self.len); + for c in &self.chunks { + v.extend_from_slice(c); + } + v + } + + /// The anchors as a `Vec`, without a copy when they are in one chunk no other list shares. + pub fn into_vec(mut self) -> Vec { + if self.chunks.len() == 1 + && let Some(one) = self.chunks.pop() + { + return Arc::try_unwrap(one).unwrap_or_else(|shared| (*shared).clone()); + } + self.to_vec() + } + + /// Change the anchors as a `Vec` (for edits that move many of them, such as reversing). + pub fn with_vec(&mut self, f: impl FnOnce(&mut Vec) -> R) -> R { + let mut v = std::mem::take(self).into_vec(); + let r = f(&mut v); + *self = Self::from(v); + r + } + + /// Rebuild `ends` and `len` after the chunks changed, dropping empty chunks. + fn reindex(&mut self) { + self.chunks.retain(|c| !c.is_empty()); + self.len = self.chunks.iter().map(|c| c.len()).sum(); + let n = self.chunks.len(); + let uniform = self.chunks.iter().enumerate().all(|(k, c)| if k + 1 == n { c.len() <= CHUNK } else { c.len() == CHUNK }); + self.ends.clear(); + if !uniform { + let mut total = 0; + self.ends.extend(self.chunks.iter().map(|c| { + total += c.len(); + total + })); + } + } + + /// Add `a` at the end. + pub fn push(&mut self, a: Anchor) { + match self.chunks.last_mut() { + Some(last) if last.len() < CHUNK => Arc::make_mut(last).push(a), + _ => { + self.chunks.push(Arc::new(vec![a])); + if !self.ends.is_empty() { + self.ends.push(self.len); + } + } + } + self.len += 1; + if let Some(end) = self.ends.last_mut() { + *end = self.len; + } + } + + /// Remove the last anchor. + pub fn pop(&mut self) -> Option { + let last = self.chunks.last_mut()?; + let a = Arc::make_mut(last).pop(); + if last.is_empty() { + self.chunks.pop(); + self.ends.pop(); + } + self.len = self.len.saturating_sub(1); + if let Some(end) = self.ends.last_mut() { + *end = self.len; + } + a + } + + /// Insert `a` before anchor `i`, or at the end when `i` is the length or more. + pub fn insert(&mut self, i: usize, a: Anchor) { + if i >= self.len { + self.push(a); + return; + } + let (c, o) = self.locate(i); + let Some(chunk) = self.chunks.get_mut(c) else { return }; + let chunk = Arc::make_mut(chunk); + chunk.insert(o.min(chunk.len()), a); + if chunk.len() > CHUNK { + let tail = chunk.split_off(chunk.len() / 2); + self.chunks.insert(c + 1, Arc::new(tail)); + } + self.reindex(); + } + + /// Remove anchor `i`, or `None` past the end. + pub fn try_remove(&mut self, i: usize) -> Option { + if i >= self.len { + return None; + } + let (c, o) = self.locate(i); + let chunk = Arc::make_mut(self.chunks.get_mut(c)?); + let a = (o < chunk.len()).then(|| chunk.remove(o)); + // A chunk left small joins the next one when the two fit in one chunk. + if chunk.len() < CHUNK / 4 + && let Some(next) = self.chunks.get(c + 1) + && self.chunks.get(c).map_or(0, |c| c.len()) + next.len() <= CHUNK + { + let next = self.chunks.remove(c + 1); + if let Some(chunk) = self.chunks.get_mut(c) { + Arc::make_mut(chunk).extend_from_slice(&next); + } + } + self.reindex(); + a + } + + /// Remove anchor `i`. Panics past the end, as `Vec::remove` does. + pub fn remove(&mut self, i: usize) -> Anchor { + match self.try_remove(i) { + Some(a) => a, + None => { + let none: &[Anchor] = &[]; + none[i] + } + } + } + + /// Keep the first `n` anchors. + pub fn truncate(&mut self, n: usize) { + if n >= self.len { + return; + } + let (c, o) = self.locate(n); + self.chunks.truncate(c + 1); + if let Some(last) = self.chunks.last_mut() { + Arc::make_mut(last).truncate(o); + } + self.reindex(); + } + + /// Remove every anchor. + pub fn clear(&mut self) { + *self = Self::new(); + } + + /// Add the anchors of `s` at the end. + pub fn extend_from_slice(&mut self, s: &[Anchor]) { + self.extend(s.iter().copied()); + } + + /// Keep the anchors for which `f` is true. + pub fn retain(&mut self, f: impl FnMut(&Anchor) -> bool) { + self.with_vec(|v| v.retain(f)); + } + + /// Keep the anchors for which `f` is true, letting `f` change them. + pub fn retain_mut(&mut self, f: impl FnMut(&mut Anchor) -> bool) { + self.with_vec(|v| v.retain_mut(f)); + } + + /// Reverse the order of the anchors. + pub fn reverse(&mut self) { + self.with_vec(|v| v.reverse()); + } + + /// Rotate the anchors `k` places towards the start. + pub fn rotate_left(&mut self, k: usize) { + self.with_vec(|v| v.rotate_left(k)); + } + + /// Rotate the anchors `k` places towards the end. + pub fn rotate_right(&mut self, k: usize) { + self.with_vec(|v| v.rotate_right(k)); + } + + /// Remove the anchors in `range` and return them. + pub fn drain(&mut self, range: impl RangeBounds) -> std::vec::IntoIter { + self.with_vec(|v| v.drain(range).collect::>()).into_iter() + } + + /// Replace the anchors in `range` with those of `with`, and return the anchors replaced. + pub fn splice(&mut self, range: impl RangeBounds, with: impl IntoIterator) -> std::vec::IntoIter { + self.with_vec(|v| v.splice(range, with).collect::>()).into_iter() + } + + /// The pairs of anchors in the same place in `self` and `other`, with that place, in order. + /// When the two are chunked alike, the chunks they share in the same place are skipped (their + /// anchors are the same); otherwise every pair is visited. Stops at the end of the shorter list. + pub fn pairs_not_shared<'a>(&'a self, other: &'a Self) -> impl Iterator + 'a { + let alike = self.len == other.len && self.chunks.len() == other.chunks.len() && self.ends == other.ends; + let chunked = alike.then(|| { + let starts = self.chunks.iter().scan(0, |start, c| { + let at = *start; + *start += c.len(); + Some(at) + }); + starts + .zip(self.chunks.iter().zip(&other.chunks)) + .filter(|(_, (a, b))| !Arc::ptr_eq(a, b)) + .flat_map(|(at, (a, b))| a.iter().zip(b.iter()).enumerate().map(move |(o, (x, y))| (at + o, x, y))) + }); + let flat = (!alike).then(|| self.iter().zip(other.iter()).enumerate().map(|(i, (a, b))| (i, a, b))); + chunked.into_iter().flatten().chain(flat.into_iter().flatten()) + } + + /// Each chunk's address and capacity (in anchors): two lists hold a chunk in common when they + /// give the same address. For memory estimates. + pub fn chunk_allocations(&self) -> impl Iterator + '_ { + self.chunks.iter().map(|c| (Arc::as_ptr(c) as usize, c.capacity())) + } + + /// The bytes the list's own tables hold on the heap (the chunk table and the running totals), + /// without the chunks. + pub fn table_bytes(&self) -> usize { + self.chunks.capacity() * size_of::>>() + self.ends.capacity() * size_of::() + } +} + +/// [`Anchors::iter_mut`]'s iterator. +pub type IterMut<'a> = std::iter::FlatMap< + std::slice::IterMut<'a, Arc>>, + std::slice::IterMut<'a, Anchor>, + fn(&mut Arc>) -> std::slice::IterMut<'_, Anchor>, +>; + +/// [`Anchors::iter`]'s iterator. +#[derive(Clone)] +pub struct Iter<'a> { + /// The chunks neither end has reached. + chunks: std::slice::Iter<'a, Arc>>, + /// What is left of the chunk the front is in. + front: std::slice::Iter<'a, Anchor>, + /// What is left of the chunk the back is in. + back: std::slice::Iter<'a, Anchor>, + /// The anchors left. + left: usize, +} + +impl<'a> Iterator for Iter<'a> { + type Item = &'a Anchor; + + fn next(&mut self) -> Option<&'a Anchor> { + loop { + if let Some(a) = self.front.next() { + self.left -= 1; + return Some(a); + } + match self.chunks.next() { + Some(c) => self.front = c.iter(), + None => { + let a = self.back.next()?; + self.left -= 1; + return Some(a); + } + } + } + } + + fn size_hint(&self) -> (usize, Option) { + (self.left, Some(self.left)) + } + + fn nth(&mut self, mut n: usize) -> Option<&'a Anchor> { + // Whole chunks are skipped without visiting their anchors. + loop { + let f = self.front.len(); + if n < f { + self.left -= n + 1; + return self.front.nth(n); + } + n -= f; + self.left -= f; + self.front = Default::default(); + match self.chunks.as_slice().first() { + Some(c) if c.len() <= n => { + n -= c.len(); + self.left -= c.len(); + self.chunks.next(); + } + Some(_) => { + if let Some(c) = self.chunks.next() { + self.front = c.iter(); + } + } + None => { + let b = self.back.len(); + if n < b { + self.left -= n + 1; + return self.back.nth(n); + } + self.left -= b; + self.back = Default::default(); + return None; + } + } + } + } + + fn count(self) -> usize { + self.left + } + + fn last(mut self) -> Option<&'a Anchor> { + self.next_back() + } +} + +impl<'a> DoubleEndedIterator for Iter<'a> { + fn next_back(&mut self) -> Option<&'a Anchor> { + loop { + if let Some(a) = self.back.next_back() { + self.left -= 1; + return Some(a); + } + match self.chunks.next_back() { + Some(c) => self.back = c.iter(), + None => { + let a = self.front.next_back()?; + self.left -= 1; + return Some(a); + } + } + } + } +} + +impl ExactSizeIterator for Iter<'_> {} + +impl std::iter::FusedIterator for Iter<'_> {} + +impl Index for Anchors { + type Output = Anchor; + + /// Anchor `i`. Panics past the end, as indexing a slice does. + fn index(&self, i: usize) -> &Anchor { + let (c, o) = self.locate(i); + &self.chunks[c][o] + } +} + +impl IndexMut for Anchors { + /// Anchor `i` to change ([`Anchors::get_mut`]). Panics past the end, as indexing a slice does. + fn index_mut(&mut self, i: usize) -> &mut Anchor { + let (c, o) = self.locate(i); + &mut Arc::make_mut(&mut self.chunks[c])[o] + } +} + +impl From> for Anchors { + fn from(v: Vec) -> Self { + if v.is_empty() { + return Self::new(); + } + let len = v.len(); + let chunks = if len <= CHUNK { vec![Arc::new(v)] } else { v.chunks(CHUNK).map(|c| Arc::new(c.to_vec())).collect() }; + Self { chunks, ends: Vec::new(), len } + } +} + +impl From<&[Anchor]> for Anchors { + fn from(s: &[Anchor]) -> Self { + Self::from(s.to_vec()) + } +} + +impl From<[Anchor; N]> for Anchors { + fn from(s: [Anchor; N]) -> Self { + Self::from(Vec::from(s)) + } +} + +impl From for Vec { + fn from(a: Anchors) -> Self { + a.into_vec() + } +} + +impl FromIterator for Anchors { + fn from_iter>(it: I) -> Self { + Self::from(it.into_iter().collect::>()) + } +} + +impl Extend for Anchors { + fn extend>(&mut self, it: I) { + for a in it { + self.push(a); + } + } +} + +impl<'a> Extend<&'a Anchor> for Anchors { + fn extend>(&mut self, it: I) { + self.extend(it.into_iter().copied()); + } +} + +impl<'a> IntoIterator for &'a Anchors { + type Item = &'a Anchor; + type IntoIter = Iter<'a>; + + fn into_iter(self) -> Iter<'a> { + self.iter() + } +} + +impl<'a> IntoIterator for &'a mut Anchors { + type Item = &'a mut Anchor; + type IntoIter = IterMut<'a>; + + fn into_iter(self) -> IterMut<'a> { + self.iter_mut() + } +} + +impl IntoIterator for Anchors { + type Item = Anchor; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> std::vec::IntoIter { + self.into_vec().into_iter() + } +} + +impl PartialEq for Anchors { + fn eq(&self, other: &Self) -> bool { + self.len == other.len && self.iter().eq(other.iter()) + } +} + +impl PartialEq<[Anchor]> for Anchors { + fn eq(&self, other: &[Anchor]) -> bool { + self.len == other.len() && self.iter().eq(other.iter()) + } +} + +impl PartialEq<&[Anchor]> for Anchors { + fn eq(&self, other: &&[Anchor]) -> bool { + *self == **other + } +} + +impl PartialEq> for Anchors { + fn eq(&self, other: &Vec) -> bool { + *self == **other + } +} + +impl PartialEq<[Anchor; N]> for Anchors { + fn eq(&self, other: &[Anchor; N]) -> bool { + *self == other[..] + } +} + +impl PartialEq for [Anchor] { + fn eq(&self, other: &Anchors) -> bool { + *other == *self + } +} + +impl PartialEq for Vec { + fn eq(&self, other: &Anchors) -> bool { + *other == **self + } +} + +impl fmt::Debug for Anchors { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list().entries(self.iter()).finish() + } +} + +impl Serialize for Anchors { + fn serialize(&self, s: S) -> Result { + s.collect_seq(self.iter()) + } +} + +impl<'de> Deserialize<'de> for Anchors { + fn deserialize>(d: D) -> Result { + Vec::::deserialize(d).map(Self::from) + } +} + +#[cfg(test)] +mod tests { + use kurbo::Point; + + use super::*; + + fn line(n: usize) -> Vec { + (0..n).map(|i| Anchor::corner(Point::new(i as f64, (i % 7) as f64))).collect() + } + + /// Chunks of `a` that `b` does not hold, with their capacity in anchors. + fn own_chunks(a: &Anchors, b: &Anchors) -> Vec { + let theirs: Vec = b.chunk_allocations().map(|(id, _)| id).collect(); + a.chunk_allocations().filter(|(id, _)| !theirs.contains(id)).map(|(_, cap)| cap).collect() + } + + #[test] + fn editing_one_anchor_copies_one_chunk_whatever_the_length() { + for n in [10_000, 200_000] { + let before = Anchors::from(line(n)); + let mut after = before.clone(); + after[n / 2].p = Point::new(-1.0, -1.0); + if let Some(a) = after.get_mut(n / 2 + 1) { + a.h_out = Point::new(-2.0, -2.0); + } + let copied = own_chunks(&after, &before); + assert_eq!(copied.len(), 1, "{n} anchors: one chunk copied"); + assert!(copied.iter().all(|&cap| cap <= CHUNK), "{n} anchors: {copied:?}"); + assert_eq!(own_chunks(&before, &after).len(), 1, "{n} anchors: the old version keeps one chunk of its own"); + // The old version is as it was; the new one differs only at the two anchors. + assert_eq!(before, line(n)); + let mut expected = line(n); + expected[n / 2].p = Point::new(-1.0, -1.0); + expected[n / 2 + 1].h_out = Point::new(-2.0, -2.0); + assert_eq!(after, expected); + } + } + + #[test] + fn inserting_and_removing_copy_only_the_chunk_touched() { + let n = 200_000; + let before = Anchors::from(line(n)); + let mut after = before.clone(); + let extra = Anchor::corner(Point::new(0.5, 0.5)); + after.insert(n / 2, extra); + assert!(own_chunks(&after, &before).len() <= 2, "the chunk split in two"); + assert_eq!(after.len(), n + 1); + assert_eq!(after[n / 2], extra); + assert_eq!(after[n / 2 + 1], line(n)[n / 2]); + assert_eq!(after.remove(n / 2), extra); + assert_eq!(after, line(n)); + assert!(own_chunks(&after, &before).len() <= 2); + assert_eq!(before, line(n)); + } + + #[test] + fn acts_as_a_vec_through_edits() { + // A run of edits applied to an `Anchors` and to a `Vec` leaves the two the same, across + // chunk splits, joins and the uniform and non-uniform layouts. + let mut v = line(700); + let mut a = Anchors::from(v.clone()); + let mut seed = 7u64; + let mut next = |m: usize| { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (seed >> 33) as usize % m.max(1) + }; + for step in 0..3000 { + let x = Anchor::corner(Point::new(step as f64, 0.25)); + match next(8) { + 0 | 1 => { + let i = next(v.len() + 1); + v.insert(i, x); + a.insert(i, x); + } + 2 | 3 if !v.is_empty() => { + let i = next(v.len()); + assert_eq!(v.remove(i), a.remove(i)); + } + 4 => { + v.push(x); + a.push(x); + } + 5 => assert_eq!(v.pop(), a.pop()), + 6 if !v.is_empty() => { + let i = next(v.len()); + v[i].h_in = x.p; + a[i].h_in = x.p; + } + _ => { + let keep = v.len().saturating_sub(next(3)); + v.truncate(keep); + a.truncate(keep); + } + } + assert_eq!(a.len(), v.len(), "step {step}"); + if step % 50 == 0 { + assert_eq!(a, v, "step {step}"); + for i in 0..v.len() { + assert_eq!(a.get(i), v.get(i), "step {step}, anchor {i}"); + } + } + } + assert_eq!(a, v); + assert_eq!(a.iter().rev().copied().collect::>(), v.iter().rev().copied().collect::>()); + assert_eq!(a.get(v.len()), None); + } + + #[test] + fn iterators_skip_and_count_as_a_slice_does() { + let v = line(1000); + let a = Anchors::from(v.clone()); + for skip in [0, 1, 255, 256, 257, 999, 1000, 1001] { + assert_eq!(a.iter().nth(skip), v.get(skip), "nth {skip}"); + assert_eq!(a.iter().skip(skip).count(), v.iter().skip(skip).count(), "skip {skip}"); + assert_eq!(a.iter().skip(skip).len(), v.iter().skip(skip).len(), "len after {skip}"); + } + let mut both = a.iter(); + let mut vs = v.iter(); + for k in 0..1000 { + let (x, y) = if k % 3 == 0 { (both.next_back(), vs.next_back()) } else { (both.next(), vs.next()) }; + assert_eq!(x, y, "step {k}"); + assert_eq!(both.len(), vs.len()); + } + assert_eq!(both.next(), None); + assert_eq!(a.iter().last(), v.last()); + let mut m = a.clone(); + m.iter_mut().for_each(|x| x.p.x += 1.0); + assert!(m.iter().zip(&v).all(|(x, y)| x.p.x == y.p.x + 1.0)); + assert_eq!(a, v, "changing a copy left the original alone"); + } + + #[test] + fn reads_and_writes_as_a_flat_list() { + let v = line(600); + let a = Anchors::from(v.clone()); + assert_eq!(serde_json::to_string(&a).ok(), serde_json::to_string(&v).ok()); + let back: Anchors = serde_json::from_str(&serde_json::to_string(&v).unwrap_or_default()).unwrap_or_default(); + assert_eq!(back, v); + assert_eq!(format!("{a:?}"), format!("{v:?}")); + } + + #[test] + fn pairs_not_shared_skip_the_chunks_both_hold() { + let before = Anchors::from(line(10_000)); + let mut after = before.clone(); + after[5000].p = Point::new(-3.0, 0.0); + let pairs: Vec<_> = before.pairs_not_shared(&after).collect(); + assert!(pairs.len() <= CHUNK); + let changed: Vec = pairs.iter().filter(|(_, a, b)| a != b).map(|(i, _, _)| *i).collect(); + assert_eq!(changed, [5000]); + assert!(pairs.iter().all(|&(i, a, b)| *a == before[i] && *b == after[i])); + // Chunked differently: every pair. + let mut moved = before.clone(); + moved.insert(0, Anchor::corner(Point::ZERO)); + moved.remove(0); + assert_eq!(before.pairs_not_shared(&moved).count(), 10_000); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 12a7492e7f..7d3e3f0f4b 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -7,6 +7,7 @@ //! Coordinates are document points (1/72 in), y pointing down. #![forbid(unsafe_code)] +pub mod anchors; pub mod arc; pub mod bez; pub mod corners; @@ -17,6 +18,7 @@ pub mod recognize; pub mod shapes; pub mod snap; +pub use anchors::Anchors; pub use arc::ArcPath; pub use kurbo; pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2}; diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index bef18334ae..c5343171ef 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -6,6 +6,7 @@ use kurbo::{Affine, BezPath, CubicBez, ParamCurve, ParamCurveExtrema, ParamCurve use serde::{Deserialize, Serialize}; use crate::EPS; +use crate::anchors::Anchors; /// Fill rule for paths and compound paths. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -224,15 +225,15 @@ pub fn with_anchor_budget(max: usize, f: impl FnOnce() -> T) -> T { } /// A subpath's anchors read within [`with_anchor_budget`]. -fn anchors_within<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { +fn anchors_within<'de, D: serde::Deserializer<'de>>(d: D) -> Result { struct V; impl<'de> serde::de::Visitor<'de> for V { - type Value = Vec; + type Value = Anchors; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.write_str("a list of anchors") } - fn visit_seq>(self, mut a: A) -> Result, A::Error> { - let mut out = Vec::new(); + fn visit_seq>(self, mut a: A) -> Result { + let mut out = Anchors::new(); while let Some(anchor) = a.next_element::()? { if let Some(left) = ANCHORS_LEFT.get() { let left = left @@ -262,13 +263,17 @@ pub fn measured_segments() -> u64 { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct SubPath { #[serde(deserialize_with = "anchors_within")] - pub anchors: Vec, + pub anchors: Anchors, #[serde(default)] pub closed: bool, } impl SubPath { pub fn new(anchors: Vec, closed: bool) -> Self { + Self { anchors: anchors.into(), closed } + } + /// A subpath of `anchors`, sharing their chunks. + pub fn of(anchors: Anchors, closed: bool) -> Self { Self { anchors, closed } } /// Polyline from points. @@ -442,7 +447,7 @@ impl SubPath { let (run, cuts): (Vec, BTreeSet) = if self.closed { (self.anchors.iter().cycle().skip(first).take(n + 1).copied().collect(), cuts.map(|c| (c + n - first) % n).chain([n]).collect()) } else { - (self.anchors.clone(), cuts.collect()) + (self.anchors.to_vec(), cuts.collect()) }; let mut pieces = vec![]; let mut cur: Vec = vec![]; @@ -511,11 +516,11 @@ impl PathData { { subpaths.push(sp); } - cur = Some(SubPath { anchors: vec![Anchor::corner(p)], closed: false }); + cur = Some(SubPath::new(vec![Anchor::corner(p)], false)); last = p; } PathEl::LineTo(p) => { - let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false }); + let sp = cur.get_or_insert_with(|| SubPath::new(vec![Anchor::corner(last)], false)); sp.anchors.push(Anchor::corner(p)); last = p; } @@ -752,7 +757,7 @@ pub fn segment_bounds(seg: PathSeg) -> Rect { } fn push_curve(cur: &mut Option, last: Point, c1: Point, c2: Point, p: Point) { - let sp = cur.get_or_insert_with(|| SubPath { anchors: vec![Anchor::corner(last)], closed: false }); + let sp = cur.get_or_insert_with(|| SubPath::new(vec![Anchor::corner(last)], false)); if let Some(a) = sp.anchors.last_mut() { a.h_out = c1; } diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index 3baf55f147..0ae8a75190 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -814,7 +814,7 @@ mod tests { for decay in [20.0, 30.0, 50.0, 80.0, 95.0] { for clockwise in [true, false] { let p = spiral(center, 100.0, decay, 8, clockwise); - let anchors = &p.subpaths[0].anchors; + let anchors = &p.subpaths[0].anchors.to_vec(); let k = (decay / 100.0_f64).ln() / TAU; let mut worst = 0.0_f64; for pair in anchors.windows(2) { diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index b7cc85c1b9..ecbdf780fb 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -392,7 +392,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "type.lineComposer", "Toggle Line Composer", "Cmd+Alt+Shift+C", - "{} the selected type's paragraphs (or those the Type tool's selection touches) use the Single-line Composer when they all use the Every-line Composer, else the Every-line Composer (text.setFormat {composer}) → \"singleLine\" | \"everyLine\"", + "{} the selected type's paragraphs (or those the Type tool's selection touches) use the Single-line Composer when they all use the Every-line Composer, else the Every-line Composer; paragraphs that all use a Standard composer keep it, any other mix takes the Middle Eastern & South Asian one (text.setFormat {composer}) → \"singleLine\" | \"everyLine\" | \"standardSingleLine\" | \"standardEveryLine\"", ), ( "type.hyphenate", @@ -432,6 +432,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("view.nextArtboard", "Next Artboard", "", "{} the navigator's Next Artboard button: view.goToArtboard {index: \"next\"} → {index}"), ("view.lastArtboard", "Last Artboard", "", "{} the navigator's Last Artboard button: view.goToArtboard {index: \"last\"} → {index}"), + ( + "view.statusBarShow", + "Status Bar Show", + "", + "{show: \"artboardName\" | \"currentTool\" | \"dateTime\" | \"undos\" | \"colorProfile\"} the status bar's Show menu (a click on its info field): the field shows the name of the artboard the navigator shows, the tool in use (the default), the date and time (local on the desktop, UTC on the web), the active document's undo and redo step counts, or the profile its colors use (the one assigned for its color mode, else, or when that one is not available, that mode's working space); kept with the UI state; any other value is an error → show", + ), ("view.edges", "Hide Edges", "Cmd+H", "{}"), ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), ( @@ -612,6 +618,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Shift+Tab", "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", ), + ( + "window.panels.peek", + "Show Hidden Panels at Edge", + "", + "{side: left|right|null} while Tab or Shift+Tab hides the panels, show the docked panels on that side of the canvas over its edge, as resting the pointer on that edge of the window for 0.3 s does; they hide again 0.3 s after the pointer moves away from them, and null hides them now. Floating panels, the toolbar and the Control bar stay hidden, and Tab or Shift+Tab shows the panels for good. Fails and changes nothing while the panels show, in Presentation Mode, for any other side, or when no panel is docked on that side; returns the side shown or null", + ), ( "window.panel", "Show Panel", @@ -1609,7 +1621,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.actionSet.load", "Load Actions…", "", - "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", + "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file. A Set Selection step (found by its internal name, whatever its label) comes in as select.byNote {note, caseSensitive, wholeWord} when it has exactly three recorded values: text (ustring, UTF-8; 1 to 1,024 bytes once trimmed), word and case (boolean, 0 or 1); with any other values it is left out. The other steps, every other step with no label and every other step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", ), ( "ui.actionSet.delete", diff --git a/crates/pathops/src/addons/cleanup.rs b/crates/pathops/src/addons/cleanup.rs index 75cf45b583..b0c000be9a 100644 --- a/crates/pathops/src/addons/cleanup.rs +++ b/crates/pathops/src/addons/cleanup.rs @@ -104,7 +104,7 @@ pub fn merge_duplicate_anchors(sp: &mut SubPath, distance: f64) -> usize { { *f = Anchor::with_handles(f.p, moved(f.p, last.p, last.h_in), f.h_out); } - sp.anchors = out; + sp.anchors = out.into(); before - sp.anchors.len() } diff --git a/crates/pathops/src/addons/keep_shape.rs b/crates/pathops/src/addons/keep_shape.rs index a3721f8f22..12ba4b21df 100644 --- a/crates/pathops/src/addons/keep_shape.rs +++ b/crates/pathops/src/addons/keep_shape.rs @@ -27,7 +27,7 @@ const ACCURACY: f64 = 1e-6; fn merged(prev: &Anchor, cur: &Anchor, next: &Anchor) -> CubicBez { let mut run = SubPath::new(vec![*prev, *cur, *next], false); remove_anchor(&mut run, 1); - match run.anchors.as_slice() { + match &*run.anchors.contiguous() { [a, b] => CubicBez::new(prev.p, a.h_out, b.h_in, next.p), _ => CubicBez::new(prev.p, prev.h_out, next.h_in, next.p), } diff --git a/crates/pathops/src/addons/pathtools/shared_edge.rs b/crates/pathops/src/addons/pathtools/shared_edge.rs index 6b4e8bfb1e..5acee01def 100644 --- a/crates/pathops/src/addons/pathtools/shared_edge.rs +++ b/crates/pathops/src/addons/pathtools/shared_edge.rs @@ -377,7 +377,7 @@ fn splice(sp: &SubPath, st: Stretch, chain: &[CubicBez]) -> Option { let own = backward(chain); let chain = if st.reversed { own.as_slice() } else { chain }; let (first, last) = (chain.first()?, chain.last()?); - let mut out = sp.anchors.clone(); + let mut out = sp.anchors.to_vec(); let start = st.start.min(out.len()); out.rotate_left(start); let settle = |a: &mut Anchor| { diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 87b4cb000d..3ad1281eb2 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use kurbo::{CubicBez, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Rect, Vec2}; use vectorcraft_geom::path::measured_arclen; -use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; +use vectorcraft_geom::{Anchor, AnchorKind, Anchors, PathData, SubPath}; use crate::boolean::{Seg, segs_to_subpath}; use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, largest_second_difference, sample, start_tangent, unit}; @@ -1091,7 +1091,7 @@ pub fn remove_anchor(sp: &mut SubPath, index: usize) -> bool { /// Refit the facing handles of anchors `pi` and `ni` so one cubic follows the two segments that /// meet at anchor `index` between them ([`remove_anchor`]). Two straight segments are left as /// they are, and so are the handles when the fit is not finite. -fn refit_across(anchors: &mut [Anchor], pi: usize, index: usize, ni: usize) { +fn refit_across(anchors: &mut Anchors, pi: usize, index: usize, ni: usize) { let (Some(&a), Some(&b), Some(&c)) = (anchors.get(pi), anchors.get(index), anchors.get(ni)) else { return }; let line = |a: &Anchor, b: &Anchor| !a.has_out() && !b.has_in(); if line(&a, &b) && line(&b, &c) { @@ -1233,7 +1233,7 @@ pub fn add_anchor_points(path: &PathData) -> PathData { v[2 * seg + 1] = Anchor { p: l.p3, h_in: l.p2, h_out: r.p1, kind: AnchorKind::Smooth }; } } - sp.anchors = v; + sp.anchors = v.into(); } out } diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 0b5b0bbf25..a31c9af2e4 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -769,7 +769,7 @@ fn add_anchor_points_doubles() { /// The one-pass build gives what inserting a midpoint into each segment (last first) gives, open or closed. #[test] fn add_anchor_points_matches_inserting() { - let open = PathData::single(SubPath::new(circle(0.0, 0.0, 10.0).subpaths[0].anchors.clone(), false)); + let open = PathData::single(SubPath::new(circle(0.0, 0.0, 10.0).subpaths[0].anchors.to_vec(), false)); for src in [circle(0.0, 0.0, 10.0), rect(0.0, 0.0, 10.0, 10.0), open] { let mut want = src.clone(); for sp in &mut want.subpaths { diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 403cbc5431..71b65e65d0 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -859,7 +859,9 @@ impl Exporter<'_> { /// Opens the blend mode and the transparency group `n` is drawn with (`container` when it is a /// group or layer whose children `knockout` each other). Returns how many were opened, and /// whether the children are to be drawn in an isolated group of their own: the knockout group of -/// an unclipped group's elements is its isolated group too. +/// an unclipped group's elements is its isolated group too. An opaque knockout group in Normal +/// mode that does not isolate opens no group: its elements are drawn on the art below it, in a +/// knockout group without `/I` when there are several. fn transparency(s: &mut Surface, n: &Node, page: Rect, container: bool, knockout: bool) -> (usize, bool) { let mut pushes = 0; if n.blend != BlendMode::Normal { @@ -876,6 +878,8 @@ fn transparency(s: &mut Surface, n: &Node, page: Rect, container: bool, knockout } else if n.opacity < 1.0 { s.push_opacity(norm(n.opacity)); pushes += 1; + } else if knockout && !n.isolate && n.blend == BlendMode::Normal { + // A knockout group that does not isolate: its elements start from the art below it. } else if knockout && matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { isolated_children = true; } else { diff --git a/crates/pdf/src/export/paint.rs b/crates/pdf/src/export/paint.rs index 6baffa48a8..2855a61ff1 100644 --- a/crates/pdf/src/export/paint.rs +++ b/crates/pdf/src/export/paint.rs @@ -529,18 +529,18 @@ impl Exporter<'_> { /// their own. fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) { let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points(); - let prints_spot = || g.gradient.stops.iter().zip(g.gradient.drawn_stops()).any(|(s, d)| self.printed_spot(s, d).is_some()); - if from_stops && prints_spot() { + if from_stops && self.prints_spot(&g.gradient) { self.warn( "spot colors in gradients exported as images (freeform or dithered gradients, ones mixed around the hue wheel, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", ); } } - /// Is a stop of `g` linked to a spot swatch? - fn has_spot_stop(&self, g: &vectorcraft_color::Gradient) -> bool { - let doc = self.doc; - g.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) + /// Does a stop of `g` print a spot swatch ([`Self::printed_spot`]): color something that draws + /// ([`vectorcraft_color::Gradient::drawn_stops`]) at a tint above 0 or with process plates? A + /// stop linked to a spot swatch that colors nothing adds no spot ink. + fn prints_spot(&self, g: &vectorcraft_color::Gradient) -> bool { + g.stops.iter().zip(g.drawn_stops()).any(|(s, d)| self.printed_spot(s, d).is_some()) } /// Draw diamond gradient `g`, placed on `bounds`, over `area` (inside the current clip) as four @@ -638,8 +638,8 @@ impl Exporter<'_> { /// scaled type gets its pixels too): a freeform gradient's colour field (at most /// [`MAX_FIELD_PX`] a side), or a linear, radial, conic or diamond gradient (at most [`MAX_GRADIENT_PX`] a side), /// with dither's noise when it paints a fill (`fill`). A linear or radial gradient whose stops - /// are all CMYK and not linked to a spot swatch is a CMYK image, unless its Perceptual curve - /// has too many stops; any other is an RGB image. + /// are all CMYK and none of which prints a spot swatch ([`Self::prints_spot`]) is a CMYK image, + /// unless its Perceptual curve has too many stops; any other is an RGB image. pub(super) fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) { use vectorcraft_color::cms::Model; use vectorcraft_color::freeform::{painted_box, spread_scale}; @@ -667,7 +667,7 @@ impl Exporter<'_> { } else if !freeform { self.warn("linear and radial gradients drawn as pixels are exported as images"); } - let ramp_inks = if freeform || self.has_spot_stop(&g.gradient) { None } else { g.ink_pixels(b, area, cols, rows, fill) }; + let ramp_inks = if freeform || self.prints_spot(&g.gradient) { None } else { g.ink_pixels(b, area, cols, rows, fill) }; let ink_image = |inks, alpha| { let convert = if self.convert { self.out.cmyk_pixels() } else { None }; crate::images::from_inks(inks, alpha, cols.into(), rows.into(), self.interpolate, convert) diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 6806aab756..ac1d77f372 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -87,8 +87,8 @@ fn icc_for(icc: Option>, space: ImageColorspace) -> Option>> icc.filter(|icc| valid_icc(icc, want)).map(Arc::new) } -/// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, -/// which reads tags of any size), another format's as its decoder reads it. `None` without one. +/// The ICC profile image `bytes` carries: a TIFF's tag 34675 (read by the TIFF decoder itself, up +/// to 16 MiB), another format's as its decoder reads it. `None` without one. pub(crate) fn source_icc(bytes: &[u8]) -> Option> { let reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?; if reader.format() == Some(ImageFormat::Tiff) { @@ -183,8 +183,8 @@ fn lossless( /// it holds is still written. pub(crate) const MAX_EMBED_PIXELS: u64 = 1 << 27; -/// The most bytes an ICC profile carried by an image is written with (16 MiB). -const MAX_ICC: usize = 16 << 20; +/// The most bytes an ICC profile carried by an image is written or read with (16 MiB). +pub(crate) const MAX_ICC: usize = 16 << 20; /// The CMYK ICC profile image `blob` carries ([`ImageBlob::cmyk_icc_profile`]), when it is one a /// CMYK image can be tagged with ([`valid_icc`]). `None` for other images and other profiles. diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index d978680d67..e5a5e2e382 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -27,7 +27,7 @@ use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, TextOverprint, Walk use crate::import_shading::{ FunctionBudget, FunctionPaint, clipped, extend_clip, fold_stop_opacity, function_paint, gradient_shape, mesh_shading, shading_gradient, }; -use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright}; +use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, TypeStroke, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; mod actual_text; diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 0b32054c7b..0d50c58f31 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -4,7 +4,7 @@ use super::entry::{ UNNAMED_CHARACTERS_NOTE, max_objects, }; use super::reshaped; -use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, same_stroke, stroke_layer}; +use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, mean_scale, paints, round3, same_stroke, stroke_layer, type_stroke}; use super::{ Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget, FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint, @@ -961,7 +961,7 @@ impl<'p> Builder<'p> { } None => (None, 1.0), }; - let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); + let stroke = stroke.zip(paint.clone()).map(|(p, paint)| type_stroke(paint, p, scale)); let look = Look { font: font_key, family: info.family, @@ -1109,7 +1109,7 @@ impl<'p> Builder<'p> { }; // What the glyph is drawn with as an outline, should its line be kept so. let drawn = paint.clone().map(|p| (p, stroke.cloned())); - let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); + let stroke = stroke.zip(paint.clone()).map(|(p, paint)| type_stroke(paint, p, scale)); let look = Look { font: key, family: info.family, diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index 12eb4498dc..7deb2bd73b 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -1,7 +1,7 @@ use super::cid::{CidText, FontInfo}; use super::{ Affine, Arc, BezPath, BlendMode, Colors, Dash, Families, FillRule, FontNames, FunctionBudget, HashMap, HashSet, ImageBlob, LineCap, LineFacts, - LineJoin, LumaData, MaskSpec, Node, NodeId, Paint, PathEl, PatternDef, Rect, Scan, StrokeLayer, StrokeProps, TextAs, TextLine, + LineJoin, LumaData, MaskSpec, Node, NodeId, Paint, PathEl, PatternDef, Rect, Scan, StrokeLayer, StrokeProps, TextAs, TextLine, TypeStroke, }; /// Decoded RGBA pixels, width, height and hayro's scale factors. @@ -277,6 +277,13 @@ pub(super) fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f st } +/// The stroke `p` draws type with in `paint` at `scale`: width, cap, join and miter limit as +/// [`stroke_layer`] takes them (#7741). +pub(super) fn type_stroke(paint: Paint, p: &StrokeProps, scale: f64) -> TypeStroke { + let st = stroke_layer(paint, 1.0, p, scale); + TypeStroke { paint: st.paint, width: st.width, cap: st.cap, join: st.join, miter_limit: st.miter_limit } +} + /// The most entries of a dash array kept (an even count keeps it a pattern of dashes and gaps). const MAX_DASH_ENTRIES: usize = 64; /// Most dash periods [`stroke_outline`] cuts a stroke into. diff --git a/crates/pdf/src/import_image.rs b/crates/pdf/src/import_image.rs index d376c2b31e..cafae6d448 100644 --- a/crates/pdf/src/import_image.rs +++ b/crates/pdf/src/import_image.rs @@ -2,7 +2,10 @@ //! four components. A JPEG whose Decode array inverts its samples (Adobe's CMYK JPEGs, the //! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any //! other is decoded, its Decode array applied, and stored as a CMYK TIFF -//! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer +//! ([`ImageBlob::cmyk_tiff`]). An ICC-based image keeps the CMYK profile its color space holds +//! ([`icc_profile`]): the TIFF carries it, and a kept JPEG carries it in place of its own +//! ([`jpeg_with_icc`]), or, when its segments can't hold it, is stored as a CMYK TIFF carrying it. +//! A mask (`/SMask`, `/Mask`) doesn't change that: the importer //! carries it as the image's opacity mask ([`has_mask`]). A DeviceN image of process plates only //! keeps them the same way, as a CMYK image ([`process_devicen`]). An image of one ink (a //! Separation, or a DeviceN of one colorant) keeps it: its tints are read as a grayscale image @@ -12,8 +15,9 @@ use craft_pdf::syntax::Filter; use craft_pdf::syntax::object::stream::{ImageColorSpace, ImageDecodeParams}; use craft_pdf::syntax::object::{Array, Dict, Name, Stream}; use vectorcraft_doc::ImageBlob; -use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components}; +use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components, jpeg_with_icc}; +use crate::images::{MAX_ICC, valid_icc}; use crate::import_color::ink_names; /// A CMYK image read as RGB: why. @@ -42,6 +46,24 @@ fn cmyk_space(d: &Dict<'_>) -> bool { } } +/// The profile of the image's four-component `ICCBased` color space, when it is a CMYK profile an +/// image can be tagged with ([`valid_icc`], at most [`MAX_ICC`] bytes). `None` for other color +/// spaces and other profiles: the image then keeps its samples untagged. +fn icc_profile(d: &Dict<'_>) -> Option> { + let a = d.get::>(key(d, b"ColorSpace", b"CS"))?; + let mut it = a.flex_iter(); + if it.next::>()?.as_ref() != b"ICCBased" { + return None; + } + let s = it.next::>()?; + if s.dict().get::(b"N") != Some(4) { + return None; + } + // One byte over the limit, so a longer profile reads as too long rather than cut short. + let icc = s.decoded_within(MAX_ICC + 1).ok()?; + valid_icc(&icc, *b"CMYK").then(|| icc.into_owned()) +} + /// Each component's Decode range (`[0 1]` each when the array is missing or malformed). fn decode_ranges(d: &Dict<'_>) -> [(f32, f32); 4] { let mut out = [(0.0, 1.0); 4]; @@ -63,11 +85,25 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result(key(d, b"BitsPerComponent", b"BPC")).unwrap_or(8); @@ -91,7 +127,7 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result, - pub stroke: Option<(Paint, f64)>, + pub stroke: Option, /// The fill, the stroke overprints. pub overprint_fill: bool, pub overprint_stroke: bool, @@ -102,7 +113,11 @@ impl Look { } fn style(&self) -> CharStyle { - let (stroke, stroke_width) = self.stroke.clone().unwrap_or((Paint::None, 0.0)); + let default = CharStyle::default(); + let (stroke, stroke_width, stroke_cap, stroke_join, stroke_miter_limit) = match self.stroke.clone() { + Some(s) => (s.paint, s.width, s.cap, s.join, s.miter_limit), + None => (Paint::None, 0.0, default.stroke_cap, default.stroke_join, default.stroke_miter_limit), + }; CharStyle { font_family: self.family.clone(), font_style: self.style.clone(), @@ -112,9 +127,12 @@ impl Look { fill: self.fill.clone().unwrap_or(Paint::None), stroke, stroke_width, + stroke_cap, + stroke_join, + stroke_miter_limit, overprint_fill: self.overprint_fill, overprint_stroke: self.overprint_stroke, - ..CharStyle::default() + ..default } } } @@ -187,6 +205,9 @@ pub(crate) struct TextLine { /// Runs of glyphs drawn alike: their look, how far the file raised them off the line's /// baseline (points, up; text rise, `Ts`), their text. runs: Vec<(Look, f64, String)>, + /// Where the last glyph's text starts in the last run (bytes): a stroke over that glyph + /// alone restyles only its text ([`stroke_last`](Self::stroke_last)). + last_start: usize, /// The bytes of text the horizontal glyphs added to `runs` so far (counted as they come: a /// sum over the runs for each glyph is quadratic when every glyph is a run). text_bytes: usize, @@ -257,6 +278,7 @@ impl TextLine { last_size: at.size, baseline: vec![], runs: vec![], + last_start: 0, text_bytes: 0, slots: vec![], authored: vec![], @@ -303,6 +325,7 @@ impl TextLine { let size = self.at.size.max(at.size); let Some((last, _, run)) = self.runs.last_mut() else { self.runs.push((look.clone(), 0.0, text.to_string())); + self.last_start = 0; self.record_actual_fill(look, at, content); self.authored.extend(text.chars().map(|_| content.authored())); (self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin); @@ -334,9 +357,11 @@ impl TextLine { && (matches!(content, TextContent::Pen) || (!matches!(content, TextContent::Actual(_)) && !self.authored.last().copied().unwrap_or(false))) { + self.last_start = run.len(); run.push_str(text); } else { self.runs.push((look.clone(), 0.0, text.to_string())); + self.last_start = 0; } self.record_actual_fill(look, at, content); self.authored.extend(text.chars().map(|_| content.authored())); @@ -425,12 +450,15 @@ impl TextLine { && (*last_shift - shift).abs() < 1e-3 && (pen || (!matches!(content, TextContent::Actual(_)) && !self.authored.last().copied().unwrap_or(false))) { + self.last_start = run.len(); run.push_str(text); } else { self.runs.push((look.clone(), shift, text.to_string())); + self.last_start = 0; } } else { self.runs.push((look.clone(), 0.0, text.to_string())); + self.last_start = 0; } self.record_actual_fill(look, at, content); self.text_bytes = self.text_bytes.saturating_add(text.len()); @@ -463,7 +491,7 @@ impl TextLine { /// The reader fills a whole Tj/TJ before replaying its glyphs as strokes. A replay /// must neither consume the replacement nor rewind the pen. Only the first glyph's /// style belongs to the replacement; suppressed glyphs have no text to restyle. - pub fn stroke_actual(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool { + pub fn stroke_actual(&mut self, font: u128, at: Placement, stroke: TypeStroke, overprint: bool) -> bool { let key = (font, at.origin.x.to_bits(), at.origin.y.to_bits()); let Some(run) = self.actual_fills.remove(&key) else { return false }; if let Some((look, ..)) = run.and_then(|i| self.runs.get_mut(i)) { @@ -501,13 +529,34 @@ impl TextLine { Some(bp) } - /// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill - /// and stroke rendering), which `overprint`s or not: the run is stroked. `false`: it isn't - /// that glyph. - pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool { - let Some((look, ..)) = self.runs.last_mut().filter(|(l, ..)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else { + /// A stroke (`stroke`: paint, width, cap, join and miter limit) over the glyph just drawn at + /// `at` in `font` (fill and stroke rendering), which `overprint`s or not: that glyph is + /// stroked. `false`: it isn't that glyph. + /// + /// A filled glyph joins a run of the same fill whatever its stroke ([`Look::takes`]), so the + /// run can hold glyphs before it that are stroked otherwise or not at all: then the glyph's + /// text becomes a run of its own with this stroke, and the glyphs before it keep theirs + /// (#7741). The line's text is the same either way, so the characters' slots, gaps and + /// authored flags, kept along the whole line, still line up. + pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: TypeStroke, overprint: bool) -> bool { + let start = self.last_start; + let Some((look, shift, text)) = + self.runs.last_mut().filter(|(l, ..)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) + else { return false; }; + let same = look.stroke.as_ref() == Some(&stroke) && look.overprint_stroke == overprint; + // Only the last glyph's text in the run (`start` within it, on a character boundary): + // split it off. A run of that glyph alone is stroked whole, as before. + if !same && start > 0 && start < text.len() && text.is_char_boundary(start) { + let tail = text.split_off(start); + let mut own = look.clone(); + (own.stroke, own.overprint_stroke) = (Some(stroke), overprint); + let shift = *shift; + self.runs.push((own, shift, tail)); + self.last_start = 0; + return true; + } (look.stroke, look.overprint_stroke) = (Some(stroke), overprint); true } diff --git a/crates/pdf/src/tests/blend.rs b/crates/pdf/src/tests/blend.rs index c55a0b7504..1cd5c949e0 100644 --- a/crates/pdf/src/tests/blend.rs +++ b/crates/pdf/src/tests/blend.rs @@ -151,6 +151,77 @@ fn a_knockout_group_is_written_as_one_pdf_knockout_group() { } } +/// A knockout group with nothing blending inside is drawn in an isolated group (`/I true`) only +/// when the group isolates, clipped or not, with opaque or translucent elements, and reads back +/// knocking out. +#[test] +fn a_knockout_group_without_blending_inside_is_isolated_only_when_it_isolates() { + for clip in [false, true] { + for opacity in [0.5, 1.0] { + for isolate in [false, true] { + let mut d = knockout_squares(3, false, isolate); + let g = d.layers[0].children().unwrap()[1].id; + let mut squares: Vec<_> = d.node(g).unwrap().children().unwrap().to_vec(); + for s in &mut squares { + std::sync::Arc::make_mut(s).opacity = opacity; + } + if clip { + let c = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(10.0, 10.0, 190.0, 190.0)), + Appearance::basic(Paint::None, Paint::None, 0.0), + ); + squares.insert(0, std::sync::Arc::new(c)); + } + d.node_mut(g).unwrap().kind = vectorcraft_doc::NodeKind::Group { children: squares, clip }; + let case = format!("clip {clip}, opacity {opacity}, isolate {isolate}"); + let t = text(&d); + assert_eq!(t.matches("/Ktrue").count(), 1, "{case}"); + // Each translucent element is an isolated form with its opacity. + let elements = if opacity < 1.0 { 3 } else { 0 }; + assert_eq!(t.matches("/Itrue").count(), elements + usize::from(isolate), "{case}"); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap_or_else(|e| panic!("{case}: {e}")); + let mut found = vec![]; + back.walk(|n| { + if n.knockout == vectorcraft_doc::Knockout::On { + found.push(n.isolate); + } + }); + assert_eq!(found, [false], "{case}"); + } + } + } +} + +/// An overprinting element of a knockout group that does not isolate is drawn in a `/K` group +/// without `/I true`, so it overprints the art below the group, and its graphics state still +/// overprints (`/OP`, `/op`, `/OPM 1`). An isolated group keeps `/I true`. +#[test] +fn an_overprinting_element_of_a_knockout_group_that_does_not_isolate_overprints_the_art_below() { + for isolate in [false, true] { + let mut d = knockout_squares(3, false, isolate); + let g = d.layers[0].children().unwrap()[1].id; + let squares: Vec<_> = d.node(g).unwrap().children().unwrap().iter().map(|s| s.id).collect(); + for s in &squares { + d.node_mut(*s).unwrap().opacity = 1.0; + } + d.node_mut(squares[2]).unwrap().appearance.fill_mut().unwrap().overprint = true; + let t = text(&d); + assert_eq!(t.matches("/Ktrue").count(), 1, "isolate {isolate}"); + assert_eq!(t.matches("/Itrue").count(), usize::from(isolate), "isolate {isolate}"); + assert!(t.contains("/OPtrue/optrue/OPM1"), "isolate {isolate}"); + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap_or_else(|e| panic!("isolate {isolate}: {e}")); + let mut overprints = vec![]; + back.walk(|n| { + if let Some(f) = n.appearance.fill() { + overprints.push(f.overprint); + } + }); + assert_eq!(overprints, [false, false, false, true], "isolate {isolate}"); + assert_eq!(knockout_groups(&back), [3], "isolate {isolate}"); + } +} + /// The knockout group of an isolated group is isolated too (`/I true` beside `/K true`), and the /// group reads back as isolated and knocking out. (Each translucent element is a group of its own.) #[test] diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 4719beec70..590afa3df9 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -176,9 +176,17 @@ fn a_cmyk_jpeg_passes_through_open_and_every_pdf_export() { assert_eq!(w[0].decode, [1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0], "{settings}"); assert!(w[0].data == jpg, "{settings}: the JPEG data unchanged"); assert!(out.warnings.iter().all(|w| !w.contains("image")), "{settings}: {:?}", out.warnings); - // And open again: the same JPEG. + // And open again: the same JPEG, carrying the profile it was written in when it was + // ICC-based. let back = import(&out.bytes).unwrap(); - assert_eq!(blobs(&back)[0].bytes.as_slice(), jpg.as_slice(), "{settings}"); + let blob = blobs(&back)[0]; + if icc_n.is_some() { + assert_eq!(blob.mime, "image/jpeg", "{settings}"); + assert_eq!(blob.cmyk(), b[0].cmyk(), "{settings}: the same samples"); + assert_eq!(blob.cmyk_icc_profile(), w[0].icc, "{settings}: the profile it was written in"); + } else { + assert_eq!(blob.bytes.as_slice(), jpg.as_slice(), "{settings}"); + } } } @@ -669,6 +677,125 @@ fn a_cmyk_image_with_a_malformed_profile_is_written_untagged() { assert_eq!((w.len(), w[0].space.as_str()), (1, "DeviceCMYK"), "{w:?}"); } +/// An ICC-based CMYK image opens with the profile its color space holds, byte for byte, beside its +/// ink amounts: stored as a CMYK TIFF carrying it whatever its Decode array and depth, and a CMYK +/// JPEG kept with its samples as they are, carrying that profile in place of its own. An export +/// with source profiles writes the image in that profile. DeviceCMYK and process-plate DeviceN +/// images have no profile of their own and stay untagged. +#[test] +fn an_icc_based_cmyk_image_opens_with_its_profile() { + let ink = [0u8, 102, 255, 0]; + let inks: Vec = [[0, 0, 0, 0], [255, 0, 0, 0], [10, 20, 30, 40], [0, 102, 255, 0]].concat(); + let z = |data: &[u8]| crate::output::deflate(data).unwrap(); + let inverted: Vec = inks.iter().map(|v| 255 - v).collect(); + let wide: Vec = inks.iter().flat_map(|v| (*v as u16 * 257).to_be_bytes()).collect(); + let icc = cmyk_profile(|_| {}); + let other = cmyk_profile(|i| i[152] = b'X'); + let jpg = |icc: Option<&[u8]>| match icc { + Some(icc) => cmyk_jpeg_with(&flat(ink, 4), 2, 2, icc), + None => cmyk_jpeg(&flat(ink, 4), 2, 2), + }; + let jpeg_inks = ImageBlob::new("image/jpeg", jpg(None)).cmyk().unwrap().data; + let iccbased = |more: &str| cmyk_dict(2, 2, more).replace("/DeviceCMYK", "[/ICCBased @icc 0 R]"); + // At most five images fit the page. The profile and a type 4 function follow them. + let open = |images: &[(String, Vec)]| { + let n = images.len(); + let images: Vec<(String, Vec)> = + images.iter().map(|(d, data)| (d.replace("@icc", &(5 + n).to_string()).replace("@fn", &(6 + n).to_string()), data.clone())).collect(); + let function = stream("/FunctionType 4 /Domain [0 1 0 1 0 1 0 1] /Range [0 1 0 1 0 1 0 1]", b"{ }"); + let r = import_with_report(&pdf_with_images(&images, &[stream("/N 4 /Filter /FlateDecode", &z(&icc)), function]), &ImportOptions::default()) + .unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(blobs(&r.document).len(), n); + r.document + }; + let tiffs = open(&[ + (iccbased("/Filter /FlateDecode"), z(&inks)), + (iccbased(&format!("/Filter /FlateDecode {INVERTED}")), z(&inverted)), + (iccbased("/Filter /FlateDecode").replace("/BitsPerComponent 8", "/BitsPerComponent 16"), z(&wide)), + (cmyk_dict(2, 2, "/Filter /FlateDecode"), z(&inks)), + (cmyk_dict(2, 2, "/Filter /FlateDecode").replace("/DeviceCMYK", "[/DeviceN [/Cyan /Magenta /Yellow /Black] /DeviceCMYK @fn 0 R]"), z(&inks)), + ]); + for (i, blob) in blobs(&tiffs).iter().enumerate() { + let profile = (i < 3).then_some(icc.as_slice()); + assert_eq!( + (blob.mime.as_str(), blob.cmyk().unwrap().data, blob.cmyk_icc_profile().as_deref()), + ("image/tiff", inks.clone(), profile), + "image {i}" + ); + } + // A JPEG without a profile, with the same one and with another. + let jpegs = open(&[ + (iccbased(&format!("/Filter /DCTDecode {INVERTED}")), jpg(None)), + (iccbased(&format!("/Filter /DCTDecode {INVERTED}")), jpg(Some(&icc))), + (iccbased(&format!("/Filter /DCTDecode {INVERTED}")), jpg(Some(&other))), + ]); + for (i, blob) in blobs(&jpegs).iter().enumerate() { + assert_eq!( + (blob.mime.as_str(), blob.cmyk().unwrap().data, blob.cmyk_icc_profile().as_deref()), + ("image/jpeg", jpeg_inks.clone(), Some(icc.as_slice())), + "image {i}" + ); + } + assert!(*blobs(&jpegs)[1].bytes == jpg(Some(&icc)), "the JPEG carrying the same profile, as it is"); + + // Images written the same way are written once: some are tagged, and only the TIFFs have + // untagged ones (the DeviceCMYK and DeviceN images, in the document's profile). + for (d, untagged) in [(tiffs, true), (jpegs, false)] { + let out = export_with_report(&d, &opts(json!({"output": {"profiles": "all"}}))).unwrap(); + let w: Vec = written(&out.bytes).into_iter().filter(|w| w.width == 2).collect(); + let tagged = w.iter().filter(|w| w.icc.as_deref() == Some(icc.as_slice())).count(); + assert!( + tagged > 0 && (w.len() > tagged) == untagged, + "{tagged} of {} tagged: {:?}", + w.len(), + w.iter().map(|w| (&w.space, &w.filter, w.icc.as_ref().map(|i| i.len()))).collect::>() + ); + } +} + +/// A profile an image can't be tagged with (malformed, of RGB colors, or over 16 MiB) leaves the +/// ICC-based image's inks as they are, untagged and without a warning; a profile of 16 MiB exactly +/// is kept. +#[test] +fn an_icc_based_cmyk_image_with_a_profile_it_cant_carry_opens_untagged() { + let inks: Vec = [[0, 0, 0, 0], [255, 0, 0, 0], [10, 20, 30, 40], [0, 102, 255, 0]].concat(); + let z = |data: &[u8]| crate::output::deflate(data).unwrap(); + let sized = |len: usize| { + cmyk_profile(|i| { + i.resize(len, 0); + i[0..4].copy_from_slice(&u32::try_from(len).unwrap().to_be_bytes()); + }) + }; + let limit = crate::images::MAX_ICC; + let jpeg = cmyk_jpeg(&flat([0, 102, 255, 0], 4), 2, 2); + for (what, icc, kept) in [ + ("signature", cmyk_profile(|i| i[36..40].copy_from_slice(b"xxxx")), false), + ("RGB color space", cmyk_profile(|i| i[16..20].copy_from_slice(b"RGB ")), false), + ("declared size", cmyk_profile(|i| i[0..4].copy_from_slice(&200u32.to_be_bytes())), false), + ("empty", vec![], false), + ("over 16 MiB", sized(limit + 1), false), + ("16 MiB", sized(limit), true), + ] { + let profile = stream("/N 4 /Filter /FlateDecode", &z(&icc)); + let images = [ + (cmyk_dict(2, 2, "/Filter /FlateDecode").replace("/DeviceCMYK", "[/ICCBased 7 0 R]"), z(&inks)), + (cmyk_dict(2, 2, &format!("/Filter /DCTDecode {INVERTED}")).replace("/DeviceCMYK", "[/ICCBased 7 0 R]"), jpeg.clone()), + ]; + let r = import_with_report(&pdf_with_images(&images, &[profile]), &ImportOptions::default()).unwrap(); + assert!(r.warnings.is_empty(), "{what}: {:?}", r.warnings); + let b = blobs(&r.document); + assert_eq!(b.len(), 2, "{what}"); + assert_eq!((b[0].mime.as_str(), b[0].cmyk().unwrap().data), ("image/tiff", inks.clone()), "{what}: the inks as they were"); + assert_eq!(b[0].cmyk_icc_profile().is_some_and(|p| p == icc), kept, "{what}"); + if kept { + assert_eq!(b[1].cmyk_icc_profile().is_some_and(|p| p == icc), kept, "{what}"); + } else { + assert!(b[1].mime == "image/jpeg" && *b[1].bytes == jpeg, "{what}: the JPEG as it is"); + } + } +} + /// A Press Quality export of a 2 × 2 CMYK image masked by PNG `mask` on its own pixel grid /// (`clip`, `invert`): the file, and the decoded soft mask of each CMYK image (`None`: it has none). fn export_masked(mask: Vec, clip: bool, invert: bool) -> (Vec, Vec>>) { @@ -962,6 +1089,35 @@ fn gradients_with_a_stop_that_is_not_process_cmyk_stay_rgb_images() { assert_eq!(w[0].space, "DeviceCMYK", "{w:?}"); } +/// A stop linked to a spot swatch that prints no ink (it colors only clear stretches, sits out of +/// order where it colors nothing, or has a tint of 0 and no process plates) leaves a dithered +/// gradient of CMYK stops a CMYK image without the spot warning; a drawn stop with process plates +/// at a tint of 0 still prints, so its gradient stays an RGB image with the warning. +#[test] +fn gradients_whose_spot_stops_print_nothing_stay_cmyk_images() { + let gold = Color::cmyk(0.0, 0.2, 0.8, 0.1); + let stop = |offset: f32, color: Color, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, color) }; + let spot = |offset: f32, opacity: f32| GradientStop { swatch: Some("Gold".into()), ..stop(offset, gold, opacity) }; + let (cyan, black) = (Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let clear = vec![stop(0.0, cyan, 1.0), stop(0.5, black, 1.0), stop(0.6, black, 0.0), spot(0.8, 0.0), stop(1.0, black, 0.0)]; + let out_of_order = vec![stop(0.0, cyan, 1.0), stop(0.6, black, 1.0), spot(0.3, 1.0), stop(0.6, black, 1.0), stop(1.0, black, 1.0)]; + let no_tint = vec![stop(0.0, cyan, 1.0), GradientStop { tint: 0.0, ..spot(1.0, 1.0) }]; + let plates = vec![stop(0.0, cyan, 1.0), GradientStop { tint: 0.0, plates: Some([0.0, 0.0, 0.0, 0.5]), ..spot(1.0, 1.0) }]; + for (name, stops, prints) in + [("clear", clear, false), ("out of order", out_of_order, false), ("tint 0", no_tint, false), ("plates", plates, true)] + { + let mut d = gradient_doc(ColorMode::Cmyk, drawn_as_pixels(GradientKind::Linear, stops, true)); + d.swatches.push(Swatch { name: "Gold".into(), paint: Paint::solid(gold), global: true, spot: true }); + let out = export_with_report(&d, &opts(json!({}))).unwrap(); + let w = written(&out.bytes); + let warned = out.warnings.iter().any(|m| m.contains("written in process colors")); + let space = if prints { "DeviceRGB" } else { "DeviceCMYK" }; + assert!(w.iter().any(|i| i.space == space), "{name}: {w:?}"); + assert!(!w.iter().any(|i| i.space == if prints { "DeviceCMYK" } else { "DeviceRGB" }), "{name}: {w:?}"); + assert_eq!(warned, prints, "{name}: {:?}", out.warnings); + } +} + #[test] fn freeform_gradients_of_lab_points_are_written_as_their_lab_mix() { let d = freeform_doc(ColorMode::Rgb, Color::lab(90.0, -60.0, 40.0), Color::lab(30.0, 60.0, -40.0), 1.0); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index ecd1f25ce1..7fa887a90f 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -662,6 +662,17 @@ fn a_spot_gradient_stop_prints_its_process_plates() { assert_eq!(first(json!({"output": {"mode": "separations", "spotsToProcess": true}})), [25.0, 10.0, 64.0, 5.0]); } +#[test] +fn a_spot_gradient_stop_whose_tint_is_not_a_number_prints_no_spot_ink() { + let mut mixed = GradientStop::new(0.0, Color::cmyk(0.25, 0.1, 0.8, 0.05)); + (mixed.swatch, mixed.tint, mixed.plates) = (Some("Gold".into()), f32::NAN, Some([0.25, 0.0, 0.4, 0.0])); + let g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![mixed, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))])); + let d = gold_gradient_doc(g); + let first = |v| gradient_plates(&d, v).into_iter().map(|(s, _)| s).collect::>(); + assert_eq!(first(json!({"output": {"mode": "separations"}})), [25.0, 0.0, 40.0, 0.0, 0.0], "Cyan, Magenta, Yellow, Black, Gold"); + assert_eq!(first(json!({"output": {"mode": "separations", "spotsToProcess": true}})), [25.0, 0.0, 40.0, 0.0]); +} + #[test] fn a_freeform_gradients_points_separate_into_plates() { let mut g = GradientPaint::new(Gradient::new(GradientKind::Freeform, vec![GradientStop::new(0.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))])); diff --git a/crates/pdf/src/tests/textstroke.rs b/crates/pdf/src/tests/textstroke.rs index 2669f02844..f28e525f27 100644 --- a/crates/pdf/src/tests/textstroke.rs +++ b/crates/pdf/src/tests/textstroke.rs @@ -55,3 +55,70 @@ fn an_aligned_character_stroke_is_clipped_to_its_side_of_the_glyphs() { assert!(ops.contains(&if clip { "W*" } else { "W" }), "{align:?}: clipped to its side"); } } + +/// The cap, join and miter limit a PDF strokes its type with, whatever the rendering mode that +/// strokes it (stroke, fill and stroke, and either with a clip), and with replacement text +/// (`ActualText`) too: not butt caps and miter joins (#7741). +#[test] +fn imported_stroked_type_keeps_the_files_cap_join_and_miter_limit() { + use vectorcraft_testkit::pdf::{PdfPage, pdf}; + let page = |content: &str| { + let resources = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>"; + pdf(&[PdfPage { resources: resources.into(), ..PdfPage::new(300.0, 200.0, content) }], None) + }; + let styles = |content: &str| { + let d = import(&page(content)).unwrap(); + let mut runs = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + runs.extend(t.runs.iter().map(|r| r.style.clone())); + } + }); + runs + }; + let cases = [ + ("1 J 1 j", (LineCap::Round, LineJoin::Round, 10.0)), + ("2 J 2 j 3 M", (LineCap::Square, LineJoin::Bevel, 3.0)), + ("0 J 0 j 3 M", (LineCap::Butt, LineJoin::Miter, 3.0)), + ]; + for (settings, want) in cases { + for mode in ["1", "2", "5", "6"] { + // A word shown at once, a glyph at a time (a stroke over the glyph just filled), and + // in a span with replacement text. + for text in ["(WAVE) Tj", "(W) Tj (A) Tj", "/Span << /ActualText (WAVE) >> BDC (WAVE) Tj EMC"] { + let content = format!("0 g 0 0 0 RG 4 w {settings} BT {mode} Tr /F1 60 Tf 40 110 Td {text} 0 Tr ET"); + let runs = styles(&content); + let stroked: Vec<_> = runs.iter().filter(|s| !s.stroke.is_none()).collect(); + assert!(!stroked.is_empty(), "{content}: stroked type comes in: {runs:?}"); + if matches!(mode, "2" | "6") && !text.starts_with("(WAVE)") { + assert!(stroked.iter().any(|s| !s.fill.is_none()), "{content}: the stroke is over the filled glyphs: {runs:?}"); + } + for s in stroked { + assert!((s.stroke_width - 4.0).abs() < 1e-6, "{content}: width {}", s.stroke_width); + let got = (s.stroke_cap, s.stroke_join, s.stroke_miter_limit); + assert_eq!(got, want, "{content}"); + } + } + } + } + // Glyphs stroked with another join are a run of their own. + let runs = styles("0 0 0 RG 4 w BT 1 Tr /F1 60 Tf 40 110 Td 1 j (WA) Tj 0 j (VE) Tj 0 Tr ET"); + let joins: Vec<_> = runs.iter().map(|s| s.stroke_join).collect(); + assert_eq!(joins, [LineJoin::Round, LineJoin::Miter]); + // Filled and stroked a glyph at a time: the next glyph's fill joins the run before it, and its + // stroke, with another join, restyles that glyph alone. + for mode in ["2", "6"] { + let content = format!("0 g 0 0 0 RG 4 w BT {mode} Tr /F1 60 Tf 40 110 Td 1 j (W) Tj 0 j (A) Tj 0 Tr ET"); + let runs = styles(&content); + let d = import(&page(&content)).unwrap(); + let mut text = String::new(); + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + text.push_str(&t.plain_text()); + } + }); + assert_eq!(text, "WA", "{content}"); + let joins: Vec<_> = runs.iter().map(|s| (!s.stroke.is_none()).then_some(s.stroke_join)).collect(); + assert_eq!(joins, [Some(LineJoin::Round), Some(LineJoin::Miter)], "{content}: {runs:?}"); + } +} diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index 6d549ed070..02b212cb23 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -620,6 +620,44 @@ mod tests { registry::remove(ID); } + /// A cached result whose gradient stop prints `plates` beside a spot swatch is refused once that + /// swatch is no longer a spot swatch, and used again when it is one again. + #[test] + fn cached_painted_plates_refuse_after_spot_becomes_process() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + const ID: &str = "test.effect.spotplates"; + let manifest = format!(r#"{{"id":"{ID}","name":"Test","version":"1","kind":"effect","api":2,"paintOutput":true,"params":{{}}}}"#); + let gray = json!({"model": "gray", "k": 0}); + let stops = json!([{"offset": 0, "color": gray}, {"offset": 1, "color": gray, "swatch": "Ink", "plates": [0.1, 0.2, 0.3, 0.4]}]); + let fill = json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": stops}}); + let square = PathData::single(SubPath::polyline(&[(0.0, 0.0), (9.0, 0.0), (9.0, 9.0)].map(|(x, y)| Point::new(x, y)), true)); + let output = json!({"objects": [{"path": square, "fills": [fill]}]}).to_string(); + let src = crate::wat::with_imports(&manifest, "", "", &output); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap(); + let with_ink = |spot: bool| { + let mut doc = Document::new(100.0, 100.0); + doc.swatches.push(vectorcraft_color::Swatch { + name: "Ink".into(), + paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), + global: true, + spot, + }); + doc + }; + let (spot, process) = (with_ink(true), with_ink(false)); + let path = line(2); + let draw = + |doc: &Document| apply_painted(ID, &json!({}), &path, Rect::new(0.0, 0.0, 1.0, 1.0), FillRule::NonZero, Paints::default(), Some(doc)); + assert!(draw(&spot).is_some()); + assert_eq!(last_error(ID), None); + assert!(draw(&process).is_none(), "from the cache, refused"); + let e = last_error(ID).unwrap(); + assert!(e.contains("objects[0].fills[0].gradient.stops[1].plates: `plates` requires a stop linked to a spot swatch"), "{e}"); + assert!(draw(&spot).is_some(), "from the cache, used again"); + registry::remove(ID); + } + /// A clock that reads `step` milliseconds later each time it is read. fn stepping(step: u64) -> crate::deferred::Clock { let t = Arc::new(std::sync::atomic::AtomicU64::new(0)); diff --git a/crates/plugins/src/painted.rs b/crates/plugins/src/painted.rs index 5b58318073..484e761677 100644 --- a/crates/plugins/src/painted.rs +++ b/crates/plugins/src/painted.rs @@ -7,8 +7,9 @@ //! An effect on a fill may return only `fills`, one on a stroke only `strokes`. //! //! The result is checked whole before any of it is used ([`decode`]): every number finite and in -//! range (nothing is clamped), every key known, and every swatch and pattern a paint names in the -//! document ([`check_references`]). One fault refuses the whole result, naming the field. +//! range (nothing is clamped), every key known, a gradient stop's `plates` only on a stop linked to +//! a spot swatch, and every swatch and pattern a paint names in the document ([`check_references`]). +//! One fault refuses the whole result, naming the field. use serde_json::Value; use vectorcraft_color::{Color, Paint}; @@ -236,7 +237,8 @@ fn paint_keys(v: &Value, at: &str) -> Result<()> { } /// A paint as the filter form has it (`null`: none), checked rather than clamped: colors, tints, -/// stops, opacities, midpoints and placements in range. +/// stops, opacities, midpoints and placements in range, and a gradient stop's `plates` only on a +/// stop linked to a swatch ([`check_references`] checks that the swatch is a spot swatch). fn paint(v: &Value, at: &str) -> Result { if v.is_null() { return Ok(Paint::None); @@ -265,6 +267,11 @@ fn paint(v: &Value, at: &str) -> Result { for x in s.plates.iter().flatten() { unit(*x, &format!("{at}.plates"), "a plate")?; } + // Plates print beside the tint of the spot swatch the stop itself links to; a + // gradient's own `swatch` does not link its stops. + if s.plates.is_some() && s.swatch.is_none() { + return Err(out_err(&format!("{at}.plates"), "`plates` requires a stop linked to a spot swatch")); + } } if stops.windows(2).any(|w| w[1].offset < w[0].offset) { return Err(out_err(&format!("{at}.gradient.stops"), "stops must be in order of `offset`")); @@ -308,8 +315,9 @@ fn paint(v: &Value, at: &str) -> Result { } /// Checks that every swatch a solid paint, gradient or gradient stop of `pieces` links to is a -/// swatch of `doc`, and every pattern a pattern paint names is one of its patterns. Without a -/// document (`None`), any swatch or pattern a piece names is refused. +/// swatch of `doc`, that a gradient stop with `plates` links to a spot swatch, and every pattern a +/// pattern paint names is one of its patterns. Without a document (`None`), any swatch or pattern a +/// piece names is refused. pub fn check_references(pieces: &[Piece], doc: Option<&Document>) -> Result<()> { let swatch = |name: &Option, at: &str| match name { Some(n) if doc.and_then(|d| d.swatch(n)).is_none() => Err(out_err(at, format!("the document has no swatch {n:?}"))), @@ -321,7 +329,16 @@ pub fn check_references(pieces: &[Piece], doc: Option<&Document>) -> Result<()> Paint::Solid { swatch: s, .. } => swatch(s, &format!("{at}.swatch")), Paint::Gradient(g) => { swatch(&g.swatch, &format!("{at}.swatch"))?; - g.gradient.stops.iter().enumerate().try_for_each(|(i, s)| swatch(&s.swatch, &format!("{at}.gradient.stops[{i}].swatch"))) + g.gradient.stops.iter().enumerate().try_for_each(|(i, s)| { + let at = format!("{at}.gradient.stops[{i}]"); + swatch(&s.swatch, &format!("{at}.swatch"))?; + match s.swatch.as_deref().and_then(|n| doc.and_then(|d| d.swatch(n))) { + Some(w) if s.plates.is_some() && !w.spot => { + Err(out_err(&format!("{at}.plates"), "`plates` requires a stop linked to a spot swatch")) + } + _ => Ok(()), + } + }) } Paint::Pattern { pattern, .. } if doc.and_then(|d| d.pattern(pattern)).is_none() => { Err(out_err(&format!("{at}.pattern"), format!("the document has no pattern {pattern:?}"))) diff --git a/crates/plugins/src/painted/tests.rs b/crates/plugins/src/painted/tests.rs index 3ed6dd653e..59acf18521 100644 --- a/crates/plugins/src/painted/tests.rs +++ b/crates/plugins/src/painted/tests.rs @@ -239,3 +239,97 @@ fn named_swatches_and_patterns_must_exist() { let e = check_references(&pieces(json!([{"path": square(), "fills": [{"type": "pattern", "pattern": "Dots"}]}])), None).unwrap_err().to_string(); assert!(e.contains("objects[0].fills[0].pattern"), "{e}"); } + +/// A gradient of two stops: a plain first one, then `stop`, with `extra` keys on the paint. +fn ramp(stop: Value, extra: Value) -> Value { + let mut g = json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [{"offset": 0, "color": {"model": "gray", "k": 0}}, stop]}}); + g.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + g +} + +/// A second stop with process `plates`, and `extra` keys. +fn plated(extra: Value) -> Value { + let mut s = json!({"offset": 1, "color": {"model": "gray", "k": 1}, "plates": [0.1, 0.2, 0.3, 0.4]}); + s.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + s +} + +/// A gradient stop's process `plates` need the stop's own link to a swatch: a stop without one +/// (no `swatch` or a `null` one) refuses the whole result, naming the stop's `plates`, in a fill or +/// a stroke at every scope. +#[test] +fn unlinked_gradient_stop_plates_refuse_the_whole_painted_result() { + const WHY: &str = "`plates` requires a stop linked to a spot swatch"; + for orphan in [ramp(plated(json!({})), json!({})), ramp(plated(json!({"swatch": null})), json!({}))] { + let fills = out(json!([{"path": square(), "fills": [red()]}, {"path": square(), "fills": [red(), orphan]}])); + for scope in [Scope::Object, Scope::Fill] { + let e = decode(&fills, scope, FillRule::NonZero).unwrap_err(); + assert_eq!(e.to_string(), format!("plug-in output: objects[1].fills[1].gradient.stops[1].plates: {WHY}"), "{scope:?}"); + } + let strokes = out(json!([{"path": square(), "strokes": [{"paint": red()}]}, {"path": square(), "strokes": [{"paint": orphan, "width": 2}]}])); + for scope in [Scope::Object, Scope::Stroke] { + let e = decode(&strokes, scope, FillRule::NonZero).unwrap_err(); + assert_eq!(e.to_string(), format!("plug-in output: objects[1].strokes[0].paint.gradient.stops[1].plates: {WHY}"), "{scope:?}"); + } + } + // `plugin.info`'s `lastError` names the plug-in before it. + let e = decode(&out(json!([{"path": square(), "fills": [ramp(plated(json!({})), json!({}))]}])), Scope::Object, FillRule::NonZero).unwrap_err(); + assert_eq!(named("Test Tone", &e), format!("Test Tone: plug-in output: objects[0].fills[0].gradient.stops[1].plates: {WHY}")); + // A plate out of range keeps its own error. + let wide = ramp(json!({"offset": 1, "color": {"model": "gray", "k": 1}, "plates": [2, 0, 0, 0]}), json!({})); + let e = decode(&out(json!([{"path": square(), "fills": [wide]}])), Scope::Object, FillRule::NonZero).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].gradient.stops[1].plates: a plate must be from 0 to 1"), "{e}"); +} + +/// The gradient's own `swatch` does not link its stops, so it does not admit a stop's `plates`. +#[test] +fn gradient_swatch_does_not_link_its_stops_plates() { + let fill = ramp(plated(json!({})), json!({"swatch": "Ink"})); + let e = decode(&out(json!([{"path": square(), "fills": [fill]}])), Scope::Object, FillRule::NonZero).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].gradient.stops[1].plates: `plates` requires a stop linked to a spot swatch"), "{e}"); +} + +/// A stop's `plates` beside a link to a swatch that is not a spot swatch (a process swatch or +/// Registration) refuse the result at the reference check; a link to a missing swatch keeps the +/// error that names the swatch. +#[test] +fn process_swatch_cannot_back_stop_plates() { + let mut doc = Document::new(100.0, 100.0); + doc.swatches.push(Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: true }); + doc.swatches.push(Swatch { name: "Process".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: false }); + let check = |pieces: Value| check_references(&decode(&out(pieces), Scope::Object, FillRule::NonZero).unwrap(), Some(&doc)); + for name in ["Process", vectorcraft_color::swatch::REGISTRATION] { + let fill = ramp(plated(json!({"swatch": name})), json!({})); + let e = check(json!([{"path": square(), "fills": [fill.clone()]}])).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].gradient.stops[1].plates: `plates` requires a stop linked to a spot swatch"), "{name}: {e}"); + let e = check(json!([{"path": square()}, {"path": square(), "strokes": [{"paint": fill}]}])).unwrap_err().to_string(); + assert!(e.contains("objects[1].strokes[0].paint.gradient.stops[1].plates:"), "{name}: {e}"); + // Without `plates`, a stop may link to any swatch of the document. + let plain = ramp(json!({"offset": 1, "color": {"model": "gray", "k": 1}, "swatch": name}), json!({})); + assert!(check(json!([{"path": square(), "fills": [plain]}])).is_ok(), "{name}"); + } + let gone = ramp(plated(json!({"swatch": "Gone"})), json!({})); + let e = check(json!([{"path": square(), "fills": [gone]}])).unwrap_err().to_string(); + assert!(e.contains("objects[0].fills[0].gradient.stops[1].swatch:"), "{e}"); +} + +/// A stop linked to a spot swatch keeps its `plates` as given, all-zero ones included, through the +/// reference check and the swatch colors. +#[test] +fn linked_spot_stop_plates_are_kept() { + let mut doc = Document::new(100.0, 100.0); + doc.swatches.push(Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0)), global: true, spot: true }); + let zero = json!({"offset": 0, "color": {"model": "gray", "k": 0}, "swatch": "Ink", "plates": [0, 0, 0, 0]}); + let fill = json!({"type": "gradient", "gradient": {"kind": "Linear", "stops": [zero, plated(json!({"swatch": "Ink"}))]}}); + let stroke = ramp(plated(json!({"swatch": "Ink"})), json!({})); + let pieces = + decode(&out(json!([{"path": square(), "fills": [fill], "strokes": [{"paint": stroke}]}])), Scope::Object, FillRule::NonZero).unwrap(); + assert!(check_references(&pieces, Some(&doc)).is_ok()); + let pieces = with_swatch_colors(std::sync::Arc::new(pieces), &doc); + let plates = |p: &Paint| match p { + Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.plates).collect::>(), + _ => panic!("{p:?}"), + }; + assert_eq!(plates(&pieces[0].fills.as_ref().unwrap()[0]), [Some([0.0; 4]), Some([0.1, 0.2, 0.3, 0.4])]); + assert_eq!(plates(&pieces[0].strokes.as_ref().unwrap()[0].paint), [None, Some([0.1, 0.2, 0.3, 0.4])]); +} diff --git a/crates/render/src/bounds.rs b/crates/render/src/bounds.rs index c3aaf98322..ae7ccba77e 100644 --- a/crates/render/src/bounds.rs +++ b/crates/render/src/bounds.rs @@ -58,6 +58,15 @@ pub fn drawn_bounds(doc: &Document, n: &Node) -> Option { fx::painted_reach(&doc.symbol_boxes(), n) } +/// For a path or compound path whose own Rasterize effect is followed by Transform effects that +/// move the art once ([`crate::effects::rasterize_stage`]): `n` with those Transform effects +/// hidden, which the image is made of, and the map they move the image by. The canvas draws the +/// image on its own pixel grid and moves it, and the room around the art, with the map. `None` +/// for other objects. +pub fn rasterize_stage(doc: &Document, n: &Node) -> Option<(Node, vectorcraft_geom::Affine)> { + fx::rasterize_stage(doc, n) +} + /// The outline, with `n`'s geometry effects applied, that the canvas clips `n`'s Rasterize with /// Create Clipping Mask to. Object › Rasterize's clipping mask follows it. pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0dd0beb0eb..2bf05ef537 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -72,7 +72,7 @@ mod renderer; pub(crate) use bounds::visual_bounds; pub(crate) use bounds::{ brushed_cull_bounds, cull_bounds, drawn_outline, drawn_outlines, painted_brush_width, painted_cull_bounds, painted_reach, path_cull_bounds, - three_d_art_bounds, with_own_raster_reach, + rasterize_stage, three_d_art_bounds, with_own_raster_reach, }; #[cfg(test)] pub(crate) use entries::Drawn; diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index c79260e1c0..e6400c90af 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -6,7 +6,7 @@ use super::{ }; use vectorcraft_doc::symbol_overrides::dynamic_art; -fn node_bezpath(n: &Node) -> Option { +pub(super) fn node_bezpath(n: &Node) -> Option { match &n.kind { NodeKind::Path { path, .. } => Some(path.to_bezpath()), NodeKind::Compound { children, .. } => { @@ -48,6 +48,34 @@ pub(crate) fn effected_path_carrying(doc: &Document, n: &Node, bp: &BezPath) -> apply_carrying(&n.appearance.effects, bp, &GeomContext { doc: Some(doc), ..GeomContext::of(n) }) } +/// `n`, a path or compound path whose own Rasterize effect is followed by Transform effects that +/// move its image ([`effects::rasterize_stage`]), with those Transform effects hidden, and the map +/// they move the image by ([`effects::stage_map`]): the image is made of that node's art on its +/// own pixel grid, then drawn through the map. `None` for any other object, when the node without +/// those Transform effects is drawn through its art ([`has_object_fx`]: a Transform effect that +/// copies it, Crop Marks, a 3D effect…), and when the art before the Transform effects is empty. +pub(crate) fn rasterize_stage(doc: &Document, n: &Node) -> Option<(Node, Affine)> { + if !has_fx(n) { + return None; + } + let at = effects::rasterize_stage(&n.appearance.effects)?; + let mut stage = n.clone(); + stage.appearance.effects = effects::staged_effects(&n.appearance.effects, &at); + if has_object_fx(&stage) { + return None; + } + let g = effected_path(doc, &stage, &node_bezpath(n)?); + let map = effects::stage_map(&n.appearance.effects, &at, &vectorcraft_geom::PathData::from_bezpath(&g))?; + Some((stage, map)) +} + +/// The box `map` ([`rasterize_stage`]) takes `b` to; `None` when it flattens it to a line or a +/// point, which leaves nothing to draw. +pub(crate) fn staged_reach(map: Affine, b: Rect) -> Option { + let det = map.determinant(); + (det.is_finite() && det.abs() > 1e-12).then(|| map.transform_rect_bbox(b)) +} + pub(super) fn item_effects(item: &AppearanceItem) -> &[Effect] { match item { AppearanceItem::Fill(f) => &f.effects, @@ -74,6 +102,11 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { { return cull_bounds(symbols, &art); } + // A Rasterize image moved by the Transform effects after it reaches as far as the map takes + // what the art before them reaches, the image included. + if let Some((stage, map)) = rasterize_stage(symbols.document(), n) { + return staged_reach(map, visual_bounds(symbols, &stage)?); + } let bp = node_bezpath(n)?; let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) }; let (g, carry) = effected_path_carrying(symbols.document(), n, &bp); @@ -449,6 +482,9 @@ pub(crate) fn painted_reach(symbols: &SymbolBoxes, n: &Node) -> Option { if let Some(art) = effects::crop_marks_art(n) { return drawn_bounds(symbols, &art); } + if let Some((stage, map)) = rasterize_stage(symbols.document(), n) { + return staged_reach(map, painted_reach(symbols, &stage)?); + } let ran = ran(symbols.document(), n); let b = drawn_bounds_ran(symbols, n, Some(&ran))?; Some(spread(symbols, &ran.appearance.effects, b)) diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index d1a8429fd6..0291d998f0 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -1,4 +1,4 @@ -use super::bounds::{cull_bounds, effected_path_carrying}; +use super::bounds::{cull_bounds, effected_path_carrying, node_bezpath, rasterize_stage}; use super::entries::{Content, Drawn, FX_RASTER_BUDGET, FxGeom, Inputs, ItemGeom, Kept, Made, Reads, Shadow, ShadowEntry}; use super::predicates::{evaluates_live, is_container, rasterize_bounds, reads_outside, shifted}; use super::raster::{ @@ -330,6 +330,11 @@ impl Renderer { /// `draw_shape` for nodes with live effects. pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) { + if !f.opts.outline + && let Some(stage) = self.rasterize_stage_of(f, n, self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned()) + { + return self.draw_staged(ctx, f, stage, bp, rule); + } // The node `draw_arc` is drawing gets its geometry from the cache: `bp` is its own path, // made from the node alone, so the effects give the same result every frame while the // plug-ins installed stay the same (`check_plugins`). @@ -360,6 +365,47 @@ impl Renderer { } } + /// [`rasterize_stage`] of `n`, kept across frames when `owner` is `n` as held in its document + /// (then `true` in the third place: the stage is kept, and so are its caches). + fn rasterize_stage_of(&mut self, f: &Frame, n: &Node, owner: Option>) -> Option<(Arc, Affine, bool)> { + // Checked first: this runs for every path with effects drawn. + effects::rasterize_stage(&n.appearance.effects)?; + let Some(owner) = owner else { + return rasterize_stage(f.symbols.document(), n).map(|(stage, map)| (Arc::new(stage), map, false)); + }; + let key = Arc::as_ptr(&owner) as usize; + let stamp = self.stamp; + if let Some(e) = self.fx_stages.get_mut(&key) + && Arc::ptr_eq(&e.0, &owner) + { + e.3 = stamp; + return Some((e.1.clone(), e.2, true)); + } + let (stage, map) = rasterize_stage(f.symbols.document(), n)?; + let stage = Arc::new(stage); + self.fx_stages.insert(key, (owner, stage.clone(), map, stamp)); + Some((stage, map, true)) + } + + /// Draw `stage`, the node a path's Rasterize image is made from ([`rasterize_stage`]), through + /// `map`, the Transform effects after that Rasterize effect: its art and its image, on the + /// image's own pixel grid, move with the map. A map that flattens the art leaves nothing to + /// draw. `bp` is the path's own path; with `kept`, `stage` is kept across frames and so are + /// the geometry and rasters drawn for it. + fn draw_staged(&mut self, ctx: &mut RenderContext, f: &Frame, (stage, map, kept): (Arc, Affine, bool), bp: &BezPath, rule: FillRule) { + let det = map.determinant(); + if !(det.is_finite() && det.abs() > 1e-12) { + return; + } + // A pixel's size in document units through the view and the map, measured as a frame + // measures it from its view. + let px = f.px / det.abs().sqrt(); + let fr = Frame { view: f.view * map, visible: map.inverse().transform_rect_bbox(f.visible), px, output: f.output * map, ..*f }; + let outer = if kept { self.fx_owner.replace(stage.clone()) } else { self.fx_owner.take() }; + self.draw_shape_fx(ctx, &fr, &stage, bp, rule); + self.fx_owner = outer; + } + /// [`Self::draw_shape_fx`] on the effected geometry `g`, with the items' cached geometry: /// `paints` is `n` with the fills and strokes it paints. #[allow(clippy::too_many_arguments)] @@ -417,6 +463,15 @@ impl Renderer { if !self.charge_fx_instance(f.doc, a) { return; } + // A path whose Transform effects after its Rasterize effect hold options that reach its + // fills and strokes: its image moves with them as for any other path. + if !f.opts.outline + && let NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } = &a.kind + && let Some(stage) = self.rasterize_stage_of(f, a, cache.then(|| a.clone())) + && let Some(bp) = node_bezpath(a) + { + return self.draw_staged(ctx, f, stage, &bp, *rule); + } let art = self.fx_art(f.doc, a, cache); // Art the cache did not keep is made again in the next frame. let kept = cache && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, &art)); diff --git a/crates/render/src/kept_paths.rs b/crates/render/src/kept_paths.rs index a6deb8e334..c609590d16 100644 --- a/crates/render/src/kept_paths.rs +++ b/crates/render/src/kept_paths.rs @@ -290,7 +290,7 @@ impl Sub { /// them. fn moved(&mut self, was: &SubPath, now: &SubPath, els: &mut [PathEl], st: Option<&StrokeLayer>) { let (mut first, mut segs) = (false, vec![]); - for (k, (a, b)) in was.anchors.iter().zip(&now.anchors).enumerate() { + for (k, a, b) in was.anchors.pairs_not_shared(&now.anchors) { if same(a, b) { continue; } @@ -489,7 +489,7 @@ fn differs(was: &PathData, now: &PathData) -> bool { !std::ptr::eq(was, now) && (was.subpaths.len() != now.subpaths.len() || was.subpaths.iter().zip(&now.subpaths).any(|(w, p)| { - w.closed != p.closed || w.anchors.len() != p.anchors.len() || w.anchors.iter().zip(&p.anchors).any(|(a, b)| !same(a, b)) + w.closed != p.closed || w.anchors.len() != p.anchors.len() || w.anchors.pairs_not_shared(&p.anchors).any(|(_, a, b)| !same(a, b)) })) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6a92d91e8f..c49671fd8c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -125,6 +125,10 @@ pub struct Renderer { live_root: Option>, /// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`. fx_geom: PtrMap, + /// Per node whose Rasterize image moves with the Transform effects after it, keyed by `Arc` + /// identity like `fx_geom`: the node, the node it draws its image from and the map that moves + /// the image ([`fx::rasterize_stage`]), and the frame it was last drawn in. + fx_stages: PtrMap, Arc, Affine, u64)>, stamp: u64, /// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer). alpha: f32, @@ -274,7 +278,7 @@ mod renderer; mod text_geom; mod threads; -pub use bounds::{drawn_bounds, drawn_outline, drawn_outlines, effect_bounds, painted_bounds}; +pub use bounds::{drawn_bounds, drawn_outline, drawn_outlines, effect_bounds, painted_bounds, rasterize_stage}; pub use frame::FrameStats; use frame::{ ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey, diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 500d62d5b4..58d378173d 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -56,6 +56,7 @@ impl Renderer { fx_owner: None, live_root: None, fx_geom: PtrMap::default(), + fx_stages: PtrMap::default(), stamp: 0, alpha: 1.0, contexts: Vec::new(), @@ -230,6 +231,9 @@ impl Renderer { if self.fx_geom.len() > 256 { self.fx_geom.retain(|_, e| g - e.stamp <= 3); } + if self.fx_stages.len() > 256 { + self.fx_stages.retain(|_, e| g - e.3 <= 3); + } // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); self.type_edges.sweep(g); diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 15bd354496..37f3fbf4f7 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -146,6 +146,7 @@ impl Renderer { } if stale { self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node)); + self.fx_stages.retain(|_, e| !effects::has_plugin_effect(&e.0)); self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); // The art of a group holds what its members' plug-in effects draw. self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node)); diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index d2d228154f..9174696d20 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -145,7 +145,7 @@ fn composite_font_parts_are_not_highlighted_as_substituted_glyphs() { let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new("JetBrains Mono", "Regular"); parts.numerals = CompositePart::new("Inter", "Regular"); - db.set_composite_font(&CompositeFont { name: "CF Highlight".into(), parts }); + db.set_composite_font(&CompositeFont { name: "CF Highlight".into(), parts, custom: Vec::new() }); let style = CharStyle { size: 40.0, font_family: "CF Highlight".into(), ..Default::default() }; let t = TextObject::point(Point::new(10.0, 60.0), "Ab12", style); let g = text_geom(&t); @@ -175,7 +175,7 @@ fn a_documents_own_composite_font_draws_without_being_registered() { let (mut composite, id) = typed(NAME); let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new("JetBrains Mono", "Regular"); - composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts }); + composite.composite_fonts.push(CompositeFont { name: NAME.into(), parts, custom: Vec::new() }); let (plain, plain_id) = typed("JetBrains Mono"); assert!(db.composite_font(NAME).is_none()); let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; @@ -236,7 +236,7 @@ fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() { let definition = |roman: &str| { let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new(roman, "Regular"); - CompositeFont { name: NAME.into(), parts } + CompositeFont { name: NAME.into(), parts, custom: Vec::new() } }; let typed = |family: &str| { let mut d = Document::new(300.0, 150.0); @@ -319,7 +319,7 @@ fn a_document_draws_type_in_another_documents_composite_font_as_missing() { }; let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular")); parts.roman = CompositePart::new("JetBrains Mono", "Regular"); - db.set_composite_font(&CompositeFont { name: NAME.into(), parts }); + db.set_composite_font(&CompositeFont { name: NAME.into(), parts, custom: Vec::new() }); let (drawn, as_missing, in_front) = (draw(&back, id), draw(&missing, missing_id), draw(&plain, plain_id)); db.remove_composite_font(NAME); assert!(drawn.0 != in_front.0, "not in the front document's definition"); diff --git a/crates/render/src/tests/keptpath.rs b/crates/render/src/tests/keptpath.rs index e7097c4850..12e87f272b 100644 --- a/crates/render/src/tests/keptpath.rs +++ b/crates/render/src/tests/keptpath.rs @@ -140,7 +140,7 @@ fn a_long_path_draws_and_culls_after_each_edit_as_when_its_path_is_made_again() }), ("that anchor moved off it", |p| moved(&mut p.subpaths[0].anchors[1535], Vec2::new(5.0, 5.0))), ("a doubled anchor moved off its twin", |p| moved(&mut p.subpaths[0].anchors[1536], Vec2::new(0.0, -12.0))), - ("1,200 anchors moved", |p| p.subpaths[0].anchors[..1200].iter_mut().for_each(|a| moved(a, Vec2::new(2.0, 1.0)))), + ("1,200 anchors moved", |p| p.subpaths[0].anchors.iter_mut().take(1200).for_each(|a| moved(a, Vec2::new(2.0, 1.0)))), ("an anchor added", |p| _ = p.subpaths[0].insert_anchor(1520, 0.5)), ("an anchor deleted", |p| _ = p.subpaths[0].anchors.remove(1521)), ("the subpath closed", |p| p.subpaths[0].closed = true), @@ -454,7 +454,7 @@ fn random_edit(g: &mut Seeded, p: &mut PathData) -> String { match g.below(3) { 0 => moved(&mut sp.anchors[at], Vec2::new(g.offset(30), g.offset(30))), 1 => sp.anchors[at].h_out = sp.anchors[at].p + Vec2::new(g.offset(10), g.offset(10)), - _ => sp.anchors[at..].iter_mut().take(n / 10).for_each(|a| moved(a, Vec2::new(1.0, -1.0))), + _ => sp.anchors.iter_mut().skip(at).take(n / 10).for_each(|a| moved(a, Vec2::new(1.0, -1.0))), } format!("anchors moved at {at} of subpath {s} of {n}") } @@ -486,7 +486,7 @@ fn same_as_made_again(k: &mut crate::kept_paths::KeptPath, n: &Arc, st: &S fn random_edits_of_a_long_path_keep_what_making_its_path_again_makes() { let st = stroke(6.0, LineJoin::Miter, LineCap::Butt); let mut g = Seeded(5744); - let closed = |sp: SubPath| SubPath::new(sp.anchors, true); + let closed = |sp: SubPath| SubPath::of(sp.anchors, true); let path = PathData::new(vec![ zigzag(2, 11_990.0), closed(zigzag(3, 12_000.0)), @@ -517,7 +517,7 @@ fn random_edits_of_a_long_compound_path_keep_what_making_its_path_again_makes() Arc::new(Node::path(NodeId(ids.get()), p, Appearance::default())) }; let children = vec![ - member(PathData::new(vec![SubPath::new(zigzag(3, 11_990.0).anchors, true), zigzag(5, 12_010.0)])), + member(PathData::new(vec![SubPath::of(zigzag(3, 11_990.0).anchors, true), zigzag(5, 12_010.0)])), member(PathData::single(zigzag(3000, 0.0))), member(PathData::single(ring(40, Point::new(12_000.0, 15.0), 30.0))), member(PathData::single(zigzag(2, 12_020.0))), diff --git a/crates/render/src/tests/rasterize.rs b/crates/render/src/tests/rasterize.rs index 3f27ddfebe..a374d43d7a 100644 --- a/crates/render/src/tests/rasterize.rs +++ b/crates/render/src/tests/rasterize.rs @@ -421,3 +421,99 @@ fn rasterize_fills_its_area_on_white_and_add_around_grows_it() { let stroked = draw(&stroked, 10.0, 400); assert_eq!((pixel(&stroked, 96, 105), pixel(&stroked, 93, 105)[3]), ([255; 4], 0)); } + +/// Rasterize params for hard-edged pixels a point wide, with `more`. +fn hard(more: Value) -> Value { + let mut p = json!({"useDocumentResolution": false, "resolution": 72, "antiAlias": "none"}); + if let (Some(p), Some(more)) = (p.as_object_mut(), more.as_object()) { + p.extend(more.clone()); + } + p +} + +/// A Transform effect after Rasterize enlarges the image it is given: each of the image's pixels +/// is drawn twice as wide, and so is the room around the art. +#[test] +fn rasterize_then_scale_uses_the_pretransform_pixels() { + for strokes in [false, true] { + let scale = ("distort.transform", json!({"scaleH": 200, "scaleV": 200, "strokes": strokes})); + let r = draw(&doc(triangle(), &[("rasterize", hard(json!({}))), scale.clone()], 72.0), 10.0, 400); + // The 20 × 20 pixels of the image over 10..30 pt, each 2 pt wide over 0..40 pt. + assert!(squares_are_flat(&r, 0, 20, 20), "Scale Strokes & Effects {strokes}"); + assert_eq!((pixel(&r, 10, 10), pixel(&r, 390, 390)[3]), ([255, 0, 0, 255], 0), "Scale Strokes & Effects {strokes}"); + // 2 pt of white around the art becomes 4 pt: from -4 pt. + let framed = doc(triangle(), &[("rasterize", hard(json!({"background": "white", "addAround": 2}))), scale], 72.0); + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let r = Renderer::new().render(&framed, 600, 600, Affine::translate((100.0, 100.0)) * Affine::scale(10.0), &opts); + assert_eq!((pixel(&r, 65, 300), pixel(&r, 55, 300)[3]), ([255; 4], 0), "Scale Strokes & Effects {strokes}"); + // It is measured where it is drawn: the image over the 10..30 pt art and 2 pt around it, + // twice as wide about its center. + let n = framed.layers[0].children().unwrap()[0].clone(); + let b = crate::drawn_bounds(&framed, &n).unwrap(); + assert!((b.x0 + 4.0).abs() < 1e-9 && (b.x1 - 44.0).abs() < 1e-9, "{b:?}"); + assert!(crate::painted_bounds(&framed, &n).is_some_and(|p| p.x0 <= -4.0 && p.x1 >= 44.0)); + } +} + +/// A Transform effect after Rasterize turns, reflects or stretches the image it is given, its pixel +/// grid included: the canvas draws what the art before the Transform effect draws, moved by the +/// effect's map. +#[test] +fn rasterize_then_rotate_and_reflect_moves_the_pixel_grid() { + let about = |m: Affine| Affine::translate((20.0, 20.0)) * m * Affine::translate((-20.0, -20.0)); + let cases = [ + (json!({"rotate": 30}), about(Affine::rotate(-30f64.to_radians()))), + (json!({"reflectX": true}), about(Affine::scale_non_uniform(-1.0, 1.0))), + (json!({"scaleH": 200, "scaleV": 100}), about(Affine::scale_non_uniform(2.0, 1.0))), + ]; + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let before = doc(triangle(), &[("rasterize", hard(json!({"background": "white", "addAround": 1})))], 72.0); + for (params, map) in cases { + let fx = [("rasterize", hard(json!({"background": "white", "addAround": 1}))), ("distort.transform", params.clone())]; + let r = draw(&doc(triangle(), &fx, 72.0), 10.0, 400); + let want = Renderer::new().render(&before, 400, 400, Affine::scale(10.0) * map, &opts); + let off = r.pixels.iter().zip(&want.pixels).filter(|(a, b)| a.abs_diff(**b) > 2).count(); + assert_eq!(off, 0, "{params}"); + } + // Twice as wide: each pixel 20 × 10 device pixels. + let r = draw(&doc(triangle(), &[("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 200, "scaleV": 100}))], 72.0), 10.0, 400); + let flat = (0..20 * 20).all(|k| { + let (x0, y0) = ((k % 20) * 20, 100 + (k / 20) * 10); + (0..200).all(|i| pixel(&r, x0 + i % 20, y0 + i / 20) == pixel(&r, x0, y0)) + }); + assert!(flat); +} + +/// A Transform effect that scales the image to nothing leaves nothing to draw or measure. +#[test] +fn rasterize_then_nonuniform_scale_and_zero_scale_are_bounded() { + let fx = [("rasterize", hard(json!({"background": "white"}))), ("distort.transform", json!({"scaleH": 0, "scaleV": 100}))]; + let d = doc(triangle(), &fx, 72.0); + let r = draw(&d, 10.0, 400); + assert!(r.pixels.chunks(4).all(|p| p[3] == 0)); + let n = d.layers[0].children().unwrap()[0].clone(); + assert_eq!(crate::drawn_bounds(&d, &n), None); + // A huge scale enlarges where the image is drawn, not the image: 20 × 20 pixels still. + let fx = [("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 10000, "scaleV": 10000}))]; + let drawn = || crate::fx::RASTERIZED.with(std::cell::Cell::get); + let before = drawn(); + let opts = RenderOptions { background: None, artboards: false, ..Default::default() }; + let r = Renderer::new().render(&doc(triangle(), &fx, 72.0), 100, 100, Affine::translate((500.0, 500.0)), &opts); + assert_eq!(drawn() - before, 1); + // The view, around -450 pt, lies inside the image's pixel at 15..16 pt, drawn 100 pt wide + // about the art's center at 20 pt. + assert_eq!(pixel(&r, 50, 50), [255, 0, 0, 255]); +} + +/// A hidden Transform effect, or one with Transform Objects off, leaves the image where it is. +#[test] +fn rasterize_before_a_hidden_transform_keeps_its_image() { + let plain = draw(&doc(triangle(), &[("rasterize", hard(json!({})))], 72.0), 10.0, 400); + let off = + draw(&doc(triangle(), &[("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 200, "objects": false}))], 72.0), 10.0, 400); + assert!(plain.pixels == off.pixels); + let mut hidden = doc(triangle(), &[("rasterize", hard(json!({}))), ("distort.transform", json!({"scaleH": 200}))], 72.0); + let l = hidden.layers[0].id; + Arc::make_mut(&mut hidden.children_mut(Some(l)).unwrap()[0]).appearance.effects[1].visible = false; + assert!(plain.pixels == draw(&hidden, 10.0, 400).pixels); +} diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index 36f215d032..818a64e293 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -500,8 +500,8 @@ impl Writer<'_> { /// `` when its radii are written the same, else ``. fn ellipse_shape(&self, sp: &SubPath, m: Affine) -> Option<(&'static str, String)> { let tol = 10f64.powi(-i32::from(self.opts.decimals)); - let (anchors, closed) = (sp.anchors.as_slice(), sp.closed); - let [a0, a1, a2, a3] = anchors else { return None }; + let (anchors, closed) = (sp.anchors.contiguous(), sp.closed); + let [a0, a1, a2, a3] = &*anchors else { return None }; let [p0, p1, p2, p3] = [a0.p, a1.p, a2.p, a3.p].map(|p| m * p); let c = p0.midpoint(p2); let near = |a: f64, b: f64| (a - b).abs() <= tol; diff --git a/crates/svg/src/export/paint.rs b/crates/svg/src/export/paint.rs index bedf456f11..7e67f09cf7 100644 --- a/crates/svg/src/export/paint.rs +++ b/crates/svg/src/export/paint.rs @@ -604,25 +604,41 @@ impl Writer<'_> { let mut inner = n.clone(); inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); // An SVG filter's user-space coordinates are the document's from the effect's origin: the - // object is written in them, in a group that moves it where the rest of the art goes. + // object is written in them, in a group that moves it where the rest of the art goes. A + // user space that is scaled or turned ([`turned_filter_space`]) is the group's own: the + // object is written as it is in a group inside the filter that maps it back + // ([`Self::open_filters`]), so its strokes keep their widths. let xf = self.xf; - let origin = live_svg_filter(&n.appearance.effects).map(|f| Affine::translate(f.origin())); + let live = live_svg_filter(&n.appearance.effects); + let turned = self.turned_filter_space(live.as_ref()); + let origin = live.filter(|_| turned.is_none()).map(|f| Affine::translate(f.origin())); let moved = origin.is_some_and(|o| xf * o != Affine::IDENTITY); if let Some(o) = origin.filter(|_| moved) { self.line(&format!("", self.matrix(xf * o))); self.depth += 1; self.xf = o.inverse(); } + if let Some(space) = turned { + self.line(&format!("", exact_matrix(space))); + self.depth += 1; + } let opened = self.open_filters(n, &n.appearance.effects); self.node_body(&inner); self.close_filters(opened); - if moved { + if moved || turned.is_some() { self.xf = xf; self.depth -= 1; self.line(""); } } + /// The group transform that holds a live SVG filter `live` ([`live_svg_filter`]) whose user + /// space is scaled or turned (not only moved, [`vectorcraft_effects::svg_filter::SvgFilterFx::moves_only`]): the filter's user + /// space in the output's. `None` for other filters and in SVG Tiny, which writes no filters. + fn turned_filter_space(&self, live: Option<&vectorcraft_effects::svg_filter::SvgFilterFx>) -> Option { + live.filter(|f| !f.moves_only() && !self.tiny()).map(|f| self.xf * f.to_doc()) + } + /// Open one `` per visible raster effect of `effects` (an object's, or one fill or /// stroke's) on `n`, outermost last effect; returns how many to close. pub(super) fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize { @@ -644,6 +660,7 @@ impl Writer<'_> { let mut opened = 0; // An SVG filter that is the object's last effect is written as it is ([`Self::filtered`]). let live = if std::ptr::eq(effects, n.appearance.effects.as_slice()) { live_svg_filter(effects) } else { None }; + let turned = self.turned_filter_space(live.as_ref()); for (k, f) in fx.iter().enumerate().rev() { if let RasterFx::Pixel(vectorcraft_effects::PixelFx::Svg(sf)) = f && live.is_some() @@ -654,12 +671,40 @@ impl Writer<'_> { // against the object alone, which is all it runs on ([`live_svg_filter`]). let mut input = n.clone(); input.appearance.effects.clear(); - let input_bounds = input.visual_bounds_with(&self.doc.symbol_boxes()).unwrap_or_default(); - if let Some(def) = sf.written_xml(input_bounds).and_then(|xml| vectorcraft_effects::svg_filter::with_id(&xml, &fid)) { - self.def(1, &def); + // Its box in the filter's user space: in a turned or skewed one, the box of the + // object's geometry turned into it, grown by what its strokes and other paint add + // around it in the document: a square that far to each side, as it measures in + // user space along each axis (a miter corner reaches that far along both). + let symbols = self.doc.symbol_boxes(); + let visual = input.visual_bounds_with(&symbols).unwrap_or_default(); + let user_box = if sf.moves_only() { + visual - sf.origin() + } else { + let geometric = input.geometric_bounds_with(&symbols).unwrap_or(visual); + let margin = [geometric.x0 - visual.x0, geometric.y0 - visual.y0, visual.x1 - geometric.x1, visual.y1 - geometric.y1] + .into_iter() + .fold(0.0, f64::max); + let to_user = sf.to_doc().inverse(); + input.transform(to_user, false); + let [a, b, c, d, _, _] = to_user.as_coeffs(); + let turned = input.geometric_bounds_with(&symbols).unwrap_or_default(); + turned.inflate(margin * (a.abs() + c.abs()), margin * (b.abs() + d.abs())) + }; + let def = sf.written_xml(user_box).and_then(|xml| vectorcraft_effects::svg_filter::with_id(&xml, &fid)); + if let Some(def) = &def { + self.def(1, def); self.line(&format!("")); self.depth += 1; opened += 1; + } + // Inside the group of a turned user space ([`Self::filtered`]), the object and its + // other effects are written as they are, in a group that maps them back to it. + if let Some(space) = turned { + self.line(&format!("", exact_matrix(space.inverse()))); + self.depth += 1; + opened += 1; + } + if def.is_some() { continue; } } @@ -786,8 +831,18 @@ pub(super) fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> { } } +/// `a` as an SVG `matrix(…)` with every digit its numbers need to read back the same, whatever +/// the export's decimals: the transforms around a turned SVG filter ([`Writer::turned_filter_space`]) +/// and their inverses still undo each other when one holds very small numbers. +fn exact_matrix(a: Affine) -> String { + // (Without negative zeros.) + let [a, b, c, d, e, f] = a.as_coeffs().map(|v| v + 0.0); + format!("matrix({a} {b} {c} {d} {e} {f})") +} + /// The SVG filter (Effect › SVG Filters) of an object's `effects` that SVG keeps as a ``: -/// its last visible effect, measured in points. +/// its last visible effect, in any user space the effect gives it ([`Writer::turned_filter_space`]) +/// that SVG import can store again. fn live_svg_filter(effects: &[vectorcraft_doc::Effect]) -> Option { if !vectorcraft_effects::svg_filter::writes_live(effects) { return None; @@ -797,7 +852,7 @@ fn live_svg_filter(effects: &[vectorcraft_doc::Effect]) -> Option + if (alone || !f.measured()) && f.writes_as_svg11() && f.opens_again() => { Some(f) } diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs index 4a549552d4..91180981db 100644 --- a/crates/svg/src/import/fx.rs +++ b/crates/svg/src/import/fx.rs @@ -142,19 +142,22 @@ fn source_of<'a>(filter: &Filter, sources: &'a HashMap) -> Optio /// XML from `sources` ([`sources`]), in user space mapped to the document by `ts`. `None` when one /// isn't there, looks different outside the document, or isn't a filter the effect draws /// ([`vectorcraft_effects::svg_filter::check`]), or -/// when `ts` turns or skews the user space or scales it past what the effect measures (0.001 to -/// 1000 points to a unit). Where `ts` moves the user space is the effect's `origin`. +/// when `ts` scales the user space past what the effect measures (0.001 to 1000 points to a unit, +/// as the square root of its area scale) or stretches its axes past what the effect's `axes` hold. +/// Where `ts` moves the user space is the effect's `origin`; a user space that `ts` turns, skews +/// or scales unevenly keeps those directions in the effect's `axes`, so the filter runs in the +/// element's own user space as SVG 1.1 defines it (). pub(super) fn kept(filters: &[Arc], ts: Affine, sources: &HashMap) -> Option> { - let [a, b, c, d, e, f] = ts.as_coeffs(); - if b != 0.0 || c != 0.0 || !(0.001..=1000.0).contains(&a) || (a - d).abs() > 1e-9 * a.abs().max(1.0) || !e.is_finite() || !f.is_finite() { - return None; - } + let space = vectorcraft_effects::svg_filter::user_space(ts)?; filters .iter() .map(|filter| { // A filter styled by the document is left out rather than drawn differently. let source = source_of(filter, sources).filter(|s| !s.styled)?; - let params = json!({ "name": source.name()?, "xml": source.xml, "scale": a, "origin": [e, f] }); + let mut params = json!({ "name": source.name()?, "xml": source.xml, "scale": space.scale, "origin": space.origin }); + if let Some(axes) = space.axes { + params["axes"] = json!(axes); + } Some(Effect { id: vectorcraft_effects::SVG_FILTER.into(), params, visible: true }) }) .collect() diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 3a598f5ca1..239ce7e07a 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -763,14 +763,15 @@ fn percentages_in_a_symbol_are_of_its_view_box() { } /// #1598: the warning for a filter that neither the live effects nor an SVG filter effect can hold -/// (here, on turned objects) names the filter, its primitives and the object, and says it is drawn -/// without it (not "a group": usvg wraps the element in one). Such a filter on an object that -/// isn't turned comes in as an SVG filter effect holding the filter's XML. +/// (here, on objects skewed past what the effect measures) names the filter, its primitives and the +/// object, and says it is drawn without it (not "a group": usvg wraps the element in one). Such a +/// filter on an object that isn't skewed that far comes in as an SVG filter effect holding the +/// filter's XML. #[test] fn unsupported_filters_are_named_in_the_warning() { let body = r#" - - + + "#; let (d, w) = import_with_report(&svg(body)).unwrap(); assert!(w.iter().any(|w| w == "filter 'f' (feColorMatrix) on an object isn't supported; drawn without it"), "{w:?}"); diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index 1d10c5d9fe..b39842e4fa 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -522,7 +522,8 @@ fn a_filter_used_twice_among_many_is_kept_twice() { assert_eq!(filters, vec![vec![json!("g")], vec![json!("g")]], "{:?}", art(&d)); } -/// Other filters come in as SVG filters; one on a turned object is reported and left out. +/// Other filters come in as SVG filters, on a turned or skewed object too, with the object's user +/// space in `scale`, `origin` and `axes`; one past what the effect measures is reported and left out. #[test] fn other_filters_are_kept_or_reported() { let (d, w) = @@ -530,8 +531,32 @@ fn other_filters_are_kept_or_reported() { assert!(w.is_empty(), "{w:?}"); let fx = effects(&d); assert_eq!((fx.len(), fx[0].id.as_str(), &fx[0].params["name"]), (1, "svg.filter", &json!("t"))); + assert!(fx[0].params.get("axes").is_none(), "the document's axes: {:?}", fx[0].params); let (d, w) = open( - r##""##, + r##""##, + ); + assert!(w.is_empty(), "{w:?}"); + let fx = effects(&d); + assert_eq!(fx.len(), 1); + let p = &fx[0].params; + let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + let num = |v: &serde_json::Value| v.as_f64().unwrap(); + assert!((num(&p["scale"]) - 2.0).abs() < 1e-6 && p["origin"] == json!([10.0, 20.0]), "{p}"); + let axes: Vec = p["axes"].as_array().unwrap().iter().map(num).collect(); + assert!(axes.iter().zip([c, s, -s, c]).all(|(a, b)| (a - b).abs() < 1e-6), "{axes:?}"); + // Skewed, and scaled unevenly: the axes keep both. + let (d, w) = open( + r##""##, + ); + assert!(w.is_empty(), "{w:?}"); + let all: Vec> = + art(&d).iter().flat_map(|n| &n.appearance.effects).map(|e| e.params["axes"].as_array().unwrap().iter().map(num).collect()).collect(); + assert_eq!(all.len(), 2); + assert!(all.iter().any(|a| a.iter().zip([1.0, 0.0, 1.0, 1.0]).all(|(a, b)| (a - b).abs() < 1e-6)), "{all:?}"); + assert!(all.iter().any(|a| a.iter().zip([0.5, 0.0, 0.0, 2.0]).all(|(a, b)| (a - b).abs() < 1e-6)), "{all:?}"); + // Skewed so far that an axis would stretch past 1000 times: reported and left out. + let (d, w) = open( + r##""##, ); assert!(w.iter().any(|w| w.contains("filter")), "{w:?}"); assert!(effects(&d).is_empty()); @@ -543,6 +568,40 @@ fn other_filters_are_kept_or_reported() { assert!(effects(&d).is_empty()); } +/// A filter on a turned object runs in the object's user space (SVG 1.1 measures its lengths +/// there): opened, it draws as the same filter does on the same art drawn unturned with its offset +/// turned by hand. +#[test] +fn a_filter_on_a_turned_object_draws_in_its_user_space() { + let filter = |dx: f64, dy: f64| { + format!( + r#""# + ) + }; + let turned = format!( + r##"{}"##, + filter(24.0, 0.0) + ); + let (d, w) = open(&turned); + assert!(w.is_empty(), "{w:?}"); + assert_eq!(effects(&d).iter().map(|e| e.id.as_str()).collect::>(), ["svg.filter"]); + // The same square, its corners turned by hand, with the offset 24 along the turned x axis. + let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + let corner = |x: f64, y: f64| format!("{},{}", 80.0 + c * (x - 80.0) - s * (y - 50.0), 50.0 + s * (x - 80.0) + c * (y - 50.0)); + let reference = format!( + r##"{}"##, + filter(24.0 * c, 24.0 * s), + corner(60.0, 30.0), + corner(100.0, 30.0), + corner(100.0, 70.0), + corner(60.0, 70.0) + ); + let canvas = render_artboard(&d); + // The moved square lies beyond where it was: its center, turned and moved, is near (100.8, 62). + assert!(canvas.over_white(101, 62) != [255, 255, 255], "drawn where the offset puts it"); + assert_similar(&canvas, &resvg_render(&svg(&reference), 200, 120), 40.0, 0.02); +} + #[test] fn a_filter_and_a_clip_on_one_object_filter_first() { let body = r##" @@ -638,6 +697,69 @@ fn filtered_rect(xml: &str) -> Document { d } +/// An SVG filter in a scaled, turned or skewed user space is written live: the filter's user space +/// is a group's transform, and the art is written in document coordinates in a group inside the +/// filter that maps it back, so its stroke keeps its width. Opened again, it is the same effect +/// and draws as the canvas drew it. +#[test] +fn an_exported_turned_filter_opens_as_the_same_effect() { + let xml = r#""#; + for (scale, axes) in [(2.0, json!([0.8, 0.6, -0.6, 0.8])), (1.0, json!([1.0, 0.0, 0.5, 1.0])), (0.5, serde_json::Value::Null)] { + let mut d = Document::new(200.0, 120.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.1, 0.1)), Paint::solid(Color::BLACK), 4.0), + ); + let mut params = json!({ "xml": xml, "scale": scale, "origin": [30.0, 10.0] }); + if !axes.is_null() { + params["axes"] = axes.clone(); + } + n.appearance.effects = vec![Effect { id: "svg.filter".into(), params, visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = export(&d, &ExportOptions::default()); + assert!(out.contains(" = fx[0].params["origin"].as_array().unwrap().iter().map(num).collect(); + assert!((origin[0] - 30.0).abs() < 1e-6 && (origin[1] - 10.0).abs() < 1e-6, "{origin:?}"); + match fx[0].params.get("axes") { + Some(got) => { + let got: Vec = got.as_array().unwrap().iter().map(num).collect(); + let want: Vec = axes.as_array().unwrap().iter().map(num).collect(); + assert!(got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 1e-6), "{got:?}"); + } + None => assert!(axes.is_null(), "{:?}", fx[0].params), + } + assert_similar(&render_artboard(&d), &render_artboard(&back), 40.0, 0.02); + } + // A user space stretched 10,000 times along x: the inverse transform holds 0.0001, which the + // export's three decimals would round to 0, and the art still opens at its place. + let mut d = Document::new(200.0, 120.0); + let mut n = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(40.0, 20.0, 100.0, 70.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.1, 0.1)), Paint::None, 0.0), + ); + let xml = r#""#; + let params = json!({ "xml": xml, "scale": 10.0, "axes": [1000.0, 0.0, 0.0, 1.0] }); + n.appearance.effects = vec![Effect { id: "svg.filter".into(), params, visible: true }]; + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = export(&d, &ExportOptions::default()); + let (back, w) = import_with_report(&out).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let bounds = |d: &Document| art(d).iter().find_map(|n| n.path_data().and_then(|p| p.bounds())).unwrap(); + let (a, b) = (bounds(&d), bounds(&back)); + assert!((a.x0 - b.x0).abs() < 1e-3 && (a.x1 - b.x1).abs() < 1e-3 && (a.y1 - b.y1).abs() < 1e-3, "{a:?} {b:?}: {out}"); +} + /// SVG 1.1 export writes an SVG filter's image reference as `xlink:href`, and it imports back. #[test] fn an_exported_filter_image_has_an_xlink_reference() { diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 11f174e1a4..4891e679c3 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -52,6 +52,10 @@ pub struct OtFeatures { pub extra_values: [u16; EXTRA_FEATURES.len()], /// The value `swsh=N` gives swashes ([`Self::swash`]), as [`Self::extra_values`] for `salt`. pub swash_value: u16, + /// Set a complex script (Indic, Arabic, Hebrew) glyph by glyph as the font maps its + /// characters, with none of its layout tables: the Standard composers. Set per paragraph by + /// layout from the paragraph's composer, not by a style. + pub unshaped_complex: bool, } /// The largest value a `tag=N` feature takes: a font's glyph ids are 16 bits, so is an alternate's @@ -94,6 +98,7 @@ impl Default for OtFeatures { extra: 0, extra_values: [0; EXTRA_FEATURES.len()], swash_value: 0, + unshaped_complex: false, } } } @@ -167,6 +172,7 @@ impl OtFeatures { extra: u32::MAX >> (32 - EXTRA_FEATURES.len()), extra_values: [0; EXTRA_FEATURES.len()], swash_value: 0, + unshaped_complex: false, }; change(&mut off); change(&mut on); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index f315f2bb69..e96e1d0fae 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -119,6 +119,9 @@ pub struct FontDb { /// type naming one as its family is set in its parts' fonts. A thread's guards can lend others /// in their place ([`FontDb::lend_composite_fonts`]). composites: RwLock>>, + /// The lookups of the custom character sets of the composite fonts held or lent, each built + /// when first used. + custom_classes: Mutex, } /// Where a database's system font scan looks: folders (read with their subfolders) and font files. diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index 79a608788b..7b17fb74de 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -1,15 +1,18 @@ //! Composite fonts (Type › Composite Fonts…) in the font database: each is listed first in the //! font menus and resolves to its base (Kanji) font, and layout sets each character of type in one -//! in the font of the character's class ([`FontDb::composite_split`]). +//! in the font of the custom character set that lists it, else of its class +//! ([`FontDb::composite_split`]). use std::cell::RefCell; use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; use std::marker::PhantomData; +use std::sync::Weak; use std::sync::atomic::{AtomicU64, Ordering}; use unicode_segmentation::UnicodeSegmentation; -use vectorcraft_doc::{CharClass, CompositeFont, TextObject, TextRun}; +use vectorcraft_doc::text::CompositeClasses; +use vectorcraft_doc::{CharClass, CompositeClass, CompositeFont, CompositePart, TextObject, TextRun}; use super::face::FALLBACK_FAMILY; use super::{Arc, FontDb, FontFace, FontMatch}; @@ -17,6 +20,13 @@ use super::{Arc, FontDb, FontFace, FontMatch}; /// A face [`FontDb::resolve`] found and how it matched. type Resolved = Option<(Arc, FontMatch)>; +/// The base class: type without characters and anything no part names takes its part. +const KANJI: CompositeClass = CompositeClass::Fixed(CharClass::Kanji); + +/// Lookups of the custom character sets of composite fonts, by the address of the font's `Arc`, +/// each with a weak reference that tells when that font has gone and the address may be reused. +pub(super) type CustomClasses = HashMap, Arc)>; + thread_local! { /// The composite fonts the guards of this thread lend ([`FontDb::lend_composite_fonts`]), the /// latest loan last. Only the latest loan of a database is looked in. @@ -41,15 +51,25 @@ struct Loan { print: u64, } -/// Feed composite font `f`'s name and parts to `h`. +/// Feed composite font `f`'s name, parts and custom character sets to `h`. fn hash_font(f: &CompositeFont, h: &mut DefaultHasher) { f.name.hash(h); let p = &f.parts; for part in [&p.kanji, &p.kana, &p.punctuation, &p.symbols, &p.roman, &p.numerals] { - (&part.font, &part.style, part.scale_at_center).hash(h); - for v in [part.size, part.baseline, part.h_scale, part.v_scale] { - v.to_bits().hash(h); - } + hash_part(part, h); + } + f.custom.len().hash(h); + for s in &f.custom { + (&s.name, &s.characters).hash(h); + hash_part(&s.part, h); + } +} + +/// Feed part `part`'s font and values to `h`. +fn hash_part(part: &CompositePart, h: &mut DefaultHasher) { + (&part.font, &part.style, part.scale_at_center).hash(h); + for v in [part.size, part.baseline, part.h_scale, part.v_scale] { + v.to_bits().hash(h); } } @@ -136,8 +156,8 @@ impl FontDb { .unwrap_or(g) } - /// The face composite font `name` sets character `c` in (its class's part's), and whether - /// that part's font is missing. `None` when `name` is no composite font. + /// The face composite font `name` sets character `c` in (its custom set's or its class's + /// part's), and whether that part's font is missing. `None` when `name` is no composite font. pub fn composite_face(&self, name: &str, c: char) -> Option<(bool, u32)> { self.composite_resolve(name, c).map(|(face, m)| (m == FontMatch::Missing, face.id())) } @@ -149,30 +169,58 @@ impl FontDb { self.composite_resolve(name, first) } - /// The face composite font `name` sets character `c` in (its class's part's), and how that - /// part's font matched ([`FontDb::resolve`]). `None` when `name` is no composite font, or no - /// font at all is loaded. + /// The face composite font `name` sets character `c` in (its custom set's or its class's + /// part's), and how that part's font matched ([`FontDb::resolve`]). `None` when `name` is no + /// composite font, or no font at all is loaded. pub fn composite_resolve(&self, name: &str, c: char) -> Option<(Arc, FontMatch)> { let f = self.composite_font(name)?; - let (family, style) = self.part_font(&f, CharClass::of(c)); + let (family, style) = self.part_font(&f, self.composite_class(&f, c)); self.resolve(&family, &style) } + /// Where composite font `f` sets character `c`: the last custom set that lists it, else its + /// class. The lookup of `f`'s sets is built once and kept while `f` lives. + fn composite_class(&self, f: &Arc, c: char) -> CompositeClass { + if f.custom.is_empty() { + return CompositeClass::Fixed(CharClass::of(c)); + } + self.custom_lookup(f).of(c) + } + + /// The lookup of composite font `f`'s custom character sets, built when first asked for. + fn custom_lookup(&self, f: &Arc) -> Arc { + if f.custom.is_empty() { + return Arc::default(); + } + let key = Arc::as_ptr(f) as usize; + let mut cache = self.custom_classes.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((font, lookup)) = cache.get(&key) + && font.upgrade().is_some_and(|g| Arc::ptr_eq(&g, f)) + { + return lookup.clone(); + } + cache.retain(|_, (font, _)| font.strong_count() > 0); + let lookup = Arc::new(f.classes()); + cache.insert(key, (Arc::downgrade(f), lookup.clone())); + lookup + } + /// The fonts (family, style) composite font `name` sets `text` in: those of the parts its /// grapheme clusters fall in (each by its first character, as layout splits them), each once, /// in the order first used; the Kanji part's for text without characters. `None` when `name` is /// no composite font. pub fn composite_fonts_for(&self, name: &str, text: &str) -> Option> { let f = self.composite_font(name)?; - let mut classes: Vec = Vec::new(); + let lookup = self.custom_lookup(&f); + let mut classes: Vec = Vec::new(); for g in text.graphemes(true) { - let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let c = g.chars().next().map_or(KANJI, |c| lookup.of(c)); if !classes.contains(&c) { classes.push(c); } } if classes.is_empty() { - classes.push(CharClass::Kanji); + classes.push(KANJI); } let mut fonts: Vec<(String, String)> = Vec::with_capacity(classes.len()); for c in classes { @@ -196,8 +244,9 @@ impl FontDb { FontMatch::Style => 1, FontMatch::Missing => 2, }; - let mut faces: Vec<(CharClass, Resolved)> = Vec::new(); - let mut face_of = |class: CharClass| { + let lookup = self.custom_lookup(&f); + let mut faces: Vec<(CompositeClass, Resolved)> = Vec::new(); + let mut face_of = |class: CompositeClass| { if let Some((_, found)) = faces.iter().find(|(c, _)| *c == class) { return found.clone(); } @@ -213,7 +262,7 @@ impl FontDb { graphemes.push(""); } for g in graphemes { - let class = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let class = g.chars().next().map_or(KANJI, |c| lookup.of(c)); let found = face_of(class); let m = found.as_ref().map_or(FontMatch::Missing, |(_, m)| *m); if worst.as_ref().is_none_or(|(w, _)| rank(m) > rank(*w)) { @@ -307,10 +356,11 @@ impl FontDb { LentCompositeFonts { number: pushed.then_some(number), thread: PhantomData } } - /// The family and style composite font `f` sets characters of `class` in: its part's, or the - /// fallback font's when that part names a composite font. - fn part_font(&self, f: &CompositeFont, class: CharClass) -> (String, String) { - let p = f.parts.get(class); + /// The family and style composite font `f` sets characters of `class` in: its part's (the + /// Kanji part's for a custom set it doesn't have), or the fallback font's when that part names + /// a composite font. + fn part_font(&self, f: &CompositeFont, class: CompositeClass) -> (String, String) { + let p = f.part(class).unwrap_or(&f.parts.kanji); if self.composite_font(&p.font).is_some() { (FALLBACK_FAMILY.to_string(), "Regular".to_string()) } else { (p.font.clone(), p.style.clone()) } } @@ -326,11 +376,13 @@ impl FontDb { /// names a composite font too. `None` when `name` is no composite font. pub(super) fn composite_base(&self, name: &str) -> Option<(String, String)> { let f = self.composite_font(name)?; - Some(self.part_font(&f, CharClass::Kanji)) + Some(self.part_font(&f, KANJI)) } - /// `t` with each run set in a composite font split into runs of one [`CharClass`] each, set in - /// that class's font: its size and baseline shift from the part's size and baseline (in + /// `t` with each run set in a composite font split into runs of one custom character set or + /// [`CharClass`] each (a set listing a character takes it ahead of its class, the font's last + /// such set when several do), set in that set's or class's font: its size and baseline shift + /// from the part's size and baseline (in /// percent of the run's size), its scales multiplied by the part's, and Kana scaled at the /// center raised so the em box's center stays where it was. Each grapheme cluster goes with /// its first character. Also the index of the run of `t` each run came from. `None` when no run @@ -352,10 +404,11 @@ impl FontDb { from.push(i); continue; }; + let lookup = self.custom_lookup(font); let mut start = 0; let mut class = None; for (at, g) in r.text.grapheme_indices(true) { - let c = g.chars().next().map_or(CharClass::Kanji, CharClass::of); + let c = g.chars().next().map_or(KANJI, |c| lookup.of(c)); if let Some(k) = class && k != c { @@ -365,16 +418,16 @@ impl FontDb { } class = Some(c); } - runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(CharClass::Kanji))); + runs.push(self.composite_run(r.text.get(start..).unwrap_or_default(), r, font, class.unwrap_or(KANJI))); from.push(i); } Some((TextObject { runs, ..t.clone() }, from)) } - /// Characters `text` of run `r` (set in composite font `font`), all of class `class`, as a run - /// set in that class's part. - fn composite_run(&self, text: &str, r: &TextRun, font: &CompositeFont, class: CharClass) -> TextRun { - let p = font.parts.get(class); + /// Characters `text` of run `r` (set in composite font `font`), all of class or set `class`, + /// as a run set in that class's or set's part. + fn composite_run(&self, text: &str, r: &TextRun, font: &CompositeFont, class: CompositeClass) -> TextRun { + let p = font.part(class).unwrap_or(&font.parts.kanji); let mut st = r.style.clone(); let base = st.size; let (family, style) = self.part_font(font, class); @@ -386,7 +439,7 @@ impl FontDb { st.baseline_shift += base * p.baseline / 100.0; st.h_scale *= p.h_scale / 100.0; st.v_scale *= p.v_scale / 100.0; - if class == CharClass::Kana && p.scale_at_center && p.v_scale != 100.0 { + if class == CompositeClass::Fixed(CharClass::Kana) && p.scale_at_center && p.v_scale != 100.0 { let centre = self.style_face(&st).map_or(0.38, |f| f.ideographic_centre()); st.baseline_shift += st.size * centre * (1.0 - p.v_scale / 100.0); } diff --git a/crates/text/src/fontdb/db.rs b/crates/text/src/fontdb/db.rs index 2fda0e79a1..738a158952 100644 --- a/crates/text/src/fontdb/db.rs +++ b/crates/text/src/fontdb/db.rs @@ -74,6 +74,7 @@ impl FontDb { stamp: Mutex::new(None), instances: Mutex::new(Instances::default()), composites: RwLock::new(HashMap::new()), + custom_classes: Mutex::new(HashMap::new()), } } diff --git a/crates/text/src/kashida.rs b/crates/text/src/kashida.rs index c36eacbf2d..8db784a2bc 100644 --- a/crates/text/src/kashida.rs +++ b/crates/text/src/kashida.rs @@ -78,10 +78,11 @@ fn joining(c: char) -> Joining { } /// Is the cluster of glyph `a` joined to the cluster of glyph `b` that follows it in `text`: both -/// right to left in one face, `a`'s last character other than a mark joining the letter after it, -/// and `b` starting with a letter that joins the one before it? +/// right to left in one face and set as joining letters (not by a Standard composer), `a`'s last +/// character other than a mark joining the letter after it, and `b` starting with a letter that +/// joins the one before it? fn joined(text: &str, a: &SGlyph, b: &SGlyph) -> bool { - if a.face.id() != b.face.id() || a.level != b.level || !a.level.is_rtl() || a.byte + a.len != b.byte { + if a.face.id() != b.face.id() || a.level != b.level || !a.level.is_rtl() || a.byte + a.len != b.byte || !(a.cursive && b.cursive) { return false; } let last = text.get(a.byte..a.byte + a.len).and_then(|s| s.chars().rev().find(|&c| joining(c) != Joining::Transparent)); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 6f558d86dd..0aa6e1aee5 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -22,7 +22,7 @@ use crate::shape::{ Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, run_face, shape_range, shape_runs, soft_line_start, style_metrics, }; -use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, ListMarker, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; +use crate::{FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, ListMarker, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; mod bidi; mod breaking; @@ -229,11 +229,14 @@ impl Ctx<'_> { out } - /// Shape paragraph `r` (its bidi resolution `bidi`). - fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { + /// Shape paragraph `r` (its bidi resolution `bidi`, its attributes `para`). A Standard composer + /// sets a complex script glyph by glyph ([`OtFeatures::unshaped_complex`]). + fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>, para: &ParaStyle) -> Vec { let mut v = Vec::with_capacity(r.len()); let levels = bidi.map_or(&[][..], |b| &b.levels); - shape_runs(self.db, self.text, r, &self.runs, &self.inlines, &self.faces, &self.opts.features, levels, &mut v); + let standard = !self.opts.composer.unwrap_or(para.composer).shapes_complex_scripts(); + let feats = OtFeatures { unshaped_complex: standard, ..self.opts.features }; + shape_runs(self.db, self.text, r, &self.runs, &self.inlines, &self.faces, &feats, levels, &mut v); self.finish_shaping(&mut v, |g| self.style_at(g.byte)); v } @@ -373,7 +376,8 @@ impl Ctx<'_> { // Only the glyph right after an upright glyph can continue its cluster. let cluster = self.upright_cluster.take(); // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). - let blank = g.is_soft_hyphen() || g.ch.is_control() || g.ch == '\u{2028}'; + // Joiners and direction marks are format controls: no ink either, whatever glyph the font maps. + let blank = g.is_soft_hyphen() || g.ch.is_control() || g.ch == '\u{2028}' || crate::justify::is_format_control(g.ch); // A colour glyph draws its art (COLR layers, an SVG document) or its image (a bitmap, or // COLR paints documents have no form for), not its outline; COLR, then SVG, then bitmaps. // Art in the text's colours takes the run's fill (black when it isn't a colour) and diff --git a/crates/text/src/layout/breaking.rs b/crates/text/src/layout/breaking.rs index fadb0165d5..ef62fa2bcc 100644 --- a/crates/text/src/layout/breaking.rs +++ b/crates/text/src/layout/breaking.rs @@ -395,8 +395,29 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins if end >= g.len() { set(w, trailing, ink) } else { w } } +/// Whether the cluster `j..end` is drawn last, at the right end, of the left-to-right line of +/// glyphs `i..end`: the line's glyphs are reordered by their levels (UAX #9 L2), with each tab and +/// the spaces before it at the paragraph's level (L1). +fn drawn_last(g: &[SGlyph], i: usize, j: usize, end: usize) -> bool { + let line = g.get(i..end).unwrap_or_default(); + let mut levels: Vec = line.iter().map(|h| h.level).collect(); + let mut before_tab = false; + for (level, h) in levels.iter_mut().zip(line).rev() { + if h.ch == '\t' && h.len > 0 { + before_tab = true; + } else if !h.is_space() { + before_tab = false; + } + if before_tab { + *level = unicode_bidi::Level::ltr(); + } + } + unicode_bidi::BidiInfo::reorder_visual(&levels).last().is_some_and(|&k| i + k >= j) +} + /// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`. -/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it. +/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it, +/// unless No Break or Bunri-Kinshi keeps characters after it on its line. /// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when /// the line may hyphenate a word; a line whose text before the overflowing word already ends /// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop @@ -416,7 +437,9 @@ pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kins /// line holds a glyph with a negative advance and has been wider than `width` in the order of its /// text, it reads at most [`MAX_DRAWN_CHECKS_STEPPING_BACK`] legal breaks further, and a forced /// line break right after the last of them, and ends at the latest of the breaks read that fits as -/// drawn. +/// drawn. Such a line with a tab hangs a burasagari mark only when the mark is drawn at its right +/// end in the visual order of the line's levels ([`drawn_last`]) and the line before the mark fits +/// as drawn. #[allow(clippy::too_many_arguments)] pub(super) fn break_line( text: &str, @@ -543,8 +566,24 @@ pub(super) fn break_line( let measured = if tabbed && fits.is_some() { (ink + cluster_ink).max(stops_reached) } else { x + cluster_width }; overflowed |= tabbed && fits.is_some() && j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS; if j > first && !gl.is_space() && measured - end_credit > width + EPS { - // It ends the line with the spaces after it. - if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { + // It ends the line with the spaces after it, when the mark is all the characters kept + // together (the line's last character is the mark, which is what hangs). Once the line + // holds a tab and is measured as drawn, the mark hangs only where it is drawn at the + // line's right end (its cluster is last in the line's visual order, [`drawn_last`]) and + // the line before it fits as drawn with no line-end trim on its last glyph; otherwise the + // line ends at a legal break that fits as drawn. + let alone = g[j..cluster_end].iter().all(|h| h.byte == gl.byte); + let hangs_as_drawn = || match fits.filter(|_| tabbed) { + None => true, + Some(fits) => { + let mut body = j; + while body > i && g[body - 1].is_space() { + body -= 1; + } + drawn_last(g, i, j, cluster_end) && line_end_credit(g, line_end, body) == 0.0 && fits(j, false) + } + }; + if burasagari != Burasagari::None && hangs(gl) && alone && kinsoku_allows(g, cluster_end - 1, kinsoku) && hangs_as_drawn() { let mut end = cluster_end; while g.get(end).is_some_and(SGlyph::is_space) { end += 1; diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 30b615c5fc..0c4e1ae615 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -8,9 +8,9 @@ use super::vertical::{ EM_CENTER, MojikumiRules, align_shift, compress_punctuation, dot_quarter, glyph_em, halve_punctuation, punct_half, space_japanese_and_latin, }; use super::{ - Affine, BidiInfo, Burasagari, CharStyle, Composer, Ctx, EPS, Flex, Justify, Kinsoku, KinsokuShoriType, LeadingModel, LineFit, LineInfo, - ListMarker, ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, - hyphen_glyph, is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, + Affine, BidiInfo, Burasagari, CharStyle, Ctx, EPS, Flex, Justify, Kinsoku, KinsokuShoriType, LeadingModel, LineFit, LineInfo, ListMarker, + ParaStyle, Params, Point, Punct, Range, SGlyph, SingleWordJustify, TextObject, apply_desired, cap_x_heights, compose, fit_line, hyphen_glyph, + is_cjk, is_word_space, kashida, mark_word_ends, no_line_end, no_line_start, style_metrics, }; use crate::justify::is_format_control; use crate::shape::{keep_inseparable_pairs, keep_korean_words}; @@ -62,7 +62,7 @@ fn compose_para( // The tab of a list marker is on the first line, with the marker, and has a fixed width. let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); let has_soft_break = sg.iter().any(|g| g.ch == '\u{2028}'); - if composer != Composer::EveryLine + if composer.is_single_line() || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS @@ -247,7 +247,7 @@ fn prepare_paragraph<'a, 'text>( let text = cx.text; let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(&pr)); let rtl = is_rtl(bidi.as_ref()); - let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); + let mut sg = cx.shape_para(pr.clone(), bidi.as_ref(), para); // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); // Korean breaks between words, unless Korean Line Break Anywhere lets it break between syllables. @@ -321,7 +321,7 @@ fn prepare_paragraph<'a, 'text>( let composed = compose_para(cx, &sg, &flex, &line_end, para, pen, rtl, continued); // Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the // Hyphen Limit counts the lines in a row that end with a hyphen. - let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 }; + let zone = if cx.opts.composer.unwrap_or(para.composer).is_single_line() { para.hyphenation.zone } else { 0.0 }; let hyphen_limit = usize::try_from(para.hyphenation.hyphen_limit).unwrap_or(usize::MAX); let mirror = mirrors_indents(para, rtl, cx.vertical); ParagraphData { @@ -474,7 +474,9 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC ¶.tabs, ind_l, hyphenate, - para.burasagari, + // A right-to-left paragraph draws a burasagari mark inside its line + // ([`prepare_line_geometry`]): the line ends where it fits with the mark. + if *rtl { Burasagari::None } else { para.burasagari }, para.kinsoku, ty, *hanging, diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index d1072d3781..ecb1377d10 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -422,7 +422,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, let text = cx.text; let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical); - let mut glyphs = cx.shape_para(pr.clone(), bidi.as_ref()); + let mut glyphs = cx.shape_para(pr.clone(), bidi.as_ref(), t.para_at(pi)); // Bullets and numbers: a list paragraph's text starts after its marker here too, unless // the paragraph continues from the frame before in its thread. if pi > 0 || !t.first_para_continues { diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index a0a09956bc..c7d8ff8a4b 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -477,6 +477,95 @@ fn a_tabbed_line_ends_at_the_latest_break_that_fits_as_drawn() { assert_eq!(ended, (8, false)); } +/// A tabbed line measured as drawn hangs a burasagari full stop that overflows only when the stop +/// is drawn at the line's right end (last in the visual order of the line's levels, whatever its +/// own level) and the line before it fits as drawn with no line-end trim on its last glyph; +/// otherwise it ends at the latest legal break that fits as drawn. +#[test] +fn a_tabbed_burasagari_mark_hangs_only_at_the_drawn_end_of_a_fitting_line() { + let text = "a \t\u{05D0} 漢。"; + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + glyphs[3].level = unicode_bidi::Level::rtl(); + // The text without its tab is 60 pt wide at 。 (glyph 6), past 55 pt; kinsoku keeps 。 off a line + // start, so the latest legal break before it ends the line before 漢 (glyph 5). + let end = |glyphs: &[SGlyph], line_end: &[f64], fits: Option<&dyn Fn(usize, bool) -> bool>| { + break_line( + text, + glyphs, + 0, + 55.0, + &[], + 0.0, + None, + Burasagari::Standard, + Kinsoku::Hard, + KinsokuShoriType::PushOutOnly, + false, + false, + line_end, + fits, + ) + }; + let all_fit = |_end: usize, _hyphenated: bool| true; + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (7, false), "drawn at the right end of a line that fits: 。 hangs"); + let body_too_wide = |end: usize, _hyphenated: bool| end <= 5; + assert_eq!(end(&glyphs, &[], Some(&body_too_wide)), (5, false), "the line before 。 is too wide as drawn"); + let mut trimmed = vec![0.0; glyphs.len()]; + trimmed[5] = 2.0; + assert_eq!(end(&glyphs, &trimmed, Some(&all_fit)), (5, false), "the line before 。 fits only with 漢 trimmed"); + let level = |n: u8| unicode_bidi::Level::new(n).unwrap_or_else(|_| panic!("level {n}")); + glyphs[6].level = level(2); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (7, false), "。 set left to right in right-to-left text is drawn at the right end"); + glyphs[6].level = level(1); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (7, false), "。 alone right to left after 漢 is drawn at the right end"); + glyphs[5].level = level(1); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (5, false), "。 right to left after 漢 in the same run is drawn inside the line"); + glyphs[6].level = level(2); + assert_eq!(end(&glyphs, &[], Some(&all_fit)), (5, false), "。 left to right inside the right-to-left run of 漢 is drawn inside the line"); +} + +/// A burasagari full stop that No Break keeps with the character after it does not hang: the line +/// would end with that character, which is drawn inside the line, so it ends at an earlier break. +#[test] +fn a_burasagari_mark_kept_with_the_next_character_does_not_hang() { + let text = "漢漢。漢"; + let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect(); + let mut glyphs = synthetic_glyphs(&specs); + let end = |glyphs: &[SGlyph]| { + break_line(text, glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[], None) + }; + assert_eq!(end(&glyphs), (3, false), "alone, 。 overflows by 5 pt and hangs"); + glyphs[2].no_break = true; + glyphs[3].no_break = true; + assert_eq!(end(&glyphs), (1, false), "kept with the 漢 after it, 。 does not end the line"); +} + +/// A line with a tab and right-to-left text pushes a closing bracket out with every Kinsoku Shori +/// Type, where the same widths without them push it in. +#[test] +fn push_in_leaves_a_tabbed_right_to_left_line_to_push_out() { + use vectorcraft_doc::{TabAlign, TabStop}; + let tabbed = "\u{05D0}\t一二三四五」六"; + let mut glyphs = full_width(tabbed); + glyphs[0].level = unicode_bidi::Level::rtl(); + glyphs[1].adv = 2.0; + let tabs = [TabStop { position: 12.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + let all_fit = |_end: usize, _hyphenated: bool| true; + let fits: Option<&dyn Fn(usize, bool) -> bool> = Some(&all_fit); + // 」 (glyph 7) ends 72 pt along, 5 pt past the measure: five gaps close by 1 pt each. + let end = |ty| break_line(tabbed, &glyphs, 0, 67.0, &tabs, 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[], fits); + for ty in [KinsokuShoriType::PushInFirst, KinsokuShoriType::PushOutFirst, KinsokuShoriType::PushOutOnly] { + assert_eq!(end(ty), (6, false), "{ty:?}: 五 is pushed out with 」"); + } + let plain = "ax一二三四五」六"; + let mut glyphs = full_width(plain); + glyphs[1].adv = 2.0; + let pushed = + break_line(plain, &glyphs, 0, 67.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushInFirst, false, false, &[], None); + assert_eq!(pushed, (8, false), "without the tab and right-to-left text, 」 is pushed in"); +} + /// Past the breaks a tabbed line measures one by one, it ends at the latest break without a hyphen /// that fits as drawn, whatever the breaks with a hyphen between them measure. #[test] diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 4c320a2bb4..13cd2e06de 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -128,6 +128,27 @@ pub(crate) fn joins(script: Script) -> bool { ) } +/// Is `script` one a Standard composer leaves unshaped: Indic (conjuncts, half forms, marks placed +/// by the font's layout tables), the joining scripts ([`joins`]: Arabic, Syriac, N'Ko, Mongolian…) +/// and Hebrew? +fn is_complex_script(script: Script) -> bool { + joins(script) + || matches!( + script, + Script::Hebrew + | Script::Devanagari + | Script::Bengali + | Script::Gurmukhi + | Script::Gujarati + | Script::Oriya + | Script::Tamil + | Script::Telugu + | Script::Kannada + | Script::Malayalam + | Script::Sinhala + ) +} + /// How `vpal` and the vertical kerning with it move an upright glyph in vertical type and change /// its length down the column, in points: `advance` down the column, `dx` and `dy` in glyph space /// (y down; `dy` is down the column once the glyph stands upright). `proportional`: `vpal` itself @@ -1394,7 +1415,13 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let source_runs = &(source_runs.start + first..source_runs.start + last); let text_seg = &text[range.clone()]; // The segment's script: that of its first character of a script of its own. - let cursive = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)).is_some_and(joins); + let first_script = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)); + // A Standard composer sets a complex script glyph by glyph (no layout tables, nominal glyphs). + // shortcut: decided for the whole segment by its first strong script, as `cursive` is; segments + // already split where the script changes. + let unshaped = feats.unshaped_complex && first_script.is_some_and(is_complex_script); + // Unshaped letters don't join, so they are not bent along a path as joined ones are. + let cursive = first_script.is_some_and(joins) && !unshaped; let full = st.size.max(0.0); // Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size. let (_, pos_shift) = st.position.scale_shift(full); @@ -1475,7 +1502,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe .and_then(|l| l.parse::().ok()); let mut shaped = Shaped::default(); let Shaped { raw, proportional, vpal, chars, unkerned } = &mut shaped; - let hb_shaped = face.hb().map(|hb| { + let hb_shaped = face.hb().filter(|_| !unshaped).map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); // The baseline and character-level pass use exactly the same resolved features. // Alternate positioning passes still resolve their own features, with `more` last. @@ -1591,7 +1618,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe } }); if hb_shaped.is_none() { - // Fallback: nominal glyphs and hmtx advances, no shaping. + // Fallback: nominal glyphs and hmtx advances, no shaping (also a complex script under a + // Standard composer). if let Some(f) = face.skrifa() { let cmap = f.charmap(); let gm = f.glyph_metrics(Size::unscaled(), face.location()); @@ -1600,8 +1628,14 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let c = st.digits.map(c); let chars: Vec = if upper { c.to_uppercase().collect() } else { vec![c] }; for u in chars { - let g = cmap.map(u).unwrap_or_default(); - let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5); + // Format characters (joiners, direction marks) have no glyph of their own in most + // fonts, and the shaper hides them: draw the space's glyph with no advance. + let (g, adv) = if crate::justify::is_format_control(u) { + (cmap.map(' ').unwrap_or_default(), 0.0) + } else { + let g = cmap.map(u).unwrap_or_default(); + (g, gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5)) + }; raw.push((g.to_u32(), cl, adv.round() as i32, 0, 0)); } } diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 7abae3f0eb..d17ce78603 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -534,7 +534,8 @@ pub fn half_form_font(half: bool) -> Option> { pub const CONJUNCT_FAMILY: &str = "Conjuncttest Sans"; /// A test-only cmap over bundled Source Sans 3: Devanagari Ka uses K, the halant uses a period and -/// the vowel sign I (drawn before its syllable) uses i. The `GSUB` table's `dev2` script has an +/// the vowel sign I (drawn before its syllable) uses i; Gujarati Ka, virama and vowel sign I use the +/// same glyphs. The `GSUB` table's `dev2` and `gjr2` scripts have an /// `aalt` single substitution of K by k and a `salt` one of K by i, which list k and the vowel /// sign's glyph as alternates of Ka, and a `half` ligature of K and the period into k (Ka's half /// form). Family [`CONJUNCT_FAMILY`]. `None` when it can't be built. @@ -549,7 +550,7 @@ pub fn conjunct_font() -> Option> { const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); let font = skrifa::FontRef::new(BASE).ok()?; let charmap = font.charmap(); - let mapping = [('क', 'K'), ('\u{094D}', '.'), ('\u{093F}', 'i')]; + let mapping = [('क', 'K'), ('\u{094D}', '.'), ('\u{093F}', 'i'), ('ક', 'K'), ('\u{0ACD}', '.'), ('\u{0ABF}', 'i')]; let mappings = mapping.into_iter().map(|(c, source)| Some((c, write_fonts::types::GlyphId::new(charmap.map(source)?.to_u32())))); let cmap = Cmap::from_mappings(mappings.collect::>>()?).ok()?; let family = CONJUNCT_FAMILY; @@ -574,7 +575,8 @@ pub fn conjunct_font() -> Option> { SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![half])), SubstitutionLookup::Single(Lookup::new(Default::default(), vec![salt.into()])), ]); - let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"dev2"), Script::new(Some(LangSys::new(vec![0, 1, 2])), vec![]))]); + let langsys = || Script::new(Some(LangSys::new(vec![0, 1, 2])), vec![]); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"dev2"), langsys()), ScriptRecord::new(Tag::new(b"gjr2"), langsys())]); builder.add_table(&Gsub::new(scripts, features, lookups)).ok()?; for r in font.table_directory.table_records() { let tag = Tag::new(&r.tag().to_be_bytes()); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 3837a1adf4..4bab8aa561 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -10,6 +10,7 @@ mod bidi; mod bitmaps; mod colr; mod combos; +mod composers; mod composite; mod devanagari; mod embed; diff --git a/crates/text/src/tests/composers.rs b/crates/text/src/tests/composers.rs new file mode 100644 index 0000000000..45e760b9d2 --- /dev/null +++ b/crates/text/src/tests/composers.rs @@ -0,0 +1,113 @@ +//! The four composers: the Middle Eastern & South Asian ones shape complex scripts with the font's +//! layout tables, the Standard ones set them glyph by glyph as the font maps their characters. +//! Uses a font built in code ([`conjunct_font`]): Ka K, halant a period and vowel sign I i, in +//! Devanagari and Gujarati, with a `half` ligature of K and the period into k. +use crate::test_fonts::{CONJUNCT_FAMILY, conjunct_font, source_sans_gid}; +use crate::*; +use kurbo::Point; +use vectorcraft_doc::{CharStyle, Composer, TextObject}; + +/// Ka, halant, Ka, vowel sign I in Devanagari, then in Gujarati. +const DEVANAGARI: &str = "क\u{94D}क\u{93F}"; +const GUJARATI: &str = "ક\u{ACD}ક\u{ABF}"; + +fn db() -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(conjunct_font().expect("the test font builds")); + db +} + +fn object(text: &str, composer: Composer) -> TextObject { + let st = CharStyle { font_family: CONJUNCT_FAMILY.into(), font_style: "Regular".into(), size: 20.0, ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, st); + t.para.composer = composer; + t +} + +/// The glyph ids drawn for `text` with `composer`, and each glyph's source cluster length. +fn drawn(db: &FontDb, text: &str, composer: Composer) -> Vec<(u32, usize)> { + layout(db, &object(text, composer)).glyphs.iter().map(|g| (g.gid, g.len)).collect() +} + +fn g(c: char) -> u32 { + u32::from(source_sans_gid(c).expect("a Source Sans glyph")) +} + +/// Under the ME&SA composers the conjunct forms (the vowel sign I drawn before its syllable and Ka's +/// half form, in Devanagari and in Gujarati); under the Standard ones each character +/// has its own glyph, in text order, as the font maps it. +#[test] +fn standard_composers_set_indic_text_glyph_by_glyph() { + let db = db(); + let ids = |v: Vec<(u32, usize)>| v.into_iter().map(|(gid, _)| gid).collect::>(); + for composer in [Composer::SingleLine, Composer::EveryLine] { + assert_eq!(ids(drawn(&db, DEVANAGARI, composer)), [g('i'), g('k'), g('K')], "{composer:?} Devanagari"); + assert_eq!(ids(drawn(&db, GUJARATI, composer)), [g('i'), g('k'), g('K')], "{composer:?} Gujarati"); + } + for composer in [Composer::StandardSingleLine, Composer::StandardEveryLine] { + let plain = [g('K'), g('.'), g('K'), g('i')]; + let devanagari = drawn(&db, DEVANAGARI, composer); + assert_eq!(ids(devanagari.clone()), plain, "{composer:?} Devanagari"); + assert_eq!(ids(drawn(&db, GUJARATI, composer)), plain, "{composer:?} Gujarati"); + assert!(devanagari.iter().all(|&(_, len)| len == 3), "each glyph is one character's own cluster: {devanagari:?}"); + } +} + +/// The composer is per paragraph: of two paragraphs of the same text, each is shaped by its own. +#[test] +fn each_paragraph_follows_its_own_composer() { + let db = db(); + let mut t = object(&format!("{DEVANAGARI}\n{DEVANAGARI}"), Composer::EveryLine); + let standard = vectorcraft_doc::ParaStyle { composer: Composer::StandardEveryLine, ..t.para.clone() }; + t.set_paragraph_styles(vec![t.para.clone(), standard]); + let l = layout(&db, &t); + let second = DEVANAGARI.len() + 1; + let of = |from: usize, to: usize| l.glyphs.iter().filter(|g| (from..to).contains(&g.byte) && g.len > 0).map(|g| g.gid).collect::>(); + assert_eq!(of(0, second), [g('i'), g('k'), g('K')], "ME&SA paragraph"); + assert_eq!(of(second, second + DEVANAGARI.len()), [g('K'), g('.'), g('K'), g('i')], "Standard paragraph"); +} + +/// Layout's composer override ([`LayoutOptions::composer`]) decides the shaping too. +#[test] +fn the_layout_composer_override_decides_the_shaping() { + let db = db(); + let t = object(DEVANAGARI, Composer::EveryLine); + let with = |c| layout_with(&db, &t, &LayoutOptions { composer: Some(c), ..Default::default() }).glyphs.len(); + assert_eq!((with(Composer::SingleLine), with(Composer::StandardSingleLine)), (3, 4)); +} + +/// Standard composers break lines as the ME&SA ones do (`is_single_line`), and old files' composers +/// keep their meaning. +#[test] +fn composers_split_into_breaking_and_shaping() { + use Composer::*; + for (c, single, shapes) in + [(SingleLine, true, true), (EveryLine, false, true), (StandardSingleLine, true, false), (StandardEveryLine, false, false)] + { + assert_eq!((c.is_single_line(), c.shapes_complex_scripts()), (single, shapes), "{c:?}"); + assert_eq!(c.with_breaking(single), c, "{c:?}"); + assert_eq!( + (c.with_breaking(!single).is_single_line(), c.with_breaking(!single).shapes_complex_scripts()), + (!single, shapes), + "{c:?} toggled" + ); + } + assert_eq!(Composer::default(), EveryLine, "new type and old documents are ME&SA Every-line"); +} + +/// A joiner or non-joiner has no glyph in most fonts and the shaper hides it; a Standard composer +/// draws it as no width at all, not as a missing-glyph box. +#[test] +fn standard_composers_give_format_characters_no_width() { + let db = db(); + let text = "क\u{200C}क"; + for composer in [Composer::StandardSingleLine, Composer::StandardEveryLine] { + let l = layout(&db, &object(text, composer)); + let widths: Vec = l.glyphs.iter().map(|g| g.advance).collect(); + assert_eq!(widths.len(), 3, "{composer:?}: {widths:?}"); + assert_eq!(widths[1], 0.0, "{composer:?}: the non-joiner takes no room: {widths:?}"); + assert!(widths[0] > 0.0 && widths[2] > 0.0, "{composer:?}: {widths:?}"); + // The test font maps no space: the control must still draw nothing (not glyph 0's outline). + assert!(l.glyphs[1].outline.elements().is_empty(), "{composer:?}: the non-joiner has no ink"); + } +} diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 0765b88aaf..b31b597ce7 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -55,6 +55,51 @@ fn composite_runs_split_by_class() { assert!(db.composite_split(&TextObject::point(Point::ZERO, "Ab", style("Inter", 12.0))).is_none()); } +/// A custom character set takes the characters it lists ahead of their class, the later of two +/// sets wins a character both list, and the set's part sizes, raises and scales them. The fonts +/// used for the text, how it is covered and the face of one character follow the sets too. +#[test] +fn custom_character_sets_take_their_characters_first() { + use vectorcraft_doc::{CompositePart, CustomCharSet}; + let db = FontDb::with_font_dirs(vec![]); + let mut f = mixed("Custom"); + let mut part = CompositePart::new("Inter", "Regular"); + part.size = 150.0; + part.baseline = -10.0; + part.h_scale = 90.0; + let mut first = CustomCharSet::new("Letters", CompositePart::new("Source Sans 3", "Regular")); + first.characters = "Bか".into(); + let mut second = CustomCharSet::new("B only", part); + second.characters = "B".into(); + f.custom = vec![first, second]; + db.set_composite_font(&f); + let t = TextObject::point(Point::ZERO, "ABか1", style("Custom", 20.0)); + let (split, from) = db.composite_split(&t).expect("split"); + let pieces: Vec<(&str, &str)> = split.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str())).collect(); + assert_eq!(pieces, [("A", "JetBrains Mono"), ("B", "Inter"), ("か", "Source Sans 3"), ("1", "Inter")]); + assert_eq!(from, [0, 0, 0, 0]); + let b = &split.runs[1].style; + assert_eq!((b.size, b.baseline_shift, b.h_scale, b.v_scale), (30.0, -2.0, 90.0, 100.0)); + // か is in a set, so the Kana part's Scale at Center does not apply to it. + let ka = &split.runs[2].style; + assert_eq!((ka.v_scale, ka.baseline_shift), (100.0, 0.0)); + assert_eq!(db.composite_face("Custom", 'B').map(|(_, id)| id), Some(face_id(&db, "Inter"))); + let fonts = db.composite_fonts_for("Custom", "BA").unwrap(); + assert_eq!(fonts, [("Inter".to_string(), "Regular".to_string()), ("JetBrains Mono".to_string(), "Regular".to_string())]); + let l = layout(&db, &t); + let ids: Vec = l.glyphs.iter().map(|g| g.font_id).collect(); + assert_eq!(ids[1], face_id(&db, "Inter")); + // A changed set is a changed definition: lending it lays the type out again. + let mut changed = f.clone(); + changed.custom[1].characters = "A".into(); + let g0 = db.generation(); + let lent = db.lend_composite_fonts(&[changed]); + assert_ne!(db.generation(), g0); + let (split, _) = db.composite_split(&t).expect("split"); + assert_eq!(split.runs[0].style.font_family, "Inter"); + drop(lent); +} + /// Laid out, each character is drawn in its class's font, sized by its part, and the glyphs still /// name the run of the text they belong to. #[test] diff --git a/crates/text/src/tests/hang.rs b/crates/text/src/tests/hang.rs index 5ccb79a5ca..ba978147bf 100644 --- a/crates/text/src/tests/hang.rs +++ b/crates/text/src/tests/hang.rs @@ -293,3 +293,38 @@ fn burasagari_counts_the_hung_start() { assert!((quote.origin.x + quote.advance).abs() < EPS, "the quote hangs: {:?}", quote.origin); assert!((comma.origin.x + comma.advance - 100.0).abs() < EPS, "、 inside, on the edge: {:?} {}", comma.origin, comma.advance); } + +/// A right-to-left paragraph draws a burasagari full stop inside its line, so the line breaks where +/// it fits with the stop: in a frame too narrow for the whole line by half the stop, the line ends +/// after the first word and nothing is drawn past the frame. +#[test] +fn right_to_left_burasagari_keeps_the_line_inside_the_frame() { + use vectorcraft_doc::{Burasagari, Kinsoku, ParaDirection}; + const EPS: f64 = 1e-6; + let text = "\u{05D0}\u{05D1} \u{05D2}\u{05D3}\u{3002}"; + let lay = |width: f64| { + let mut t = area(text, 20.0, Rect::new(0.0, 0.0, width, 400.0), Justify::Auto); + t.para.direction = Some(ParaDirection::RightToLeft); + t.para.burasagari = Burasagari::Standard; + t.para.kinsoku = Kinsoku::Hard; + let l = layout(db(), &t); + (t, l) + }; + let (_, wide) = lay(1000.0); + assert_eq!(wide.lines.len(), 1); + let full: f64 = wide.glyphs.iter().map(|g| g.advance).sum(); + let Some(stop) = wide.glyphs.iter().find(|g| g.byte == text.len() - '\u{3002}'.len_utf8()) else { + panic!("。 is set as a glyph of its own"); + }; + let (t, l) = lay(full - stop.advance / 2.0); + assert!(!l.overflow); + assert_eq!(l.lines.len(), 2, "the line ends after the first word"); + for (li, gs) in inked(&t, &l) { + // A right-to-left line's trailing space is set at its left, outside the aligned text. + for g in gs.into_iter().filter(|g| !is_space(ch(&t, g))) { + let c = ch(&t, g); + assert!(g.origin.x >= li.avail.0 - EPS, "{c:?} starts at {} before {}", g.origin.x, li.avail.0); + assert!(g.origin.x + g.advance <= li.avail.1 + EPS, "{c:?} ends at {} past {}", g.origin.x + g.advance, li.avail.1); + } + } +} diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index a75821e696..0d9566e5e5 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -1457,7 +1457,7 @@ fn touching_contours_keep_their_parts_and_holes() { #[test] fn an_open_subpath_closes_straight_whether_or_not_contours_meet() { let open = vectorcraft_geom::SubPath { - anchors: vec![ + anchors: vectorcraft_geom::anchors![ vectorcraft_geom::Anchor { p: Point::new(0.0, 0.0), h_in: Point::new(0.0, -50.0), @@ -1505,7 +1505,7 @@ fn flat_far_and_too_large_merges() { assert!(close(area(&cap), 200.0, 1e-3), "joined, not lost"); // Handles count too: a lobe whose handles reach 1.3e8 widens the boolean's snapping to 1.85e-6. let lobe = vectorcraft_geom::SubPath { - anchors: vec![ + anchors: vectorcraft_geom::anchors![ vectorcraft_geom::Anchor { p: Point::new(0.0, 1000.0), h_in: Point::new(0.0, 1000.0), @@ -1604,7 +1604,7 @@ fn meets_within_twice_the_boolean_tolerance() { // What the boolean is given decides the tolerance: an open subpath's end handles (closed // straight) and a one-anchor subpath (skipped) don't widen it. let ends = vectorcraft_geom::SubPath { - anchors: vec![ + anchors: vectorcraft_geom::anchors![ vectorcraft_geom::Anchor { p: Point::new(0.0, 0.0), h_in: Point::new(1e9, 0.0), @@ -1620,7 +1620,8 @@ fn meets_within_twice_the_boolean_tolerance() { ], closed: false, }; - let speck = vectorcraft_geom::SubPath { anchors: vec![vectorcraft_geom::Anchor::corner(Point::new(1e9, 1e9))], closed: true }; + let speck = + vectorcraft_geom::SubPath { anchors: vectorcraft_geom::anchors![vectorcraft_geom::Anchor::corner(Point::new(1e9, 1e9))], closed: true }; let quiet = PathData::new(vec![ends, speck]); assert_eq!(rings::touch(&rings::closed_straight(&quiet)), 1e-6); // A closing-side handle at either end alone is enough to close the subpath straight. @@ -1653,7 +1654,7 @@ fn the_merged_rings_are_turned_as_a_whole() { #[test] fn a_one_anchor_subpath_builds_nothing() { let loop_ = vectorcraft_geom::SubPath { - anchors: vec![vectorcraft_geom::Anchor { + anchors: vectorcraft_geom::anchors![vectorcraft_geom::Anchor { p: Point::new(0.0, 0.0), h_in: Point::new(-20.0, 30.0), h_out: Point::new(20.0, 30.0), diff --git a/crates/tools/src/addons/extend_path.rs b/crates/tools/src/addons/extend_path.rs index 2269396f90..816000ac9f 100644 --- a/crates/tools/src/addons/extend_path.rs +++ b/crates/tools/src/addons/extend_path.rs @@ -178,10 +178,10 @@ impl Tool for ExtendPathTool { let (k, n) = (end.sp.anchors.len(), ext.anchors.len()); // From the old end on; a stretched last curve (no anchor added) shows whole. let from = if n > k { k - 1 } else { n.saturating_sub(2) }; - let tail = if end.start { ext.anchors.get(..n - from) } else { ext.anchors.get(from..) }; + let tail = if end.start { ext.anchors.get_range(..n - from) } else { ext.anchors.get_range(from..) }; if let Some(tail) = tail { let mut bp = vectorcraft_geom::BezPath::new(); - SubPath::new(tail.to_vec(), false).to_bezpath_into(&mut bp); + SubPath::new(tail, false).to_bezpath_into(&mut bp); out.push(Overlay::Path { path: bp, color: GREY, width: 1.0, dashed: true }); } } diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index c25ab8f670..43269ed580 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -478,9 +478,9 @@ impl SmartPenTool { } else { straighten_segment(&mut sp, prev); } - let tail = sp.anchors.get(prev..)?; + let tail = sp.anchors.get_range(prev..)?; let mut bp = BezPath::new(); - SubPath::new(tail.to_vec(), false).to_bezpath_into(&mut bp); + SubPath::new(tail, false).to_bezpath_into(&mut bp); Some(bp) } } diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs index efc68132a1..0dc599444f 100644 --- a/crates/tools/src/distort/liquify.rs +++ b/crates/tools/src/distort/liquify.rs @@ -547,7 +547,7 @@ fn simplify(sp: &mut SubPath, tol: f64, region: Rect) { None => out[0].h_in = h_in, } } - sp.anchors = out; + sp.anchors = out.into(); } /// Apply a liquify stroke (its dabs, at the brush's intensity) to `path`. `salt` decorrelates diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index d1a2672402..fee24c340b 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -1,8 +1,10 @@ //! Flare, Reshape, Shaper and Graph tools. //! //! Flare: drag sets the center and its size and turns the rays as the pointer moves up or down -//! (Shift holds their turn; click without dragging opens the Flare Tool Options), then a click -//! sets the end point of the rings; Space held during either drag moves the flare. The rest comes +//! (Shift holds their turn, ↑/↓ add or remove rays and Ctrl/Cmd keeps the center's size while the +//! drag sizes the halo; click without dragging opens the Flare Tool Options), then a click sets the +//! end point of the rings (↑/↓ add or remove rings and ~ places them anew); Space held during +//! either drag moves the flare. The rest comes //! from the tool's options (the Flare Tool Options, which last). Reshape: a click on a selected //! path sets a focal point (adding an anchor on a segment), Shift-click adds or removes more, and //! a drag from a focal point moves them all; the engine's `path.reshape` stretches the selected @@ -57,8 +59,19 @@ pub struct FlareTool { /// The pointer at the last press or drag, from which Space held moves the flare. last: Point, began: bool, - /// Ray count changed with ↑/↓ during the gesture (None = the options' count). + /// Ray count changed with ↑/↓ during the center drag (None = the options' count). rays: Option, + /// Ring count changed with ↑/↓ while the rings' end point is placed (None = the options' count). + rings: Option, + /// The rings' placement ~ chose (`shape.flare`'s `ringSeed`; None = the placement `seed` gives). + ring_seed: Option, + /// Whether backquote (the ~ key) was held at the last pointer event. + quote: bool, + /// Ctrl/Cmd held in the center drag: the center's diameter, the Halo growth (%) and the + /// pointer's distance from the center when the key went down. + held: Option<(f64, f64, f64)>, + /// The Halo growth (%) the drag set while Ctrl/Cmd held the center (None = the options'). + growth: Option, /// The rays' turn (`shape.flare`'s `rotation`, degrees) the center drag set. turn: f64, /// The options' values, in [`FLARE_OPTIONS`] order. @@ -77,6 +90,11 @@ impl Default for FlareTool { last: Point::ZERO, began: false, rays: None, + rings: None, + ring_seed: None, + quote: false, + held: None, + growth: None, turn: 0.0, values: FLARE_OPTIONS.map(|(_, default, _, _)| default), rays_on: true, @@ -123,9 +141,27 @@ fn ray_turn(turn: f64, dy: f64, shift: bool) -> f64 { (turn + dy * RAY_DEGREES_PER_POINT).rem_euclid(360.0) } +/// The ring placement ~ chooses after `last`, with the pointer at `at`: a new number below 2^53, so +/// a JSON client reads it exactly, and never `last` again. +fn next_ring_seed(last: Option, at: Point) -> u64 { + let mut x = last.unwrap_or(0x9e37_79b9_7f4a_7c15) ^ at.x.to_bits() ^ at.y.to_bits().rotate_left(29); + loop { + // SplitMix64. + x = x.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = x; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + let seed = (z ^ (z >> 31)) & ((1 << 53) - 1); + if Some(seed) != last { + return seed; + } + } +} + impl FlareTool { /// `shape.flare`'s params: the options, with the dragged center and diameter, the rays' turn, - /// the ray count ↑/↓ chose and the rings' end point once clicked. + /// the ray and ring counts ↑/↓ chose, the Halo growth Ctrl/Cmd set, the ring placement ~ chose + /// and the rings' end point once placed. fn params(&self, c: Point, diameter: f64, end: Option) -> Value { let mut v = flare_params(&self.options(), c); v["diameter"] = json!(diameter.max(1.0)); @@ -133,12 +169,62 @@ impl FlareTool { if let Some(r) = self.rays.filter(|_| self.rays_on) { v["rays"] = json!(r); } + if let Some(r) = self.rings.filter(|_| self.rings_on) { + v["rings"] = json!(r); + } + if let Some(g) = self.growth { + v["growth"] = json!(g); + } + if let Some(s) = self.ring_seed.filter(|_| self.rings_on) { + v["ringSeed"] = json!(s); + } if let Some(e) = end { v["x2"] = json!(e.x); v["y2"] = json!(e.y); } v } + + /// Flare Tool Options value `key` as the tool has it. + fn option(&self, key: &str) -> f64 { + FLARE_OPTIONS.iter().position(|(k, ..)| *k == key).map_or(0.0, |i| self.values[i]) + } + + /// Count `now` (else the options' `key`) one up or down, within the options' range for `key`. + fn step_count(&self, key: &str, now: Option, up: bool) -> u64 { + let most = FLARE_OPTIONS.iter().find(|(k, ..)| *k == key).map_or(0.0, |o| o.3) as u64; + let r = now.unwrap_or(self.option(key) as u64); + if up { (r + 1).min(most) } else { r.saturating_sub(1).min(most) } + } + + /// Ctrl/Cmd in the center drag, with the pointer `dist` from the center after `before` at the + /// last drag: once the flare has begun, the center keeps the diameter it showed when the key + /// went down, and the halo's radius keeps its ratio to the pointer's distance through the Halo + /// growth, within the options' 0 to 300 %. Letting the key go sizes the center with the drag + /// again, and the flare keeps the new Halo growth. The key is pressed while dragging: a press + /// made with it already held goes to the selection tool the session lends for Ctrl/Cmd. + fn hold_center(&mut self, before: f64, dist: f64, cmd: bool) { + match (cmd, self.held) { + (true, None) if self.began && before > 0.0 => { + let growth = self.growth.unwrap_or_else(|| self.option("growth")); + self.held = Some((2.0 * before, growth, before)); + } + (false, Some(_)) => self.held = None, + _ => {} + } + if let Some((_, growth, at)) = self.held { + let most = FLARE_OPTIONS.iter().find(|(k, ..)| *k == "growth").map_or(0.0, |o| o.3); + let g = (100.0 + growth) * dist / at - 100.0; + if g.is_finite() { + self.growth = Some(g.clamp(0.0, most)); + } + } + } + + /// The center's diameter with the pointer `dist` from it: twice that, unless Ctrl/Cmd holds it. + fn center_diameter(&self, dist: f64) -> f64 { + self.held.map_or(2.0 * dist, |(d, ..)| d) + } } impl Tool for FlareTool { @@ -150,34 +236,47 @@ impl Tool for FlareTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { self.hover = ev.pos; + // ~ pressed while the rings' end point is placed places the rings anew (Rings on). + let quote_pressed = ev.backquote && !self.quote; + self.quote = ev.backquote; + if quote_pressed && self.rings_on && self.placed.is_some() && matches!(ev.kind, PointerKind::Drag | PointerKind::Move | PointerKind::Up) { + self.ring_seed = Some(next_ring_seed(self.ring_seed, ev.pos)); + } match ev.kind { PointerKind::Down => { if self.placed.is_none() { self.drag = Some((ev.pos, ev.pos)); self.rays = None; + self.rings = None; + self.ring_seed = None; + self.held = None; + self.growth = None; self.turn = 0.0; } self.last = ev.pos; vec![] } PointerKind::Drag => { - if let Some((mut c, _)) = self.drag { + if let Some((mut c, p)) = self.drag { // Space held moves the center with the pointer, so the size and the turn stay. + let before = p.distance(c); let dy = ev.pos.y - self.last.y; crate::shape::space_moves(&mut c, &mut self.last, ev, self.began); self.drag = Some((c, ev.pos)); if !ev.mods.space { self.turn = ray_turn(self.turn, dy, ev.mods.shift); } - if !self.began && ev.pos.distance(c) >= cx.tol(3.0) { - self.began = true; - return vec![ - Action::Begin("Flare".into()), - Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None)), - ]; + let dist = ev.pos.distance(c); + let begins = !self.began && dist >= cx.tol(3.0); + self.began |= begins; + // With the key held from the start, the center keeps the first size the flare shows. + self.hold_center(if begins { dist } else { before }, dist, ev.mods.cmd); + let preview = Action::Preview("shape.flare".into(), self.params(c, self.center_diameter(dist), None)); + if begins { + return vec![Action::Begin("Flare".into()), preview]; } if self.began { - return vec![Action::Preview("shape.flare".into(), self.params(c, 2.0 * ev.pos.distance(c), None))]; + return vec![preview]; } } else if let Some((mut c, d)) = self.placed { // Space held moves the whole flare, rings' end point included. @@ -196,7 +295,8 @@ impl Tool for FlareTool { let Some((c, p)) = self.drag.take() else { return vec![] }; if self.began { // Keep the interaction open: the next click places the end point. - self.placed = Some((c, 2.0 * p.distance(c))); + self.placed = Some((c, self.center_diameter(p.distance(c)))); + self.held = None; vec![] } else { vec![Action::Dialog("flare".into(), json!({ "x": c.x, "y": c.y }))] @@ -212,19 +312,28 @@ impl Tool for FlareTool { } } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - // ↑/↓ while drawing add/remove rays. - if matches!(key, ToolKey::Up | ToolKey::Down) && self.began && self.rays_on { - let r = self.rays.unwrap_or_else(|| self.options()["rays"].as_u64().unwrap_or(0)); - self.rays = Some(if key == ToolKey::Up { (r + 1).min(50) } else { r.saturating_sub(1) }); + // ↑/↓ add/remove rays in the center drag and rings while the rings' end point is placed. + if matches!(key, ToolKey::Up | ToolKey::Down) && self.began { + let up = key == ToolKey::Up; let preview = match (self.drag, self.placed) { - (Some((c, p)), _) => self.params(c, 2.0 * p.distance(c), None), - (_, Some((c, d))) => self.params(c, d, Some(self.hover)), + (Some((c, p)), _) if self.rays_on => { + self.rays = Some(self.step_count("rays", self.rays, up)); + self.params(c, self.center_diameter(p.distance(c)), None) + } + (_, Some((c, d))) if self.rings_on => { + self.rings = Some(self.step_count("rings", self.rings, up)); + self.params(c, d, Some(self.hover)) + } _ => return vec![], }; return vec![Action::Preview("shape.flare".into(), preview)]; } if key == ToolKey::Escape && self.busy() { self.rays = None; + self.rings = None; + self.ring_seed = None; + self.held = None; + self.growth = None; self.drag = None; self.placed = None; let began = std::mem::take(&mut self.began); @@ -235,12 +344,14 @@ impl Tool for FlareTool { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { // Switching away after the centre drag keeps the flare with the rings its options give. self.drag = None; + self.held = None; if self.placed.take().is_some() && std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] } } fn overlays(&self, _cx: &ToolContext) -> Vec { match (self.drag, self.placed) { (Some((c, p)), _) => { - vec![Overlay::Path { path: ellipse_path(c, p.distance(c), p.distance(c), 0.0), color: BLUE, width: 1.0, dashed: false }] + let r = self.center_diameter(p.distance(c)) / 2.0; + vec![Overlay::Path { path: ellipse_path(c, r, r, 0.0), color: BLUE, width: 1.0, dashed: false }] } (_, Some((c, _))) => vec![Overlay::Line { a: c, b: self.hover, color: BLUE, dashed: true }], _ => vec![], @@ -839,6 +950,131 @@ mod tests { assert_eq!((v["cx"].as_f64(), v["cy"].as_f64(), v["x2"].as_f64()), (Some(120.0), Some(110.0), Some(340.0))); } + /// ↑/↓ change the rays in the center drag and the rings while the rings' end point is placed, + /// within the options' 0 to 50; with Rings off they leave the second drag alone. + #[test] + fn flare_arrows_change_the_rays_then_the_rings() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let counts = |v: &Value| (v["rays"].as_u64(), v["rings"].as_u64()); + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0)); + assert_eq!(counts(&flare_preview(&t.key(&cx, ToolKey::Up, Mods::default()))), (Some(16), Some(10))); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 320.0, 300.0)); + assert_eq!(counts(&flare_preview(&t.key(&cx, ToolKey::Up, Mods::default()))), (Some(16), Some(11))); + t.key(&cx, ToolKey::Down, Mods::default()); + assert_eq!(counts(&flare_preview(&t.key(&cx, ToolKey::Down, Mods::default()))), (Some(16), Some(9))); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 330.0, 300.0)); + assert_eq!(a.last(), Some(&Action::Commit)); + assert_eq!(counts(&flare_preview(&a)), (Some(16), Some(9))); + // The next flare starts from the options' counts; the counts stop at 0 and 50. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + let mut v = Value::Null; + for _ in 0..12 { + v = flare_preview(&t.key(&cx, ToolKey::Down, Mods::default())); + } + assert_eq!(counts(&v), (Some(15), Some(0))); + for _ in 0..60 { + v = flare_preview(&t.key(&cx, ToolKey::Up, Mods::default())); + } + assert_eq!(counts(&v), (Some(15), Some(50))); + t.key(&cx, ToolKey::Escape, Mods::default()); + // Rings off: the arrows do nothing in the second drag. + t.set_option("ringsOn", &json!(false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + assert!(t.key(&cx, ToolKey::Up, Mods::default()).is_empty()); + } + + /// ~ (backquote) pressed while the rings' end point is placed gives the rings a new placement + /// each time, which the flare drawn keeps; holding it, the center drag, Rings off and the next + /// flare give none. + #[test] + fn flare_tilde_places_the_rings_anew() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let quote = |kind, x, y, held| PointerEvent { backquote: held, ..PointerEvent::new(kind, x, y) }; + let seed = |a: &[Action]| flare_preview(a).get("ringSeed").and_then(Value::as_u64); + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + // In the center drag ~ does nothing. + assert_eq!(seed(&t.pointer(&cx, "e(PointerKind::Drag, 140.0, 100.0, true))), None); + t.pointer(&cx, "e(PointerKind::Drag, 141.0, 100.0, false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 141.0, 100.0)); + assert_eq!(seed(&t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 200.0, 200.0))), None); + let first = seed(&t.pointer(&cx, "e(PointerKind::Move, 200.0, 200.0, true))); + assert!(first.is_some_and(|s| s < 1 << 53)); + // Held, it places them once; pressed again, anew. + assert_eq!(seed(&t.pointer(&cx, "e(PointerKind::Move, 210.0, 200.0, true))), first); + t.pointer(&cx, "e(PointerKind::Down, 210.0, 200.0, false)); + t.pointer(&cx, "e(PointerKind::Drag, 220.0, 200.0, false)); + let second = seed(&t.pointer(&cx, "e(PointerKind::Drag, 220.0, 200.0, true))); + assert!(second.is_some() && second != first); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 230.0, 200.0)); + assert_eq!((a.last(), seed(&a)), (Some(&Action::Commit), second)); + // The next flare starts without one, and with Rings off ~ does not change the flare. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(seed(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0))), None); + t.set_option("ringsOn", &json!(false)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 100.0)); + assert_eq!(seed(&t.pointer(&cx, "e(PointerKind::Move, 200.0, 200.0, true))), None); + assert_eq!(t.ring_seed, None); + } + + /// Ctrl/Cmd in the center drag keeps the center's diameter as the last drag showed it, and the + /// drag sizes the halo through the Halo growth (0 to 300 %); let go, the drag sizes the center + /// again and the flare keeps the new growth. + #[test] + fn flare_ctrl_keeps_the_center_and_the_drag_sizes_the_halo() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let cmd = Mods { cmd: true, ..Default::default() }; + let sized = |a: &[Action]| { + let v = flare_preview(a); + (v["diameter"].as_f64().unwrap_or(0.0), v["growth"].as_f64().unwrap_or(0.0)) + }; + let mut t = FlareTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 100.0))), (80.0, 20.0)); + // The key goes down with the pointer's next move: the center stays as the last drag showed it. + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 100.0).with_mods(cmd))), (80.0, 140.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0).with_mods(cmd))), (80.0, 0.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 900.0, 100.0).with_mods(cmd))), (80.0, 300.0)); + assert_eq!(sized(&t.key(&cx, ToolKey::Up, Mods::default())), (80.0, 300.0)); + // Let go: the center follows the drag again and the growth stays. + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 100.0))), (100.0, 300.0)); + // Pressed again, the hold starts from the new center and growth. + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 125.0, 100.0).with_mods(cmd))), (100.0, 100.0)); + // A release with the key held places the held center; the second drag draws with it. + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 125.0, 100.0).with_mods(cmd)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 330.0)); + assert_eq!((a.last(), sized(&a)), (Some(&Action::Commit), (100.0, 100.0))); + // With the key held from the press, the center keeps the first size the flare shows; the + // next flare starts from the options' growth. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 110.0).with_mods(cmd))), (20.0, 20.0)); + assert_eq!(sized(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 120.0).with_mods(cmd))), (20.0, 140.0)); + } + #[test] fn reshape_drag_honors_measurement_labels() { use crate::testutil::*; diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs index 32c91318e6..badb8193a7 100644 --- a/crates/tools/src/pen/tests.rs +++ b/crates/tools/src/pen/tests.rs @@ -607,7 +607,7 @@ fn a_drag_from_the_first_anchor_always_closes() { fn alt_mid_close_keeps_the_turned_outgoing_handle() { use vectorcraft_geom::{Anchor, SubPath}; let anchors = vec![Anchor::smooth(Point::new(100.0, 300.0), Point::new(150.0, 300.0)), Anchor::corner(Point::new(200.0, 200.0))]; - let (mut d, id, s) = drawing(SubPath { anchors, closed: false }); + let (mut d, id, s) = drawing(SubPath::new(anchors, false)); let p = paint(); let mut t = PenTool { drawing: true, ..PenTool::default() }; assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), close_press(id, Point::new(100.0, 300.0))); @@ -634,7 +634,7 @@ fn alt_mid_close_keeps_the_turned_outgoing_handle() { fn space_moves_the_closing_anchor() { use vectorcraft_geom::{Anchor, SubPath}; let anchors = vec![Anchor::smooth(Point::new(100.0, 300.0), Point::new(150.0, 300.0)), Anchor::corner(Point::new(200.0, 200.0))]; - let (mut d, id, s) = drawing(SubPath { anchors, closed: false }); + let (mut d, id, s) = drawing(SubPath::new(anchors, false)); let p = paint(); let mut t = PenTool { drawing: true, ..PenTool::default() }; assert_eq!(t.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 100.0, 300.0)), close_press(id, Point::new(100.0, 300.0))); diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 0d99a2acfc..b489e4dd82 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -57,6 +57,7 @@ mod logo; mod mosaic; mod opaque; mod quantize; +mod seam; mod shapes; mod smooth; mod subpixel; @@ -71,6 +72,7 @@ pub use gradient::TracedGradient; pub use mosaic::mosaic; pub use opaque::{ALPHA_MAX_DECODE_BYTES, OUTLINE_MAX_ANCHORS, OUTLINE_MAX_PIXELS, decode_alpha, opaque_outline}; pub use quantize::{FULL_TONE_MAX, Quantized, TRANSPARENT, denoise, quantize}; +pub use seam::{SEAM_WIDTH, seam_fills}; pub use shapes::TracedShape; use subpixel::Crack; diff --git a/crates/trace/src/seam.rs b/crates/trace/src/seam.rs new file mode 100644 index 0000000000..726916e240 --- /dev/null +++ b/crates/trace/src/seam.rs @@ -0,0 +1,277 @@ +//! Abutting with Fills: seam fills, thin strokes under the filled paths along the edges they share. +//! +//! Abutting paths meet edge to edge, so a renderer that anti-aliases each path on its own leaves +//! the pixels along a shared edge partly covered by each path and lets what is behind them show +//! through as a light hairline. A stroke along the edge in the color of the path drawn first, +//! under every path, fills those pixels with that color: drawing the two paths over it then gives +//! each pixel the colors of the two paths in the shares each covers. The paths themselves are not +//! changed, and an edge a path shares with no other path (the image's border, a transparent area, +//! a color left out) gets no stroke. + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use vectorcraft_geom::{Anchor, PathData, Point, SubPath}; + +use super::params::{Method, Mode, TraceParams}; +use super::pipeline::TracedPath; + +/// How wide each seam fill's stroke is, in pixels: it covers every pixel the shared edge passes +/// through, at any angle (a pixel's corner is up to 0.71 px from a line through it). +pub const SEAM_WIDTH: f64 = 1.5; + +/// A segment, the same for the segment and the segment run backwards: two 64-bit hashes of its +/// points' bits, 16 bytes a segment instead of 64. Two different segments with the same key would +/// only give the lower one's color a stroke along a piece it does not share. +type Key = u128; + +fn key(a: &Anchor, b: &Anchor) -> Key { + let bits = |[p, q, r, s]: [Point; 4]| [p.x, p.y, q.x, q.y, r.x, r.y, s.x, s.y].map(f64::to_bits); + let points = bits([a.p, a.h_out, b.h_in, b.p]).min(bits([b.p, b.h_in, a.h_out, a.p])); + let hash = |seed: u8| { + let mut h = DefaultHasher::new(); + (seed, points).hash(&mut h); + h.finish() + }; + (u128::from(hash(0)) << 64) | u128::from(hash(1)) +} + +/// The seam fills of a trace made with `params`: for Abutting with Fills (not Flat Logo), thin +/// strokes to draw under every path of `paths` (bottom-most first). For each color, one path +/// stroked [`SEAM_WIDTH`] pixels wide along the edges where a filled path of that color meets a +/// filled path drawn after it, with `color` and `gradient` that path's; the paths with a gradient +/// get one each. Bottom-most first, as the paths they lie under. The stroke has at most as many +/// anchors as `paths` (each of its segments is a segment of a path), and finding them keeps about +/// 40 bytes for each segment of `paths` while it runs. Empty for any other trace. +pub fn seam_fills(paths: &[TracedPath], params: &TraceParams) -> Vec { + if params.method != Method::Abutting || !params.fills || params.mode == Mode::Logo { + return vec![]; + } + fills(paths) +} + +/// The segments of `sp`: each anchor and the one after it (back to the first, if closed). +fn segments(sp: &SubPath) -> impl Iterator { + let n = sp.anchors.len(); + let count = if sp.closed { n } else { n.saturating_sub(1) }; + (0..count).filter_map(move |k| Some((sp.anchors.get(k)?, sp.anchors.get((k + 1) % n)?))) +} + +/// [`seam_fills`] of Abutting `paths`, whatever the settings. +fn fills(paths: &[TracedPath]) -> Vec { + // For each segment, the first path it is in, and whether another path has it too. + let mut owner: HashMap = HashMap::new(); + for (i, p) in paths.iter().enumerate().filter(|(_, p)| p.stroke.is_none()) { + for sp in &p.path.subpaths { + for (a, b) in segments(sp).filter(|(a, b)| a.p != b.p) { + let e = owner.entry(key(a, b)).or_insert((i, false)); + if e.0 != i { + e.1 = true; + } + } + } + } + // One fill per color (per path, for a gradient), in the order of the first path it lies under. + let mut out: Vec = vec![]; + let mut by_color: HashMap<[u8; 3], usize> = HashMap::new(); + for (i, p) in paths.iter().enumerate().filter(|(_, p)| p.stroke.is_none()) { + let mut runs: Vec = vec![]; + for sp in &p.path.subpaths { + let lower = |a: &Anchor, b: &Anchor| a.p != b.p && owner.get(&key(a, b)) == Some(&(i, true)); + let marks: Vec = segments(sp).map(|(a, b)| lower(a, b)).collect(); + let n = sp.anchors.len(); + if marks.is_empty() { + continue; + } + if sp.closed && marks.iter().all(|&m| m) { + runs.push(sp.clone()); + continue; + } + // Start after an unmarked segment, so that a run across the subpath's first anchor + // stays one run. + let start = if sp.closed { marks.iter().position(|&m| !m).map_or(0, |k| k + 1) } else { 0 }; + let mut run: Vec = vec![]; + for j in 0..marks.len() { + let k = (start + j) % marks.len(); + let (Some(&a), Some(&b)) = (sp.anchors.get(k), sp.anchors.get((k + 1) % n)) else { continue }; + if marks.get(k).copied().unwrap_or(false) { + if run.is_empty() { + run.push(Anchor { h_in: a.p, ..a }); + } + run.push(b); + } else if !run.is_empty() { + runs.push(open_run(std::mem::take(&mut run))); + } + } + if !run.is_empty() { + runs.push(open_run(run)); + } + } + if runs.is_empty() { + continue; + } + let slot = if p.gradient.is_some() { None } else { by_color.get(&p.color).copied() }; + match slot.and_then(|s| out.get_mut(s)) { + Some(fill) => fill.path.subpaths.extend(runs), + None => { + if p.gradient.is_none() { + by_color.insert(p.color, out.len()); + } + out.push(TracedPath { + path: PathData::new(runs), + color: p.color, + pixels: 0, + stroke: Some(SEAM_WIDTH), + shape: None, + gradient: p.gradient.clone(), + square_caps: false, + }); + } + } + } + out +} + +/// An open run of anchors: the last one has no handle out. +fn open_run(mut run: Vec) -> SubPath { + if let Some(last) = run.last_mut() { + last.h_out = last.p; + } + SubPath::new(run, false) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Method, Mode, Raster, TraceParams, trace}; + + fn polygon(rings: &[&[(f64, f64)]], color: u8) -> TracedPath { + let subpaths = rings.iter().map(|r| SubPath::polyline(&r.iter().map(|&(x, y)| Point::new(x, y)).collect::>(), true)).collect(); + TracedPath { path: PathData::new(subpaths), color: [color; 3], pixels: 1, stroke: None, shape: None, gradient: None, square_caps: false } + } + + fn points(sp: &SubPath) -> Vec<(f64, f64)> { + sp.anchors.iter().map(|a| (a.p.x, a.p.y)).collect() + } + + /// Each segment of `p`'s subpaths, by [`key`]. + fn keys(p: &TracedPath) -> Vec { + p.path.subpaths.iter().flat_map(|sp| segments(sp).map(|(a, b)| key(a, b)).collect::>()).collect() + } + + #[test] + fn a_shared_edge_gets_a_stroke_in_the_color_of_the_path_drawn_first() { + let left = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10); + let right = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0)]], 20); + let fills = fills(&[left.clone(), right.clone()]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!((fills[0].color, fills[0].stroke, fills[0].gradient.as_ref()), ([10; 3], Some(SEAM_WIDTH), None)); + assert_eq!(fills[0].path.subpaths.len(), 1); + let sp = &fills[0].path.subpaths[0]; + assert!(!sp.closed); + assert_eq!(points(sp), [(2.0, 0.0), (2.0, 2.0)]); + // Drawn the other way round, the stroke takes the other color. + let swapped = super::fills(&[right, left]); + assert_eq!(swapped.len(), 1); + assert_eq!((swapped[0].color, points(&swapped[0].path.subpaths[0])), ([20; 3], vec![(2.0, 2.0), (2.0, 0.0)])); + } + + #[test] + fn an_island_gets_a_closed_stroke_in_the_color_around_it() { + let hole: &[(f64, f64)] = &[(2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0)]; + let around = polygon(&[&[(0.0, 0.0), (6.0, 0.0), (6.0, 6.0), (0.0, 6.0)], hole], 10); + let island = polygon(&[&[(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0)]], 20); + let fills = fills(&[around, island]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!(fills[0].color, [10; 3]); + assert_eq!(fills[0].path.subpaths.len(), 1); + assert!(fills[0].path.subpaths[0].closed); + assert_eq!(points(&fills[0].path.subpaths[0]), hole); + } + + #[test] + fn a_shared_run_across_a_subpaths_first_anchor_stays_one_stroke() { + // The left square starts halfway along the edge it shares; the right one splits that edge + // at the same point. + let left = polygon(&[&[(2.0, 1.0), (2.0, 2.0), (0.0, 2.0), (0.0, 0.0), (2.0, 0.0)]], 10); + let right = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0), (2.0, 1.0)]], 20); + let fills = fills(&[left, right]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!(fills[0].path.subpaths.len(), 1); + assert_eq!(points(&fills[0].path.subpaths[0]), [(2.0, 0.0), (2.0, 1.0), (2.0, 2.0)]); + } + + #[test] + fn edges_shared_with_no_other_filled_path_get_no_stroke() { + let square = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10); + assert_eq!(fills(std::slice::from_ref(&square)), []); + // A stroked line along the square's edge is not a filled neighbour. + let mut line = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 2.0), (2.0, 2.0)]], 20); + line.stroke = Some(1.0); + assert_eq!(fills(&[square, line]), []); + } + + #[test] + fn paths_of_one_color_share_one_stroke_and_gradients_keep_their_own() { + let a = polygon(&[&[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]], 10); + let b = polygon(&[&[(0.0, 4.0), (2.0, 4.0), (2.0, 6.0), (0.0, 6.0)]], 10); + let c = polygon(&[&[(2.0, 0.0), (4.0, 0.0), (4.0, 6.0), (2.0, 6.0), (2.0, 4.0), (2.0, 2.0)]], 20); + let fills = fills(&[a, b, c]); + assert_eq!(fills.len(), 1, "{fills:?}"); + assert_eq!(fills[0].path.subpaths.iter().map(points).collect::>(), [vec![(2.0, 0.0), (2.0, 2.0)], vec![(2.0, 4.0), (2.0, 6.0)]]); + } + + /// Two colors side by side, the edge between them anti-aliased at `x = 10.4`. + fn two_colors() -> Raster { + 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] + }) + } + + fn color(method: Method) -> TraceParams { + TraceParams { mode: Mode::Color, colors: 2, method, ignore_white: false, ..TraceParams::default() } + } + + #[test] + fn an_abutting_trace_strokes_the_edges_its_filled_paths_share() { + let params = color(Method::Abutting); + let res = trace(&two_colors(), ¶ms); + assert_eq!(res.paths.len(), 2, "{res:?}"); + let seams = seam_fills(&res.paths, ¶ms); + assert_eq!(seams.len(), 1, "{seams:?}"); + let seam = &seams[0]; + assert_eq!((seam.color, seam.stroke), (res.paths[0].color, Some(SEAM_WIDTH))); + // Every piece of the stroke is a piece of both paths, and it runs from the image's top to + // its bottom along the edge, not along the image's border. + let (lower, upper) = (keys(&res.paths[0]), keys(&res.paths[1])); + assert!(keys(seam).iter().all(|k| lower.contains(k) && upper.contains(k))); + assert!(seam.path.anchor_count() <= res.anchor_count()); + for a in seam.path.subpaths.iter().flat_map(|sp| &sp.anchors) { + assert!((a.p.x - 10.4).abs() < 0.5, "{a:?}"); + } + let ys: Vec = seam.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p.y)).collect(); + assert_eq!((ys.iter().copied().fold(f64::INFINITY, f64::min), ys.iter().copied().fold(f64::NEG_INFINITY, f64::max)), (0.0, 16.0)); + } + + #[test] + fn only_abutting_fills_get_seam_strokes() { + let seams = |img: &Raster, params: &TraceParams| seam_fills(&trace(img, params).paths, params); + let img = two_colors(); + assert_eq!(seams(&img, &color(Method::Overlapping)), []); + assert_eq!(seams(&img, &TraceParams { fills: false, strokes: true, ..color(Method::Abutting) }), []); + // Flat Logo's shapes are stacked, whatever the method says. + let abutting = trace(&img, &color(Method::Abutting)); + assert_eq!(seam_fills(&abutting.paths, &TraceParams { mode: Mode::Logo, ..color(Method::Abutting) }), []); + // A color left out leaves no neighbour: a dark bar on white, with Ignore White. + let bar = Raster::from_fn(24, 16, |x, _| if (8..16).contains(&x) { [0, 0, 0, 255] } else { [255; 4] }); + let bw = TraceParams { mode: Mode::BlackAndWhite, method: Method::Abutting, ignore_white: true, ..TraceParams::default() }; + let res = trace(&bar, &bw); + assert_eq!(res.paths.len(), 1, "{res:?}"); + assert_eq!(seam_fills(&res.paths, &bw), []); + // Both colors traced, the bar's two sides are shared. + let both = seams(&bar, &TraceParams { ignore_white: false, ..bw }); + assert_eq!(both.iter().map(|s| s.path.subpaths.len()).sum::(), 2, "{both:?}"); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 2ea7ac8079..92a942f4c5 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -54,6 +54,9 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] craft-ui = { workspace = true, features = ["native-windows"] } craft-update = { workspace = true } +# The local date and time the status bar's Date and Time readout shows (`chrono::Local`). Already in +# Cargo.lock with the time-zone crates `clock` uses. +chrono = { workspace = true, features = ["clock"] } [target.'cfg(target_arch = "wasm32")'.dependencies] # `now_ms` (src/lib.rs) reads the page's `performance.now()` with this crate in the web build. It is diff --git a/crates/ui-egui/src/addons/shapes.rs b/crates/ui-egui/src/addons/shapes.rs index fae45a190e..b856f15603 100644 --- a/crates/ui-egui/src/addons/shapes.rs +++ b/crates/ui-egui/src/addons/shapes.rs @@ -8,6 +8,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::shapes::EllipseMode; use vectorcraft_tools::addons::box3d::{self, CustomShape}; +use crate::chrome::Fit; use crate::menus::Item; use crate::theme::Tokens; use crate::{VectorcraftApp, widgets}; @@ -17,8 +18,10 @@ pub fn menu() -> Vec { } /// Draw context-sensitive shape customization controls in the top Control bar when a shape tool is -/// active. Returns `true` when controls were drawn. -pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { +/// active. Returns `true` when a shape tool is active. Each button, and each label with its field, +/// shows only while the row has room for it and `reserve` after it, and the controls after one left +/// out are left out too. +pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) -> bool { let tool = app.session.tool_id().to_string(); if !matches!(tool.as_str(), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "spiral" | "box3d") { return false; @@ -29,6 +32,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { st.selection.objects.iter().find_map(|&id| st.doc.node(id).and_then(|n| CustomShape::read_for_update(n, active_shape == "spiral"))) }); + let fit = &mut Fit::new(reserve); if tool == "box3d" { for (s_id, lbl, icon) in [ ("box3d", "3D Box", "box"), @@ -38,24 +42,35 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { ("star", "Star", "star"), ("spiral", "Spiral", "tornado"), ] { - if widgets::icon_button(ui, icon, tl!(lbl), active_shape == s_id, 22.0).clicked() { + if fit.room(ui, &[22.0]) && widgets::icon_button(ui, icon, tl!(lbl), active_shape == s_id, 22.0).clicked() { app.run("tool.setOption", json!({ "key": "shape", "value": s_id })).ok(); } } - crate::chrome::group_separator(ui); + fit.separator(ui); } match active_shape.as_str() { - "ellipse" => draw_ellipse_controls(app, ui, &opts, selected_custom), - "rectangle" | "roundedRectangle" => draw_rectangle_controls(app, ui, &opts, selected_custom), - "polygon" | "star" => draw_polygon_controls(app, ui, &opts, &active_shape, selected_custom), - "spiral" => draw_spiral_controls(app, ui, &opts, selected_custom), - "box3d" => draw_box3d_controls(app, ui, &opts, selected_custom), + "ellipse" => draw_ellipse_controls(app, ui, fit, &opts, selected_custom), + "rectangle" | "roundedRectangle" => draw_rectangle_controls(app, ui, fit, &opts, selected_custom), + "polygon" | "star" => draw_polygon_controls(app, ui, fit, &opts, &active_shape, selected_custom), + "spiral" => draw_spiral_controls(app, ui, fit, &opts, selected_custom), + "box3d" => draw_box3d_controls(app, ui, fit, &opts, selected_custom), _ => return false, } true } +/// Whether `fit` has room in the row `ui` for the label `text` with a field `field` wide after it. +fn field_room(ui: &Ui, fit: &mut Fit, text: &egui::RichText, field: f32) -> bool { + fit.room(ui, &[widgets::text_width(ui, text.clone()), field]) +} + +/// The shape options' Reset button, at least 52 points wide and wider for a longer translated label, +/// while `fit` has room for it. Whether it was clicked. +fn reset_button(ui: &mut Ui, fit: &mut Fit) -> bool { + fit.room(ui, &[widgets::flat_button_fit_width(ui, tl!("Reset"), 52.0)]) && widgets::flat_button_fit(ui, tl!("Reset"), 52.0).clicked() +} + fn apply_shape(app: &mut VectorcraftApp, tool_kvs: &[(&str, Value)], live_patch: Value) { for (k, v) in tool_kvs { app.run("tool.setOption", json!({ "key": *k, "value": v.clone() })).ok(); @@ -65,22 +80,28 @@ fn apply_shape(app: &mut VectorcraftApp, tool_kvs: &[(&str, Value)], live_patch: } } -fn draw_arc_controls(app: &mut VectorcraftApp, ui: &mut Ui, start_deg: f64, end_deg: f64, mode: &str) { +fn draw_arc_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, start_deg: f64, end_deg: f64, mode: &str) { let t = Tokens::get(ui.ctx()); - widgets::field_label(ui, egui::RichText::new(tl!("Start:")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-start"), start_deg, "°", 1, 52.0) { - let next_mode = if mode == "whole" { "pie" } else { mode }; - apply_shape(app, &[("arcStart", json!(v)), ("arcMode", json!(next_mode))], json!({ "startAngle": v, "arcMode": next_mode })); + let start = egui::RichText::new(tl!("Start:")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &start, 52.0) { + widgets::field_label(ui, start); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-start"), start_deg, "°", 1, 52.0) { + let next_mode = if mode == "whole" { "pie" } else { mode }; + apply_shape(app, &[("arcStart", json!(v)), ("arcMode", json!(next_mode))], json!({ "startAngle": v, "arcMode": next_mode })); + } } - widgets::field_label(ui, egui::RichText::new(tl!("End:")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-end"), end_deg, "°", 1, 52.0) { - let next_mode = if mode == "whole" { "pie" } else { mode }; - apply_shape(app, &[("arcEnd", json!(v)), ("arcMode", json!(next_mode))], json!({ "endAngle": v, "arcMode": next_mode })); + let end = egui::RichText::new(tl!("End:")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &end, 52.0) { + widgets::field_label(ui, end); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-end"), end_deg, "°", 1, 52.0) { + let next_mode = if mode == "whole" { "pie" } else { mode }; + apply_shape(app, &[("arcEnd", json!(v)), ("arcMode", json!(next_mode))], json!({ "endAngle": v, "arcMode": next_mode })); + } } for (m_id, tip_label, icon) in [("pie", "Slice", "circle-dot"), ("chord", "Closed", "circle-off"), ("arc", "Arc", "dc-arc"), ("whole", "Open", "dc-ellipse")] { - if widgets::icon_button(ui, icon, tl!(tip_label), mode == m_id, 22.0).clicked() { + if fit.room(ui, &[22.0]) && widgets::icon_button(ui, icon, tl!(tip_label), mode == m_id, 22.0).clicked() { let (s0, e0) = if m_id == "whole" { (0.0, 0.0) } else if (end_deg - start_deg).abs() < 1e-4 || ((end_deg - start_deg).abs() - 360.0).abs() < 1e-4 { @@ -97,19 +118,19 @@ fn draw_arc_controls(app: &mut VectorcraftApp, ui: &mut Ui, start_deg: f64, end_ } } -fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { +fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option) { let (start_deg, end_deg, mode) = match selected_custom { Some(CustomShape::Ellipse { pie: (a0, a1), arc_mode, .. }) => (a0, a1, mode_str(arc_mode)), _ => (opts["arcStart"].as_f64().unwrap_or(0.0), opts["arcEnd"].as_f64().unwrap_or(0.0), opts["arcMode"].as_str().unwrap_or("whole")), }; - draw_arc_controls(app, ui, start_deg, end_deg, mode); - if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { + draw_arc_controls(app, ui, fit, start_deg, end_deg, mode); + if reset_button(ui, fit) { apply_shape(app, &[("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))], json!({ "reset": true })); } - crate::chrome::group_separator(ui); + fit.separator(ui); } -fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { +fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option) { let t = Tokens::get(ui.ctx()); let unit = app.session.general_unit(); let sym = opts["cornerSymmetry"].as_bool().unwrap_or(true); @@ -129,19 +150,26 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, end_deg = a1; mode = mode_str(arc_mode); } - if widgets::icon_button(ui, if sym { "link" } else { "unlink" }, tl!("Link"), sym, 22.0).clicked() { + if fit.room(ui, &[22.0]) && widgets::icon_button(ui, if sym { "link" } else { "unlink" }, tl!("Link"), sym, 22.0).clicked() { let next_sym = !sym; apply_shape(app, &[("cornerSymmetry", json!(next_sym))], if next_sym { json!({ "radius": radii[0] }) } else { json!({ "radii": radii }) }); } if sym { - widgets::field_label(ui, egui::RichText::new(tl!("Radius:")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::num_field(ui, ("cb-shape", "rect-r"), Some(radii[0]), unit, 54.0) { - let r = v.max(0.0); - apply_shape(app, &[("cornerRadius", json!(r)), ("cornerRadii", json!([r, r, r, r]))], json!({ "radius": r })); + let text = egui::RichText::new(tl!("Radius:")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 54.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::num_field(ui, ("cb-shape", "rect-r"), Some(radii[0]), unit, 54.0) { + let r = v.max(0.0); + apply_shape(app, &[("cornerRadius", json!(r)), ("cornerRadii", json!([r, r, r, r]))], json!({ "radius": r })); + } } } else { for (i, lbl) in [(0, "TL"), (1, "TR"), (2, "BR"), (3, "BL")] { - widgets::field_label(ui, egui::RichText::new(lbl).size(11.0).color(t.text_dim)); + let text = egui::RichText::new(lbl).size(11.0).color(t.text_dim); + if !field_room(ui, fit, &text, 44.0) { + break; + } + widgets::field_label(ui, text); if let Some(v) = widgets::num_field(ui, ("cb-shape-r", i), Some(radii[i]), unit, 44.0) { let mut next = radii; next[i] = v.max(0.0); @@ -149,9 +177,9 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, } } } - ui.separator(); - draw_arc_controls(app, ui, start_deg, end_deg, mode); - if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { + fit.separator(ui); + draw_arc_controls(app, ui, fit, start_deg, end_deg, mode); + if reset_button(ui, fit) { apply_shape( app, &[ @@ -164,10 +192,17 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, json!({ "reset": true }), ); } - crate::chrome::group_separator(ui); + fit.separator(ui); } -fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, active_shape: &str, selected_custom: Option) { +fn draw_polygon_controls( + app: &mut VectorcraftApp, + ui: &mut Ui, + fit: &mut Fit, + opts: &Value, + active_shape: &str, + selected_custom: Option, +) { let t = Tokens::get(ui.ctx()); let mut is_star = if active_shape == "star" { true } else { opts["polyIsStar"].as_bool().unwrap_or(false) }; let mut count = if is_star { opts["points"].as_u64().unwrap_or(5) } else { opts["sides"].as_u64().unwrap_or(6) } as u32; @@ -188,47 +223,57 @@ fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, ac end_deg = a1; mode = mode_str(arc_mode); } - if widgets::icon_button(ui, "dc-polygon", tl!("Polygon"), !is_star, 22.0).clicked() { + if fit.room(ui, &[22.0]) && widgets::icon_button(ui, "dc-polygon", tl!("Polygon"), !is_star, 22.0).clicked() { apply_shape(app, &[("polyIsStar", json!(false))], json!({ "isStar": false })); } - if widgets::icon_button(ui, "star", tl!("Star"), is_star, 22.0).clicked() { + if fit.room(ui, &[22.0]) && widgets::icon_button(ui, "star", tl!("Star"), is_star, 22.0).clicked() { apply_shape(app, &[("polyIsStar", json!(true))], json!({ "isStar": true, "spokeRatio": spoke })); } - widgets::field_label(ui, egui::RichText::new(if is_star { tl!("Points:") } else { tl!("Sides:") }).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-corners"), f64::from(count), "", 0, 44.0) { - let c = (v.round() as i64).clamp(3, 1000) as u32; - apply_shape(app, &[("sides", json!(c)), ("points", json!(c))], json!({ "sides": c })); + let text = egui::RichText::new(if is_star { tl!("Points:") } else { tl!("Sides:") }).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 44.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-corners"), f64::from(count), "", 0, 44.0) { + let c = (v.round() as i64).clamp(3, 1000) as u32; + apply_shape(app, &[("sides", json!(c)), ("points", json!(c))], json!({ "sides": c })); + } } - if is_star { - widgets::field_label(ui, egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim)); + let text = egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim); + if is_star && field_room(ui, fit, &text, 46.0) { + widgets::field_label(ui, text); if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-spoke"), spoke, "", 2, 46.0) { let sr = v.clamp(0.01, 1.0); apply_shape(app, &[("starRatio", json!(sr))], json!({ "spokeRatio": sr })); } } - widgets::field_label(ui, egui::RichText::new(tl!("Round")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-round"), rounded, "", 2, 46.0) { - let rnd = v.clamp(-10.0, 10.0); - apply_shape(app, &[("rounded", json!(rnd))], json!({ "rounded": rnd })); + let text = egui::RichText::new(tl!("Round")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 46.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-round"), rounded, "", 2, 46.0) { + let rnd = v.clamp(-10.0, 10.0); + apply_shape(app, &[("rounded", json!(rnd))], json!({ "rounded": rnd })); + } } - widgets::field_label(ui, egui::RichText::new(tl!("Random")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-rand"), randomized, "", 2, 46.0) { - let rand = v.clamp(-10.0, 10.0); - apply_shape(app, &[("randomized", json!(rand))], json!({ "randomized": rand })); + let text = egui::RichText::new(tl!("Random")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 46.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-rand"), randomized, "", 2, 46.0) { + let rand = v.clamp(-10.0, 10.0); + apply_shape(app, &[("randomized", json!(rand))], json!({ "randomized": rand })); + } } - ui.separator(); - draw_arc_controls(app, ui, start_deg, end_deg, mode); - if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { + fit.separator(ui); + draw_arc_controls(app, ui, fit, start_deg, end_deg, mode); + if reset_button(ui, fit) { apply_shape( app, &[("rounded", json!(0.0)), ("randomized", json!(0.0)), ("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))], json!({ "reset": true }), ); } - crate::chrome::group_separator(ui); + fit.separator(ui); } -fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { +fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option) { let t = Tokens::get(ui.ctx()); let (mut turns, mut divergence, mut inner_r) = (opts["turns"].as_f64().unwrap_or(3.0), opts["divergence"].as_f64().unwrap_or(1.0), opts["innerRadius"].as_f64().unwrap_or(0.0)); @@ -237,28 +282,37 @@ fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sel divergence = div_v; inner_r = ir_v; } - widgets::field_label(ui, egui::RichText::new(tl!("Segments")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-turns"), turns, "", 2, 50.0) { - let tv = v.clamp(0.01, 1024.0); - apply_shape(app, &[("turns", json!(tv))], json!({ "turns": tv })); + let text = egui::RichText::new(tl!("Segments")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 50.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-turns"), turns, "", 2, 50.0) { + let tv = v.clamp(0.01, 1024.0); + apply_shape(app, &[("turns", json!(tv))], json!({ "turns": tv })); + } } - widgets::field_label(ui, egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-div"), divergence, "", 2, 50.0) { - let dv = v.clamp(0.0, 1000.0); - apply_shape(app, &[("divergence", json!(dv))], json!({ "divergence": dv })); + let text = egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 50.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-div"), divergence, "", 2, 50.0) { + let dv = v.clamp(0.0, 1000.0); + apply_shape(app, &[("divergence", json!(dv))], json!({ "divergence": dv })); + } } - widgets::field_label(ui, egui::RichText::new(tl!("Inner Radius:")).size(12.0).color(t.text_dim)); - if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-inner"), inner_r, "", 2, 50.0) { - let iv = v.clamp(0.0, 0.999); - apply_shape(app, &[("innerRadius", json!(iv))], json!({ "innerRadius": iv })); + let text = egui::RichText::new(tl!("Inner Radius:")).size(12.0).color(t.text_dim); + if field_room(ui, fit, &text, 50.0) { + widgets::field_label(ui, text); + if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-inner"), inner_r, "", 2, 50.0) { + let iv = v.clamp(0.0, 0.999); + apply_shape(app, &[("innerRadius", json!(iv))], json!({ "innerRadius": iv })); + } } - if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { + if reset_button(ui, fit) { apply_shape(app, &[("turns", json!(3.0)), ("divergence", json!(1.0)), ("innerRadius", json!(0.0))], json!({ "reset": true })); } - crate::chrome::group_separator(ui); + fit.separator(ui); } -fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option) { +fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option) { let t = Tokens::get(ui.ctx()); let (mut ax, mut ay, mut az) = (opts["angleX"].as_f64().unwrap_or(30.0), opts["angleY"].as_f64().unwrap_or(30.0), opts["angleZ"].as_f64().unwrap_or(0.0)); @@ -275,16 +329,20 @@ fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sele for (lbl, a_key, p_key, a_val, p_val) in [("X:", "angleX", "parallelX", ax, px), ("Y:", "angleY", "parallelY", ay, py), ("Angle:", "angleZ", "parallelZ", az, pz)] { - widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim)); + let text = egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim); + if !field_room(ui, fit, &text, 52.0) { + break; + } + widgets::field_label(ui, text); if let Some(v) = widgets::plain_field(ui, ("cb-box3d", a_key), a_val, "°", 1, 52.0) { apply_shape(app, &[(a_key, json!(v))], json!({ a_key: v })); } - if widgets::icon_button(ui, "dc-line", tl!("Link"), p_val, 20.0).clicked() { + if fit.room(ui, &[20.0]) && widgets::icon_button(ui, "dc-line", tl!("Link"), p_val, 20.0).clicked() { let next = !p_val; apply_shape(app, &[(p_key, json!(next))], json!({ p_key: next })); } } - if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() { + if reset_button(ui, fit) { apply_shape( app, &[ @@ -298,7 +356,7 @@ fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sele json!({ "reset": true }), ); } - crate::chrome::group_separator(ui); + fit.separator(ui); } fn mode_str(m: EllipseMode) -> &'static str { diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index a3ba294db3..e7e5c0394b 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -22,7 +22,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo // double-taps) where it was seen last (#491). let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p))); let view = app.view_info(); - let (_, backquote_before) = backquote_seen(ui); + let (backquote_now, backquote_before) = backquote_seen(ui); let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view, backquote_before, modal }; touch_gestures(app, ui, rect, view, modal); // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the @@ -31,14 +31,15 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo drop_press(app, ui, view); } let drag: Option = ui.data(|d| d.get_temp(drag_id())); - // A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows - // without moving the mouse (Alt switches the Shape Builder to erase mode). + // A modifier or Backtick pressed or released over the canvas re-hovers the tool, so what it + // changes shows without moving the mouse (Alt switches the Shape Builder to erase mode, and ~ + // places the rings of a flare being drawn anew). let mods_changed = ui.data_mut(|d| { let id = egui::Id::unique("canvas-mods"); let prev = d.get_temp::(id); d.insert_temp(id, m); prev.is_some_and(|prev| prev != m) - }); + }) || backquote_before.is_some_and(|before| before != backquote_now); // Where the tool hovers: the pointer over the canvas itself, not over a floating panel, a menu, // a popup or a dialog drawn on top of it ([`on_canvas`]). let hover_tool = hover.filter(|p| on_canvas(ui, resp, *p)); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 6e222cc405..139d0adc7d 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -86,6 +86,11 @@ impl<'a> HandleLook<'a> { } } +/// How many points bigger than the other selected anchors the key anchor's square is drawn. +const KEY_ANCHOR_GROW: f32 = 2.0; +/// The gap between the key anchor's square and the frame drawn around it. +const KEY_ANCHOR_GAP: f32 = 2.0; + pub(super) fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) { let r = egui::Rect::from_center_size(c, vec2(size, size)); if filled { @@ -188,6 +193,14 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k)))); } marks.paint(st, p, xf, &options, key_outline, if big { &[] } else { &st.selection.objects }); + // The key anchor (Align to Key Anchor): a filled square bigger than the other anchors, in + // a frame as thick as a key object's outline. + if let Some(((k, _), q)) = st.selection.key_anchor.zip(st.selection.key_anchor_point(&st.doc)) { + let (c, color, size) = (xf.to_screen(q), c32(st.doc.layer_color(k)), anchor(direct) + KEY_ANCHOR_GROW); + anchor_square(p, c, color, true, size); + let frame = egui::Rect::from_center_size(c, vec2(size + 2.0 * KEY_ANCHOR_GAP, size + 2.0 * KEY_ANCHOR_GAP)); + p.rect_stroke(frame, 0.0, Stroke::new(KEY_OUTLINE, color), StrokeKind::Outside); + } } // The spine of each selected blend (or of the blend a selected key object belongs to). let mut spines = vec![]; diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index b49b50881a..c3321195c0 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -55,6 +55,26 @@ fn selection_tool_with_selection(app: &VectorcraftApp) -> bool { && app.session.active().is_some_and(|st| !st.selection.is_empty()) } +/// The basic shape tools whose Contextual Task Bar shows Edit Path, Fill, Stroke and the selected +/// shape's properties. The Line Segment tool is not one of them. +const SHAPE_TOOLS: [&str; 5] = ["rectangle", "roundedRectangle", "ellipse", "polygon", "star"]; + +/// Whether a basic shape tool is in use with one closed path selected whole, as each of those tools +/// leaves the shape it draws. +fn shape_tool_on_shape(app: &VectorcraftApp) -> bool { + SHAPE_TOOLS.contains(&app.session.tool_id()) + && app.session.active().is_some_and(|st| { + st.selection.objects.len() == 1 + && st.selection.anchors.is_empty() + && st + .selection + .objects + .first() + .and_then(|id| st.doc.node(*id)) + .is_some_and(|n| matches!(&n.kind, NodeKind::Path { path, .. } if path.is_closed())) + }) +} + /// Whether the bar shows its blank canvas actions, Draw (Pencil) and Place Image: a selection tool is /// in use and the active document has no artwork, only layers and guides. fn blank_canvas(app: &VectorcraftApp) -> bool { @@ -107,7 +127,11 @@ pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool { /// With the Artboard tool it sits under the selected artboards and offers Resize (Artboard /// Options), Duplicate, Rearrange All (with two or more artboards) and Export (Export for Screens). /// A basic shape selected with the Selection tool shows a Shape Properties button in place of Lock. -/// Selected type also shows the font family, font style and Font Size controls before its Fill. +/// While a basic shape tool (Rectangle, Rounded Rectangle, Ellipse, Polygon or Star) has one closed +/// path selected, as after it draws one, the bar offers Edit Path, Fill and Stroke, and Shape +/// Properties for a live rectangle, rounded rectangle, ellipse or polygon; with nothing selected, or +/// any other selection, those tools show no bar. Selected type also shows the font family, font +/// style and Font Size controls before its Fill. /// While a Type tool edits type, or has type selected, the bar shows only those controls and the /// Fill. When one row would be wider than the canvas, the font controls take a row of their own, /// narrowed to fit it, and the actions wrap onto rows under the handle's ([`bar_layout`]). @@ -123,7 +147,10 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let typing = type_tool_on_type(app); // A blank canvas offers Draw (Pencil) and Place Image, as the same help page describes. let blank = !on_artboards && blank_canvas(app); - if !(on_artboards || typing || blank || selection_tool_with_selection(app)) { + // A basic shape tool with its shape selected offers Edit Path, Fill, Stroke and the shape's + // properties, as the same help page describes. + let shaping = shape_tool_on_shape(app); + if !(on_artboards || typing || blank || shaping || selection_tool_with_selection(app)) { return; } // Selected type has the font controls too, as the help page describes @@ -153,20 +180,22 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { f.name.as_deref() == Some("Image Trace") && f.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_)))) }); // A basic shape (a live rectangle, rounded rectangle, ellipse or polygon) selected whole with the - // Selection tool: its properties take the place of Lock, as in the reference app - // (). + // Selection tool or a basic shape tool: its properties take the place of Lock, as the help page + // describes (). use vectorcraft_doc::LiveShape; let shape = first .filter(|f| { n == 1 && st.selection.anchors.is_empty() - && app.session.tool_id() == "selection" + && (app.session.tool_id() == "selection" || shaping) && matches!(&f.kind, NodeKind::Path { live: Some(l), .. } if matches!(**l, LiveShape::Rectangle { .. } | LiveShape::Ellipse { .. } | LiveShape::Polygon { .. })) }) .cloned(); let mut items: Vec<(&str, &str, &str, serde_json::Value)> = vec![]; // (label, icon, command, params) let none = || json!({}); + // Edit Path: the Direct Selection tool, to edit the path's anchors. + let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); if on_artboards { // As in the reference app, the Artboard tool offers to resize, duplicate and export the // artboards (), @@ -181,11 +210,12 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { // Draw (Pencil) switches to the Pencil tool; Place Image picks files for the Place dialog. items.push((tl!("Draw (Pencil)"), "pencil", "tool.select", json!({"tool": "pencil"}))); items.push((tl!("Place Image"), "image", "file.place", none())); + } else if shaping { + // A basic shape tool's one action; its Fill, Stroke and Shape Properties follow as chips. + items.push(edit_path()); } else if !typing { // The selection tools' object actions. A Type tool's bar leaves them out, Create Outlines // and the type conversions included. - // Edit Path: the Direct Selection tool, to edit the path's anchors. - let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); // As in the reference app, an image offers Image Trace, Crop Image and Remove Background, and // traced art offers Expand (, // ). @@ -288,13 +318,18 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let fonts = fonts && room >= crate::panels::character::task_bar_min_room(); // The selected objects' Fill, Stroke, Recolor Artwork (when they have colours to recolour) and // Lock (a basic shape's properties instead), left out with the Artboard tool and on a blank - // canvas, which have no selected objects. A Type tool's bar has the Fill alone. + // canvas, which have no selected objects. A Type tool's bar has the Fill alone. A basic shape + // tool's bar has the Fill, the Stroke and a live shape's Shape Properties. let objects = !on_artboards && !blank; - let chips: Vec = - [(objects, Chip::Fill), (objects && !typing, Chip::Stroke), (objects && !typing && colored, Chip::Recolor), (objects && !typing, Chip::Lock)] - .into_iter() - .filter_map(|(shown, chip)| shown.then_some(chip)) - .collect(); + let chips: Vec = [ + (objects, Chip::Fill), + (objects && !typing, Chip::Stroke), + (objects && !typing && !shaping && colored, Chip::Recolor), + (objects && !typing && (!shaping || shape.is_some()), Chip::Lock), + ] + .into_iter() + .filter_map(|(shown, chip)| shown.then_some(chip)) + .collect(); let widths: Vec = items.iter().map(|(label, _, cmd, _)| if icon_only(cmd) { BAR_MORE } else { action_width(ui, label) }).collect(); let font_w = fonts.then(|| { let w = crate::panels::character::TASK_BAR_WIDTHS; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 3e092bca71..6803e977a1 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -9687,6 +9687,64 @@ fn backtick_changed_mid_selection_drag_applies_with_the_pointer_held_still() { frame(&mut app, &ctx, vec![button(to, false)]); } +/// Backtick pressed while a flare's rings' end point is placed places the rings anew, with the +/// pointer held still. +#[test] +fn backtick_places_a_flares_rings_anew_with_the_pointer_held_still() { + use egui::Event; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("flare"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.ui.view.smart_guides = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let from = xf.to_screen(Point::new(100.0, 100.0)); + let to = xf.to_screen(Point::new(130.0, 100.0)); + let end = xf.to_screen(Point::new(180.0, 160.0)); + assert!(app.canvas_rect.unwrap().contains(end)); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let key = |pressed| Event::Key { key: egui::Key::Backtick, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let rings = |app: &VectorcraftApp| { + let st = app.session.doc().unwrap(); + let g = st.doc.node(st.selection.objects[0]).unwrap(); + let vectorcraft_doc::NodeKind::Group { children, .. } = &g.kind else { panic!("flare is a group") }; + children.iter().filter(|c| c.name.as_deref() == Some("Ring")).map(|c| c.geometric_bounds().unwrap()).collect::>() + }; + // Frames a sixtieth of a second apart while the pointer moves, and a second apart with it + // still, so it counts as still once it stops. + let mut time = 0.0; + let mut step = |app: &mut VectorcraftApp, events: Vec| { + time += if events.iter().any(|e| matches!(e, Event::PointerMoved(_))) { 1.0 / 60.0 } else { 1.0 }; + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + step(&mut app, vec![Event::PointerMoved(from), button(from, true)]); + step(&mut app, vec![Event::PointerMoved(to)]); + step(&mut app, vec![button(to, false)]); + step(&mut app, vec![Event::PointerMoved(end - vec2(5.0, 5.0))]); + step(&mut app, vec![Event::PointerMoved(end)]); + step(&mut app, vec![]); + step(&mut app, vec![]); + let before = rings(&app); + assert_eq!(before.len(), 10); + assert!(before.iter().all(|r| r.center().y > 90.0 && r.center().x > 90.0), "the rings run towards the pointer: {before:?}"); + step(&mut app, vec![key(true)]); + let after = rings(&app); + assert_eq!(after.len(), 10); + assert_ne!(after, before, "~ places the rings anew"); + step(&mut app, vec![key(false)]); + assert_eq!(rings(&app), after, "letting ~ go keeps the placement"); +} + /// #834 review: a press held still is not a drag. Before the press has dragged, the first held /// frame without a delta at the press's own position sends nothing, so a click stays a click; /// the first move after it does. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a6b7b11714..7e48fc244e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -24,13 +24,13 @@ pub use control_bar::{ }; #[cfg(test)] use control_bar::{CONTROL_BAR_FLOAT_AT, ISOLATION_BUTTON}; -pub(crate) use control_bar::{group_separator, left_out}; +pub(crate) use control_bar::{Fit, group_separator, left_out, link_reserve}; #[cfg(test)] use hints::hint_segments; pub use hints::{hint_bar, hint_strings}; -pub use status_bar::status_bar; +pub use status_bar::{StatusBarShow, deserialize_status_bar_show, set_status_bar_show, status_bar}; #[cfg(test)] -use status_bar::{StatusReadoutWidths, status_readouts}; +use status_bar::{StatusReadoutWidths, color_profile_name, date_time_label, status_readouts}; #[cfg(test)] use tabs::{CLOSE_BEFORE_TITLE, first_tab_shown, tab_drop, tab_groups, tab_title}; pub use tabs::{doc_tabs, isolation_bar, step_document}; diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 70297a04c0..71b737dbfc 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -227,6 +227,64 @@ fn row_width(ui: &Ui, widths: &[f32]) -> f32 { widths.iter().map(|w| w + ui.spacing().item_spacing.x).sum() } +/// The room left in the Control bar's row for a group that another module draws in it (a tool's +/// options, an image's, an Image Trace object's, an envelope's or symbol instances' controls, or the +/// Artboard tool's), so that none of its controls runs into the panel menu button. The group asks +/// [`Fit::room`] before each control, or each label with its field, and draws it only on a yes. Once +/// one has no room, the rest of the group is left out too, so the group loses controls from its +/// right end. +pub(crate) struct Fit { + reserve: f32, + full: bool, +} + +impl Fit { + /// A fit that leaves `reserve` after the group's controls: the Transform link's room + /// ([`transform_reserve`]), or none. + pub(crate) fn new(reserve: f32) -> Self { + Self { reserve, full: false } + } + + /// Whether the row `ui` has room for controls `widths` wide side by side, with the spacing after + /// each and the reserve after them, and no control before them in the group was left out. A + /// control left out is noted with [`left_out`]. + pub(crate) fn room(&mut self, ui: &Ui, widths: &[f32]) -> bool { + self.fits(ui, row_width(ui, widths)) + } + + /// [`Fit::room`] for controls that end the row, with no spacing after the last of them. + pub(crate) fn room_at_end(&mut self, ui: &Ui, widths: &[f32]) -> bool { + self.fits(ui, row_width(ui, widths) - ui.spacing().item_spacing.x) + } + + fn fits(&mut self, ui: &Ui, width: f32) -> bool { + self.full = self.full || ui.available_width() < width + self.reserve; + if self.full { + left_out(ui); + } + !self.full + } + + /// Leaves `reserve` after the group's controls still to come, in place of the reserve before. + pub(crate) fn set_reserve(&mut self, reserve: f32) { + self.reserve = reserve; + } + + /// A separator between two parts of the group ([`group_separator`]), while no control before it + /// was left out. It stays only while a control follows it. + pub(crate) fn separator(&self, ui: &mut Ui) { + if !self.full { + group_separator(ui); + } + } +} + +/// The room the Transform link takes with the separator ahead of it, which the Artboard tool's +/// controls ahead of the link leave for it. +pub(crate) fn link_reserve(ui: &Ui) -> f32 { + separator_width(ui) + crate::panels::transform::link_width(ui) +} + /// The size of the Control bar's Fill, Stroke and Graphic Style chips. const PAINT_CHIP: f32 = 22.0; @@ -299,7 +357,7 @@ fn left_out_key(ui: &Ui) -> egui::Id { /// Notes that a control of the Control bar's row `ui` was left out in this pass for want of room, /// so the controls the bar leaves out first stay out too ([`any_left_out`]). -pub(crate) fn left_out(ui: &mut Ui) { +pub(crate) fn left_out(ui: &Ui) { let (key, pass) = (left_out_key(ui), ui.ctx().cumulative_pass_nr()); ui.data_mut(|d| d.insert_temp(key, pass)); } @@ -938,22 +996,24 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, if ui.available_width() >= 6.0 { ui.add_space(6.0); } + let reserve = transform_reserve(app, ui, !sel.is_empty()); // An image or an Image Trace object shows its own controls in place of Fill and Stroke, - // and leaves Fill and Stroke out while the Image Options group is off too. + // and leaves Fill and Stroke out while the Image Options group is off too. These groups, + // the tool options, the envelope's and the symbol instances' each show a control only while + // the Transform link's room is left after it. let image = if group_on(app, "imageOptions") { - crate::place::control_bar_details(app, ui) || crate::panels::image_trace::control_bar(app, ui) + crate::place::control_bar_details(app, ui, reserve) || crate::panels::image_trace::control_bar(app, ui, reserve) } else { crate::place::selected_image_info(app).is_some() || crate::panels::image_trace::selected_trace(app).is_some() }; if group_on(app, "toolOptions") { - crate::toolbar::control_bar_options(app, ui); + crate::toolbar::control_bar_options(app, ui, reserve); } if object_options { - crate::dialogs::envelope::control_bar(app, ui); - crate::panels::symbols::control_bar(app, ui); + crate::dialogs::envelope::control_bar(app, ui, reserve); + crate::panels::symbols::control_bar(app, ui, reserve); } let opacity = if has_first { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 }; - let reserve = transform_reserve(app, ui, !sel.is_empty()); if !image && group_on(app, "fillStroke") { fill_and_stroke(app, ui, reserve); } @@ -969,8 +1029,8 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect, } // The Transform link opens the whole Transform panel (reference point, rotate, // shear, options) in a popover; X/Y/W/H follow inline while the bar has room. The - // groups from Fill and Stroke on keep its room, but the controls ahead of them can - // still take it. + // groups after the object's name keep its room, but the name and the anchor and + // isolation buttons ahead of them can still take it. if ui.available_width() < crate::panels::transform::link_width(ui) { erase_trailing(ui); return; diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 1e946b56da..45cd600771 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -73,19 +73,37 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { app.status(e); } ui.separator(); - let tool = vectorcraft_tools::tool_info(app.session.tool_id()) - .map(|t| { - // The catalog keys carry the full label ("Selection Tool"); only English drops the suffix. - let shown = tl!(t.label); - if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown } - }) - .unwrap_or(""); - // The tool name leaves the status message its least width and ends in an ellipsis first. + let (info, next) = info_text(app); + if let Some(secs) = next { + ui.ctx().request_repaint_after(std::time::Duration::from_secs(secs)); + } + // The info field leaves the status message its least width and ends in an ellipsis first. + // An empty readout shows a dash, so the field can still be clicked. let room = (ui.available_width() - MESSAGE_MIN - ui.spacing().item_spacing.x).max(0.0); - ui.scope(|ui| { - ui.set_max_width(room); - clipped_label(ui, egui::RichText::new(tool).size(11.5).color(t.text_dim)); + let shown = if info.is_empty() { "–" } else { info.as_str() }; + let field = ui + .scope(|ui| { + ui.set_max_width(room); + clipped_label(ui, egui::RichText::new(shown).size(11.5).color(t.text_dim)) + }) + .inner + .interact(Sense::click()); + let mut pick = None; + egui::Popup::menu(&field).show(|ui| { + ui.menu_button(tl!("Show"), |ui| { + for show in StatusBarShow::ALL { + if widgets::menu_item_name(ui, show.label(), true, app.ui.status_bar_show == show) { + pick = Some(show); + ui.close(); + } + } + }); }); + if let Some(show) = pick + && let Err(e) = app.run("view.statusBarShow", json!({ "show": show.param() })) + { + app.status(e); + } status_readouts(app, ui, &t); }); }); @@ -95,6 +113,152 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { /// The least width the status message keeps when the window is narrow. const MESSAGE_MIN: f32 = 180.0; +/// What the status bar's info field shows, chosen from the Show menu that a click on the field +/// opens (`view.statusBarShow`). Kept with the UI state ([`crate::UiState::status_bar_show`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum StatusBarShow { + /// The name of the artboard the navigator shows. + ArtboardName, + /// The name of the tool in use. + #[default] + CurrentTool, + /// The date and time. + DateTime, + /// How many steps the active document can undo and redo. + Undos, + /// The profile the active document's colors use. + ColorProfile, +} + +impl StatusBarShow { + /// Every choice, in the Show menu's order. + pub const ALL: [StatusBarShow; 5] = + [StatusBarShow::ArtboardName, StatusBarShow::CurrentTool, StatusBarShow::DateTime, StatusBarShow::Undos, StatusBarShow::ColorProfile]; + + /// The `show` param of `view.statusBarShow`. + pub fn param(self) -> &'static str { + match self { + StatusBarShow::ArtboardName => "artboardName", + StatusBarShow::CurrentTool => "currentTool", + StatusBarShow::DateTime => "dateTime", + StatusBarShow::Undos => "undos", + StatusBarShow::ColorProfile => "colorProfile", + } + } + + /// The choice whose param is `param`. + pub fn from_param(param: &str) -> Option { + StatusBarShow::ALL.into_iter().find(|s| s.param() == param) + } + + /// The Show menu's label, in the interface language. + pub fn label(self) -> &'static str { + match self { + StatusBarShow::ArtboardName => tl!("Artboard Name"), + StatusBarShow::CurrentTool => tl!("Current Tool"), + StatusBarShow::DateTime => tl!("Date and Time"), + StatusBarShow::Undos => tl!("Number of Undos"), + StatusBarShow::ColorProfile => tl!("Document Color Profile"), + } + } +} + +/// Serde reader for the saved choice ([`crate::UiState::status_bar_show`]): a value this version +/// does not know reads as Current Tool. +pub fn deserialize_status_bar_show<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = ::deserialize(d)?; + Ok(v.as_str().and_then(StatusBarShow::from_param).unwrap_or_default()) +} + +/// `view.statusBarShow {show}`: the info field shows that readout from now on → the choice. +pub fn set_status_bar_show(app: &mut VectorcraftApp, p: &serde_json::Value) -> Result { + let key = "show"; + let Some(show) = p.get(key).and_then(serde_json::Value::as_str).and_then(StatusBarShow::from_param) else { + let all: Vec<&str> = StatusBarShow::ALL.iter().map(|s| s.param()).collect(); + return Err(format!("`{key}` must be one of {}", all.join(", "))); + }; + app.ui.status_bar_show = show; + Ok(json!(show.param())) +} + +/// The info field's text for the choice in the Show menu, and for Date and Time the seconds until +/// the minute it shows ends (when the bar draws again). +pub(super) fn info_text(app: &VectorcraftApp) -> (String, Option) { + let st = app.session.active(); + match app.ui.status_bar_show { + StatusBarShow::CurrentTool => { + let tool = vectorcraft_tools::tool_info(app.session.tool_id()) + .map(|t| { + // The catalog keys carry the full label ("Selection Tool"); only English drops the suffix. + let shown = tl!(t.label); + if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown } + }) + .unwrap_or(""); + (tool.to_string(), None) + } + StatusBarShow::ArtboardName => { + let name = st.and_then(|d| { + let n = d.doc.artboards.len(); + d.doc.artboards.get(crate::panels::artboards::selected(app, n)).map(|a| a.name.clone()) + }); + (name.unwrap_or_default(), None) + } + StatusBarShow::DateTime => { + let (text, next) = date_time_now(); + (text, Some(next)) + } + StatusBarShow::Undos => { + let text = st.map(|d| format!("{}: {} {}: {}", tl!("Undos"), d.history.undo.len(), tl!("Redos"), d.history.redo.len())); + (text.unwrap_or_default(), None) + } + StatusBarShow::ColorProfile => (st.map(|d| color_profile_name(&d.doc)).unwrap_or_default(), None), + } +} + +/// The profile `doc`'s colors use for its color mode, as the context it draws through has it +/// ([`vectorcraft_engine::cmd::colormgmt::doc_cms`]): the one assigned (Edit › Assign Profile), else +/// the working space of that mode (Edit › Color Settings), also when the assigned one is not +/// available. +pub(super) fn color_profile_name(doc: &vectorcraft_doc::Document) -> String { + let cms = vectorcraft_engine::cmd::colormgmt::doc_cms(doc); + let settings = cms.settings(); + let name = match doc.color_mode { + vectorcraft_doc::ColorMode::Rgb => &settings.rgb, + vectorcraft_doc::ColorMode::Cmyk => &settings.cmyk, + }; + vectorcraft_color::cms::canonical_name(name).to_string() +} + +/// `[year, month, day, hour, minute, second]` as the info field shows it: `2026-11-15 14:03`. +pub(super) fn date_time_label([y, mo, d, h, mi, _]: [i64; 6]) -> String { + format!("{y:04}-{mo:02}-{d:02} {h:02}:{mi:02}") +} + +/// The local date and time ([`date_time_label`]) and the seconds until the minute ends. +#[cfg(not(target_arch = "wasm32"))] +fn date_time_now() -> (String, u64) { + use chrono::{Datelike, Timelike}; + let now = chrono::Local::now(); + let parts = [ + i64::from(now.year()), + i64::from(now.month()), + i64::from(now.day()), + i64::from(now.hour()), + i64::from(now.minute()), + i64::from(now.second()), + ]; + (date_time_label(parts), 60 - u64::from(now.second().min(59))) +} + +/// The web build reads no time zone, so it shows the date and time in UTC. +#[cfg(target_arch = "wasm32")] +fn date_time_now() -> (String, u64) { + let secs = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + let parts = vectorcraft_doc::metadata::civil(i64::try_from(secs).unwrap_or(i64::MAX)); + (format!("{} UTC", date_time_label(parts)), 60 - u64::try_from(parts[5].clamp(0, 59)).unwrap_or(0)) +} + /// A label that ends in an ellipsis when it is wider than the room left in `ui`. Only a cut label /// shows its full text in a tooltip; screen readers always receive the full text. pub(super) fn clipped_label(ui: &mut Ui, text: impl Into) -> egui::Response { diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index dc71c3f3dc..d4a7c7f0e8 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -576,6 +576,155 @@ fn a_clipped_tool_name_shows_in_full_on_hover() { } } +/// The texts of one status bar frame after `events`, `dt` seconds after the last one. +fn status_after(app: &mut crate::VectorcraftApp, ctx: &egui::Context, dt: f64, events: Vec) -> Vec { + let t = ctx.input(|i| i.time) + dt; + bar_frame(app, ctx, 1400.0, Some(t), events, super::status_bar).0 +} + +/// A press and release of the primary button at `pos`. +fn click_at(pos: egui::Pos2) -> Vec { + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + vec![egui::Event::PointerMoved(pos), button(true), button(false)] +} + +/// The center of the first text that is `text`. +fn text_center(texts: &[StatusText], text: &str) -> egui::Pos2 { + let at = texts.iter().find(|t| t.text == text).unwrap_or_else(|| panic!("no {text:?} in {texts:?}")); + at.clip.intersect(at.bounds).center() +} + +/// The status bar's info field shows the readout `view.statusBarShow` chooses: the navigator's +/// artboard name, the tool in use, the date and time, the undo and redo counts, or the document's +/// color profile. +#[test] +fn the_status_bar_info_field_shows_the_chosen_readout() { + let (mut app, ctx) = status_readout_app(); + let texts = status_after(&mut app, &ctx, 0.1, vec![]); + assert!(texts.iter().any(|t| t.text == "Selection"), "Current Tool by default: {texts:?}"); + + app.run("artboard.new", json!({"x": 200, "y": 0, "width": 100, "height": 100, "name": "Back Cover"})).unwrap(); + app.run("view.statusBarShow", json!({"show": "artboardName"})).unwrap(); + let first = app.session.active().unwrap().doc.artboards[0].name.clone(); + assert!(status_after(&mut app, &ctx, 0.1, vec![]).iter().any(|t| t.text == first), "the first artboard's name"); + app.run("view.goToArtboard", json!({"index": 1})).unwrap(); + assert!(status_after(&mut app, &ctx, 0.1, vec![]).iter().any(|t| t.text == "Back Cover"), "the navigator's artboard"); + + app.run("view.statusBarShow", json!({"show": "undos"})).unwrap(); + let counts = |app: &crate::VectorcraftApp| { + let h = &app.session.active().unwrap().history; + format!("Undos: {} Redos: {}", h.undo.len(), h.redo.len()) + }; + let before = app.session.active().unwrap().history.undo.len(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert_eq!(counts(&app), format!("Undos: {} Redos: 0", before + 1)); + assert!(status_after(&mut app, &ctx, 0.1, vec![]).iter().any(|t| t.text == counts(&app))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(counts(&app), format!("Undos: {before} Redos: 1")); + assert!(status_after(&mut app, &ctx, 0.1, vec![]).iter().any(|t| t.text == counts(&app)), "the redo step counts"); + + app.run("view.statusBarShow", json!({"show": "colorProfile"})).unwrap(); + let working = vectorcraft_color::cms::working_settings(); + let mode_working = |app: &crate::VectorcraftApp| match app.session.active().unwrap().doc.color_mode { + vectorcraft_doc::ColorMode::Rgb => working.rgb.clone(), + vectorcraft_doc::ColorMode::Cmyk => working.cmyk.clone(), + }; + let untagged = vectorcraft_color::cms::canonical_name(&mode_working(&app)).to_string(); + assert_eq!(super::color_profile_name(&app.session.active().unwrap().doc), untagged, "untagged: the working space"); + assert!(status_after(&mut app, &ctx, 0.1, vec![]).iter().any(|t| t.text == untagged)); + // A profile assigned for the document's color mode shows; one that is not available shows the + // working space the document draws through. + let (key, assigned) = match app.session.active().unwrap().doc.color_mode { + vectorcraft_doc::ColorMode::Rgb => ("rgb", vectorcraft_color::cms::PROPHOTO_RGB), + vectorcraft_doc::ColorMode::Cmyk => ("cmyk", vectorcraft_color::cms::DEVICE_CMYK), + }; + app.run("edit.assignProfile", json!({ key: assigned })).unwrap(); + assert!(status_after(&mut app, &ctx, 0.1, vec![]).iter().any(|t| t.text == assigned), "the assigned profile"); + let st = app.session.active_mut().unwrap(); + std::sync::Arc::make_mut(&mut st.doc).color_profiles = + vectorcraft_doc::ColorProfiles { rgb: Some("Not Installed".into()), cmyk: Some("Not Installed".into()) }; + assert!(status_after(&mut app, &ctx, 0.1, vec![]).iter().any(|t| t.text == untagged), "an unavailable profile: the working space"); + + app.run("view.statusBarShow", json!({"show": "dateTime"})).unwrap(); + let texts = status_after(&mut app, &ctx, 0.1, vec![]); + let is_date = |s: &str| { + let b = s.as_bytes(); + b.len() == 16 + && b.iter().enumerate().all(|(i, c)| match i { + 4 | 7 => *c == b'-', + 10 => *c == b' ', + 13 => *c == b':', + _ => c.is_ascii_digit(), + }) + }; + assert!(texts.iter().any(|t| is_date(&t.text)), "a date and time: {texts:?}"); + assert_eq!(super::date_time_label([2026, 3, 7, 9, 5, 59]), "2026-03-07 09:05"); + + // Without a document the field shows a dash, which can still be clicked. + app.run("view.statusBarShow", json!({"show": "undos"})).unwrap(); + while app.session.active().is_some() { + app.session.execute("file.close", &json!({})).unwrap(); + } + assert!(status_after(&mut app, &ctx, 0.1, vec![]).iter().any(|t| t.text == "–")); +} + +/// A click on the info field opens its Show menu, which checks the readout shown and changes it. +#[test] +fn a_click_on_the_info_field_opens_the_show_menu() { + let (mut app, ctx) = status_readout_app(); + status_after(&mut app, &ctx, 1.0, vec![egui::Event::PointerGone]); + let texts = status_after(&mut app, &ctx, 0.1, vec![]); + let field = text_center(&texts, "Selection"); + status_after(&mut app, &ctx, 0.1, click_at(field)); + let texts = status_after(&mut app, &ctx, 0.1, vec![]); + let show = text_center(&texts, "Show"); + status_after(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(show)]); + status_after(&mut app, &ctx, 0.5, vec![]); + let texts = status_after(&mut app, &ctx, 0.5, vec![]); + for label in [" Artboard Name", "✓ Current Tool", " Date and Time", " Number of Undos", " Document Color Profile"] { + assert!(texts.iter().any(|t| t.text == label), "no {label:?} in {texts:?}"); + } + let undos = text_center(&texts, " Number of Undos"); + status_after(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(egui::pos2(undos.x, show.y)), egui::Event::PointerMoved(undos)]); + status_after(&mut app, &ctx, 0.1, click_at(undos)); + assert_eq!(app.ui.status_bar_show, super::StatusBarShow::Undos); + assert_eq!(crate::menus::checked(&app, "view.statusBarShow", &json!({"show": "undos"})), Some(true)); + assert_eq!(crate::menus::checked(&app, "view.statusBarShow", &json!({"show": "currentTool"})), Some(false)); +} + +/// `view.statusBarShow` takes only the five readouts, leaving the choice as it was otherwise, and the +/// choice is saved with the UI state, which reads a value it does not know as Current Tool. +#[test] +fn the_status_bar_show_choice_is_checked_and_saved() { + let (mut app, _ctx) = status_readout_app(); + assert_eq!(app.run("view.statusBarShow", json!({"show": "dateTime"})).unwrap(), json!("dateTime")); + for bad in [json!({}), json!({"show": "zoom"}), json!({"show": 2}), json!({"show": null}), json!({"show": "DateTime"})] { + let err = app.run("view.statusBarShow", bad.clone()).unwrap_err(); + assert!(err.contains("`show` must be one of artboardName, currentTool, dateTime, undos, colorProfile"), "{bad}: {err}"); + assert_eq!(app.ui.status_bar_show, super::StatusBarShow::DateTime, "{bad} leaves the choice"); + } + let saved = serde_json::to_value(&app.ui).unwrap(); + assert_eq!(saved["status_bar_show"], json!("dateTime")); + let read: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap(); + assert_eq!(read.status_bar_show, super::StatusBarShow::DateTime); + for (stored, shown) in [ + (json!("colorProfile"), super::StatusBarShow::ColorProfile), + (json!("zoom"), super::StatusBarShow::CurrentTool), + (json!(7), super::StatusBarShow::CurrentTool), + ] { + let mut v = saved.clone(); + v["status_bar_show"] = stored; + assert_eq!(serde_json::from_value::(v).unwrap().status_bar_show, shown); + } + let mut v = saved; + v.as_object_mut().unwrap().remove("status_bar_show"); + assert_eq!( + serde_json::from_value::(v).unwrap().status_bar_show, + super::StatusBarShow::CurrentTool, + "state saved before it" + ); +} + /// Busy-job transitions preserve message placement, labels and accessibility. #[test] fn background_activity_keeps_the_status_message_origin() { @@ -3044,7 +3193,7 @@ fn control_bar_overflow(app: &mut crate::VectorcraftApp, ctx: &egui::Context, sc control_bar_frame_in(app, ctx, screen, vec![]); let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui)); let nodes: Vec<_> = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); - let menu_left = nodes.iter().find(|n| n.label().is_some_and(|l| l == "Panel menu")).and_then(|n| n.bounds())?.x0 as f32; + let menu_left = nodes.iter().find(|n| n.label().is_some_and(|l| l == tl!("Panel menu"))).and_then(|n| n.bounds())?.x0 as f32; // The bar's frame spans the whole bar, the panel menu button included. let bar_width = if app.ui.control_bar_floating { screen.width().min(800.0) } else { screen.width() }; let shapes = out.shapes.iter().filter_map(|cs| { @@ -3057,8 +3206,12 @@ fn control_bar_overflow(app: &mut crate::VectorcraftApp, ctx: &egui::Context, sc } /// In a narrow window, the Control bar leaves out a control it has no room for, Fill and Stroke, -/// Opacity and Character included: every control it shows is drawn whole, left of the panel menu button, which -/// shows in every width, with the bar docked to the top or the bottom or floating. +/// Opacity and Character included, and the groups other modules draw in it: a shape tool's, the +/// Dimension, Mirror & Cut, Puppet Warp and Crop Image tools' options, a linked or embedded image's +/// controls, an Image Trace object's, a warp or mesh envelope's, a symbol instance's and the Artboard +/// tool's, in English and in German. Every control it shows is drawn whole, left of the panel menu +/// button, which shows in every width, with the bar docked to the top or the bottom or floating. In a +/// wide window, each of those groups shows its controls. #[test] fn the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window() { use crate::tests_removeanchors::{control_frame, has}; @@ -3098,8 +3251,113 @@ fn the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window() { let bar = control_frame(&mut app, &ctx, vec![]); assert!(has(&bar, "Character:"), "the Character group shows in a wide window: {bar:?}"); check(&mut app, "type selected"); + let area = app.run("text.create", json!({"x": 20, "y": 150, "text": "Area", "area": {"width": 150, "height": 40}})).unwrap()["id"].clone(); + app.run("select.set", json!({ "ids": [area] })).unwrap(); + check(&mut app, "area type selected"); app.run("select.set", json!({ "ids": [] })).unwrap(); check(&mut app, "nothing selected"); + // A wide window shows the group's controls named `names`. + let wide = |app: &mut crate::VectorcraftApp, case: &str, names: &[&str]| { + control_frame(app, &ctx, vec![]); + let bar = control_frame(app, &ctx, vec![]); + let missing: Vec<_> = names.iter().filter(|n| !has(&bar, n)).collect(); + assert!(missing.is_empty(), "{case}: {missing:?} missing in a wide window: {bar:?}"); + }; + // The tools' options, with a rectangle selected and with nothing selected. + app.run("select.set", json!({ "ids": [id] })).unwrap(); + for tool in ["rectangle", "ellipse", "polygon", "star", "spiral", "box3d", "dimension", "mirrorCut", "puppetWarp"] { + app.select_tool(tool); + check(&mut app, tool); + } + app.select_tool("polygon"); + wide(&mut app, "Polygon", &["Round", "Random", "Reset"]); + app.select_tool("mirrorCut"); + wide(&mut app, "Mirror & Cut", &["Axis:", "Keep:"]); + app.select_tool("puppetWarp"); + wide(&mut app, "Puppet Warp", &["Expand:", "Show Mesh", "Select All Pins"]); + app.select_tool("dimension"); + wide(&mut app, "Dimension", &["Linear", "Radial", "Tool Options…"]); + app.run("select.set", json!({ "ids": [] })).unwrap(); + app.select_tool("spiral"); + check(&mut app, "spiral, nothing selected"); + wide(&mut app, "Spiral", &["Segments", "Inner Radius:", "Reset"]); + app.select_tool("selection"); + // An embedded and a linked image, an Image Trace object and Crop Image's options. + let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let selected = |app: &crate::VectorcraftApp| app.session.active().unwrap().selection.objects.iter().map(|n| n.0).collect::>(); + app.run("file.place", json!({"name": "half.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + check(&mut app, "an embedded image"); + wide(&mut app, "an embedded image", &["Unembed…", "Image Trace", "Mask", "Crop Image", "Remove Background"]); + app.select_tool(vectorcraft_tools::cropimage::ID); + check(&mut app, "Crop Image"); + wide(&mut app, "Crop Image", &["X:", "Y:", "W:"]); + app.select_tool("selection"); + app.run("imageTrace.make", json!({"preset": "Default"})).unwrap(); + let trace = selected(&app); + check(&mut app, "an Image Trace object"); + wide(&mut app, "an Image Trace object", &["Preset:", "View:", "Expand"]); + let dir = vectorcraft_testkit::temp_dir("control-bar-narrow-linked"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("half.png"); + std::fs::write(&path, &png).unwrap(); + app.run("file.place", json!({"path": path, "link": true})).unwrap(); + let linked = selected(&app); + check(&mut app, "a linked image"); + wide(&mut app, "a linked image", &["Embed", "Edit Original", "Remove Background"]); + // A warp and a mesh envelope, and a symbol instance. + let shape = |app: &mut crate::VectorcraftApp, x: f64| { + let id = app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + app.run("select.set", json!({ "ids": [id] })).unwrap(); + id + }; + shape(&mut app, 60.0); + app.run("object.envelope.makeWithWarp", json!({})).unwrap(); + check(&mut app, "a warp envelope"); + wide(&mut app, "a warp envelope", &["Style:", "Horizontal", "Vertical", "Bend:", "V:", "Reset"]); + shape(&mut app, 110.0); + app.run("object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2})).unwrap(); + let mesh = selected(&app); + check(&mut app, "a mesh envelope"); + wide(&mut app, "a mesh envelope", &["Rows:", "Columns:", "Reset"]); + let art = shape(&mut app, 160.0); + let instance = app.run("symbol.new", json!({"name": "Mark", "ids": [art]})).unwrap()["id"].clone(); + app.run("select.set", json!({ "ids": [instance] })).unwrap(); + check(&mut app, "a symbol instance"); + wide(&mut app, "a symbol instance", &["Replace:", "Reset"]); + // The Artboard tool. + app.select_tool("artboard"); + check(&mut app, "the Artboard tool"); + wide(&mut app, "the Artboard tool", &["Preset:", "Name:", "Transform"]); + // German labels, which are longer. + crate::i18n::set_current(crate::i18n::Lang::from_code("de").expect("de registered")); + check(&mut app, "the Artboard tool, in German"); + app.select_tool("selection"); + app.run("select.set", json!({ "ids": [instance] })).unwrap(); + check(&mut app, "a symbol instance, in German"); + shape(&mut app, 210.0); + app.run("object.envelope.makeWithWarp", json!({})).unwrap(); + check(&mut app, "a warp envelope, in German"); + app.select_tool("puppetWarp"); + check(&mut app, "Puppet Warp, in German"); + app.select_tool("dimension"); + check(&mut app, "Dimension, in German"); + app.select_tool("selection"); + for (ids, case) in [(&trace, "an Image Trace object"), (&linked, "a linked image"), (&mesh, "a mesh envelope")] { + app.run("select.set", json!({ "ids": ids })).unwrap(); + check(&mut app, &format!("{case}, in German")); + } + app.run("file.place", json!({"name": "half.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + check(&mut app, "an embedded image, in German"); + app.select_tool(vectorcraft_tools::cropimage::ID); + check(&mut app, "Crop Image, in German"); + for tool in ["polygon", "spiral", "box3d", "mirrorCut"] { + app.select_tool(tool); + check(&mut app, &format!("{tool}, in German")); + } + app.select_tool("selection"); + crate::i18n::set_current(crate::i18n::Lang::EN); assert!(faults.is_empty(), "a control drawn past the bar's room: {faults:#?}"); } @@ -3158,6 +3416,84 @@ fn a_narrow_control_bar_shows_preferences_without_document_setup() { assert!(preferences_only, "some width has room for Preferences but not Document Setup"); } +/// A narrow Control bar shows a linked image's Embed button without Edit Original, and the Artboard +/// tool's Transform link after an Artboard tool option it had no room for. +#[test] +fn a_narrow_control_bar_shows_embed_without_edit_original_and_the_artboard_link_without_its_options() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let dir = vectorcraft_testkit::temp_dir("control-bar-embed-alone"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("half.png"); + std::fs::write(&path, &png).unwrap(); + app.run("file.place", json!({"path": path, "link": true})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let shows = |nodes: &[egui::accesskit::Node], name: &str| nodes.iter().any(|n| n.label().is_some_and(|l| l == name)); + // Whether some width shows `first` and `second`, and whether some width shows `first` alone. + let sweep = |app: &mut crate::VectorcraftApp, first: &str, second: &str| { + let (mut both, mut first_only) = (false, false); + for w in (150..=1400).step_by(2) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0)); + control_bar_frame_in(app, &ctx, screen, vec![]); + let nodes = control_bar_frame_in(app, &ctx, screen, vec![]); + let (a, b) = (shows(&nodes, first), shows(&nodes, second)); + both |= a && b; + first_only |= a && !b; + } + (both, first_only) + }; + let (both, embed_only) = sweep(&mut app, "Embed", "Edit Original"); + assert!(both, "a wide bar shows Embed and Edit Original"); + assert!(embed_only, "some width has room for Embed but not Edit Original"); + app.run("select.set", json!({ "ids": [] })).unwrap(); + app.select_tool("artboard"); + let (both, link_only) = sweep(&mut app, "Transform", "Rearrange All Artboards"); + assert!(both, "a wide bar shows the Artboard tool's Transform link and Rearrange All Artboards"); + assert!(link_only, "some width has room for the Transform link but not Rearrange All Artboards"); +} + +/// The Control bar's Select Similar Objects button takes only the room every control ahead of it +/// left, so it never shows while the bar left out one of a selected image's controls for room. +#[test] +fn select_similar_stays_out_while_an_image_control_is_left_out() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let dir = vectorcraft_testkit::temp_dir("control-bar-similar-image"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("half.png"); + std::fs::write(&path, &png).unwrap(); + app.run("file.place", json!({"path": path, "link": false})).unwrap(); + // With these groups off, the image's controls come right before the button. + for group in ["opacity", "graphicStyle", "alignDistribute", "transform"] { + app.run("window.control.options", json!({"group": group, "visible": false})).unwrap(); + } + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let shows = |nodes: &[egui::accesskit::Node], name: &str| nodes.iter().any(|n| n.label().is_some_and(|l| l == name)); + let mut seen = false; + for w in (150..=1600).step_by(2) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0)); + control_bar_frame_in(&mut app, &ctx, screen, vec![]); + let nodes = control_bar_frame_in(&mut app, &ctx, screen, vec![]); + if shows(&nodes, "Select Similar Objects") { + assert!(shows(&nodes, "Remove Background"), "{w}: shown while Remove Background is left out"); + seen = true; + } + } + assert!(seen, "a wide bar shows the button after the image's controls"); +} + /// The Anchor Points group holds Convert and Anchors for direct-selected anchors; off, the bar keeps /// its Anchor Point label. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4ab0ed5778..875e8025a1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -97,6 +97,7 @@ pub mod plugin; mod png_options; pub mod print; pub mod print_presets; +pub mod psd_import; mod psd_options; pub mod raster_effects; pub mod rearrange_artboards; @@ -417,6 +418,7 @@ registry! { PluginStopped: [safe_mode::STOPPED] => safe_mode::STOPPED_SPEC, SafeMode: [safe_mode::KIND] => safe_mode::SPEC, DxfImport: [dxf_import::KIND] => dxf_import::SPEC, + PsdImport: [psd_import::KIND] => psd_import::SPEC, RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::SPEC, SaveForWeb: [save_for_web::KIND] => save_for_web::SPEC, PsdOptions: ["psdOptions"] => png_options::SPEC, @@ -685,6 +687,7 @@ pub fn cancel(app: &mut VectorcraftApp) { if d.kind == script_run::NOTICE_KIND { script_run::notice_cancel(app, &d); } + missing_links::cancelled(app, &d); // Toolbar-only Flare options start no preview and must not cancel a gesture // that was already active before opening them. The generic parameter dialog has a spec // without a preview, yet previews some commands (Grid Repeat Options) when asked to. diff --git a/crates/ui-egui/src/dialogs/composite_fonts.rs b/crates/ui-egui/src/dialogs/composite_fonts.rs index cc71cc32da..6e3b6f84e1 100644 --- a/crates/ui-egui/src/dialogs/composite_fonts.rs +++ b/crates/ui-egui/src/dialogs/composite_fonts.rs @@ -1,19 +1,27 @@ //! Type › Composite Fonts… (with Show East Asian Options): the active document's composite fonts, //! the one picked in Font shown as a table with a row for each class of character (Kanji, Kana, -//! Punctuation, Symbols, Roman, Numerals) and its font, style, size, baseline and scales. New adds -//! a font based on the one picked, Delete Font takes it out, Import… adds the fonts of a file and -//! Export… writes the one picked to a file. Nothing changes in the document until OK, which runs -//! `type.compositeFonts.set` with every font as one undo step; Cancel leaves them as they were. +//! Punctuation, Symbols, Roman, Numerals), then a row for each custom character set, with its font, +//! style, size, baseline and scales. New adds a font based on the one picked, Delete Font takes it +//! out, Import… adds the fonts of a file and Export… writes the one picked to a file. Custom shows +//! the custom character sets of the font picked: New adds a set based on the one picked, Delete Set +//! takes it out, and Add puts the characters typed (Direct Entry) or the character of a Unicode code +//! into it; Delete takes the character picked out. Nothing changes in the document until OK, which +//! runs `type.compositeFonts.set` with every font as one undo step; Cancel leaves them as they were. //! //! Fields: `fonts` (the composite fonts as `type.compositeFonts.set` takes them), `selected` (the -//! index of the one picked) and `name` (its name as typed). +//! index of the one picked) and `name` (its name as typed); `custom` (whether the custom character +//! sets show), `set` (the index of the set picked), `setName` (its name as typed), `entryMode` +//! (`direct` or `unicode`), `entry` (the characters or code typed) and `char` (the index of the +//! character picked in the set). //! //! Behavior reference (public documentation): -//! . +//! , +//! custom character sets: +//! . use serde_json::{Value, json}; -use vectorcraft_doc::text::{COMPOSITE_BASELINE, COMPOSITE_SCALE, COMPOSITE_SIZE}; -use vectorcraft_doc::{CharClass, CompositeFont, CompositePart}; +use vectorcraft_doc::text::{COMPOSITE_BASELINE, COMPOSITE_SCALE, COMPOSITE_SIZE, CUSTOM_CHARS_MAX, CUSTOM_SETS_MAX}; +use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CustomCharSet}; use vectorcraft_engine::cmd::compositefonts::EXTS; use super::{DialogSpec, form}; @@ -27,12 +35,28 @@ pub const KIND: &str = "compositeFonts"; /// The command OK runs, and the menu item's. pub const COMMAND: &str = "type.compositeFonts.set"; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Composite Fonts").into(), body, confirm, min_width: 760.0, ..DialogSpec::FORM }; +/// How many of a custom set's characters each row of its list shows. +const CHARS_PER_ROW: usize = 20; -/// Open Composite Fonts on the active document's composite fonts, the first one picked. +/// The tallest the table of parts grows before it scrolls, in points: the header, the six classes +/// and a few custom sets. +const TABLE_HEIGHT: f32 = 340.0; + +/// `char` is held only while a character is picked, so `ui.dialog.set` takes it from `inputs`. +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Composite Fonts").into(), + body, + confirm, + min_width: 760.0, + inputs: |_| &["custom", "entry", "char"], + ..DialogSpec::FORM +}; + +/// Open Composite Fonts on the active document's composite fonts, the first one picked, with the +/// custom character sets hidden and nothing typed. pub fn open(app: &mut VectorcraftApp) -> Result { let fonts = app.session.active().ok_or("no document open")?.doc.composite_fonts.to_vec(); - let mut d = Dialog::new(KIND, json!({ "fonts": fonts, "selected": 0 })); + let mut d = Dialog::new(KIND, json!({ "fonts": fonts, "selected": 0, "custom": false, "entryMode": "direct", "entry": "" })); show_selected(&mut d); app.ui.dialog = Some(d); Ok(Value::Null) @@ -47,12 +71,17 @@ fn put_fonts(d: &mut Dialog, fonts: &[CompositeFont]) { d.fields.insert("fonts".into(), json!(fonts)); } -/// The index of the font picked, within the fonts (0 when there are none). -fn selected(d: &Dialog, count: usize) -> usize { - d.fields.get("selected").and_then(Value::as_u64).map_or(0, |i| i as usize).min(count.saturating_sub(1)) +/// The index field `key` holds, within `count` items (0 when there are none). +fn index(d: &Dialog, key: &str, count: usize) -> usize { + d.fields.get(key).and_then(Value::as_u64).map_or(0, |i| i as usize).min(count.saturating_sub(1)) } -/// Pick font `i`: its name goes into Name. +/// The index of the font picked, within the fonts (0 when there are none). +fn selected(d: &Dialog, count: usize) -> usize { + index(d, "selected", count) +} + +/// Pick font `i`: its name goes into Name, and its first custom set is picked. fn select(d: &mut Dialog, i: usize) { d.fields.insert("selected".into(), json!(i)); show_selected(d); @@ -62,6 +91,17 @@ fn show_selected(d: &mut Dialog) { let fonts = fonts(d); let name = fonts.get(selected(d, fonts.len())).map(|f| f.name.clone()).unwrap_or_default(); d.fields.insert("name".into(), json!(name)); + select_set(d, 0); +} + +/// Pick custom set `i` of the font picked: its name goes into the set's Name, and no character is +/// picked. +fn select_set(d: &mut Dialog, i: usize) { + d.fields.insert("set".into(), json!(i)); + d.fields.remove("char"); + let fonts = fonts(d); + let name = fonts.get(selected(d, fonts.len())).and_then(|f| f.custom.get(i)).map(|s| s.name.clone()).unwrap_or_default(); + d.fields.insert("setName".into(), json!(name)); } /// The name of a class's row. @@ -85,12 +125,25 @@ fn new_name(fonts: &[CompositeFont]) -> String { .unwrap_or_default() } +/// "Character Set N", the first N no set in `sets` is named. +fn new_set_name(sets: &[CustomCharSet]) -> String { + (1..).map(|n| format!("Character Set {n}")).find(|n| !sets.iter().any(|s| CompositeFont::same_name(&s.name, n))).unwrap_or_default() +} + /// What a button asked for, done once the table is drawn. enum Action { New, Delete, Import, Export, + /// Custom: show or hide the custom character sets. + Custom, + NewSet, + DeleteSet, + /// Add the characters typed, or the character of the code typed, to the set picked. + AddChars, + /// Take the character picked out of the set picked. + DeleteChar, } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { @@ -122,6 +175,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Export…"), 64.0)).inner.clicked() { act = Some(Action::Export); } + if ui.add_enabled_ui(!list.is_empty(), |ui| widgets::flat_button(ui, tl!("Custom"), 64.0)).inner.clicked() { + act = Some(Action::Custom); + } }); if let Some(k) = picked { select(d, k); @@ -136,6 +192,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { f.name = d.str("name"); ui.add_space(8.0); parts_table(app, ui, f); + if d.bool("custom") { + ui.add_space(8.0); + ui.separator(); + if let Some(a) = custom_sets(ui, d, f) { + act = Some(a); + } + } } put_fonts(d, &list); if let Some(a) = act { @@ -151,6 +214,80 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The custom character sets of `f`: Character Set with New and Delete Set, the set's Name, the +/// Character row that adds characters, and the set's characters with Delete. The button pressed. +fn custom_sets(ui: &mut egui::Ui, d: &mut Dialog, f: &mut CompositeFont) -> Option { + let mut act = None; + let k = index(d, "set", f.custom.len()); + let names: Vec<&str> = f.custom.iter().map(|s| s.name.as_str()).collect(); + let mut picked = None; + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Character Set:")); + if names.is_empty() { + widgets::dim_label(ui, tl!("None")); + } else if let Some(n) = widgets::dropdown_names(ui, "composite-fonts-set", names.get(k).copied().unwrap_or_default(), &names, 220.0) { + picked = Some(n); + } + ui.add_space(8.0); + ui.spacing_mut().item_spacing.x = 4.0; + if ui.add_enabled_ui(f.custom.len() < CUSTOM_SETS_MAX, |ui| widgets::flat_button(ui, tl!("New"), 48.0)).inner.clicked() { + act = Some(Action::NewSet); + } + if ui.add_enabled_ui(!f.custom.is_empty(), |ui| widgets::flat_button(ui, tl!("Delete Set"), 84.0)).inner.clicked() { + act = Some(Action::DeleteSet); + } + }); + if let Some(n) = picked { + select_set(d, n); + return None; + } + let Some(set) = f.custom.get_mut(k) else { return act }; + ui.add_space(6.0); + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Name:")); + form::text(ui, d, "setName", 260.0); + }); + set.name = d.str("setName"); + ui.horizontal(|ui| { + widgets::dim_label(ui, tl!("Character:")); + let modes = [tl!("Direct Entry"), tl!("Unicode")]; + let unicode = d.str("entryMode") == "unicode"; + if let Some(m) = widgets::dropdown(ui, "composite-fonts-entry-mode", modes[usize::from(unicode)], &modes, 120.0) { + d.fields.insert("entryMode".into(), json!(if m == 1 { "unicode" } else { "direct" })); + } + form::text(ui, d, "entry", 160.0); + if widgets::flat_button(ui, tl!("Add"), 48.0).clicked() { + act = Some(Action::AddChars); + } + }); + ui.add_space(4.0); + let chosen = d.fields.get("char").and_then(Value::as_u64).map(|c| c as usize); + let mut pick_char = None; + // Only the rows in view are built, so a set of thousands of characters stays quick to draw. + let chars: Vec = set.characters.chars().collect(); + let rows = chars.len().div_ceil(CHARS_PER_ROW); + egui::ScrollArea::vertical().id_salt("composite-fonts-chars").max_height(120.0).show_rows(ui, 24.0, rows, |ui, range| { + for row in range { + ui.horizontal(|ui| { + for (n, c) in chars.iter().enumerate().skip(row * CHARS_PER_ROW).take(CHARS_PER_ROW) { + let r = ui.add(egui::Button::selectable(chosen == Some(n), c.to_string()).min_size(egui::vec2(24.0, 24.0))); + if r.on_hover_text(format!("U+{:04X}", *c as u32)).clicked() { + pick_char = Some(n); + } + } + }); + } + }); + if let Some(n) = pick_char { + d.fields.insert("char".into(), json!(n)); + } + let can_delete = chosen.is_some_and(|n| n < chars.len()); + if ui.add_enabled_ui(can_delete, |ui| widgets::flat_button(ui, tl!("Delete"), 64.0)).inner.clicked() { + act = Some(Action::DeleteChar); + } + act +} + /// Do what button `act` asks for on the dialog's fonts. fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action) -> Result<(), String> { let mut list = fonts(d); @@ -178,61 +315,151 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action) -> Result<(), Stri let f = list.get(i).ok_or("no composite font")?; crate::io::save_command_output(app, "type.compositeFonts.export", EXTS[0], json!({ "fonts": [f] }))?; } + Action::Custom => { + let open = !d.bool("custom"); + d.fields.insert("custom".into(), json!(open)); + } + Action::NewSet | Action::DeleteSet | Action::AddChars | Action::DeleteChar => { + let f = list.get_mut(i).ok_or("no composite font")?; + let at = edit_sets(d, f, act)?; + put_fonts(d, &list); + if let Some(k) = at { + select_set(d, k); + } + } } Ok(()) } -/// The table of `f`'s parts: a row for each class with its font, style, size, baseline, scales and -/// (Kana) Scale at Center; the values a class can't take are not shown. +/// Do custom set action `act` on font `f`. The set to pick afterwards, when the sets changed. +fn edit_sets(d: &mut Dialog, f: &mut CompositeFont, act: Action) -> Result, String> { + let k = index(d, "set", f.custom.len()); + match act { + Action::NewSet => { + if f.custom.len() >= CUSTOM_SETS_MAX { + return Err(format!("a composite font keeps at most {CUSTOM_SETS_MAX} character sets")); + } + // Based on the set picked: its characters, font and values. + let mut s = f.custom.get(k).cloned().unwrap_or_else(|| CustomCharSet::new("", f.parts.kanji.clone())); + s.name = new_set_name(&f.custom); + f.custom.push(s); + Ok(Some(f.custom.len() - 1)) + } + Action::DeleteSet => { + if k < f.custom.len() { + f.custom.remove(k); + } + Ok(Some(k.min(f.custom.len().saturating_sub(1)))) + } + Action::AddChars => { + let set = f.custom.get_mut(k).ok_or("no character set")?; + let entry = d.str("entry"); + let add: Vec = if d.str("entryMode") == "unicode" { + entry + .split(|c: char| c.is_whitespace() || c == ',') + .filter(|code| !code.is_empty()) + .map(|code| CustomCharSet::char_from_code(code).ok_or_else(|| format!("`{code}` is not a code from U+0000 to U+FFFF"))) + .collect::>()? + } else { + entry.chars().collect() + }; + if let Some(c) = add.iter().find(|&&c| !CustomCharSet::takes(c)) { + return Err(format!("`{}`: U+{:04X} is a control character or past U+FFFF", set.name, *c as u32)); + } + let mut count = set.characters.chars().count(); + for c in add { + if set.characters.contains(c) { + continue; + } + if count == CUSTOM_CHARS_MAX { + return Err(format!("`{}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters", set.name)); + } + set.characters.push(c); + count += 1; + } + d.fields.insert("entry".into(), json!("")); + Ok(None) + } + Action::DeleteChar => { + let set = f.custom.get_mut(k).ok_or("no character set")?; + let n = d.fields.get("char").and_then(Value::as_u64).map(|c| c as usize); + if let Some(n) = n { + set.characters = set.characters.chars().enumerate().filter(|(j, _)| *j != n).map(|(_, c)| c).collect(); + } + d.fields.remove("char"); + Ok(None) + } + Action::New | Action::Delete | Action::Import | Action::Export | Action::Custom => Ok(None), + } +} + +/// The table of `f`'s parts: a row for each class, then for each custom character set, with its +/// font, style, size, baseline, scales and (Kana) Scale at Center; the values a row can't take are +/// not shown. Past [`TABLE_HEIGHT`] the table scrolls, so the controls under it stay on screen. fn parts_table(app: &VectorcraftApp, ui: &mut egui::Ui, f: &mut CompositeFont) { - let db = vectorcraft_text::FontDb::global(); + egui::ScrollArea::vertical().id_salt("composite-fonts-table").max_height(TABLE_HEIGHT).show(ui, |ui| parts_grid(app, ui, f)); +} + +/// The rows of [`parts_table`]. +fn parts_grid(app: &VectorcraftApp, ui: &mut egui::Ui, f: &mut CompositeFont) { egui::Grid::new("composite-fonts-parts").num_columns(8).spacing([8.0, 6.0]).show(ui, |ui| { for h in ["", tl!("Font"), tl!("Style"), tl!("Size"), tl!("Baseline"), tl!("Horizontal Scale"), tl!("Vertical Scale"), ""] { widgets::dim_label(ui, h); } ui.end_row(); for class in CharClass::ALL { - let p: &mut CompositePart = f.parts.get_mut(class); ui.label(class_label(class)); - let id = ("composite-font", class.key()); - if let Some(crate::font_menu::FontPick::Chosen(family, style)) = - crate::font_menu::font_menu(ui, id, &p.font, 190.0, None, crate::font_menu::MenuLook::of(app)) - && db.composite_font(&family).is_none() - { - p.style = style.unwrap_or_else(|| db.styles(&family).first().cloned().unwrap_or_else(|| "Regular".into())); - p.font = family; - } - let styles = db.styles(&p.font); - let style_names: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(k) = widgets::dropdown_names(ui, ("composite-font-style", class.key()), &p.style, &style_names, 120.0) - && let Some(s) = styles.get(k) - { - p.style.clone_from(s); - } - let percent = |ui: &mut egui::Ui, key: &str, v: &mut f64, range: &std::ops::RangeInclusive| { - if let Some(n) = widgets::range_field(ui, ("composite-font", class.key(), key), *v, range.clone(), "%", 1, 64.0) { - *v = n; - } - }; - percent(ui, "size", &mut p.size, &COMPOSITE_SIZE); - percent(ui, "baseline", &mut p.baseline, &COMPOSITE_BASELINE); - if class.scales() { - percent(ui, "hScale", &mut p.h_scale, &COMPOSITE_SCALE); - percent(ui, "vScale", &mut p.v_scale, &COMPOSITE_SCALE); - } else { - ui.label(""); - ui.label(""); - } - if class == CharClass::Kana { - ui.checkbox(&mut p.scale_at_center, tl!("Scale at Center")); - } else { - ui.label(""); - } - ui.end_row(); + part_row(app, ui, class.key(), f.parts.get_mut(class), class.scales(), class == CharClass::Kana); + } + for (n, s) in f.custom.iter_mut().enumerate() { + // A set's name is the user's own, shown as it is. + ui.label(s.name.as_str()); + part_row(app, ui, &format!("custom-{n}"), &mut s.part, true, false); } }); } +/// The cells of part `p`'s row (keyed `key`) after its label: font, style, size, baseline, the +/// scales when `scales`, and Scale at Center when `center`. +fn part_row(app: &VectorcraftApp, ui: &mut egui::Ui, key: &str, p: &mut CompositePart, scales: bool, center: bool) { + let db = vectorcraft_text::FontDb::global(); + let id = ("composite-font", key); + if let Some(crate::font_menu::FontPick::Chosen(family, style)) = + crate::font_menu::font_menu(ui, id, &p.font, 190.0, None, crate::font_menu::MenuLook::of(app)) + && db.composite_font(&family).is_none() + { + p.style = style.unwrap_or_else(|| db.styles(&family).first().cloned().unwrap_or_else(|| "Regular".into())); + p.font = family; + } + let styles = db.styles(&p.font); + let style_names: Vec<&str> = styles.iter().map(String::as_str).collect(); + if let Some(k) = widgets::dropdown_names(ui, ("composite-font-style", key), &p.style, &style_names, 120.0) + && let Some(s) = styles.get(k) + { + p.style.clone_from(s); + } + let percent = |ui: &mut egui::Ui, field: &str, v: &mut f64, range: &std::ops::RangeInclusive| { + if let Some(n) = widgets::range_field(ui, ("composite-font", key, field), *v, range.clone(), "%", 1, 64.0) { + *v = n; + } + }; + percent(ui, "size", &mut p.size, &COMPOSITE_SIZE); + percent(ui, "baseline", &mut p.baseline, &COMPOSITE_BASELINE); + if scales { + percent(ui, "hScale", &mut p.h_scale, &COMPOSITE_SCALE); + percent(ui, "vScale", &mut p.v_scale, &COMPOSITE_SCALE); + } else { + ui.label(""); + ui.label(""); + } + if center { + ui.checkbox(&mut p.scale_at_center, tl!("Scale at Center")); + } else { + ui.label(""); + } + ui.end_row(); +} + /// Import…: the fonts of a picked file join those in the dialog, a font of the same name replaced, /// and the first of them is picked. fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { diff --git a/crates/ui-egui/src/dialogs/composite_fonts/tests.rs b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs index 64a031e043..e50ce1b566 100644 --- a/crates/ui-egui/src/dialogs/composite_fonts/tests.rs +++ b/crates/ui-egui/src/dialogs/composite_fonts/tests.rs @@ -172,3 +172,111 @@ fn the_dialog_draws_with_and_without_fonts() { frame(&mut app); assert_eq!(fonts(dialog(&mut app)).len(), 1); } + +fn try_press(app: &mut VectorcraftApp, act: Action) -> Result<(), String> { + let mut d = app.ui.dialog.clone().unwrap(); + let r = run(app, &mut d, act); + app.ui.dialog = Some(d); + r +} + +fn sets(app: &mut VectorcraftApp) -> Vec { + fonts(dialog(app)).first().map(|f| f.custom.clone()).unwrap_or_default() +} + +/// Custom shows the font's custom character sets: New adds a set (based on the one picked), Add +/// puts typed characters or the characters of Unicode codes into it once each, Delete takes the +/// picked character out, Delete Set takes the set out; OK sets them with the font as one step. +#[test] +fn custom_character_sets_are_edited_in_the_dialog() { + let mut app = app(); + open(&mut app).unwrap(); + press(&mut app, Action::New); + press(&mut app, Action::Custom); + assert!(dialog(&mut app).bool("custom")); + frame(&mut app); + press(&mut app, Action::NewSet); + frame(&mut app); + assert_eq!(dialog(&mut app).str("setName"), "Character Set 1"); + let kanji = fonts(dialog(&mut app))[0].parts.kanji.clone(); + assert_eq!(sets(&mut app)[0].part, kanji); + // Direct Entry adds each character once. + dialog(&mut app).fields.insert("entry".into(), json!("※A※")); + press(&mut app, Action::AddChars); + assert_eq!(sets(&mut app)[0].characters, "※A"); + assert_eq!(dialog(&mut app).str("entry"), ""); + // Unicode takes codes, with or without U+, and skips characters already in the set. + dialog(&mut app).fields.insert("entryMode".into(), json!("unicode")); + dialog(&mut app).fields.insert("entry".into(), json!("U+3042, 41")); + press(&mut app, Action::AddChars); + assert_eq!(sets(&mut app)[0].characters, "※Aあ"); + for bad in ["zz", "1F600", "0009"] { + dialog(&mut app).fields.insert("entry".into(), json!(bad)); + assert!(try_press(&mut app, Action::AddChars).is_err(), "{bad}"); + assert_eq!(sets(&mut app)[0].characters, "※Aあ"); + } + dialog(&mut app).fields.insert("entryMode".into(), json!("direct")); + dialog(&mut app).fields.insert("entry".into(), json!("B😀")); + assert!(try_press(&mut app, Action::AddChars).is_err()); + assert_eq!(sets(&mut app)[0].characters, "※Aあ"); + frame(&mut app); + // Delete takes the picked character out. + dialog(&mut app).fields.insert("char".into(), json!(0)); + press(&mut app, Action::DeleteChar); + assert_eq!(sets(&mut app)[0].characters, "Aあ"); + // The set's name is edited in Name; New is based on the set picked. + dialog(&mut app).fields.insert("setName".into(), json!("Letters")); + frame(&mut app); + press(&mut app, Action::NewSet); + let list = sets(&mut app); + assert_eq!(list.iter().map(|s| (s.name.as_str(), s.characters.as_str())).collect::>(), [("Letters", "Aあ"), ("Character Set 1", "Aあ")]); + assert_eq!(dialog(&mut app).fields.get("set"), Some(&json!(1))); + press(&mut app, Action::DeleteSet); + assert_eq!(sets(&mut app).len(), 1); + assert_eq!(dialog(&mut app).str("setName"), "Letters"); + frame(&mut app); + dialog(&mut app).fields.insert("name".into(), json!("UI Composite Sets")); + frame(&mut app); + let before = steps(&app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(steps(&app), before + 1); + let f = &app.session.active().unwrap().doc.composite_fonts[0]; + assert_eq!((f.custom.len(), f.custom[0].name.as_str(), f.custom[0].characters.as_str()), (1, "Letters", "Aあ")); +} + +/// `ui.dialog.set` takes `custom`, `entry` and `char` as soon as the dialog opens, before Custom is +/// clicked or anything is typed, and Add then adds the characters it set. +#[test] +fn custom_set_fields_are_set_right_after_open() { + let mut app = app(); + let ctx = egui::Context::default(); + let font = + json!({"name": "UI Composite Open", "parts": {"kanji": {"font": "Inter"}}, "custom": [{"name": "Marks", "characters": "", "font": "Inter"}]}); + app.run(COMMAND, json!({ "fonts": [font] })).unwrap(); + open(&mut app).unwrap(); + for (field, value) in [("custom", json!(true)), ("entry", json!("※〒")), ("char", json!(0))] { + let (req, _) = crate::control::ControlRequest::new("ui.dialog.set", json!({"field": field, "value": value})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") }; + assert!(r.get("error").is_none(), "{field}: {r}"); + assert_eq!(dialog(&mut app).fields.get(field), Some(&value), "{field}"); + } + frame(&mut app); + press(&mut app, Action::AddChars); + assert_eq!(sets(&mut app)[0].characters, "※〒"); +} + +/// Two sets of the same name keep the dialog open and change nothing. +#[test] +fn custom_sets_sharing_a_name_keep_the_dialog_open() { + let mut app = app(); + open(&mut app).unwrap(); + press(&mut app, Action::New); + press(&mut app, Action::NewSet); + press(&mut app, Action::NewSet); + dialog(&mut app).fields.insert("setName".into(), json!("character set 1")); + dialog(&mut app).fields.insert("custom".into(), json!(true)); + frame(&mut app); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + assert!(names(&app).is_empty()); +} diff --git a/crates/ui-egui/src/dialogs/dimension.rs b/crates/ui-egui/src/dialogs/dimension.rs index 9e84b39f89..140ace0fde 100644 --- a/crates/ui-egui/src/dialogs/dimension.rs +++ b/crates/ui-egui/src/dialogs/dimension.rs @@ -232,11 +232,15 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { Ok(r) } -/// The Dimension tool's strip: the kind of dimension, and Tool Options. -pub fn controls(app: &mut VectorcraftApp, ui: &mut Ui) { +/// The Dimension tool's strip: the kind of dimension, and Tool Options. In the Control bar +/// (`reserve` given), each button shows only while the row has room for it and `reserve` after it, +/// and the buttons after one left out are left out too; the floating strip (`None`) shows them all. +pub fn controls(app: &mut VectorcraftApp, ui: &mut Ui, reserve: Option) { + let mut fit = reserve.map(crate::chrome::Fit::new); + let mut room = |ui: &Ui, text: &str| fit.as_mut().is_none_or(|f| f.room(ui, &[button_width(ui, text)])); let mode = app.session.tool_options()["mode"].as_str().unwrap_or("linear").to_owned(); for (id, label) in [("linear", "Linear"), ("angular", "Angular"), ("radial", "Radial")] { - if ui.selectable_label(mode == id, tl!(label)).clicked() { + if room(ui, tl!(label)) && ui.selectable_label(mode == id, tl!(label)).clicked() { // Switching away cancels previews and unfinished picks. if let Err(e) = app .run("tool.select", json!({"tool":"selection"})) @@ -247,13 +251,20 @@ pub fn controls(app: &mut VectorcraftApp, ui: &mut Ui) { } } } - if ui.button(tl!("Tool Options…")).clicked() + if room(ui, tl!("Tool Options…")) + && ui.button(tl!("Tool Options…")).clicked() && let Err(e) = open(app) { app.ui.status = e; } } +/// The width `ui.button` and `ui.selectable_label` give `text`: the text and the button padding on +/// both sides. +fn button_width(ui: &Ui, text: &str) -> f32 { + widgets::text_width(ui, RichText::new(text).text_style(egui::TextStyle::Button)) + 2.0 * ui.spacing().button_padding.x +} + /// Always available while measuring, including workspaces with the main Control bar hidden. pub fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.session.tool_id() != "dimension" || app.ui.dialog.is_some() { @@ -265,7 +276,7 @@ pub fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) { .resizable(false) .collapsible(false) .show(ctx, |ui| { - ui.horizontal(|ui| controls(app, ui)); + ui.horizontal(|ui| controls(app, ui, None)); let mode = app.session.tool_options()["mode"].as_str().unwrap_or("linear").to_owned(); let hint = match mode.as_str() { "angular" => "Drag out from a corner, or click two straight lines, then click to place.", diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index a73d68cd1a..a3142fd01f 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -251,18 +251,28 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { /// The Control bar's envelope controls (as in the reference app) while an envelope, or content /// whose envelope is being edited, is selected: Edit Envelope / Edit Contents; a warp's style, /// orientation, bend and distortions, or a mesh's rows and columns; Reset Envelope Shape; and -/// Envelope Options. -pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { +/// Envelope Options. The two editing buttons, each label with its menu or field, each radio button +/// and each other button show only while the row has room for them and `reserve` after them, and +/// the controls after one left out are left out too. +pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui, reserve: f32) { let Some((editing, bar)) = bar_of(app) else { return }; let mut run: Option<(&str, Value)> = None; + let mut fit = crate::chrome::Fit::new(reserve); crate::chrome::group_separator(ui); - for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] { - if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents { - run = Some(("object.envelope.editContents", json!({ "editing": contents }))); + if fit.room(ui, &[24.0, 24.0]) { + for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] { + if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents { + run = Some(("object.envelope.editContents", json!({ "editing": contents }))); + } + } + if ui.available_width() >= 4.0 { + ui.add_space(4.0); } } - ui.add_space(4.0); - let field = |ui: &mut egui::Ui, key: &str, label: &str, value: f64, suffix: &str| { + let field = |ui: &mut egui::Ui, fit: &mut crate::chrome::Fit, key: &str, label: &str, value: f64, suffix: &str| { + if !fit.room(ui, &[widgets::dim_label_width(ui, label), 46.0]) { + return None; + } widgets::dim_label(ui, label); widgets::plain_field(ui, ("cb-envelope", key), value, suffix, 0, 46.0) }; @@ -270,24 +280,26 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { Bar::Warp { style, bend, h, v, horizontal } => { let styles: Vec<&str> = WARP_STYLES.iter().map(|(_, l)| l.trim_end_matches('…')).collect(); let current = WARP_STYLES.iter().find(|(id, _)| id == style).map_or(style.as_str(), |(_, l)| l.trim_end_matches('…')); - widgets::dim_label(ui, tl!("Style:")); - if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) { - run = Some((OPTIONS_CMD, json!({ "style": id }))); + if fit.room(ui, &[widgets::dim_label_width(ui, tl!("Style:")), 110.0]) { + widgets::dim_label(ui, tl!("Style:")); + if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) { + run = Some((OPTIONS_CMD, json!({ "style": id }))); + } } for (h, label) in [(true, tl!("Horizontal")), (false, tl!("Vertical"))] { - if widgets::radio(ui, label, *horizontal == h, true) { + if fit.room(ui, &[widgets::choice_width(ui, label)]) && widgets::radio(ui, label, *horizontal == h, true) { run = Some((OPTIONS_CMD, json!({ "horizontal": h }))); } } for (key, label, value) in [("bend", tl!("Bend:"), *bend), ("h", tl!("H:"), *h), ("v", tl!("V:"), *v)] { - if let Some(n) = field(ui, key, label, value, "%") { + if let Some(n) = field(ui, &mut fit, key, label, value, "%") { run = Some((OPTIONS_CMD, json!({ key: n.round().clamp(-100.0, 100.0) }))); } } } Bar::Mesh { rows, cols } => { for (key, label, value) in [("rows", tl!("Rows:"), *rows), ("cols", tl!("Columns:"), *cols)] { - if let Some(n) = field(ui, key, label, f64::from(value), "") { + if let Some(n) = field(ui, &mut fit, key, label, f64::from(value), "") { run = Some((RESET_MESH, json!({ key: n.round().clamp(1.0, 50.0), "maintainShape": true }))); } } @@ -301,11 +313,12 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { Bar::TopObject => None, }; if let Some(r) = reset - && widgets::flat_button(ui, tl!("Reset"), 50.0).on_hover_text(tl!("Reset Envelope Shape")).clicked() + && fit.room(ui, &[widgets::flat_button_fit_width(ui, tl!("Reset"), 50.0)]) + && widgets::flat_button_fit(ui, tl!("Reset"), 50.0).on_hover_text(tl!("Reset Envelope Shape")).clicked() { run = Some(r); } - if widgets::icon_button(ui, "dc-options", tl!("Envelope Options"), false, 24.0).clicked() { + if fit.room(ui, &[24.0]) && widgets::icon_button(ui, "dc-options", tl!("Envelope Options"), false, 24.0).clicked() { crate::menus::invoke(app, OPTIONS_CMD, json!({})); } if let Some((cmd, p)) = run diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs index 1c79b48ce7..650f403542 100644 --- a/crates/ui-egui/src/dialogs/find_links/tests.rs +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -154,6 +154,42 @@ fn files_not_relinked_go_back_to_linked_file_not_found() { assert_eq!(d.fields["notes"], json!(["a note"])); super::super::cancel(&mut app); assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 2); + let opened = crate::io::opened_with_notes(1, "a note"); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), vec!["a note".to_string()])))); +} + +/// Relink, and Cancel, in the Find Linked Files dialog that Linked File Not Found's Find in Folder… +/// opened: the status bar shows the open's notes again and keeps them for its hover. Cancel of the +/// Links panel's Find in Folder… leaves the message as it is. +#[test] +fn the_last_find_in_folder_answer_keeps_the_open_notes() { + let root = folder("notes"); + let mut app = app_for(&root); + let bytes = opened_without(&mut app, &root, &["a.png"]); + let found = at(&root, "found/a.png"); + write(&found, &bytes[0]); + let missing = rows(app.ui.dialog.as_ref().unwrap(), "missing"); + let notes = vec!["a note".to_string(), "another note".to_string()]; + let opened = crate::io::opened_with_notes(2, "a note"); + let find = |app: &mut VectorcraftApp, back: bool| { + let mut d = dialog(app, missing.clone(), vec![], back).unwrap(); + d.fields.insert("notes".into(), json!(notes)); + d.fields.insert("picks".into(), json!([found])); + app.ui.dialog = Some(d); + app.status("Something else"); + }; + find(&mut app, false); + super::super::cancel(&mut app); + assert_eq!(app.ui.status, "Something else"); + find(&mut app, true); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Cancel"); + find(&mut app, true); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 0); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Relink"); } /// A file is chosen when the choice is clear: the only same file, else the only file. A row the diff --git a/crates/ui-egui/src/dialogs/freehand/tests.rs b/crates/ui-egui/src/dialogs/freehand/tests.rs index 1b41fc0886..3a73d6f3bb 100644 --- a/crates/ui-egui/src/dialogs/freehand/tests.rs +++ b/crates/ui-egui/src/dialogs/freehand/tests.rs @@ -172,7 +172,7 @@ fn live_curve_fitting_off_shows_the_points_until_the_release_fits_them() { let pointer = |kind, (x, y)| vectorcraft_tools::PointerEvent::new(kind, x, y); let newest = |app: &VectorcraftApp| { let doc = &app.session.doc().unwrap().doc; - doc.layers[0].children().unwrap().last().unwrap().path_data().unwrap().subpaths[0].anchors.clone() + doc.layers[0].children().unwrap().last().unwrap().path_data().unwrap().subpaths[0].anchors.to_vec() }; crate::canvas::dispatch(app, &pointer(vectorcraft_tools::PointerKind::Down, pts[0]), view); for p in &pts[1..] { diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 28a6e57828..5cf08aa84a 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -8,8 +8,8 @@ //! [`vectorcraft_engine::link_watch`]). //! //! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of -//! images whose file was modified), `notes` (the open's notes, shown with the line about the -//! modified files), `applyToAll`, `path` (the replacement file; picked when empty), `discard` +//! images whose file was modified), `notes` (the open's notes, shown again after the last question +//! or Cancel), `applyToAll`, `path` (the replacement file; picked when empty), `discard` //! (Ignore) and `searchFolder` (set it and confirm for Find in Folder… on that folder). use serde_json::{Value, json}; @@ -117,15 +117,27 @@ pub(super) fn notes(d: &Dialog) -> Vec { notes.filter_map(Value::as_str).map(str::to_string).collect() } +/// Cancel of the dialog `d`: when it is this dialog, or the Find Linked Files dialog its Find in +/// Folder… opened, the rest of the questions are not asked and the status bar shows the open's +/// notes again ([`crate::io::show_opened`]), after the line about the modified files when Update +/// Links doesn't ask about them. +pub(super) fn cancelled(app: &mut VectorcraftApp, d: &Dialog) { + if d.kind == KIND || (d.kind == super::find_links::KIND && d.bool("back")) { + let modified = d.fields.get("modified").and_then(Value::as_array).map_or(0, Vec::len); + let notice = (modified > 0 && app.session.prefs.update_links != "askWhenModified").then(|| changed_on_disk_status(modified)); + crate::io::show_opened(app, notice, notes(d)); + } +} + /// Ask about the missing files `rest`, one at a time, then offer the modified ones `modified` for -/// update. A line about the modified files is followed by the open's `notes` -/// ([`crate::io::show_opened`]). +/// update. After the last question the status bar shows the open's `notes` again, after the line +/// about the modified files when there is one ([`crate::io::show_opened`]), so their hover lists +/// them whatever message the answers showed. pub(crate) fn ask_next(app: &mut VectorcraftApp, rest: Vec, modified: Vec, notes: Vec) { if rest.is_empty() { app.ui.dialog = None; - if let Some(notice) = ask_update(app, modified) { - crate::io::show_opened(app, Some(notice), notes); - } + let notice = ask_update(app, modified); + crate::io::show_opened(app, notice, notes); } else { open(app, rest, modified, ¬es); } @@ -476,6 +488,116 @@ mod tests { } } + /// After the last question of the Linked File Not Found dialog the status bar shows the open's + /// notes again and keeps them for its hover, also when another message was shown meanwhile: + /// after Ignore, Ignore with Apply to All and Cancel, with either Update Links choice, and with + /// Ask When Modified while the update question opens. An open without notes leaves the message + /// as it is. + #[test] + fn the_last_missing_link_answer_keeps_the_open_notes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let notes = vec!["a note".to_string(), "another note".to_string()]; + let opened = crate::io::opened_with_notes(2, "a note"); + let row = |n: u64| json!({ "name": format!("{n}.png"), "path": format!("/gone/{n}.png"), "ids": [n] }); + let answer = |app: &mut VectorcraftApp, fields: &[(&str, bool)]| { + app.status("Something else"); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in fields { + d.fields.insert(k.to_string(), json!(v)); + } + super::super::confirm(app).unwrap(); + }; + for prefs in ["manually", "askWhenModified"] { + app.session.prefs.update_links = prefs.into(); + after_open_for(&mut app, &json!({ "missingLinks": [row(1)] }), notes.clone(), false); + answer(&mut app, &[("discard", true)]); + assert!(app.ui.dialog.is_none()); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Ignore, {prefs}"); + + after_open_for(&mut app, &json!({ "missingLinks": [row(1), row(2)] }), notes.clone(), false); + answer(&mut app, &[("discard", true), ("applyToAll", true)]); + assert!(app.ui.dialog.is_none()); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Apply to All, {prefs}"); + + after_open_for(&mut app, &json!({ "missingLinks": [row(1), row(2)], "modifiedLinks": [{ "ids": [3] }] }), notes.clone(), false); + app.status("Something else"); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none(), "Cancel asks nothing more"); + let cancelled = + if prefs == "manually" { crate::io::opened_after_notice(&changed_on_disk_status(1), 2, "a note") } else { opened.clone() }; + assert_eq!((&app.ui.status, &app.ui.status_notes), (&cancelled, &Some((cancelled.clone(), notes.clone()))), "Cancel, {prefs}"); + } + + after_open_for(&mut app, &json!({ "missingLinks": [row(1)], "modifiedLinks": [{ "ids": [3] }] }), notes.clone(), false); + answer(&mut app, &[("discard", true)]); + let d = app.ui.dialog.as_ref().expect("asks"); + assert_eq!((d.kind.as_str(), d.str("__command")), (super::super::confirm::KIND, "links.update".to_string())); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone())))); + app.ui.dialog = None; + + after_open_for(&mut app, &json!({ "missingLinks": [row(1)] }), vec![], false); + answer(&mut app, &[("discard", true)]); + assert_eq!(app.ui.status, "Something else"); + after_open_for(&mut app, &json!({ "missingLinks": [row(1)] }), vec![], false); + app.status("Something else"); + super::super::cancel(&mut app); + assert_eq!(app.ui.status, "Something else"); + app.session.prefs.update_links = "manually".into(); + after_open_for(&mut app, &json!({ "missingLinks": [row(1)], "modifiedLinks": [{ "ids": [3] }] }), vec![], false); + app.status("Something else"); + super::super::cancel(&mut app); + assert_eq!(app.ui.status, changed_on_disk_status(1), "Cancel keeps the line about the modified files"); + } + + /// Replace with a file given by path, with a picked file and with Apply to All: after the last + /// one the status bar shows the open's notes again. A canceled file picker leaves the question + /// open and the message as it is. + #[test] + fn the_last_replace_keeps_the_open_notes() { + let notes = vec!["a note".to_string(), "another note".to_string()]; + let opened = crate::io::opened_with_notes(2, "a note"); + let (mut app, dir, _) = opened_without_links("notes"); + let found = dir.join("found"); + std::fs::create_dir_all(&found).unwrap(); + for n in ["a.png", "b.png"] { + std::fs::write(found.join(n), png([0, 0, 200])).unwrap(); + } + app.ui.dialog.as_mut().unwrap().fields.insert("notes".into(), json!(notes)); + app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(found.join("a.png").to_string_lossy())); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().expect("asks about the next").fields["missing"][0]["name"], "b.png"); + + app.services.pick_open = Some(Box::new(|_: &crate::FilePick| None)); + app.status("Something else"); + assert_eq!(super::super::confirm(&mut app), Err("cancelled".to_string())); + assert_eq!(app.ui.dialog.as_ref().expect("still asks").fields["missing"][0]["name"], "b.png"); + assert_eq!(app.ui.status, "Something else"); + + let b = found.join("b.png").to_string_lossy().into_owned(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(b.clone()))); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "picked"); + let _ = std::fs::remove_dir_all(dir); + + let (mut app, dir, _) = opened_without_links("notes-all"); + let found = dir.join("found"); + std::fs::create_dir_all(&found).unwrap(); + for n in ["a.png", "b.png"] { + std::fs::write(found.join(n), png([0, 0, 200])).unwrap(); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("notes".into(), json!(notes)); + d.fields.insert("applyToAll".into(), json!(true)); + d.fields.insert("path".into(), json!(found.join("a.png").to_string_lossy())); + app.status("Something else"); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "both relinked"); + assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Apply to All"); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn apply_to_all_replaces_the_others_from_the_same_folder() { let (mut app, dir, _) = opened_without_links("all"); diff --git a/crates/ui-egui/src/dialogs/para_style_options.rs b/crates/ui-egui/src/dialogs/para_style_options.rs index a78790d692..d63d43be89 100644 --- a/crates/ui-egui/src/dialogs/para_style_options.rs +++ b/crates/ui-egui/src/dialogs/para_style_options.rs @@ -361,7 +361,12 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result, String> { } let same = unchanged("composer", "__resetComposition"); - keep_or(&mut attrs, "composer", same, choice("composer").filter(|v| matches!(v.as_str(), Some("singleLine" | "everyLine")))); + keep_or( + &mut attrs, + "composer", + same, + choice("composer").filter(|v| matches!(v.as_str(), Some("singleLine" | "everyLine" | "standardSingleLine" | "standardEveryLine"))), + ); let same = unchanged("roman_hanging", "__resetComposition"); keep_or(&mut attrs, "roman_hanging", same, switch("roman_hanging")); @@ -637,7 +642,9 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(&str, &str)] let shown = options.iter().find(|(value, _)| *value == selected).map_or("", |(_, label)| *label); let labels: Vec<&str> = std::iter::once(" ").chain(options.iter().map(|(_, label)| *label)).collect(); ui.add_enabled_ui(enabled, |ui| { - if let Some(i) = crate::widgets::dropdown(ui, (KIND, key), shown, &labels, 190.0) { + // shortcut: 7 pt per character approximates the widest label; measure the text when a label wraps. + let width = (labels.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f32 * 7.0).max(190.0); + if let Some(i) = crate::widgets::dropdown(ui, (KIND, key), shown, &labels, width) { d.fields.insert(key.into(), if i == 0 { Value::Null } else { options.get(i - 1).map_or(Value::Null, |(value, _)| json!(value)) }); } }); @@ -842,11 +849,16 @@ pub(crate) fn clear_tabs(d: &mut Dialog) { d.fields.remove("__tabEdited"); } -/// Composition: the Single-line or Every-line Composer, and Roman Hanging Punctuation. +/// Composition: the Middle Eastern & South Asian or Standard, Single-line or Every-line Composer, and Roman Hanging Punctuation. pub(crate) fn composition_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("paragraph-style-composition").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { let row = ui.label(tl!("Composition")); - let composers = [("singleLine", tl!("Single-line Composer")), ("everyLine", tl!("Every-line Composer"))]; + let composers = [ + ("singleLine", tl!("Middle Eastern & South Asian Single-line Composer")), + ("everyLine", tl!("Middle Eastern & South Asian Every-line Composer")), + ("standardSingleLine", tl!("Standard Single-line Composer")), + ("standardEveryLine", tl!("Standard Every-line Composer")), + ]; ui.label_inputs_by(row.id, |ui| choice(ui, d, "composer", &composers, true)); ui.end_row(); switch(ui, d, "roman_hanging", tl!("Roman Hanging Punctuation"), true); @@ -857,7 +869,7 @@ pub(crate) fn composition_body(ui: &mut egui::Ui, d: &mut Dialog) { /// unset; Hyphenation Zone is also inactive with the Every-line Composer, which does not use it. pub(crate) fn hyphenation_body(ui: &mut egui::Ui, d: &mut Dialog) { let on = d.fields.get("hyphenate").and_then(Value::as_bool) == Some(true); - let single_line = d.fields.get("composer").and_then(Value::as_str) == Some("singleLine"); + let single_line = matches!(d.fields.get("composer").and_then(Value::as_str), Some("singleLine" | "standardSingleLine")); egui::Grid::new("paragraph-style-hyphenation").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { switch(ui, d, "hyphenate", tl!("Hyphenation"), true); for (key, suffix) in [ diff --git a/crates/ui-egui/src/dialogs/psd_import.rs b/crates/ui-egui/src/dialogs/psd_import.rs new file mode 100644 index 0000000000..75fd0e0f11 --- /dev/null +++ b/crates/ui-egui/src/dialogs/psd_import.rs @@ -0,0 +1,585 @@ +//! PSD Import Options: opening a PSD or PSB file with two or more layers or with slices, or +//! placing one without Link, asks whether its layers become objects or one flat image, whether +//! hidden layers come in, and whether its slices come in (Import Slices, offered only for a file +//! with slices) +//! (). +//! A linked file shows its flat image, so placing with Link doesn't ask. The two-layer threshold, +//! the starting choices, Import Slices applying to both conversions and remembering the choices +//! are VectorCraft's own. +//! +//! Fields: `mode` (`open` | `place`), `name`, `path?`, `width`, `height` (the canvas, px), +//! `layers`, `hidden` and `slices` (counts, from `document.psdInfo`), `convert` (`objects` | +//! `flatten`), `hiddenLayers` and `importSlices`. `__place` keeps the other `file.place` params. +//! The file's bytes stay in [`crate::state::UiState::dialog_file`]. OK opens or places the file +//! with `layers`, `hiddenLayers` and (for a file with slices) `importSlices`, and remembers the +//! choices for the next file; errors keep the dialog open. + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio; + +use super::DialogSpec; +use super::import_pdf::{DialogFile, other_place_params, place_source, with_file}; +use crate::state::Dialog; +use crate::{VectorcraftApp, io, widgets}; + +pub const KIND: &str = "psdImport"; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |d| if d.str("mode") == "place" { tl!("Place PSD") } else { tl!("PSD Import Options") }.into(), + body, + confirm, + min_width: 380.0, + ..DialogSpec::FORM +}; + +/// The fewest layers a file has for the dialog to ask. +const MIN_LAYERS: usize = 2; + +/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when they +/// are a PSD or PSB file with [`MIN_LAYERS`] layers or more, or with slices; false when they aren't +/// (the loader then opens or places the file, or says why it can't). +pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option, place: Option<&Value>) -> bool { + let Ok(info) = fileio::psd_info(bytes) else { return false }; + if info.layers < MIN_LAYERS && info.slices == 0 { + return false; + } + app.ui.dialog_file = Some(DialogFile::new(bytes.to_vec())); + let (layers, hidden, slices) = last_choices(app); + let mut fields = json!({ + "mode": if place.is_some() { "place" } else { "open" }, + "name": name, + "width": info.width, + "height": info.height, + "layers": info.layers, + "hidden": info.hidden, + "slices": info.slices, + "convert": if layers { "objects" } else { "flatten" }, + "hiddenLayers": hidden, + "importSlices": slices, + }); + if let Some(p) = path { + fields["path"] = json!(p); + } + if let Some(p) = place { + fields["__place"] = other_place_params(p, &["layers", "hiddenLayers", "importSlices"]); + } + app.ui.dialog = Some(Dialog::new(KIND, fields)); + true +} + +/// `file.place` with params `p` asks first (true) when they embed a PSD or PSB file (`link: false`, +/// or the file's bytes) and give no `layers`. +pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool { + let embedded = p.get("path").is_none() || p.get("link").and_then(Value::as_bool) == Some(false); + if !embedded || p.get("layers").is_some() { + return false; + } + let Some((name, bytes, path)) = place_source(app, p, |ext| matches!(ext, "psd" | "pdd" | "psb")) else { return false }; + offer(app, &name, &bytes, path, Some(p)) +} + +/// The PSD Import Options chosen last (layers, hidden layers, slices), at first Convert Layers to +/// Objects without hidden layers, with Import Slices. +fn last_choices(app: &VectorcraftApp) -> (bool, bool, bool) { + let last = |k: &str, default: bool| app.ui.psd_import.get(k).and_then(Value::as_bool).unwrap_or(default); + (last("layers", true), last("hiddenLayers", false), last("importSlices", true)) +} + +/// Several files placed without Link load the place cursor without asking: the `file.place.queue` +/// params `q` take the PSD Import Options chosen last, which apply to each PSD or PSB file among them. +pub fn queue_options(app: &VectorcraftApp, q: &mut Value) { + let (layers, hidden, slices) = last_choices(app); + q["layers"] = json!(layers); + if layers && hidden { + q["hiddenLayers"] = json!(true); + } + q["importSlices"] = json!(slices); +} + +/// `file.place` params `p` placed without asking (Show Import Options off) that embed a PSD or PSB +/// file and give no `layers` take the PSD Import Options chosen last; `hiddenLayers` and +/// `importSlices` that the params give are kept. +pub fn last_options(app: &VectorcraftApp, p: &mut Value) { + let embedded = p.get("path").is_none() || p.get("link").and_then(Value::as_bool) == Some(false); + let name = p.get("path").or_else(|| p.get("name")).and_then(Value::as_str).unwrap_or_default(); + if embedded && p.get("layers").is_none() && matches!(fileio::extension(name).as_str(), "psd" | "pdd" | "psb") { + let (layers, hidden, slices) = last_choices(app); + p["layers"] = json!(layers); + if layers && hidden && p.get("hiddenLayers").is_none() { + p["hiddenLayers"] = json!(true); + } + if p.get("importSlices").is_none() { + p["importSlices"] = json!(slices); + } + if p.get("path").is_none() { + // A file from bytes has no file to link to; it may carry the Place dialog's Link. + p["link"] = json!(false); + } + } +} + +/// OK: open or place the file with the choices. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let file = app.ui.dialog_file.clone().ok_or("the file is no longer loaded")?; + let layers = d.str("convert") != "flatten"; + let hidden = layers && d.bool("hiddenLayers"); + // Import Slices is a choice only for a file with slices; otherwise the one made last stays. + let has_slices = d.fields.get("slices").and_then(Value::as_u64).unwrap_or(0) > 0; + let slices = if has_slices { d.bool("importSlices") } else { last_choices(app).2 }; + app.ui.psd_import = json!({ "layers": layers, "hiddenLayers": d.bool("hiddenLayers"), "importSlices": slices }); + let r = if d.str("mode") == "place" { + let mut p = with_file(d.fields.get("__place"), d, &file); + p["layers"] = json!(layers); + if layers || has_slices { + // Layers and slices are always embedded; a file from bytes may carry the Place dialog's + // Link. + p["link"] = json!(false); + } + if hidden { + p["hiddenLayers"] = json!(true); + } + if has_slices { + p["importSlices"] = json!(slices); + } + crate::place::run_for_user(app, &p).map(|_| ()) + } else { + let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); + let mut p = json!({ "layers": layers, "hiddenLayers": hidden }); + if has_slices { + p["importSlices"] = json!(slices); + } + io::open_document(app, &d.str("name"), &file.bytes, path, &p).map(|_| ()) + }; + match r { + Ok(()) => { + // Placing may have opened another dialog; only this one closes. + if app.ui.dialog.as_ref().is_some_and(|x| x.kind == KIND) { + app.ui.dialog = None; + } + Ok(Value::Null) + } + Err(e) => { + app.ui.dialog = Some(d.clone()); + Err(e) + } + } +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let count = |k: &str| d.fields.get(k).and_then(Value::as_u64).unwrap_or(0); + widgets::dim_label(ui, &format!("{} × {} px · {}: {}", count("width"), count("height"), tl!("Layers"), count("layers"))); + // Offered only for a file with slices, with either conversion. + let sliced = count("slices") > 0; + ui.add_space(8.0); + let convert = d.str("convert") != "flatten"; + if widgets::radio(ui, tl!("Convert Layers to Objects"), convert, true) { + d.fields.insert("convert".into(), json!("objects")); + } + ui.horizontal(|ui| { + ui.add_space(22.0); + let on = d.bool("hiddenLayers"); + if widgets::check(ui, tl!("Import Hidden Layers"), on && convert, convert) { + d.fields.insert("hiddenLayers".into(), json!(!on)); + } + }); + if widgets::radio(ui, tl!("Flatten Layers to a Single Image"), !convert, true) { + d.fields.insert("convert".into(), json!("flatten")); + } + ui.add_space(8.0); + let on = d.bool("importSlices"); + if widgets::check(ui, tl!("Import Slices"), on && sliced, sliced) { + d.fields.insert("importSlices".into(), json!(!on)); + } + false +} + +#[cfg(test)] +mod tests { + use vectorcraft_engine::Session; + use vectorcraft_engine::doc::NodeKind; + + use super::*; + + /// A PSD file of three layers, the middle one hidden, written by `document.export`. + fn layered() -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + let back = s.doc().unwrap().doc.layers[0].id.0; + s.execute("layer.setProps", &json!({"id": back, "name": "Back"})).unwrap(); + for (name, color) in [("Back", "#ff0000"), ("Middle", "#00ff00"), ("Front", "#0000ff")] { + if name != "Back" { + s.execute("layer.new", &json!({ "name": name })).unwrap(); + } + s.execute("paint.setFill", &json!({ "color": color })).unwrap(); + s.execute("shape.rectangle", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + } + let middle = s.doc().unwrap().doc.layers[1].id.0; + s.execute("layer.setProps", &json!({"id": middle, "visible": false})).unwrap(); + let r = s.execute("document.export", &json!({"format": "psd", "hiddenLayers": true})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + } + + fn app() -> VectorcraftApp { + VectorcraftApp::new(Session::new(), Default::default()) + } + + fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v); + } + + fn layer_names(app: &VectorcraftApp) -> Vec<(String, bool)> { + app.session.doc().unwrap().doc.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.visible)).collect() + } + + #[test] + fn opening_a_layered_file_asks_for_the_import_options() { + let psd = layered(); + let mut app = app(); + io::open_bytes(&mut app, "poster.psd", &psd, None).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("mode"), d.str("convert"), d.bool("hiddenLayers")), (KIND, "open".into(), "objects".into(), false)); + assert_eq!((&d.fields["layers"], &d.fields["hidden"], &d.fields["width"]), (&json!(3), &json!(1), &json!(20))); + assert!(app.session.active().is_none(), "nothing opens before OK"); + let text = frame(&mut app); + for label in [ + "PSD Import Options", + "20 × 20 px", + "Layers: 3", + "Convert Layers to Objects", + "Import Hidden Layers", + "Flatten Layers to a Single Image", + "OK", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + // Layers as objects, hidden ones left out. + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(layer_names(&app), [("Back".to_string(), true), ("Front".to_string(), true)]); + // With Import Hidden Layers the hidden layer comes in hidden. + io::open_bytes(&mut app, "poster.psd", &psd, None).unwrap(); + set(&mut app, "hiddenLayers", json!(true)); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(layer_names(&app), [("Back".to_string(), true), ("Middle".to_string(), false), ("Front".to_string(), true)]); + // Flatten opens one image, and the next file starts from the choices made last. + io::open_bytes(&mut app, "poster.psd", &psd, None).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("hiddenLayers"), "remembered"); + set(&mut app, "convert", json!("flatten")); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.layers.len(), 1); + assert!(matches!(st.doc.layers[0].children().map(Vec::as_slice), Some([one]) if matches!(one.kind, NodeKind::Image(_)))); + io::open_bytes(&mut app, "poster.psd", &psd, None).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("convert"), "flatten"); + } + + #[test] + fn files_with_fewer_than_two_layers_open_without_asking() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + let one = s.execute("document.export", &json!({"format": "psd"})).unwrap(); + let flat = s.execute("document.export", &json!({"format": "psd", "layers": false})).unwrap(); + for r in [one, flat] { + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let mut app = app(); + io::open_bytes(&mut app, "one.psd", &bytes, None).unwrap(); + assert!(app.ui.dialog.is_none()); + assert!(app.session.active().is_some()); + } + } + + #[test] + fn placing_without_link_asks_and_places_the_layers_as_a_group() { + let b64 = vectorcraft_format::base64_encode(&layered()); + let mut app = app(); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = crate::place::run_for_user(&mut app, &json!({"name": "poster.psd", "dataBase64": b64, "at": [50, 50]})).unwrap(); + assert_eq!(r["dialog"], KIND); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("mode"), &d.fields["__place"]), ("place".into(), &json!({"at": [50, 50]}))); + assert!(frame(&mut app).contains("Place PSD")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.doc().unwrap(); + let [id] = st.selection.objects[..] else { panic!("one object is selected") }; + let g = st.doc.node(id).unwrap(); + assert!(matches!(g.kind, NodeKind::Group { .. })); + let names: Vec<&str> = g.children().unwrap().iter().filter_map(|c| c.name.as_deref()).collect(); + assert_eq!(names, ["Back", "Front"]); + // Flatten places the merged image. + crate::place::run_for_user(&mut app, &json!({"name": "poster.psd", "dataBase64": b64})).unwrap(); + set(&mut app, "convert", json!("flatten")); + super::super::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + let [id] = st.selection.objects[..] else { panic!("one object is selected") }; + assert!(matches!(st.doc.node(id).unwrap().kind, NodeKind::Image(_))); + } + + #[test] + fn several_files_placed_without_link_take_the_last_choices() { + let dir = std::env::temp_dir().join(format!("vc-psd-import-queue-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let paths: Vec = ["a.psd", "b.psd"] + .iter() + .map(|n| { + let p = dir.join(n); + std::fs::write(&p, layered()).unwrap(); + p.to_string_lossy().into_owned() + }) + .collect(); + // Place both through the Place dialog, then click the cursor once for each: what was placed. + let place_two = |app: &mut VectorcraftApp, link: bool| -> Vec { + let files: Vec = paths.iter().map(|p| json!({ "path": p })).collect(); + let d = Dialog::new(crate::dialogs::place::KIND, json!({"files": files, "link": link, "template": false})); + crate::place::confirm(app, &d).unwrap(); + assert!(app.ui.dialog.is_none(), "the cursor loads without asking"); + let view = vectorcraft_engine::ViewInfo::default(); + (0..2) + .map(|_| { + for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { + for r in app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, 10.0, 10.0), view).unwrap() { + if let vectorcraft_engine::UiRequest::Place(p) = r { + crate::place::place_from_cursor(app, &p); + } + } + } + let st = app.session.doc().unwrap(); + let [id] = st.selection.objects[..] else { panic!("one object is placed") }; + st.doc.node(id).unwrap().clone() + }) + .collect() + }; + let visible = |n: &vectorcraft_engine::doc::Node| -> Vec { n.children().unwrap().iter().map(|c| c.visible).collect() }; + let mut app = app(); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + // At first, Convert Layers to Objects without hidden layers. + for n in place_two(&mut app, false) { + assert!(matches!(n.kind, NodeKind::Group { .. })); + assert_eq!(visible(&n), [true, true]); + } + // Then the choices made last. + app.ui.psd_import = json!({"layers": true, "hiddenLayers": true}); + for n in place_two(&mut app, false) { + assert_eq!(visible(&n), [true, false, true]); + } + app.ui.psd_import = json!({"layers": false, "hiddenLayers": true}); + assert!(place_two(&mut app, false).iter().all(|n| matches!(n.kind, NodeKind::Image(_)))); + // Placed with Link, the files keep their flat images. + app.ui.psd_import = json!({"layers": true, "hiddenLayers": false}); + assert!(place_two(&mut app, true).iter().all(|n| matches!(n.kind, NodeKind::Image(_)))); + std::fs::remove_dir_all(&dir).ok(); + } + + /// `psd` with a slices resource (1050, version 6) holding one user slice over its top-left + /// 10 × 10 px, named "cell". + fn sliced(psd: &[u8]) -> Vec { + let ustr = |s: &str| -> Vec { + let units: Vec = 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 + }; + let mut data = 6u32.to_be_bytes().to_vec(); + data.extend([0u8; 16]); + data.extend(ustr("")); + data.extend(1u32.to_be_bytes()); + // ID, group ID, origin (made by the user), name, type (Image), left, top, right, bottom. + data.extend([1u32, 0, 2].iter().flat_map(|v| v.to_be_bytes())); + data.extend(ustr("cell")); + data.extend([1u32, 0, 0, 10, 10].iter().flat_map(|v| v.to_be_bytes())); + // URL, target, message, alt text, cell text is HTML, cell text, alignments, color. + for _ in 0..4 { + data.extend(ustr("")); + } + data.push(0); + data.extend(ustr("")); + data.extend([0u8; 12]); + 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(1050u16.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 slice_names(app: &VectorcraftApp) -> Vec { + app.session.doc().unwrap().doc.slices.iter().map(|s| s.options.name.clone()).collect() + } + + #[test] + fn a_file_with_slices_asks_with_import_slices_on_either_conversion() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap(); + let r = s.execute("document.export", &json!({"format": "psd"})).unwrap(); + // One layer: without slices it opens at once, with them it asks. + let psd = sliced(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let mut app = app(); + io::open_bytes(&mut app, "one.psd", &psd, None).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), &d.fields["slices"], d.bool("importSlices")), (KIND, &json!(1), true)); + assert!(frame(&mut app).contains("Import Slices")); + // Flatten keeps Import Slices: the merged image opens with the slice. + set(&mut app, "convert", json!("flatten")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(slice_names(&app), ["cell"]); + // Left off, the slice stays out, and the next file starts from that choice. + io::open_bytes(&mut app, "one.psd", &psd, None).unwrap(); + set(&mut app, "importSlices", json!(false)); + super::super::confirm(&mut app).unwrap(); + assert!(slice_names(&app).is_empty()); + io::open_bytes(&mut app, "one.psd", &psd, None).unwrap(); + assert!(!app.ui.dialog.as_ref().unwrap().bool("importSlices"), "remembered"); + // A layered file without slices keeps the choice made last for the next sliced one. + app.ui.dialog = None; + io::open_bytes(&mut app, "poster.psd", &layered(), None).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["slices"], json!(0)); + set(&mut app, "importSlices", json!(true)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.ui.psd_import["importSlices"], json!(false)); + } + + #[test] + fn placing_a_sliced_file_without_link_brings_its_slices_with_the_art() { + let b64 = vectorcraft_format::base64_encode(&sliced(&layered())); + let mut app = app(); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + crate::place::run_for_user(&mut app, &json!({"name": "poster.psd", "dataBase64": b64, "at": [50, 50]})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("importSlices")); + super::super::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + assert_eq!(st.doc.slices.len(), 1); + assert_eq!(st.doc.slices[0].rect, vectorcraft_geom::Rect::new(40.0, 40.0, 50.0, 50.0)); + // Several files placed together take the choice made last. + let mut q = json!({}); + app.ui.psd_import = json!({"layers": true, "importSlices": false}); + queue_options(&app, &mut q); + assert_eq!(q["importSlices"], json!(false)); + } + + #[test] + fn placing_with_show_import_options_off_takes_the_last_choices() { + let b64 = vectorcraft_format::base64_encode(&sliced(&layered())); + let mut app = app(); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + // The file's bytes carry the Place dialog's Link, which they have no file for. + let place = |app: &mut VectorcraftApp, slices: Option| { + let mut p = json!({"name": "poster.psd", "dataBase64": b64, "link": true, "showImportOptions": false}); + if let Some(s) = slices { + p["importSlices"] = json!(s); + } + crate::place::run_for_user(app, &p).unwrap(); + assert!(app.ui.dialog.is_none(), "placed without asking"); + let st = app.session.doc().unwrap(); + let [id] = st.selection.objects[..] else { panic!("one object is selected") }; + (st.doc.node(id).unwrap().kind.clone(), st.doc.slices.len()) + }; + // At first, Convert Layers to Objects with Import Slices. + let (kind, slices) = place(&mut app, None); + assert!(matches!(kind, NodeKind::Group { .. })); + assert_eq!(slices, 1); + // An `importSlices` the params give is kept. + let (kind, slices) = place(&mut app, Some(false)); + assert!(matches!(kind, NodeKind::Group { .. })); + assert_eq!(slices, 1, "no new slice"); + // Then the choices made last. + app.ui.psd_import = json!({"layers": false, "importSlices": false}); + let (kind, slices) = place(&mut app, None); + assert!(matches!(kind, NodeKind::Image(_))); + assert_eq!(slices, 1, "no new slice"); + let (_, slices) = place(&mut app, Some(true)); + assert_eq!(slices, 2, "the given importSlices brings the slice in"); + } + + #[test] + fn files_from_bytes_placed_with_link_take_the_last_choices() { + let files: Vec = ["a.psd", "b.psd"].iter().map(|n| json!({ "name": n })).collect(); + let mut app = app(); + for n in ["a.psd", "b.psd"] { + app.place.picked.push((n.to_string(), sliced(&layered()))); + } + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + // The dialog's Link is on, but files from bytes have no file to link to. + let d = Dialog::new(crate::dialogs::place::KIND, json!({"files": files, "link": true, "template": false, "showImportOptions": false})); + crate::place::confirm(&mut app, &d).unwrap(); + assert!(app.ui.dialog.is_none(), "the cursor loads without asking"); + let view = vectorcraft_engine::ViewInfo::default(); + for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { + for r in app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, 10.0, 10.0), view).unwrap() { + if let vectorcraft_engine::UiRequest::Place(p) = r { + crate::place::place_from_cursor(&mut app, &p); + } + } + } + let st = app.session.doc().unwrap(); + let [id] = st.selection.objects[..] else { panic!("one object is placed") }; + // At first, Convert Layers to Objects with Import Slices. + assert!(matches!(st.doc.node(id).unwrap().kind, NodeKind::Group { .. })); + assert_eq!(st.doc.slices.len(), 1); + } + + #[test] + fn a_cursor_loaded_with_show_import_options_on_asks_for_each_psd_file() { + let dir = std::env::temp_dir().join(format!("vc-psd-import-asks-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let paths: Vec = ["a.psd", "b.psd"] + .iter() + .map(|n| { + let p = dir.join(n); + std::fs::write(&p, layered()).unwrap(); + p.to_string_lossy().into_owned() + }) + .collect(); + let files: Vec = paths.iter().map(|p| json!({ "path": p })).collect(); + let services = crate::Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + // The choices made last would place the layers without asking with Show Import Options off. + app.ui.psd_import = json!({"layers": true, "importSlices": true}); + let d = Dialog::new(crate::dialogs::place::KIND, json!({"files": files, "link": false, "template": false, "showImportOptions": true})); + crate::place::confirm(&mut app, &d).unwrap(); + assert!(app.ui.dialog.is_none(), "the cursor loads without asking"); + assert_eq!(app.session.tool_options()["count"], 2); + // A click on the canvas: the file the cursor's request names asks. + let view = vectorcraft_engine::ViewInfo::default(); + for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] { + for r in app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, 10.0, 10.0), view).unwrap() { + if let vectorcraft_engine::UiRequest::Place(p) = r { + crate::place::place_from_cursor(&mut app, &p); + } + } + } + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND), "the clicked file asks"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn scripted_places_and_places_that_give_layers_dont_ask() { + let b64 = vectorcraft_format::base64_encode(&layered()); + let mut app = app(); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + // `file.place` run as a command places at once, as before. + let r = crate::place::run(&mut app, &json!({"name": "poster.psd", "dataBase64": b64})).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(r["format"], "psd"); + // The user's place with `layers` given doesn't ask either. + crate::place::run_for_user(&mut app, &json!({"name": "poster.psd", "dataBase64": b64, "layers": false})).unwrap(); + assert!(app.ui.dialog.is_none()); + } +} diff --git a/crates/ui-egui/src/dialogs/tests_modal.rs b/crates/ui-egui/src/dialogs/tests_modal.rs index c202094102..06eef4d2c6 100644 --- a/crates/ui-egui/src/dialogs/tests_modal.rs +++ b/crates/ui-egui/src/dialogs/tests_modal.rs @@ -532,7 +532,7 @@ fn simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path() let anchors: Vec<_> = (0..=40).map(|i| json!({"x": 20 + i * 8, "y": 150.0 + 60.0 * (f64::from(i) / 6.0).sin()})).collect(); let id = app.run("path.create", json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap(); let path = |app: &VectorcraftApp| { - app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).and_then(|n| n.path_data()).expect("a path").subpaths[0].anchors.clone() + app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).and_then(|n| n.path_data()).expect("a path").subpaths[0].anchors.to_vec() }; let original = path(&app); let run: Vec<_> = (10..=30).map(|i| json!([0, i])).collect(); diff --git a/crates/ui-egui/src/dialogs/three_d_classic.rs b/crates/ui-egui/src/dialogs/three_d_classic.rs index a3857c914a..22c18df97e 100644 --- a/crates/ui-egui/src/dialogs/three_d_classic.rs +++ b/crates/ui-egui/src/dialogs/three_d_classic.rs @@ -1,7 +1,7 @@ //! The own controls of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) in the //! compact 3D dialog: the extrude and bevel options, or the revolve options, on the Object tab -//! (Rotate (Classic) has none), and the surface with its lights, highlight, blend steps and shading -//! color on the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D +//! (Rotate (Classic) has none), and the surface with its lights, highlight, blend steps, shading +//! color and Preserve Spot Colors on the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D //! effect shares. use serde_json::{Value, json}; @@ -41,8 +41,8 @@ pub(super) fn revolve_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { } /// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic -/// only, and not for Rotate (Classic), which has no Plastic surface), Blend Steps and Shading Color; -/// Draw Hidden Faces last, for Extrude & Bevel (Classic) (Revolve (Classic) draws every face, and +/// only, and not for Rotate (Classic), which has no Plastic surface), Blend Steps, Shading Color and +/// Preserve Spot Colors (unavailable while Shading Color is Custom); Draw Hidden Faces last, for Extrude & Bevel (Classic) (Revolve (Classic) draws every face, and /// Rotate (Classic)'s plane is seen from either side). pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { let id = d.str("__effect"); @@ -69,9 +69,11 @@ pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { } number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None); choice(ui, d, "shadingColor", tl!("Shading Color"), &options_of(effect, "shadingColor")); - if d.str("shadingColor") == "custom" { + let custom = d.str("shadingColor") == "custom"; + if custom { shading_custom(ui, d); } + ui.add_enabled_ui(!custom, |ui| toggle(ui, d, "preserveSpotColors", tl!("Preserve Spot Colors"), false)); }); if id == CLASSIC_EXTRUDE { toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index a18fc8597c..dad6e3f43b 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -1,5 +1,7 @@ -//! The Variables dialogs (`variable.define`, `variable.bind`, `dataset.new`, `dataset.set` -//! and `dataset.select`), opened from Window › Variables or the command palette. +//! The Variables dialogs (`variable.define`, `variable.rename` as Variable Options, `variable.bind`, +//! `dataset.new`, `dataset.set`, `dataset.rename` and `dataset.select`), opened from Window › +//! Variables or the command palette. Variable Options shows the variable's name and kind, and OK +//! changes either or both. //! //! One dialog covers all of them: what it shows is what the command takes. A variable is a //! name and a kind; a dataset is a name and one field per variable — a text field for a @@ -24,6 +26,10 @@ pub const KIND: &str = "variables"; /// renames. const EDITED: &str = "__dataset"; +/// The field of Variable Options that names the variable its Name and Kind fields belong to. +/// When the Variable field names another one, the next frame fills them from that one. +const FILLED: &str = "__filled"; + /// The kinds a variable can have, as `variable.define` names them. const KINDS: [&str; 4] = ["text", "visibility", "image", "graph"]; @@ -50,7 +56,7 @@ fn opened(id: &str) -> Option<&'static str> { } /// The row `id` renames, which the dialog offers as a dropdown when the document has more than -/// one. Variable Options renames the one the panel has highlighted, so it starts there. +/// one. Variable Options edits the one the panel has highlighted, so it starts there. fn rename_of(id: &str) -> Option<&'static str> { Some(match id { "variable.rename" => "variable", @@ -116,8 +122,14 @@ pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() }); return true; } - fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default())); - fields.insert("newName".into(), json!("")); + let row = preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default(); + fields.insert(key.into(), json!(row)); + if key == "variable" { + // Variable Options starts on the variable's own name and kind. + fill_variable_options(&vars, row, &mut fields); + } else { + fields.insert("newName".into(), json!("")); + } } } fields.insert("__command".into(), json!(id)); @@ -126,6 +138,16 @@ pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool true } +/// Variable Options' Name and Kind fields for the variable `name`: its name, and its kind as +/// `variable.rename` takes it (empty for a variable of unknown kind, which OK leaves as it is). +/// [`FILLED`] records which variable they were filled for. +fn fill_variable_options(vars: &Variables, name: &str, fields: &mut serde_json::Map) { + let kind = vars.variable(name).map(|v| v.kind).filter(|k| *k != VariableKind::Unknown).map(VariableKind::label).unwrap_or_default(); + fields.insert("newName".into(), json!(name)); + fields.insert("kind".into(), json!(kind)); + fields.insert(FILLED.into(), json!(name)); +} + /// Whether `id` opens one of these dialogs. pub fn opens(id: &str) -> bool { opened(id).is_some() @@ -181,13 +203,25 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { text(ui, d, "name", 220.0); ui.end_row(); label(ui, tl!("Kind")); - // The kinds by their translated names; the field keeps the command's own word. - let labels = kind_labels(); - let shown = KINDS.iter().position(|k| *k == d.str("kind")).and_then(|i| labels.get(i)).copied().unwrap_or_default(); - if let Some(kind) = widgets::dropdown_names(ui, ("variables-dd", "kind"), shown, &labels, 220.0).and_then(|i| KINDS.get(i)) { - d.fields.insert("kind".into(), json!(kind)); + kind_dropdown(ui, d); + ui.end_row(); + } + // Variable Options: which variable, and its name and kind; picking another variable + // starts its fields over from that one. + "variable.rename" => { + label(ui, tl!("Variable")); + dropdown(ui, d, "variable", &variable_names, 220.0); + let picked = d.str("variable"); + if picked != d.str(FILLED) { + fill_variable_options(vars, &picked, &mut d.fields); } ui.end_row(); + label(ui, tl!("Name")); + text(ui, d, "newName", 220.0); + ui.end_row(); + label(ui, tl!("Kind")); + kind_dropdown(ui, d); + ui.end_row(); } "variable.bind" => { label(ui, tl!("Variable")); @@ -199,15 +233,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { dropdown(ui, d, "name", &dataset_names, 220.0); ui.end_row(); } - // A rename: which row, and its new name. - "variable.rename" | "dataset.rename" => { - let (key, label_text, names) = if d.str("__command") == "variable.rename" { - ("variable", tl!("Variable").to_string(), &variable_names) - } else { - ("name", tl!("Data Set").to_string(), &dataset_names) - }; - label(ui, &label_text); - dropdown(ui, d, key, names, 220.0); + // Rename Data Set: which row, and its new name. + "dataset.rename" => { + label(ui, tl!("Data Set")); + dropdown(ui, d, "name", &dataset_names, 220.0); ui.end_row(); label(ui, tl!("New Name")); text(ui, d, "newName", 220.0); @@ -245,6 +274,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// The Kind dropdown: the kinds by their translated names, while the `kind` field keeps the +/// command's own word. An empty field (a variable of unknown kind) shows no kind. +fn kind_dropdown(ui: &mut egui::Ui, d: &mut Dialog) { + let labels = kind_labels(); + let shown = KINDS.iter().position(|k| *k == d.str("kind")).and_then(|i| labels.get(i)).copied().unwrap_or_default(); + if let Some(kind) = widgets::dropdown_names(ui, ("variables-dd", "kind"), shown, &labels, 220.0).and_then(|i| KINDS.get(i)) { + d.fields.insert("kind".into(), json!(kind)); + } +} + /// A text field for `key`, written back as it is typed. fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response { let mut s = d.str(key); @@ -263,8 +302,26 @@ fn set_text(d: &mut Dialog, key: &str, s: String) { /// Records that `field` was typed in or set by `ui.dialog.set`: it no longer holds the row's /// empty text value, so emptying it unsets the variable. +/// +/// In Variable Options, setting the Variable field clears Name and Kind, which OK reads as the +/// variable's own name and kind, until a frame fills them from it; setting Name or Kind ties them +/// to the variable already named, so no frame fills them over. pub(super) fn note_set(d: &mut Dialog, field: &str) { d.fields.remove(&empty_marker(field)); + if d.str("__command") == "variable.rename" { + match field { + // Null rather than removed, so `ui.dialog.set` can still set them. + "variable" => { + d.fields.insert("newName".into(), Value::Null); + d.fields.insert("kind".into(), Value::Null); + } + "newName" | "kind" => { + let row = d.str("variable"); + d.fields.insert(FILLED.into(), json!(row)); + } + _ => {} + } + } } /// A checkbox for `key`, written back as it is toggled, which screen readers call `name`. @@ -329,7 +386,18 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let p = match cmd.as_str() { "variable.define" => json!({"name": name, "kind": d.str("kind")}), "variable.bind" => json!({"variable": d.str("variable")}), - "variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}), + // A Name the Variable field just cleared keeps the variable's name (an emptied one is sent, + // and refused), and a cleared or empty Kind (a variable of unknown kind) keeps its kind. + "variable.rename" => { + let variable = d.str("variable"); + let new_name = if matches!(d.fields.get("newName"), Some(Value::Null) | None) { variable.clone() } else { d.str("newName") }; + // Any other kind value goes to the command, which refuses one it doesn't know. + match d.fields.get("kind") { + None | Some(Value::Null) => json!({"name": variable, "newName": new_name}), + Some(Value::String(k)) if k.is_empty() => json!({"name": variable, "newName": new_name}), + Some(k) => json!({"name": variable, "newName": new_name, "kind": k}), + } + } "dataset.rename" => json!({"name": name, "newName": d.str("newName")}), "dataset.new" | "dataset.set" => { let values = app.session.active().map(|st| values_of(d, &st.doc.variables)).unwrap_or_else(|| json!({})); @@ -550,4 +618,55 @@ mod tests { open(&mut app, "dataset.rename", Some("Row 1")); assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Row 1"); } + + #[test] + fn variable_options_shows_the_name_and_kind_and_changes_either() { + let mut app = app_with_variables(); + let kind_of = |app: &VectorcraftApp, name: &str| app.session.active().and_then(|st| st.doc.variables.variable(name).map(|v| v.kind)); + open(&mut app, "variable.rename", Some("Badge")); + { + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("newName"), d.str("kind")), ("Badge".to_string(), "visibility".to_string())); + } + app.ui.dialog.as_mut().unwrap().fields.insert("kind".into(), json!("text")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(kind_of(&app, "Badge"), Some(VariableKind::Text)); + + // OK with nothing changed adds no undo step, and an emptied Name is refused. + let undo = app.session.active().unwrap().history.undo.len(); + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "newName".into(), json!("")); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(kind_of(&app, "Title").is_some(), "Title keeps its name"); + + // Setting the Variable field keeps that variable's own name and kind until Name or Kind is + // set, and a frame fills them from it. + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "variable".into(), json!("Badge")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((kind_of(&app, "Title"), kind_of(&app, "Badge")), (Some(VariableKind::Text), Some(VariableKind::Text))); + open(&mut app, "variable.rename", Some("Title")); + { + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::set_field(d, "variable".into(), json!("Badge")); + assert!(crate::dialogs::accepts_field(d, "newName") && crate::dialogs::accepts_field(d, "kind"), "Name and Kind stay settable"); + crate::dialogs::set_field(d, "newName".into(), json!("Flag")); + crate::dialogs::set_field(d, "kind".into(), json!("visibility")); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!((kind_of(&app, "Flag"), kind_of(&app, "Title")), (Some(VariableKind::Visibility), Some(VariableKind::Text))); + + // A frame after the Variable field changes fills Name and Kind from that variable. + open(&mut app, "variable.rename", Some("Title")); + crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "variable".into(), json!("Flag")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("newName"), d.str("kind")), ("Flag".to_string(), "visibility".to_string())); + } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 377df81a72..8a59c7e567 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -682,14 +682,22 @@ const MESSAGE_DEPTH: usize = 5; /// Translate a status or error message for display (see the module docs). Unknown messages, and /// parts of them (file names, numbers, a reason with no entry, a JSON object or array such as /// `{"objects": [...]}`, a value the message quotes in `"…"`, `“…”` or backticks, such as a -/// symbol's name), stay as they are. A part that starts with `{` or `[` is translated only -/// when it is a whole message with an exact entry, such as `[x, y] (two numbers)`. +/// symbol's name), stay as they are. A part that starts with `{` is translated only when it is a +/// whole message with an exact entry. A part that starts with `[` is translated when it has an +/// exact entry, such as `[x, y] (two numbers)`, or when it fits a template that starts with `[` +/// and an index of digits, such as an effect's refusal ``[0] `id`: {why}``. pub fn message(lang: Lang, s: &str) -> String { message_at(lang, s, MESSAGE_DEPTH) } fn message_at(lang: Lang, s: &str, depth: usize) -> String { - let Some((forms, caps)) = lang.catalog().message_forms(s) else { return s.to_string() }; + fill_message(lang, s, lang.catalog().message_forms(s), depth) +} + +/// `s` shown through the catalog entry `found` matched for it, its placeholder values translated +/// in turn while `depth` lasts. +fn fill_message(lang: Lang, s: &str, found: Option<(&[String], catalog::Captures<'_, '_>)>, depth: usize) -> String { + let Some((forms, caps)) = found else { return s.to_string() }; let count = caps.iter().find(|(k, _)| *k == "n").and_then(|(_, v)| v.parse::().ok()); let form = count.map_or(forms.len().saturating_sub(1), |n| (lang.0.plural)(n).min(forms.len().saturating_sub(1))); let Some(template) = forms.get(form) else { return s.to_string() }; @@ -697,8 +705,10 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { let nested = |v: &str| { if depth <= 1 || quoted(v) { v.to_string() - } else if v.starts_with(['{', '[']) { + } else if v.starts_with('{') { exact_translation(lang.catalog(), v).unwrap_or_else(|| v.to_string()) + } else if v.starts_with('[') { + fill_message(lang, v, lang.catalog().indexed_message_forms(v), depth - 1) } else { message_at(lang, v, depth - 1) } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 7812216da8..2c14eb4e39 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -146,6 +146,7 @@ Align to Artboard Подравняване спрямо работната област Align to Glyph Bounds Подравняване спрямо границите на глифа Align to Key Object Подравняване спрямо ключов обект + Align to Key Anchor Подравняване спрямо ключова опорна точка Align to Page Подравняване спрямо страницата Align to Path Подравняване спрямо пътя Align to Selection Подравняване спрямо селекцията @@ -1053,7 +1054,10 @@ font-technology Variable Променливи Every (minutes) На всеки (минути) Every option is at its default. Всички опции са в състояние по подразбиране. Every option matches {base}. Всички опции съответстват на {base}. - Every-line Composer Композитор за всеки ред + Middle Eastern & South Asian Single-line Composer Ред по ред композитор (Близък изток и Южна Азия) + Middle Eastern & South Asian Every-line Composer Композитор за всеки ред (Близък изток и Южна Азия) + Standard Single-line Composer Стандартен ред по ред композитор + Standard Every-line Composer Стандартен композитор за всеки ред Exact dash lengths Точни дължини на тиретата Exceptions (comma separated) Изключения (разделени със запетаи) Exclude Изключване @@ -1347,6 +1351,14 @@ font-technology Variable Променливи Previous Artboard Предишна работна област Next Artboard Следваща работна област Last Artboard Последна работна област + Status Bar Show Показване в лентата на състоянието + Artboard Name Име на работната област + Current Tool Текущ инструмент + Date and Time Дата и час + Number of Undos Брой отмени + Document Color Profile Цветови профил на документа + Undos Отмени + Redos Повторения Go To Link Към връзката GPL palettes keep solid colours only, as RGB. Палитрите GPL запазват само плътни цветове като RGB. GPU Performance Производителност на графичния процесор @@ -3359,7 +3371,6 @@ digits Malayalam Малаялам Single Left Quotation Mark Лява единична кавичка Single Quotes: Единични кавички: Single Right Quotation Mark Дясна единична кавичка - Single-line Composer Ред по ред композитор Toggle Line Composer Превключване на композитора на редове Sixth Space Шесто разстояние Size Размер @@ -3654,6 +3665,8 @@ digits Malayalam Малаялам Kana Кана Numerals Цифри Scale at Center Мащабиране от центъра + Delete Set Изтриване на набор + Direct Entry Директно въвеждане Up/Down: Нагоре/надолу: Left/Right: Наляво/надясно: Tate-chu-yoko Up/Down Тате-чу-йоко нагоре/надолу @@ -5050,6 +5063,11 @@ axis Both И двете @msg Imported library {name} Импортирана библиотека {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Правило за RGB: + CMYK Policy: Правило за CMYK: + Preserve Embedded Profiles Запазване на вградените профили + Convert to Working Space Преобразуване в работното пространство + Preserve Numbers Запазване на числата Texture Текстура Craquelure… Кракелюр… Grain… Зърнистост… @@ -6977,3 +6995,4 @@ breakText Glyphs Глифи Nest Shapes… Подреждане на форми… Nest Shapes Подреждане на форми Roll Ролка +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer трябва да е "singleLine", "everyLine", "standardSingleLine" или "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 2fb70385ff..9b98280028 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -986,6 +986,11 @@ NTSC Colors NTSC boje RGB: RGB: CMYK: CMYK: + RGB Policy: RGB pravilo: + CMYK Policy: CMYK pravilo: + Preserve Embedded Profiles Zadrži ugrađene profile + Convert to Working Space Pretvori u radni prostor + Preserve Numbers Zadrži brojeve CMYK CMYK Texture Tekstura Craquelure… Krakelura… @@ -1080,6 +1085,14 @@ lighting Light Svjetlo Previous Artboard Prethodna radna ploča Next Artboard Sljedeća radna ploča Last Artboard Posljednja radna ploča + Status Bar Show Prikaz u traci stanja + Artboard Name Naziv radne ploče + Current Tool Trenutni alat + Date and Time Datum i vrijeme + Number of Undos Broj poništavanja + Document Color Profile Profil boja dokumenta + Undos Poništavanja + Redos Ponavljanja Graphic Style Options… Opcije grafičkog stila… Symbol Options Opcije simbola Symbol Options… Opcije simbola… @@ -1492,6 +1505,7 @@ lighting Light Svjetlo Align to Artboard Poravnaj s radnom pločom Align to Glyph Bounds Poravnaj s granicama glifa Align to Key Object Poravnaj s ključnim objektom + Align to Key Anchor Poravnaj s ključnom sidrenom tačkom Align to Selection Poravnaj prema odabiru Align To: Poravnaj s: Cancel Key Object Otkaži ključni objekat @@ -1641,6 +1655,8 @@ digits Malayalam Malajalam Kana Kana Numerals Brojevi Scale at Center Skaliraj od centra + Delete Set Izbriši skup + Direct Entry Direktan unos Up/Down: Gore/dolje: Left/Right: Lijevo/desno: Tate-chu-yoko Up/Down Tate-chu-yoko gore/dolje @@ -2020,7 +2036,10 @@ digits Malayalam Malajalam Tabular Lining Tabelarne brojke Tabular Oldstyle Tabelarne stare brojke Titling Alternates Alternative za naslove - Every-line Composer Slagač svih redova + Middle Eastern & South Asian Single-line Composer Slagač jednog reda (Bliski istok i Južna Azija) + Middle Eastern & South Asian Every-line Composer Slagač svih redova (Bliski istok i Južna Azija) + Standard Single-line Composer Standardni slagač jednog reda + Standard Every-line Composer Standardni slagač svih redova First-line Left Indent Lijeva uvlaka prvog reda Hyphenation… Rastavljanje riječi… Words Longer Than: Riječi duže od: @@ -2060,7 +2079,6 @@ digits Malayalam Malajalam East Asian Composition Settings Postavke istočnoazijske kompozicije Korean Composition Settings Postavke korejske kompozicije Select a text object to edit its paragraph attributes. Odaberite tekstualni objekat da uredite njegove atribute pasusa. - Single-line Composer Slagač jednog reda Toggle Line Composer Uključi/isključi Line Composer Space After Paragraph Razmak nakon pasusa Space Before Paragraph Razmak prije pasusa @@ -7030,3 +7048,4 @@ shape Open Otvoren Nest Shapes… Slaganje oblika… Nest Shapes Slaganje oblika Roll Rolna +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer mora biti "singleLine", "everyLine", "standardSingleLine" ili "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index a6d12d4f80..f98daa7f92 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -242,6 +242,7 @@ Align to Artboard Alinea al tauler Align to Glyph Bounds Alinea als límits del glif Align to Key Object Alinea a l’objecte clau + Align to Key Anchor Alinea al punt d’ancoratge clau Align to Page Alinea a la pàgina Align to Path Alinea al traçat Align to Selection Alinea a la selecció @@ -764,6 +765,11 @@ antiAlias Sharp Nítid CMYK Equivalents Equivalents CMYK CMYK profile in effect Perfil CMYK en vigor CMYK: CMYK: + RGB Policy: Política RGB: + CMYK Policy: Política CMYK: + Preserve Embedded Profiles Conserva els perfils incrustats + Convert to Working Space Converteix a l'espai de treball + Preserve Numbers Conserva els números Coarse Dots Punts gruixuts Collapse Panel Group Replega el grup de panells Collapse Panels to Icons Replega els panells a icones @@ -1531,7 +1537,10 @@ antiAlias Sharp Nítid Every (minutes) Cada (minuts) Every option is at its default. Totes les opcions són al valor predeterminat. Every option matches {base}. Totes les opcions coincideixen amb {base}. - Every-line Composer Composició línia per línia + Middle Eastern & South Asian Single-line Composer Compositor d’una línia (Orient Pròxim i Àsia Meridional) + Middle Eastern & South Asian Every-line Composer Composició línia per línia (Orient Pròxim i Àsia Meridional) + Standard Single-line Composer Compositor estàndard d’una línia + Standard Every-line Composer Composició estàndard línia per línia Exact Exacte Exact dash lengths Longituds de guió exactes Example: {name} Exemple: {name} @@ -2319,6 +2328,14 @@ digits Malayalam Malaiàlam Enclosing Lasso Llaç envoltant Last Últim Last Artboard Últim tauler + Status Bar Show Mostra a la barra d'estat + Artboard Name Nom del tauler + Current Tool Eina actual + Date and Time Data i hora + Number of Undos Nombre de desfets + Document Color Profile Perfil de color del document + Undos Desfets + Redos Refets Last commit Últim commit Last Effect: {name} Últim efecte: {name} Last Effect: {} Últim efecte: {} @@ -2477,6 +2494,8 @@ digits Malayalam Malaiàlam Save Variable Library… Desa la biblioteca de variables… @msg a variable library holds text variables, and the document has none una biblioteca de variables conté variables de text, i el document no en té cap @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` la variable `{name}` no es pot desar en una biblioteca de variables: canvieu-li el nom perquè comenci amb una lletra o `_`, sense espais ni símbols que no siguin `-`, `_` i `.` +@msg {k} has control characters {k} conté caràcters de control +@msg {k} has control characters or line breaks {k} conté caràcters de control o salts de línia @msg data set `{name}` holds a control character, which a variable library can't hold el conjunt de dades `{name}` conté un caràcter de control, que una biblioteca de variables no pot contenir @msg data set `{name}` starts or ends with white space, which a variable library can't hold el conjunt de dades `{name}` comença o acaba amb un espai en blanc, que una biblioteca de variables no pot contenir Loaded Texture Textura carregada @@ -4387,7 +4406,6 @@ digits Malayalam Malaiàlam Single Quotes: Cometes simples: Single Right Quotation Mark Cometa simple dreta Single Word Justification: Justificació d’una sola paraula: - Single-line Composer Compositor d’una línia Sixth Space Espai de sisena part Size Mida Size Tile to Art Ajusta la mida del mosaic a l’art @@ -4772,6 +4790,8 @@ digits Malayalam Malaiàlam Kana Kana Numerals Xifres Scale at Center Escala des del centre + Delete Set Suprimeix el joc + Direct Entry Entrada directa Up/Down: Amunt/avall: Left/Right: Esquerra/dreta: Tate-chu-yoko Up/Down Tate-chu-yoko amunt/avall @@ -5699,6 +5719,17 @@ digits Malayalam Malaiàlam @msg `name` must be the name of a script engine `name` ha de ser el nom d'un motor d'scripts @msg the main script engine is new for each run and is never kept, so it cannot be dropped el motor d'scripts principal és nou a cada execució i no es conserva mai, per tant no es pot descartar @msg there is no script engine named {name} no hi ha cap motor d'scripts anomenat {name} +@msg `answers` must be a list of answers `answers` ha de ser una llista de respostes +@msg `answers` has more than {MAX_ANSWERS} answers `answers` té més de {MAX_ANSWERS} respostes +@msg `answers` is larger than 1 MiB `answers` ocupa més d'1 MiB +@msg must be an object with `kind` and `value` ha de ser un objecte amb `kind` i `value` +@msg must have exactly `kind` and `value` ha de tenir exactament `kind` i `value` +@msg is an alert's answer, whose `value` must be "ok" és la resposta d'un alert, i el seu `value` ha de ser "ok" +@msg is a confirm's answer, whose `value` must be true or false és la resposta d'un confirm, i el seu `value` ha de ser true o false +@msg is longer than 16 KiB ocupa més de 16 KiB +@msg is a prompt's answer, whose `value` must be a string or null és la resposta d'un prompt, i el seu `value` ha de ser una cadena o null +@msg must have `kind` alert, confirm or prompt ha de tenir `kind` alert, confirm o prompt +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` només és per a una execució sense ningú a qui preguntar: a l'aplicació, la persona respon cada quadre de diàleg @msg Running a script S'està executant un script @msg Copied the script report S’ha copiat l’informe de l’script @msg Copied the link info S’ha copiat la informació de l’enllaç @@ -6660,6 +6691,7 @@ digits Malayalam Malaiàlam @msg {what} points outside memory {what} apunta fora de la memòria @msg `angle` must be a number `angle` ha de ser un nombre @msg `aspect` must be from 0.01 to 100 `aspect` ha d’estar entre 0.01 i 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` ha de ser [a, b, c, d], cadascun entre -1000 i 1000, amb a·d - b·c com a mínim a 0.001 de 0 @msg `cap` must be "butt", "round" or "square" `cap` ha de ser "butt", "round" o "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` ha de ser "nonzero" o "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` ha de ser una adreça https:// de {MAX_HELP_URL} bytes com a màxim @@ -6712,6 +6744,7 @@ digits Malayalam Malaiàlam @msg stops must be in order of `offset` les parades han d’estar en ordre d’`offset` @msg the document has no pattern {pattern} el document no té el motiu {pattern} @msg the document has no swatch {n} el document no té la mostra {n} +@msg `plates` requires a stop linked to a spot swatch `plates` requereix una parada vinculada a una mostra de color directe @msg the output la sortida @msg the path has no anchor points el traçat no té punts d’ancoratge @msg too many anchor points (limit {MAX_ANCHORS}) massa punts d’ancoratge (límit {MAX_ANCHORS}) @@ -7737,3 +7770,4 @@ breakText Glyphs Glifs @msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} píxels és massa gran per a JPEG (com a màxim 65535 píxels per costat): reduïu la resolució @msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} píxels és massa gran per a Targa (com a màxim 65535 píxels per costat): reduïu la resolució @msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} píxels és massa gran per a PSD (com a màxim {MAX_SIDE} píxels per costat): reduïu la resolució +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer ha de ser "singleLine", "everyLine", "standardSingleLine" o "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index f2fab1fcfa..cbb2c5b702 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -219,8 +219,13 @@ impl Catalog { } // Templates that start with text before those that start with a placeholder, so // `plug-in manifest: {0}` wins over `{_1} is {_2} bytes (limit {_3})` for a message it - // wraps; then most literal text first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`. - c.templates.sort_by_key(|t| (matches!(t.pieces.first(), Some(Piece::Hole(_))), std::cmp::Reverse(t.literal))); + // wraps; then the longest text before the first placeholder, so `` `answers[{i}]` {e} `` wins + // over `` `{key}` must be true or false `` for a message it wraps; then most literal text + // first, so `Couldn't open {name}: {e}` wins over `{path}: {e}`. + c.templates.sort_by_key(|t| { + let lead = if let Some(Piece::Text(text)) = t.pieces.first() { text.len() } else { 0 }; + (matches!(t.pieces.first(), Some(Piece::Hole(_))), std::cmp::Reverse(lead), std::cmp::Reverse(t.literal)) + }); c } @@ -257,6 +262,28 @@ impl Catalog { self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.forms.as_slice(), caps))) } + /// [`Catalog::message_forms`] for a part of a message that starts with `[`, which may be a JSON + /// array: an exact `@msg` entry, else only a template that starts with a literal `[` and a + /// placeholder whose value is an index of ASCII digits, such as ``[{j}] `{_1}`: {why}``. An + /// array such as ``["] `x`: b"]`` fits that template's text, but not its index. + pub fn indexed_message_forms<'a>(&self, s: &'a str) -> Option<(&[String], Captures<'_, 'a>)> { + if let Some(tr) = self.messages.get(s) { + return Some((std::slice::from_ref(tr), Vec::new())); + } + if s.len() > MAX_MESSAGE_LEN { + return None; + } + self.templates.iter().find_map(|t| { + let [Piece::Text(open), Piece::Hole(index), ..] = t.pieces.as_slice() else { return None }; + if open != "[" { + return None; + } + let caps = t.captures(s)?; + let digits = caps.iter().any(|(k, v)| k == index && v.bytes().all(|b| b.is_ascii_digit())); + digits.then_some((t.forms.as_slice(), caps)) + }) + } + /// The translation of a status or error message that has an exact `@msg` entry, as written in /// the catalog (`{{` and `}}` for literal braces); templates are not tried. pub fn exact_message(&self, s: &str) -> Option<&str> { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 75f527f782..b174608032 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1005,6 +1005,11 @@ NTSC Colors Barvy NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Zásada RGB: + CMYK Policy: Zásada CMYK: + Preserve Embedded Profiles Zachovat vložené profily + Convert to Working Space Převést do pracovního prostoru + Preserve Numbers Zachovat čísla CMYK CMYK Texture Textura Craquelure… Krakelování… @@ -1104,6 +1109,14 @@ lighting Light Světlo Previous Artboard Předchozí pracovní plocha Next Artboard Další pracovní plocha Last Artboard Poslední pracovní plocha + Status Bar Show Zobrazit ve stavovém řádku + Artboard Name Název pracovní plochy + Current Tool Aktuální nástroj + Date and Time Datum a čas + Number of Undos Počet kroků zpět + Document Color Profile Barevný profil dokumentu + Undos Zpět + Redos Znovu Graphic Style Options… Možnosti grafického stylu… Symbol Options Možnosti symbolu Symbol Options… Možnosti symbolu… @@ -1520,6 +1533,7 @@ lighting Light Světlo Align to Artboard Zarovnat k pracovní ploše Align to Glyph Bounds Zarovnat k hranicím glyfů Align to Key Object Zarovnat ke klíčovému objektu + Align to Key Anchor Zarovnat ke klíčovému kotevnímu bodu Align to Selection Zarovnat k výběru Align To: Zarovnat k: Cancel Key Object Zrušit klíčový objekt @@ -1712,6 +1726,8 @@ digits Malayalam Malajálamské Kana Kana Numerals Číslice Scale at Center Měřítko od středu + Delete Set Odstranit sadu + Direct Entry Přímé zadání Up/Down: Nahoru/dolů: Left/Right: Vlevo/vpravo: Tate-chu-yoko Up/Down Tate-chu-yoko nahoru/dolů @@ -2093,7 +2109,8 @@ digits Malayalam Malajálamské Tabular Lining Tabulkové verzálkové Tabular Oldstyle Tabulkové minuskové Titling Alternates Titulkové alternativy - Every-line Composer Sazba všech řádků + Middle Eastern & South Asian Every-line Composer Sazba všech řádků (Blízký východ a Jižní Asie) + Standard Every-line Composer Standardní sazba všech řádků First-line Left Indent Odsazení prvního řádku zleva Hyphenation… Dělení slov… Words Longer Than: Slova delší než: @@ -2133,7 +2150,8 @@ digits Malayalam Malajálamské East Asian Composition Settings Nastavení východoasijské sazby Korean Composition Settings Nastavení korejské sazby Select a text object to edit its paragraph attributes. Vyberte textový objekt, abyste mohli upravit atributy jeho odstavce. - Single-line Composer Sazba jednoho řádku + Middle Eastern & South Asian Single-line Composer Sazba jednoho řádku (Blízký východ a Jižní Asie) + Standard Single-line Composer Standardní sazba jednoho řádku Toggle Line Composer Přepnout sazbu řádků Space After Paragraph Mezera za odstavcem Space Before Paragraph Mezera před odstavcem @@ -4790,6 +4808,7 @@ imageTrace Outline Obrys @msg `name` must be the name of a script engine `name` musí být název skriptovacího jádra @msg the main script engine is new for each run and is never kept, so it cannot be dropped hlavní skriptovací jádro je pro každý běh nové a nikdy se neuchovává, proto je nelze zahodit @msg there is no script engine named {name} skriptovací jádro s názvem {name} neexistuje +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` je jen pro běh, kde se nemá koho zeptat: v aplikaci odpovídá na každý dialog uživatel @msg Running a script Běží skript @msg Copied the script report Zpráva o skriptu zkopírována @msg Copied the link info Informace o propojení zkopírovány @@ -6269,6 +6288,8 @@ mark-style Japanese Japonské Save Variable Library… Uložit knihovnu proměnných… @msg a variable library holds text variables, and the document has none knihovna proměnných obsahuje textové proměnné a dokument žádné nemá @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` proměnnou `{name}` nelze uložit do knihovny proměnných: přejmenujte ji tak, aby začínala písmenem nebo `_` a neobsahovala mezery ani jiné symboly než `-`, `_` a `.` +@msg {k} has control characters {k} obsahuje řídicí znaky +@msg {k} has control characters or line breaks {k} obsahuje řídicí znaky nebo zalomení řádků @msg data set `{name}` holds a control character, which a variable library can't hold sada dat `{name}` obsahuje řídicí znak, který knihovna proměnných nemůže obsahovat @msg data set `{name}` starts or ends with white space, which a variable library can't hold sada dat `{name}` začíná nebo končí prázdným znakem, který knihovna proměnných nemůže obsahovat Make Graph Dynamic Nastavit graf jako dynamický @@ -6594,6 +6615,7 @@ axis Y Axis osa Y @msg stops must be in order of `offset` zarážky musí být seřazeny podle `offset` @msg the document has no pattern {pattern} dokument nemá vzorek {pattern} @msg the document has no swatch {n} dokument nemá vzorek barvy {n} +@msg `plates` requires a stop linked to a spot swatch `plates` vyžaduje zarážku propojenou se vzorkem přímé barvy @msg the output výstup @msg the path has no anchor points cesta nemá žádné kotevní body @msg too many anchor points (limit {MAX_ANCHORS}) příliš mnoho kotevních bodů (limit {MAX_ANCHORS}) @@ -7793,6 +7815,7 @@ breakText Glyphs Glyfy @msg Image Trace failed: {e} Vektorizace obrazu selhala: {e} @msg Image Trace finished, but its document was closed Vektorizace obrazu skončila, ale její dokument byl zavřen @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Vektorizace obrazu skončila, ale obraz nebo jeho nastavení se mezitím změnily: vektorizujte ho znovu +@msg Image Trace finished, but its document was reverted: trace it again Vektorizace obrazu skončila, ale její dokument byl vrácen k uložené verzi: vektorizujte ho znovu @msg Import SVG Filter reads SVG files, not SVGZ Importovat filtr SVG čte soubory SVG, ne SVGZ @msg Invalid dimension offset Neplatné odsazení kóty @msg Invalid point Neplatný bod @@ -7966,6 +7989,7 @@ breakText Glyphs Glyfy @msg `names` must be an array of style names `names` musí být pole názvů stylů @msg `new` is a design name, or true `new` je název návrhu, nebo true @msg `origin` must be [x, y] in points `origin` musí být [x, y] v bodech +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` musí být [a, b, c, d], každé od -1000 do 1000, a a·d - b·c se musí od 0 lišit alespoň o 0.001 @msg `path` must be a folder `path` musí být složka @msg `path` must be a path object `path` musí být objekt cesty @msg `picks` must be an array (got {v}) `picks` musí být pole (zadáno {v}) @@ -8371,7 +8395,7 @@ breakText Glyphs Glyfy @msg column: true floats the whole column and takes no onto, above or below column: true uvolní celý sloupec jako plovoucí a nepřijímá onto, above ani below @msg command must be a shape.* command command musí být příkaz shape.* @msg commands[{i}]: missing `command` commands[{i}]: chybí `command` -@msg composer must be "singleLine" or "everyLine" composer musí být "singleLine" nebo "everyLine" +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer musí být "singleLine", "everyLine", "standardSingleLine" nebo "standardEveryLine" @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes složené tvary přijímají cesty, složené cesty, skupiny, složené tvary, text, prolnutí a obálky @msg compound {_1} must contain exactly one open path to join složený objekt {_1} musí ke spojení obsahovat právě jednu otevřenou cestu @msg connect needs a path other than the one extended connect vyžaduje jinou cestu než tu prodlužovanou diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e13c1f9ae7..0ae36e3699 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -145,6 +145,18 @@ @msg `name` must be the name of a script engine `name` skal være navnet på en scriptmotor @msg the main script engine is new for each run and is never kept, so it cannot be dropped hovedscriptmotoren er ny ved hver kørsel og bevares aldrig, så den kan ikke fjernes @msg there is no script engine named {name} der findes ingen scriptmotor med navnet {name} +@msg `answers` must be a list of answers `answers` skal være en liste med svar +@msg `answers` has more than {MAX_ANSWERS} answers `answers` har flere end {MAX_ANSWERS} svar +@msg `answers` is larger than 1 MiB `answers` er større end 1 MiB +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg must be an object with `kind` and `value` skal være et objekt med `kind` og `value` +@msg must have exactly `kind` and `value` skal have præcis `kind` og `value` +@msg is an alert's answer, whose `value` must be "ok" er svaret på en alert, hvis `value` skal være "ok" +@msg is a confirm's answer, whose `value` must be true or false er svaret på en confirm, hvis `value` skal være true eller false +@msg is longer than 16 KiB er længere end 16 KiB +@msg is a prompt's answer, whose `value` must be a string or null er svaret på en prompt, hvis `value` skal være en streng eller null +@msg must have `kind` alert, confirm or prompt skal have `kind` alert, confirm eller prompt +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` er kun til en kørsel uden nogen at spørge: i appen besvarer personen hver dialog @msg Running a script Kører et script @msg Data Recovery can't keep copies in {path}: {e} Datagendannelse kan ikke opbevare kopier i {path}: {e} @msg Plug-ins can't be loaded from {path}: {e} Plug-ins kan ikke indlæses fra {path}: {e} @@ -427,6 +439,7 @@ @msg {what} points outside memory {what} peger uden for hukommelsen @msg `angle` must be a number `angle` skal være et tal @msg `aspect` must be from 0.01 to 100 `aspect` skal være fra 0,01 til 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` skal være [a, b, c, d], hver fra -1000 til 1000, med a·d - b·c mindst 0,001 fra 0 @msg `cap` must be "butt", "round" or "square" `cap` skal være "butt", "round" eller "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` skal være "nonzero" eller "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` skal være en https://-adresse på højst {MAX_HELP_URL} byte @@ -479,6 +492,7 @@ @msg stops must be in order of `offset` farvestoppene skal stå i rækkefølge efter `offset` @msg the document has no pattern {pattern} dokumentet har intet mønster {pattern} @msg the document has no swatch {n} dokumentet har ingen farveprøve {n} +@msg `plates` requires a stop linked to a spot swatch `plates` kræver et gradientstop, der er knyttet til en spotfarveprøve @msg the output outputtet @msg the path has no anchor points kurven har ingen ankerpunkter @msg too many anchor points (limit {MAX_ANCHORS}) for mange ankerpunkter (grænse {MAX_ANCHORS}) @@ -1538,6 +1552,11 @@ NTSC Colors NTSC-farver RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-politik: + CMYK Policy: CMYK-politik: + Preserve Embedded Profiles Bevar indlejrede profiler + Convert to Working Space Konverter til arbejdsfarverum + Preserve Numbers Bevar tal CMYK CMYK Texture Tekstur Craquelure… Krakelering… @@ -2037,6 +2056,7 @@ lighting Light Lys Align to Artboard Juster til tegnebræt Align to Glyph Bounds Juster til glyfafgrænsning Align to Key Object Juster til nøgleobjekt + Align to Key Anchor Juster til nøgleankerpunkt Align to Selection Juster til markering Align To: Juster til: Cancel Key Object Annullér nøgleobjekt @@ -2228,6 +2248,8 @@ digits Malayalam Malayalam Kana Kana Numerals Tal Scale at Center Skalér fra midten + Delete Set Slet sæt + Direct Entry Direkte indtastning Up/Down: Op/ned: Left/Right: Venstre/højre: Tate-chu-yoko Up/Down Tate-chu-yoko op/ned @@ -2511,7 +2533,10 @@ digits Malayalam Malayalam Tabular Lining Tabulære versalcifre Tabular Oldstyle Tabulære mediævalcifre Titling Alternates Titelalternativer - Every-line Composer Hverlinjesætter + Middle Eastern & South Asian Single-line Composer Enlinjesætter (Mellemøsten og Sydasien) + Middle Eastern & South Asian Every-line Composer Hverlinjesætter (Mellemøsten og Sydasien) + Standard Single-line Composer Standard-enlinjesætter + Standard Every-line Composer Standard-hverlinjesætter First-line Left Indent Venstre indrykning af første linje Hyphenation… Orddeling… Justification… Justering… @@ -2543,7 +2568,6 @@ digits Malayalam Malayalam East Asian Composition Settings Indstillinger for østasiatisk komposition Korean Composition Settings Indstillinger for koreansk komposition Select a text object to edit its paragraph attributes. Markér et tekstobjekt for at redigere dets afsnitsattributter. - Single-line Composer Enlinjesætter Toggle Line Composer Skift linjesætter Space After Paragraph Mellemrum efter afsnit Space Before Paragraph Mellemrum før afsnit @@ -5390,6 +5414,7 @@ imageTrace Outline Kontur @msg plug-in failed: {0} plug-in fejlede: {0} @msg plug-in manifest: {0} plug-in-manifest: {0} @msg plug-in output: {0} plug-in-output: {0} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} @msg plug-in parameters: {0} plug-in-parametre: {0} @msg plug-in trapped: {0} plug-in'et blev fanget: {0} @msg print settings are not an object printindstillingerne er ikke et objekt @@ -6302,6 +6327,8 @@ mark-style Japanese Japansk @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` variablen `{name}` kan ikke gemmes i et variabelbibliotek: omdøb den, så den begynder med et bogstav eller `_`, uden mellemrum eller andre symboler end `-`, `_` og `.` @msg data set `{name}` holds a control character, which a variable library can't hold datasættet `{name}` indeholder et kontroltegn, som et variabelbibliotek ikke kan indeholde @msg data set `{name}` starts or ends with white space, which a variable library can't hold datasættet `{name}` begynder eller slutter med mellemrum, som et variabelbibliotek ikke kan indeholde +@msg {k} has control characters {k} indeholder kontroltegn +@msg {k} has control characters or line breaks {k} indeholder kontroltegn eller linjeskift @msg variables that aren't text were left out: {kinds} variabler, der ikke er tekst, blev udeladt: {kinds} @msg a new document won't read these variables back with their kind: {names} et nyt dokument læser ikke disse variabler tilbage med deres type: {names} @msg a new document refuses this file because of these variables: {names} et nyt dokument afviser denne fil på grund af disse variabler: {names} @@ -6503,6 +6530,14 @@ swatch Bright Violet Kraftig violet Color wheel Farvehjul First Artboard Første kunstplade Last Artboard Sidste kunstplade + Status Bar Show Vis i statuslinjen + Artboard Name Kunstpladens navn + Current Tool Aktuelt værktøj + Date and Time Dato og klokkeslæt + Number of Undos Antal fortrydelser + Document Color Profile Dokumentets farveprofil + Undos Fortrydelser + Redos Gentagelser Next Artboard Næste kunstplade Previous Artboard Forrige kunstplade Step up or down Trinvis op eller ned @@ -7645,3 +7680,4 @@ breakText Glyphs Glyffer Nest Shapes… Pak figurer tæt… Nest Shapes Pak figurer tæt Roll Rulle +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer skal være "singleLine", "everyLine", "standardSingleLine" eller "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 68da6f84ba..99e487ecb1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -135,6 +135,7 @@ Align to Artboard An Zeichenfläche ausrichten Align to Glyph Bounds An Glyphenbegrenzung ausrichten Align to Key Object An Basisobjekt ausrichten + Align to Key Anchor An Basisankerpunkt ausrichten Align to Page An Seite ausrichten Align to Path An Pfad ausrichten Align to Selection An Auswahl ausrichten @@ -1055,7 +1056,8 @@ font-technology Variable Variabel Every (minutes) Alle (Minuten) Every option is at its default. Alle Optionen haben ihren Standardwert. Every option matches {base}. Alle Optionen entsprechen {base}. - Every-line Composer Alle-Zeilen-Setzer + Middle Eastern & South Asian Every-line Composer Alle-Zeilen-Setzer (Naher Osten und Südasien) + Standard Every-line Composer Standard-Alle-Zeilen-Setzer Exact dash lengths Exakte Strichlängen Exceptions (comma separated) Ausnahmen (durch Kommas getrennt) Exclude Schnittmenge entfernen @@ -1392,6 +1394,14 @@ file-filter Templates Vorlagen Previous Artboard Vorherige Zeichenfläche Next Artboard Nächste Zeichenfläche Last Artboard Letzte Zeichenfläche + Status Bar Show In Statusleiste anzeigen + Artboard Name Name der Zeichenfläche + Current Tool Aktuelles Werkzeug + Date and Time Datum und Uhrzeit + Number of Undos Anzahl der Rückgängig-Schritte + Document Color Profile Farbprofil des Dokuments + Undos Rückgängig + Redos Wiederholen Go To Link Gehe zu Verknüpfung GPL palettes keep solid colours only, as RGB. GPL-Paletten enthalten nur einfarbige Farben, als RGB. GPU Performance GPU-Leistung @@ -3420,7 +3430,8 @@ digits Malayalam Malayalam Single Left Quotation Mark Einfaches öffnendes Anführungszeichen Single Quotes: Einfache Anführungszeichen: Single Right Quotation Mark Einfaches schließendes Anführungszeichen - Single-line Composer Einzeilen-Setzer + Middle Eastern & South Asian Single-line Composer Einzeilen-Setzer (Naher Osten und Südasien) + Standard Single-line Composer Standard-Einzeilen-Setzer Toggle Line Composer Zeilensetzer umschalten Sixth Space Sechstelgeviert-Leerzeichen Size Größe @@ -3738,6 +3749,8 @@ effect Points Punkte Kana Kana Numerals Ziffern Scale at Center Von der Mitte aus skalieren + Delete Set Satz löschen + Direct Entry Direkteingabe Up/Down: Oben/Unten: Left/Right: Links/Rechts: Tate-chu-yoko Up/Down Tate-chu-yoko oben/unten @@ -5378,6 +5391,11 @@ theme Appearance Erscheinungsbild The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Die Hauptfarbe wird zur Konturfarbe. Die anderen Farben wandern ebenso weit um den Farbkreis wie die Hauptfarbe und behalten ihre Sättigung und Helligkeit. RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-Richtlinie: + CMYK Policy: CMYK-Richtlinie: + Preserve Embedded Profiles Eingebettete Profile beibehalten + Convert to Working Space In Arbeitsfarbraum konvertieren + Preserve Numbers Werte beibehalten # PSD files. @msg not a PSD file keine PSD-Datei @msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode PSD-Dateien im Mehrkanalmodus können nicht gelesen werden: Speichern Sie die Datei im RGB-, CMYK- oder Graustufenmodus @@ -6184,6 +6202,7 @@ axis Y Axis Y-Achse @msg stops must be in order of `offset` die Farbstopps müssen nach `offset` geordnet sein @msg the document has no pattern {pattern} das Dokument hat kein Muster {pattern} @msg the document has no swatch {n} das Dokument hat kein Farbfeld {n} +@msg `plates` requires a stop linked to a spot swatch `plates` erfordert einen Farbstopp, der mit einem Volltonfarbfeld verknüpft ist @msg the output die Ausgabe @msg the path has no anchor points der Pfad hat keine Ankerpunkte @msg too many anchor points (limit {MAX_ANCHORS}) zu viele Ankerpunkte (Grenze {MAX_ANCHORS}) @@ -7252,6 +7271,7 @@ breakText Glyphs Glyphen @msg `answer` for this {kind} dialog must be one of: {_1} `answer` für dieses {kind}-Dialogfeld muss eines der folgenden sein: {_1} @msg `answers` has more than {MAX_ANSWERS} answers `answers` hat mehr als {MAX_ANSWERS} Antworten @msg `answers` is larger than 1 MiB `answers` ist größer als 1 MiB +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` ist nur für einen Lauf ohne jemanden, der gefragt werden kann: In der App beantwortet die Person jeden Dialog @msg `answers` must be a list of answers `answers` muss eine Liste von Antworten sein @msg `artboard` must be the index of an artboard of the document `artboard` muss der Index einer Zeichenfläche des Dokuments sein @msg `artboardsAt` must be [x, y] `artboardsAt` muss [x, y] sein @@ -7283,6 +7303,7 @@ breakText Glyphs Glyphen @msg `name` must be a name that is not empty `name` muss ein nicht leerer Name sein @msg `name` must not be empty `name` darf nicht leer sein @msg `origin` must be [x, y] in points `origin` muss [x, y] in Punkt sein +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` muss [a, b, c, d] sein, jeweils von -1000 bis 1000, mit a·d - b·c mindestens 0,001 von 0 entfernt @msg `picks` must be an array (got {v}) `picks` muss ein Array sein (erhalten: {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` muss 1 bis {MAX_PICKS} Auswahlen enthalten (erhalten: {_1}) @msg `radius` must be 0 or more `radius` muss 0 oder größer sein @@ -7640,6 +7661,7 @@ breakText Glyphs Glyphen @msg Image Trace failed: {e} Bildnachzeichner fehlgeschlagen: {e} @msg Image Trace finished, but its document was closed Bildnachzeichner abgeschlossen, aber sein Dokument wurde geschlossen @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Bildnachzeichner abgeschlossen, aber das Bild oder seine Einstellungen haben sich inzwischen geändert: Zeichnen Sie es erneut nach +@msg Image Trace finished, but its document was reverted: trace it again Bildnachzeichner abgeschlossen, aber sein Dokument wurde auf die letzte Version zurückgesetzt: Zeichnen Sie es erneut nach @msg Invalid dimension offset Ungültiger Bemaßungsversatz @msg Invalid point Ungültiger Punkt @msg Join: a subpath went missing Zusammenfügen: Ein Teilpfad ist verloren gegangen @@ -8054,7 +8076,7 @@ breakText Glyphs Glyphen @msg column: true floats the whole column and takes no onto, above or below column: true macht die ganze Spalte schwebend und nimmt kein onto, above oder below @msg command must be a shape.* command command muss ein shape.*-Befehl sein @msg commands[{i}]: missing `command` commands[{i}]: `command` fehlt -@msg composer must be "singleLine" or "everyLine" composer muss „singleLine“ oder „everyLine“ sein +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer muss „singleLine“, „everyLine“, „standardSingleLine“ oder „standardEveryLine“ sein @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes zusammengesetzte Formen nehmen Pfade, zusammengesetzte Pfade, Gruppen, zusammengesetzte Formen, Text, Angleichungen und Verzerrungshüllen @msg compound {_1} must contain exactly one open path to join zusammengesetzter Pfad {_1} muss genau einen offenen Pfad zum Zusammenfügen enthalten @msg connect end must be "start" or "end" connect end muss „start“ oder „end“ sein @@ -8985,7 +9007,8 @@ breakText Glyphs Glyphen @msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind}-Dateien haben keine Transparenz, und die Datei hätte {what}: Reduzieren Sie sie zuerst (object.flattenTransparency) @msg {kind} images can't be opened (see document.formats) {kind}-Bilder können nicht geöffnet werden (siehe document.formats) @msg {k} `{v}` is not one of the choices {k} `{v}` ist keine der Auswahlmöglichkeiten -@msg {k} has control characters{_1} {k} enthält Steuerzeichen{_1} +@msg {k} has control characters {k} enthält Steuerzeichen +@msg {k} has control characters or line breaks {k} enthält Steuerzeichen oder Zeilenumbrüche @msg {k} is too long (at most {MAX_TEXT} characters) {k} ist zu lang (höchstens {MAX_TEXT} Zeichen) @msg {k} must be > 0 {k} muss > 0 sein @msg {k} must be a number (pt) or a length such as "210 mm" {k} muss eine Zahl (pt) oder eine Länge wie „210 mm“ sein diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 551130c700..7fe692d27e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -140,6 +140,7 @@ Align to Artboard Στοίχιση ως προς τον πίνακα σχεδίασης Align to Glyph Bounds Στοίχιση στα όρια των γλυφών Align to Key Object Στοίχιση ως προς το βασικό αντικείμενο + Align to Key Anchor Στοίχιση ως προς το βασικό σημείο αγκύρωσης Align to Page Στοίχιση ως προς τη σελίδα Align to Path Στοίχιση ως προς τη διαδρομή Align to Selection Στοίχιση ως προς την επιλογή @@ -1020,7 +1021,10 @@ font-technology Variable Μεταβλητές Every (minutes) Κάθε (λεπτά) Every option is at its default. Όλες οι επιλογές έχουν τις προεπιλεγμένες τιμές τους. Every option matches {base}. Όλες οι επιλογές ταιριάζουν με το {base}. - Every-line Composer Σύνθεση όλων των γραμμών + Middle Eastern & South Asian Single-line Composer Σύνθεση μονής γραμμής (Μέση Ανατολή και Νότια Ασία) + Middle Eastern & South Asian Every-line Composer Σύνθεση όλων των γραμμών (Μέση Ανατολή και Νότια Ασία) + Standard Single-line Composer Τυπική σύνθεση μονής γραμμής + Standard Every-line Composer Τυπική σύνθεση όλων των γραμμών Exact dash lengths Ακριβή μήκη παυλών Exceptions (comma separated) Εξαιρέσεις (χωρισμένες με κόμμα) Exclude Εξαίρεση @@ -1322,6 +1326,14 @@ font-technology Variable Μεταβλητές Previous Artboard Προηγούμενος πίνακας σχεδίασης Next Artboard Επόμενος πίνακας σχεδίασης Last Artboard Τελευταίος πίνακας σχεδίασης + Status Bar Show Εμφάνιση στη γραμμή κατάστασης + Artboard Name Όνομα πίνακα σχεδίασης + Current Tool Τρέχον εργαλείο + Date and Time Ημερομηνία και ώρα + Number of Undos Αριθμός αναιρέσεων + Document Color Profile Χρωματικό προφίλ εγγράφου + Undos Αναιρέσεις + Redos Επαναλήψεις Go To Link Μετάβαση στη σύνδεση GPL palettes keep solid colours only, as RGB. Οι παλέτες GPL διατηρούν μόνο συμπαγή χρώματα σε RGB. GPU Performance Απόδοση GPU @@ -2484,6 +2496,18 @@ axis Both Και τα δύο @msg `name` must be the name of a script engine το `name` πρέπει να είναι το όνομα μιας μηχανής σεναρίων @msg the main script engine is new for each run and is never kept, so it cannot be dropped η κύρια μηχανή σεναρίων είναι νέα σε κάθε εκτέλεση και δεν διατηρείται ποτέ, οπότε δεν μπορεί να απορριφθεί @msg there is no script engine named {name} δεν υπάρχει μηχανή σεναρίων με όνομα {name} +@msg `answers` must be a list of answers το `answers` πρέπει να είναι λίστα απαντήσεων +@msg `answers` has more than {MAX_ANSWERS} answers το `answers` έχει περισσότερες από {MAX_ANSWERS} απαντήσεις +@msg `answers` is larger than 1 MiB το `answers` είναι μεγαλύτερο από 1 MiB +@msg `answers[{i}]` {e} το `answers[{i}]` {e} +@msg must be an object with `kind` and `value` πρέπει να είναι αντικείμενο με `kind` και `value` +@msg must have exactly `kind` and `value` πρέπει να έχει ακριβώς `kind` και `value` +@msg is an alert's answer, whose `value` must be "ok" είναι απάντηση σε alert, οπότε το `value` πρέπει να είναι "ok" +@msg is a confirm's answer, whose `value` must be true or false είναι απάντηση σε confirm, οπότε το `value` πρέπει να είναι true ή false +@msg is longer than 16 KiB είναι μεγαλύτερο από 16 KiB +@msg is a prompt's answer, whose `value` must be a string or null είναι απάντηση σε prompt, οπότε το `value` πρέπει να είναι συμβολοσειρά ή null +@msg must have `kind` alert, confirm or prompt πρέπει να έχει `kind` alert, confirm ή prompt +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog το `answers` είναι μόνο για εκτέλεση χωρίς κανέναν να ερωτηθεί: στην εφαρμογή, το άτομο απαντά σε κάθε παράθυρο διαλόγου @msg Running a script Εκτελείται σενάριο @msg Copied the script report Η αναφορά του σεναρίου αντιγράφηκε @msg Copied the link info Οι πληροφορίες συνδέσμου αντιγράφηκαν @@ -4005,7 +4029,6 @@ digits Malayalam Μαλαγιάλαμ Single Left Quotation Mark Μονό αριστερό εισαγωγικό Single Quotes: Μονά εισαγωγικά: Single Right Quotation Mark Μονό δεξί εισαγωγικό - Single-line Composer Σύνθεση μονής γραμμής Toggle Line Composer Εναλλαγή σύνθεσης γραμμών Sixth Space Διάστημα ενός έκτου Size Tile to Art Προσαρμογή μεγέθους πλακιδίου στο σχέδιο @@ -4273,6 +4296,8 @@ digits Malayalam Μαλαγιάλαμ Kana Κάνα Numerals Αριθμοί Scale at Center Κλιμάκωση από το κέντρο + Delete Set Διαγραφή συνόλου + Direct Entry Άμεση καταχώριση Up/Down: Πάνω/Κάτω: Left/Right: Αριστερά/Δεξιά: Tate-chu-yoko Up/Down Tate-chu-yoko πάνω/κάτω @@ -5350,6 +5375,11 @@ digits Malayalam Μαλαγιάλαμ @msg Imported library {name} Εισήχθη η βιβλιοθήκη {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Πολιτική RGB: + CMYK Policy: Πολιτική CMYK: + Preserve Embedded Profiles Διατήρηση ενσωματωμένων προφίλ + Convert to Working Space Μετατροπή στον χώρο εργασίας + Preserve Numbers Διατήρηση αριθμών System Title Bar Γραμμή τίτλου συστήματος VectorCraft Title Bar on Every Desktop Γραμμή τίτλου του VectorCraft σε κάθε επιφάνεια εργασίας The VECTORCRAFT_TITLEBAR environment variable chooses the title bar while it is set. Όσο είναι ορισμένη, η μεταβλητή περιβάλλοντος VECTORCRAFT_TITLEBAR επιλέγει τη γραμμή τίτλου. @@ -6126,6 +6156,8 @@ effect Points Σημεία Save Variable Library… Αποθήκευση βιβλιοθήκης μεταβλητών… @msg a variable library holds text variables, and the document has none μια βιβλιοθήκη μεταβλητών περιέχει μεταβλητές κειμένου και το έγγραφο δεν έχει καμία @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` η μεταβλητή `{name}` δεν μπορεί να αποθηκευτεί σε βιβλιοθήκη μεταβλητών: μετονομάστε την ώστε να ξεκινά με γράμμα ή `_`, χωρίς κενά ή σύμβολα εκτός από `-`, `_` και `.` +@msg {k} has control characters {k} περιέχει χαρακτήρες ελέγχου +@msg {k} has control characters or line breaks {k} περιέχει χαρακτήρες ελέγχου ή αλλαγές γραμμής @msg data set `{name}` holds a control character, which a variable library can't hold το σύνολο δεδομένων `{name}` περιέχει χαρακτήρα ελέγχου, τον οποίο δεν μπορεί να περιέχει μια βιβλιοθήκη μεταβλητών @msg data set `{name}` starts or ends with white space, which a variable library can't hold το σύνολο δεδομένων `{name}` ξεκινά ή τελειώνει με κενό διάστημα, το οποίο δεν μπορεί να περιέχει μια βιβλιοθήκη μεταβλητών Filter Φίλτρο @@ -6498,6 +6530,7 @@ axis Y Axis Άξονας Y @msg {what} points outside memory το {what} δείχνει εκτός μνήμης @msg `angle` must be a number το `angle` πρέπει να είναι αριθμός @msg `aspect` must be from 0.01 to 100 το `aspect` πρέπει να είναι από 0.01 έως 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 το `axes` πρέπει να είναι [a, b, c, d], καθένα από -1000 έως 1000, με το a·d - b·c να απέχει τουλάχιστον 0.001 από το 0 @msg `cap` must be "butt", "round" or "square" το `cap` πρέπει να είναι "butt", "round" ή "square" @msg `fillRule` must be "nonzero" or "evenodd" το `fillRule` πρέπει να είναι "nonzero" ή "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes το `helpUrl` πρέπει να είναι διεύθυνση https:// έως {MAX_HELP_URL} byte @@ -6550,6 +6583,7 @@ axis Y Axis Άξονας Y @msg stops must be in order of `offset` τα σημεία χρώματος πρέπει να είναι ταξινομημένα κατά `offset` @msg the document has no pattern {pattern} το έγγραφο δεν έχει μοτίβο {pattern} @msg the document has no swatch {n} το έγγραφο δεν έχει δείγμα χρώματος {n} +@msg `plates` requires a stop linked to a spot swatch το `plates` απαιτεί σημείο χρώματος συνδεδεμένο με δείγμα πλακάτου χρώματος @msg the output η έξοδος @msg the path has no anchor points η διαδρομή δεν έχει σημεία αγκύρωσης @msg too many anchor points (limit {MAX_ANCHORS}) πάρα πολλά σημεία αγκύρωσης (όριο {MAX_ANCHORS}) @@ -7710,3 +7744,4 @@ breakText Glyphs Γλύφοι @msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution τα {width} × {height} εικονοστοιχεία είναι υπερβολικό μέγεθος για JPEG (το πολύ 65535 εικονοστοιχεία ανά πλευρά): μειώστε την ανάλυση @msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution τα {width} × {height} εικονοστοιχεία είναι υπερβολικό μέγεθος για Targa (το πολύ 65535 εικονοστοιχεία ανά πλευρά): μειώστε την ανάλυση @msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution τα {w} × {h} εικονοστοιχεία είναι υπερβολικό μέγεθος για PSD (το πολύ {MAX_SIDE} εικονοστοιχεία ανά πλευρά): μειώστε την ανάλυση +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" το composer πρέπει να είναι "singleLine", "everyLine", "standardSingleLine" ή "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ddbbfce02e..f0a5d12844 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -160,6 +160,7 @@ Align to Artboard Alinear con la mesa de trabajo Align to Glyph Bounds Alinear con los límites del glifo Align to Key Object Alinear con el objeto clave + Align to Key Anchor Alinear con el punto de ancla clave Align to Page Alinear con la página Align to Path Alinear con el trazado Align to Selection Alinear con la selección @@ -1076,7 +1077,10 @@ font-technology Variable Variables Every (minutes) Cada (minutos) Every option is at its default. Todas las opciones tienen su valor predeterminado. Every option matches {base}. Todas las opciones coinciden con {base}. - Every-line Composer Composición de todas las líneas + Middle Eastern & South Asian Single-line Composer Composición de una línea (Oriente Medio y Sur de Asia) + Middle Eastern & South Asian Every-line Composer Composición de todas las líneas (Oriente Medio y Sur de Asia) + Standard Single-line Composer Composición estándar de una línea + Standard Every-line Composer Composición estándar de todas las líneas Exact dash lengths Longitudes exactas de guion Exceptions (comma separated) Excepciones (separadas por comas) Exclude Excluir @@ -1401,6 +1405,14 @@ font-technology Variable Variables Previous Artboard Mesa de trabajo anterior Next Artboard Mesa de trabajo siguiente Last Artboard Última mesa de trabajo + Status Bar Show Mostrar en la barra de estado + Artboard Name Nombre de la mesa de trabajo + Current Tool Herramienta actual + Date and Time Fecha y hora + Number of Undos Número de deshacer + Document Color Profile Perfil de color del documento + Undos Deshacer + Redos Rehacer Go To Link Ir al vínculo GPL palettes keep solid colours only, as RGB. Las paletas GPL solo conservan colores sólidos, en RGB. GPU Performance Rendimiento de GPU @@ -3452,7 +3464,6 @@ digits Malayalam Malayálam Single Left Quotation Mark Comilla simple de apertura Single Quotes: Comillas simples: Single Right Quotation Mark Comilla simple de cierre - Single-line Composer Composición de una línea Toggle Line Composer Alternar composición de líneas Sixth Space Sexto de espacio Size Tamaño @@ -3787,6 +3798,8 @@ digits Malayalam Malayálam Kana Kana Numerals Números Scale at Center Escalar desde el centro + Delete Set Eliminar juego + Direct Entry Entrada directa Up/Down: Arriba/abajo: Left/Right: Izquierda/derecha: Tate-chu-yoko Up/Down Tate-chu-yoko arriba/abajo @@ -4479,6 +4492,17 @@ axis Both Ambos @msg `name` must be the name of a script engine `name` debe ser el nombre de un motor de scripts @msg the main script engine is new for each run and is never kept, so it cannot be dropped el motor de scripts principal es nuevo en cada ejecución y nunca se conserva, por lo que no se puede descartar @msg there is no script engine named {name} no hay ningún motor de scripts llamado {name} +@msg `answers` must be a list of answers `answers` debe ser una lista de respuestas +@msg `answers` has more than {MAX_ANSWERS} answers `answers` tiene más de {MAX_ANSWERS} respuestas +@msg `answers` is larger than 1 MiB `answers` ocupa más de 1 MiB +@msg must be an object with `kind` and `value` debe ser un objeto con `kind` y `value` +@msg must have exactly `kind` and `value` debe tener exactamente `kind` y `value` +@msg is an alert's answer, whose `value` must be "ok" es la respuesta de un alert, cuyo `value` debe ser "ok" +@msg is a confirm's answer, whose `value` must be true or false es la respuesta de un confirm, cuyo `value` debe ser true o false +@msg is longer than 16 KiB ocupa más de 16 KiB +@msg is a prompt's answer, whose `value` must be a string or null es la respuesta de un prompt, cuyo `value` debe ser una cadena o null +@msg must have `kind` alert, confirm or prompt debe tener `kind` alert, confirm o prompt +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` solo es para una ejecución sin nadie a quien preguntar: en la aplicación, la persona responde cada cuadro de diálogo @msg Running a script Ejecutando un script @msg Copied the script report Se copió el informe del script @msg Copied the link info Se copió la información del vínculo @@ -5396,6 +5420,11 @@ axis Both Ambos @msg Imported library {name} Biblioteca {name} importada RGB: RGB: CMYK: CMYK: + RGB Policy: Norma RGB: + CMYK Policy: Norma CMYK: + Preserve Embedded Profiles Conservar perfiles incrustados + Convert to Working Space Convertir en espacio de trabajo + Preserve Numbers Conservar números Texture Textura Craquelure… Craquelado… Grain… Granulado… @@ -6214,6 +6243,8 @@ mark-style Japanese Japonesas Save Variable Library… Guardar biblioteca de variables… @msg a variable library holds text variables, and the document has none una biblioteca de variables contiene variables de texto y el documento no tiene ninguna @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` la variable `{name}` no se puede guardar en una biblioteca de variables: cámbiele el nombre para que empiece por una letra o `_`, sin espacios ni símbolos distintos de `-`, `_` y `.` +@msg {k} has control characters {k} contiene caracteres de control +@msg {k} has control characters or line breaks {k} contiene caracteres de control o saltos de línea @msg data set `{name}` holds a control character, which a variable library can't hold el conjunto de datos `{name}` contiene un carácter de control, que una biblioteca de variables no puede contener @msg data set `{name}` starts or ends with white space, which a variable library can't hold el conjunto de datos `{name}` empieza o termina con espacio en blanco, que una biblioteca de variables no puede contener Make Graph Dynamic Hacer gráfico dinámico @@ -6487,6 +6518,7 @@ axis Y Axis Eje Y @msg {what} points outside memory {what} apunta fuera de la memoria @msg `angle` must be a number `angle` debe ser un número @msg `aspect` must be from 0.01 to 100 `aspect` debe estar entre 0.01 y 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` debe ser [a, b, c, d], cada uno entre -1000 y 1000, con a·d - b·c a al menos 0.001 de 0 @msg `cap` must be "butt", "round" or "square" `cap` debe ser "butt", "round" o "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` debe ser "nonzero" o "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` debe ser una dirección https:// de {MAX_HELP_URL} bytes como máximo @@ -6539,6 +6571,7 @@ axis Y Axis Eje Y @msg stops must be in order of `offset` las paradas deben estar en orden de `offset` @msg the document has no pattern {pattern} el documento no tiene el motivo {pattern} @msg the document has no swatch {n} el documento no tiene la muestra {n} +@msg `plates` requires a stop linked to a spot swatch `plates` requiere una parada vinculada a una muestra de tinta plana @msg the output la salida @msg the path has no anchor points el trazado no tiene puntos de ancla @msg too many anchor points (limit {MAX_ANCHORS}) demasiados puntos de ancla (límite {MAX_ANCHORS}) @@ -7698,3 +7731,4 @@ breakText Glyphs Glifos Nest Shapes… Anidar formas… Nest Shapes Anidar formas Roll Rollo +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer debe ser "singleLine", "everyLine", "standardSingleLine" o "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 5915b6a69e..718b99ea1b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -989,6 +989,11 @@ NTSC Colors NTSC-värit RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-käytäntö: + CMYK Policy: CMYK-käytäntö: + Preserve Embedded Profiles Säilytä upotetut profiilit + Convert to Working Space Muunna työväriavaruuteen + Preserve Numbers Säilytä lukuarvot CMYK CMYK Texture Tekstuuri Craquelure… Krakelyyri… @@ -1083,6 +1088,14 @@ lighting Light Valo Previous Artboard Edellinen piirtoalusta Next Artboard Seuraava piirtoalusta Last Artboard Viimeinen piirtoalusta + Status Bar Show Näytä tilarivillä + Artboard Name Piirtoalustan nimi + Current Tool Nykyinen työkalu + Date and Time Päivämäärä ja aika + Number of Undos Kumousten määrä + Document Color Profile Asiakirjan väriprofiili + Undos Kumoukset + Redos Uudelleentekot Graphic Style Options… Grafiikkatyylin asetukset… Symbol Options Symbolin asetukset Symbol Options… Symbolin asetukset… @@ -1494,6 +1507,7 @@ lighting Light Valo Align to Artboard Tasaa piirtoalustaan Align to Glyph Bounds Tasaa merkin rajoihin Align to Key Object Tasaa avainobjektiin + Align to Key Anchor Tasaa avainankkuripisteeseen Align to Selection Tasaa valintaan Align To: Tasaa: Cancel Key Object Peruuta avainobjekti @@ -1685,6 +1699,8 @@ digits Malayalam Malajalam Kana Kana Numerals Numerot Scale at Center Skaalaa keskeltä + Delete Set Poista merkistö + Direct Entry Suora syöttö Up/Down: Ylös/alas: Left/Right: Vasen/oikea: Tate-chu-yoko Up/Down Tate-chu-yoko ylös/alas @@ -2054,7 +2070,10 @@ digits Malayalam Malajalam Tabular Lining Taulukkonumerot Tabular Oldstyle Vanhanaikaiset taulukkonumerot Titling Alternates Otsikkovaihtoehdot - Every-line Composer Kaikkien rivien asettelu + Middle Eastern & South Asian Single-line Composer Yhden rivin asettelu (Lähi-itä ja Etelä-Aasia) + Middle Eastern & South Asian Every-line Composer Kaikkien rivien asettelu (Lähi-itä ja Etelä-Aasia) + Standard Single-line Composer Tavallinen yhden rivin asettelu + Standard Every-line Composer Tavallinen kaikkien rivien asettelu First-line Left Indent Ensimmäisen rivin vasen sisennys Hyphenation… Tavutus… Words Longer Than: Sanat, jotka ovat pidempiä kuin: @@ -2094,7 +2113,6 @@ digits Malayalam Malajalam East Asian Composition Settings Itäaasialaisen ladonnan asetukset Korean Composition Settings Korean ladonnan asetukset Select a text object to edit its paragraph attributes. Valitse tekstiobjekti, jonka kappalemääritteitä haluat muokata. - Single-line Composer Yhden rivin asettelu Toggle Line Composer Vaihda rivien asettelu Space After Paragraph Väli kappaleen jälkeen Space Before Paragraph Väli ennen kappaletta @@ -4704,6 +4722,17 @@ imageTrace Outline Ääriviiva @msg `name` must be the name of a script engine `name` on oltava skriptimoottorin nimi @msg the main script engine is new for each run and is never kept, so it cannot be dropped pääskriptimoottori on uusi jokaisella ajolla eikä sitä koskaan säilytetä, joten sitä ei voi poistaa @msg there is no script engine named {name} skriptimoottoria nimeltä {name} ei ole +@msg `answers` must be a list of answers `answers` on oltava vastausten luettelo +@msg `answers` has more than {MAX_ANSWERS} answers kohteessa `answers` on yli {MAX_ANSWERS} vastausta +@msg `answers` is larger than 1 MiB `answers` on suurempi kuin 1 MiB +@msg must be an object with `kind` and `value` on oltava objekti, jossa on `kind` ja `value` +@msg must have exactly `kind` and `value` siinä on oltava täsmälleen `kind` ja `value` +@msg is an alert's answer, whose `value` must be "ok" on alert-vastaus, joten sen `value`-arvon on oltava "ok" +@msg is a confirm's answer, whose `value` must be true or false on confirm-vastaus, joten sen `value`-arvon on oltava true tai false +@msg is longer than 16 KiB on pidempi kuin 16 KiB +@msg is a prompt's answer, whose `value` must be a string or null on prompt-vastaus, joten sen `value`-arvon on oltava merkkijono tai null +@msg must have `kind` alert, confirm or prompt `kind`-arvon on oltava alert, confirm tai prompt +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` on vain ajolle, jossa ei ole ketään kysyttävää: sovelluksessa henkilö vastaa jokaiseen valintaikkunaan @msg Running a script Suoritetaan komentosarjaa @msg Copied the script report Komentosarjan raportti kopioitiin @msg Copied the link info Linkin tiedot kopioitiin @@ -6137,6 +6166,8 @@ mark-style Japanese Japanilainen Save Variable Library… Tallenna muuttujakirjasto… @msg a variable library holds text variables, and the document has none muuttujakirjasto sisältää tekstimuuttujia, eikä asiakirjassa ole yhtään @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` muuttujaa `{name}` ei voi tallentaa muuttujakirjastoon: nimeä se uudelleen niin, että se alkaa kirjaimella tai merkillä `_` eikä sisällä välilyöntejä tai muita symboleja kuin `-`, `_` ja `.` +@msg {k} has control characters {k} sisältää ohjausmerkkejä +@msg {k} has control characters or line breaks {k} sisältää ohjausmerkkejä tai rivinvaihtoja @msg data set `{name}` holds a control character, which a variable library can't hold tietojoukko `{name}` sisältää ohjausmerkin, jota muuttujakirjasto ei voi sisältää @msg data set `{name}` starts or ends with white space, which a variable library can't hold tietojoukko `{name}` alkaa tai päättyy tyhjeeseen, jota muuttujakirjasto ei voi sisältää Filter Suodata @@ -6576,6 +6607,7 @@ axis Y Axis Y-akseli @msg {what} points outside memory {what} osoittaa muistin ulkopuolelle @msg `angle` must be a number `angle` on oltava luku @msg `aspect` must be from 0.01 to 100 `aspect` on oltava 0.01 ja 100 välillä +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` on oltava [a, b, c, d], kukin -1000 ja 1000 välillä, ja a·d - b·c:n on oltava vähintään 0.001:n päässä 0:sta @msg `cap` must be "butt", "round" or "square" `cap` on oltava "butt", "round" tai "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` on oltava "nonzero" tai "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` on oltava enintään {MAX_HELP_URL} tavun https://-osoite @@ -6628,6 +6660,7 @@ axis Y Axis Y-akseli @msg stops must be in order of `offset` väripisteiden on oltava `offset`-järjestyksessä @msg the document has no pattern {pattern} asiakirjassa ei ole kuviota {pattern} @msg the document has no swatch {n} asiakirjassa ei ole värimallia {n} +@msg `plates` requires a stop linked to a spot swatch `plates` vaatii väripisteen, joka on linkitetty spottivärin värimalliin @msg the output tuloste @msg the path has no anchor points polulla ei ole ankkuripisteitä @msg too many anchor points (limit {MAX_ANCHORS}) liian monta ankkuripistettä (raja {MAX_ANCHORS}) @@ -7794,3 +7827,4 @@ breakText Glyphs Merkit @msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} pikseliä on liian suuri JPEG-kuvalle (enintään 65535 pikseliä sivua kohti): pienennä tarkkuutta @msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} pikseliä on liian suuri Targa-kuvalle (enintään 65535 pikseliä sivua kohti): pienennä tarkkuutta @msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} pikseliä on liian suuri PSD-kuvalle (enintään {MAX_SIDE} pikseliä sivua kohti): pienennä tarkkuutta +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer on oltava "singleLine", "everyLine", "standardSingleLine" tai "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5778a21665..6b946a7f1e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -161,6 +161,7 @@ Align to Artboard Aligner sur le plan de travail Align to Glyph Bounds Aligner sur les limites du glyphe Align to Key Object Aligner sur l’objet clé + Align to Key Anchor Aligner sur le point d’ancrage clé Align to Page Aligner sur la page Align to Path Aligner sur le tracé Align to Selection Aligner sur la sélection @@ -1071,7 +1072,10 @@ font-technology Variable Variables Every (minutes) Toutes les (minutes) Every option is at its default. Toutes les options ont leur valeur par défaut. Every option matches {base}. Toutes les options correspondent à {base}. - Every-line Composer Compositeur multiligne + Middle Eastern & South Asian Single-line Composer Compositeur ligne par ligne (Moyen-Orient et Asie du Sud) + Middle Eastern & South Asian Every-line Composer Compositeur multiligne (Moyen-Orient et Asie du Sud) + Standard Single-line Composer Compositeur standard ligne par ligne + Standard Every-line Composer Compositeur standard multiligne Exact dash lengths Longueurs de tirets exactes Exceptions (comma separated) Exceptions (séparées par des virgules) Exclude Exclusion @@ -1395,6 +1399,14 @@ font-technology Variable Variables Previous Artboard Plan de travail précédent Next Artboard Plan de travail suivant Last Artboard Dernier plan de travail + Status Bar Show Afficher dans la barre d'état + Artboard Name Nom du plan de travail + Current Tool Outil actif + Date and Time Date et heure + Number of Undos Nombre d'annulations + Document Color Profile Profil colorimétrique du document + Undos Annulations + Redos Rétablissements Go To Link Atteindre le lien GPL palettes keep solid colours only, as RGB. Les palettes GPL ne conservent que les couleurs unies, en RVB. GPU Performance Performances GPU @@ -3444,7 +3456,6 @@ digits Malayalam Malayalam Single Left Quotation Mark Guillemet simple ouvrant Single Quotes: Guillemets simples : Single Right Quotation Mark Guillemet simple fermant - Single-line Composer Compositeur ligne par ligne Toggle Line Composer Basculer le compositeur de lignes Sixth Space Espace d’un sixième Size Taille @@ -3779,6 +3790,8 @@ digits Malayalam Malayalam Kana Kana Numerals Chiffres Scale at Center Mettre à l’échelle depuis le centre + Delete Set Supprimer le jeu + Direct Entry Saisie directe Up/Down: Haut/bas : Left/Right: Gauche/droite : Tate-chu-yoko Up/Down Tate-chu-yoko haut/bas @@ -4468,6 +4481,7 @@ axis Both Les deux @msg `name` must be the name of a script engine `name` doit être le nom d'un moteur de script @msg the main script engine is new for each run and is never kept, so it cannot be dropped le moteur de script principal est nouveau à chaque exécution et n'est jamais conservé, il ne peut donc pas être supprimé @msg there is no script engine named {name} il n'y a aucun moteur de script nommé {name} +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` est réservé à une exécution sans personne à qui demander : dans l'application, la personne répond à chaque boîte de dialogue @msg Running a script Exécution d’un script @msg Copied the script report Rapport du script copié @msg Copied the link info Informations sur le lien copiées @@ -5401,6 +5415,11 @@ axis Both Les deux @msg Imported library {name} Bibliothèque {name} importée RGB: RVB : CMYK: CMJN : + RGB Policy: Règle RVB : + CMYK Policy: Règle CMJN : + Preserve Embedded Profiles Conserver les profils incorporés + Convert to Working Space Convertir en espace de travail + Preserve Numbers Conserver les valeurs numériques Texture Texture Craquelure… Craquelure… Grain… Grain… @@ -6264,6 +6283,8 @@ theme Appearance Apparence Save Variable Library… Enregistrer une bibliothèque de variables… @msg a variable library holds text variables, and the document has none une bibliothèque de variables contient des variables de texte, et le document n’en a aucune @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` la variable `{name}` ne peut pas être enregistrée dans une bibliothèque de variables : renommez-la pour qu’elle commence par une lettre ou `_`, sans espaces ni symboles autres que `-`, `_` et `.` +@msg {k} has control characters {k} contient des caractères de contrôle +@msg {k} has control characters or line breaks {k} contient des caractères de contrôle ou des sauts de ligne @msg data set `{name}` holds a control character, which a variable library can't hold le jeu de données `{name}` contient un caractère de contrôle, qu’une bibliothèque de variables ne peut pas contenir @msg data set `{name}` starts or ends with white space, which a variable library can't hold le jeu de données `{name}` commence ou se termine par une espace, qu’une bibliothèque de variables ne peut pas contenir Make Graph Dynamic Rendre le graphe dynamique @@ -6589,6 +6610,7 @@ axis Y Axis Axe Y @msg stops must be in order of `offset` les étapes doivent être classées par `offset` @msg the document has no pattern {pattern} le document n’a pas de motif {pattern} @msg the document has no swatch {n} le document n’a pas de nuance {n} +@msg `plates` requires a stop linked to a spot swatch `plates` exige une étape liée à une nuance de ton direct @msg the output la sortie @msg the path has no anchor points le tracé n’a aucun point d’ancrage @msg too many anchor points (limit {MAX_ANCHORS}) trop de points d’ancrage (limite {MAX_ANCHORS}) @@ -7661,6 +7683,7 @@ breakText Glyphs Glyphes @msg Image Trace failed: internal error: its thread stopped (please report this bug) Échec de la vectorisation de l’image : erreur interne : son thread s’est arrêté (merci de signaler ce bogue) @msg Image Trace failed: {e} Échec de la vectorisation de l’image : {e} @msg Image Trace finished, but its document was closed La vectorisation de l’image est terminée, mais son document a été fermé +@msg Image Trace finished, but its document was reverted: trace it again La vectorisation de l’image est terminée, mais son document a été rétabli à la version précédente : vectorisez-la à nouveau @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again La vectorisation de l’image est terminée, mais l’image ou ses réglages ont changé entre-temps : vectorisez-la à nouveau @msg Import SVG Filter reads SVG files, not SVGZ Importer un filtre SVG lit les fichiers SVG, pas SVGZ @msg Invalid dimension offset Décalage de cote non valide @@ -7833,6 +7856,7 @@ breakText Glyphs Glyphes @msg `names` must be an array of style names `names` doit être une liste de noms de styles @msg `new` is a design name, or true `new` est un nom de motif, ou true @msg `origin` must be [x, y] in points `origin` doit être [x, y] en points +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` doit être [a, b, c, d], chacun compris entre -1000 et 1000, avec a·d - b·c à au moins 0.001 de 0 @msg `path` must be a folder `path` doit être un dossier @msg `path` must be a path object `path` doit être un objet tracé @msg `picks` must be an array (got {v}) `picks` doit être une liste (reçu : {v}) @@ -8232,7 +8256,7 @@ breakText Glyphs Glyphes @msg column: true floats the whole column and takes no onto, above or below column: true rend flottante toute la colonne et n’accepte ni onto, ni above, ni below @msg command must be a shape.* command command doit être une commande shape.* @msg commands[{i}]: missing `command` commands[{i}] : `command` manquant -@msg composer must be "singleLine" or "everyLine" composer doit valoir "singleLine" ou "everyLine" +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer doit être "singleLine", "everyLine", "standardSingleLine" ou "standardEveryLine" @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes les formes composées acceptent les tracés, tracés transparents, groupes, formes composées, textes, dégradés de formes et enveloppes @msg compound {_1} must contain exactly one open path to join le tracé transparent {_1} doit contenir exactement un tracé ouvert à joindre @msg connect end must be "start" or "end" l’extrémité de connect doit valoir "start" ou "end" diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 31548e1933..11e79e41d1 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -188,6 +188,7 @@ Align to Artboard આર્ટબોર્ડ પર સંરેખિત કરો Align to Glyph Bounds ગ્લિફ સીમા પર સંરેખિત કરો Align to Key Object કી ઑબ્જેક્ટ પર સંરેખિત કરો + Align to Key Anchor કી એન્કર બિંદુ પર સંરેખિત કરો Align to Page પેજ પર સંરેખિત કરો Align to Path પાથ પર સંરેખિત કરો Align to Pixel Grid પિક્સેલ ગ્રિડ પર સંરેખિત કરો @@ -471,6 +472,11 @@ CMYK Images CMYK ઇમેજ CMYK colours print with their own values; off, they go through the colour settings too. CMYK રંગો પોતાની કિંમતો સાથે છાપે છે; બંધ હોય ત્યારે તે રંગ સેટિંગમાંથી પણ પસાર થાય છે. CMYK: CMYK: + RGB Policy: RGB નીતિ: + CMYK Policy: CMYK નીતિ: + Preserve Embedded Profiles એમ્બેડ કરેલી પ્રોફાઇલ જાળવો + Convert to Working Space કાર્યકારી સ્પેસમાં રૂપાંતરિત કરો + Preserve Numbers સંખ્યાઓ જાળવો CSS Properties CSS ગુણધર્મો Calligraphic Brush કેલિગ્રાફિક બ્રશ Calligraphic Brush Options કેલિગ્રાફિક બ્રશ વિકલ્પો @@ -1235,7 +1241,6 @@ Every (minutes) દર (મિનિટ) Every option is at its default. દરેક વિકલ્પ ડિફૉલ્ટ પર છે. Every option matches {base}. દરેક વિકલ્પ {base} સાથે મેળ ખાય છે. - Every-line Composer એવરી-લાઇન કમ્પોઝર Exact dash lengths ચોક્કસ ડેશ લંબાઈ Exceptions (comma separated) અપવાદો (અલ્પવિરામથી અલગ) Exclude બાકાત રાખો @@ -1902,6 +1907,14 @@ Lasso Tool લાસો ટૂલ Last છેલ્લું Last Artboard છેલ્લું આર્ટબોર્ડ + Status Bar Show સ્ટેટસ બારમાં બતાવો + Artboard Name આર્ટબોર્ડનું નામ + Current Tool વર્તમાન ટૂલ + Date and Time તારીખ અને સમય + Number of Undos પૂર્વવત્ કરવાની સંખ્યા + Document Color Profile દસ્તાવેજની રંગ પ્રોફાઇલ + Undos પૂર્વવત્ + Redos ફરીથી કરો Last Effect: {name} છેલ્લી ઇફેક્ટ: {name} Last Effect… છેલ્લી ઇફેક્ટ… Last Page છેલ્લું પેજ @@ -3299,6 +3312,8 @@ Scale Text Attributes ટેક્સ્ટ ગુણધર્મો સ્કેલ કરો Scale Tool સ્કેલ ટૂલ Scale at Center કેન્દ્રથી સ્કેલ કરો + Delete Set સેટ કાઢી નાખો + Direct Entry સીધી એન્ટ્રી Scale in Perspective પર્સ્પેક્ટિવમાં સ્કેલ કરો Scale to Fit Artboard આર્ટબોર્ડમાં બંધબેસતું સ્કેલ કરો Scale: સ્કેલ: @@ -3574,7 +3589,6 @@ Single Quotes: એકલ અવતરણ ચિહ્નો: Single Right Quotation Mark જમણું એકલ અવતરણ ચિહ્ન Single Word Justification: એક શબ્દ જસ્ટિફિકેશન: - Single-line Composer સિંગલ-લાઇન કમ્પોઝર Sixth Space છઠ્ઠી જગ્યા Size કદ Size Tile to Art ટાઇલનું કદ આર્ટ પ્રમાણે કરો @@ -4757,3 +4771,8 @@ swatch [Registration] [રજિસ્ટ્રેશન] Repeat Objects પુનરાવર્તિત ઑબ્જેક્ટ્સ @msg `{path}` must be one of {_1}, not {got} `{path}` એ {_1}માંથી એક હોવું જોઈએ, {got} નહીં Path Length પાથની લંબાઈ + Middle Eastern & South Asian Single-line Composer સિંગલ-લાઇન કમ્પોઝર (મધ્ય પૂર્વ અને દક્ષિણ એશિયા) + Middle Eastern & South Asian Every-line Composer એવરી-લાઇન કમ્પોઝર (મધ્ય પૂર્વ અને દક્ષિણ એશિયા) + Standard Single-line Composer સ્ટાન્ડર્ડ સિંગલ-લાઇન કમ્પોઝર + Standard Every-line Composer સ્ટાન્ડર્ડ એવરી-લાઇન કમ્પોઝર +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer આમાંથી એક હોવું જોઈએ: "singleLine", "everyLine", "standardSingleLine" અથવા "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index bcaa573d47..2bb71a0b45 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -283,6 +283,7 @@ Align to Corners and Ends कोनों और सिरों पर संरेखित करें Align to Glyph Bounds ग्लिफ़ की सीमाओं पर संरेखित करें Align to Key Object मुख्य ऑब्जेक्ट पर संरेखित करें + Align to Key Anchor मुख्य एंकर पॉइंट पर संरेखित करें Align to Page पेज पर संरेखित करें Align to Path पाथ पर संरेखित करें Align to Selection सिलेक्शन पर संरेखित करें @@ -891,6 +892,11 @@ antiAlias Sharp शार्प CMYK Images CMYK इमेज CMYK profile in effect इफ़ेक्ट में CMYK प्रोफ़ाइल CMYK: CMYK: + RGB Policy: RGB नीति: + CMYK Policy: CMYK नीति: + Preserve Embedded Profiles एम्बेड की गई प्रोफ़ाइल बनाए रखें + Convert to Working Space वर्किंग स्पेस में बदलें + Preserve Numbers संख्याएँ बनाए रखें Coarse Dots मोटे बिंदु Coincidence: संयोग: Collapse Panel Group पैनल समूह संक्षिप्त करें @@ -1712,7 +1718,8 @@ antiAlias Sharp शार्प Every (minutes) हर (मिनट) Every option is at its default. प्रत्येक ऑप्शन इसके डिफ़ॉल्ट रूप से होता है। Every option matches {base}. प्रत्येक ऑप्शन {base} से मेल खाता है। - Every-line Composer हर लाइन संगीतकार + Middle Eastern & South Asian Every-line Composer हर लाइन संगीतकार (मध्य पूर्व और दक्षिण एशिया) + Standard Every-line Composer मानक हर लाइन संगीतकार Exact सटीक Exact dash lengths सटीक डैश लंबाई Example: {name} उदाहरण: {name} @@ -2564,6 +2571,14 @@ antiAlias Sharp शार्प Lasso Tool लैस्सो टूल Last अंतिम Last Artboard अंतिम आर्टबोर्ड + Status Bar Show स्टेटस बार में दिखाएँ + Artboard Name आर्टबोर्ड का नाम + Current Tool मौजूदा टूल + Date and Time तारीख और समय + Number of Undos पूर्ववत करने की संख्या + Document Color Profile दस्तावेज़ की रंग प्रोफ़ाइल + Undos पूर्ववत + Redos फिर से करें Last commit अंतिम कमिट Last Effect: {} अंतिम इफ़ेक्ट: {} Last Effect: {name} अंतिम इफ़ेक्ट: {name} @@ -4734,7 +4749,8 @@ antiAlias Sharp शार्प Single Quotes: एकल उद्धरण: Single Right Quotation Mark सिंगल राइट कोटेशन मार्क Single Word Justification: एकल शब्द जस्टिफ़िकेशन: - Single-line Composer एकल लाइन संगीतकार + Middle Eastern & South Asian Single-line Composer एकल लाइन संगीतकार (मध्य पूर्व और दक्षिण एशिया) + Standard Single-line Composer मानक एकल लाइन संगीतकार Sixth Space छठे हिस्से जितना स्पेस Size आकार Size Tile to Art आकार टाइल कला के लिए @@ -5157,6 +5173,8 @@ antiAlias Sharp शार्प Kana काना Numerals अंक Scale at Center केंद्र से आकार बदलें + Delete Set सेट हटाएँ + Direct Entry सीधी प्रविष्टि Technical Drawing तकनीकी ड्राइंग Telugu तेलुगु Temperature तापमान @@ -5703,6 +5721,7 @@ antiAlias Sharp शार्प @msg needs approval: it asks for {0}, which the user grants when installing it on its own स्वीकृति आवश्यक: यह {0} माँगता है, जिसे उपयोगकर्ता इसे अलग से स्थापित करते समय देता है @msg `angle` must be a number `angle` एक संख्या होना चाहिए @msg `aspect` must be from 0.01 to 100 `aspect` 0.01 से 100 तक होना चाहिए +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` [a, b, c, d] होना चाहिए, हर मान -1000 से 1000 तक, और a·d - b·c का 0 से अंतर कम से कम 0.001 होना चाहिए @msg `cap` must be "butt", "round" or "square" `cap` "butt", "round" या "square" होना चाहिए @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` "nonzero" या "evenodd" होना चाहिए @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` अधिकतम {MAX_HELP_URL} बाइट का https:// पता होना चाहिए @@ -5755,6 +5774,7 @@ antiAlias Sharp शार्प @msg stops must be in order of `offset` स्टॉप `offset` के क्रम में होने चाहिए @msg the document has no pattern {pattern} डॉक्यूमेंट में पैटर्न {pattern} नहीं है @msg the document has no swatch {n} डॉक्यूमेंट में स्वॉच {n} नहीं है +@msg `plates` requires a stop linked to a spot swatch `plates` के लिए स्पॉट रंग स्वॉच से जुड़ा स्टॉप आवश्यक है @msg the output आउटपुट @msg the path has no anchor points पाथ में कोई एंकर पॉइंट नहीं है @msg too many anchor points (limit {MAX_ANCHORS}) बहुत अधिक एंकर पॉइंट (सीमा {MAX_ANCHORS}) @@ -5930,7 +5950,8 @@ antiAlias Sharp शार्प @msg {id} is not a slice {id} एक स्लाइस नहीं है @msg {id} opens another app: only the person at the keyboard can run it {id} एक अन्य ऐप खोलता है: केवल कीबोर्ड पर व्यक्ति इसे चला सकता है @msg {k} `{v}` is not one of the choices {k} `{v}` ऑप्शन में से एक नहीं है -@msg {k} has control characters{_1} {k} में नियंत्रण वर्ण हैं{_1} +@msg {k} has control characters {k} में नियंत्रण वर्ण हैं +@msg {k} has control characters or line breaks {k} में नियंत्रण वर्ण या लाइन ब्रेक हैं @msg {k} is too long (at most {MAX_TEXT} characters) {k} बहुत लंबा है (अधिकांश {MAX_TEXT} वर्णों पर) @msg {k} must be {{size?, position?}} {k} को {{size?, position?}} होना चाहिए @msg {k} must be > 0 {k} होना चाहिए > 0 @@ -6693,7 +6714,7 @@ antiAlias Sharp शार्प @msg command `{id}` is not available right now आदेश `{id}` अभी उपलब्ध नहीं है @msg command must be a shape.* command command कोई shape.* कमांड होना चाहिए @msg commands[{i}]: missing `command` आदेश [{i}]: लापता `command` -@msg composer must be "singleLine" or "everyLine" composer "singleLine" या "everyLine" होना चाहिए +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer "singleLine", "everyLine", "standardSingleLine" या "standardEveryLine" होना चाहिए @msg compound {_1} must contain exactly one open path to join जोड़ने के लिए कंपाउंड {_1} में ठीक एक खुला पाथ होना चाहिए @msg compound paths can only contain paths यौगिक पाथ में केवल पाथ हो सकते हैं @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes कंपाउंड शेप पाथ, कंपाउंड पाथ, समूह, कंपाउंड शेप, टाइप, ब्लेंड और एन्वेलप लेते हैं @@ -6811,6 +6832,18 @@ antiAlias Sharp शार्प @msg `name` must be the name of a script engine `name` किसी स्क्रिप्ट इंजन का नाम होना चाहिए @msg the main script engine is new for each run and is never kept, so it cannot be dropped मुख्य स्क्रिप्ट इंजन हर रन के लिए नया होता है और कभी रखा नहीं जाता, इसलिए इसे हटाया नहीं जा सकता @msg there is no script engine named {name} {name} नाम का कोई स्क्रिप्ट इंजन नहीं है +@msg `answers` must be a list of answers `answers` उत्तरों की सूची होनी चाहिए +@msg `answers` has more than {MAX_ANSWERS} answers `answers` में {MAX_ANSWERS} से अधिक उत्तर हैं +@msg `answers` is larger than 1 MiB `answers` 1 MiB से बड़ा है +@msg `answers[{i}]` {e} `answers[{i}]`: {e} +@msg must be an object with `kind` and `value` `kind` और `value` वाला ऑब्जेक्ट होना चाहिए +@msg must have exactly `kind` and `value` इसमें ठीक-ठीक `kind` और `value` होने चाहिए +@msg is an alert's answer, whose `value` must be "ok" यह alert का उत्तर है, इसलिए इसका `value` "ok" होना चाहिए +@msg is a confirm's answer, whose `value` must be true or false यह confirm का उत्तर है, इसलिए इसका `value` true या false होना चाहिए +@msg is longer than 16 KiB 16 KiB से लंबा है +@msg is a prompt's answer, whose `value` must be a string or null यह prompt का उत्तर है, इसलिए इसका `value` स्ट्रिंग या null होना चाहिए +@msg must have `kind` alert, confirm or prompt `kind` alert, confirm या prompt होना चाहिए +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` केवल ऐसे रन के लिए है जिसमें पूछने के लिए कोई नहीं है: ऐप में, व्यक्ति हर डायलॉग का उत्तर देता है @msg Running a script स्क्रिप्ट चल रही है @msg Copied the script report Copied स्क्रिप्ट रिपोर्ट @msg Copied the link info लिंक जानकारी कॉपी की गई @@ -7106,6 +7139,7 @@ antiAlias Sharp शार्प @msg Image Trace failed: internal error: its thread stopped (please report this bug) इमेज ट्रेस विफल: आंतरिक त्रुटि: इसके धागे को बंद कर दिया (कृपया इस बग की रिपोर्ट करें) @msg Image Trace finished, but its document was closed इमेज ट्रेस समाप्त हो गया, लेकिन इसका डॉक्यूमेंट बंद हो गया। @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again इमेज ट्रेस समाप्त हो गया, लेकिन इस बीच इमेज या इसकी सेटिंग्स बदल गई: इसे फिर से ट्रेस करें +@msg Image Trace finished, but its document was reverted: trace it again इमेज ट्रेस समाप्त हो गया, लेकिन इसके डॉक्यूमेंट को मूल स्थिति में लाया गया: इसे फिर से ट्रेस करें @msg Image Trace: the traced image is missing इमेज ट्रेस: ज्ञात इमेज गायब है @msg image would be too large; lower the resolution इमेज बहुत बड़ी होगी; रिज़ॉल्यूशन कम करें @msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client या server diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8a2e0b3b9a..253e8f6764 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -229,6 +229,7 @@ Align to Artboard Igazítás a rajztáblához Align to Glyph Bounds Igazítás a glifák határaihoz Align to Key Object Igazítás a kulcsobjektumhoz + Align to Key Anchor Igazítás a kulcs-horgonyponthoz Align to Page Igazítás az oldalhoz Align to Path Igazítás az útvonalhoz Align to Selection Igazítás a kijelöléshez @@ -695,6 +696,11 @@ font-technology Variable Változó CMYK Equivalents CMYK-megfelelők CMYK profile in effect Érvényes CMYK-profil CMYK: CMYK: + RGB Policy: RGB-házirend: + CMYK Policy: CMYK-házirend: + Preserve Embedded Profiles Beágyazott profilok megtartása + Convert to Working Space Konvertálás a munkaszíntérbe + Preserve Numbers Számértékek megtartása Coarse Dots Durva pontok Collapse Panels to Icons Panelek ikonná csukása Collapse to Icons Ikonná csukás @@ -1362,7 +1368,10 @@ font-technology Variable Változó Every (minutes) Gyakoriság (perc) Every option is at its default. Minden beállítás az alapértéken áll. Every option matches {base}. Minden beállítás egyezik: {base}. - Every-line Composer Többsoros tördelő + Middle Eastern & South Asian Single-line Composer Egysoros tördelő (Közel-Kelet és Dél-Ázsia) + Middle Eastern & South Asian Every-line Composer Többsoros tördelő (Közel-Kelet és Dél-Ázsia) + Standard Single-line Composer Normál egysoros tördelő + Standard Every-line Composer Normál többsoros tördelő Exact Pontos Exact dash lengths Pontos vonalhosszak Exceptions (comma separated) Kivételek (vesszővel elválasztva) @@ -1738,6 +1747,14 @@ file-filter Templates Sablonok Previous Artboard Előző rajztábla Next Artboard Következő rajztábla Last Artboard Utolsó rajztábla + Status Bar Show Megjelenítés az állapotsorban + Artboard Name Rajztábla neve + Current Tool Aktuális eszköz + Date and Time Dátum és idő + Number of Undos Visszavonások száma + Document Color Profile Dokumentum színprofilja + Undos Visszavonások + Redos Újra Go to Conflict Ugrás az ütközésre Go To Link Ugrás a csatoláshoz Good Jó @@ -4101,7 +4118,6 @@ mark-style Japanese Japán Single Left Quotation Mark Bal oldali szimpla idézőjel Single Quotes: Szimpla idézőjelek: Single Right Quotation Mark Jobb oldali szimpla idézőjel - Single-line Composer Egysoros tördelő Toggle Line Composer Sortördelő váltása Sixth Space Hatodkvirt-szóköz Size Méret @@ -4455,6 +4471,8 @@ mark-style Japanese Japán Kana Kana Numerals Számjegyek Scale at Center Méretezés középről + Delete Set Készlet törlése + Direct Entry Közvetlen bevitel Up/Down: Fel/le: Left/Right: Balra/jobbra: Tate-chu-yoko Up/Down Tate-chu-yoko fel/le @@ -5292,6 +5310,8 @@ imageTrace Outline Körvonal @msg `name` must be the name of a script engine a `name` értékének egy parancsfájlmotor nevének kell lennie @msg the main script engine is new for each run and is never kept, so it cannot be dropped a fő parancsfájlmotor minden futáshoz új, és sosem marad meg, ezért nem dobható el @msg there is no script engine named {name} nincs {name} nevű parancsfájlmotor +@msg `answers[{i}]` {e} `answers[{i}]`: {e} +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog az `answers` csak olyan futtatáshoz való, ahol nincs kit megkérdezni: az alkalmazásban minden párbeszédpanelre a felhasználó válaszol @msg Running a script Parancsfájl futtatása @msg Copied the script report Szkriptjelentés másolva @msg Copied the link info Csatolás adatai másolva @@ -6252,6 +6272,8 @@ imageTrace Outline Körvonal Save Variable Library… Változókönyvtár mentése… @msg a variable library holds text variables, and the document has none a változókönyvtár szöveges változókat tartalmaz, a dokumentumban pedig nincs ilyen @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` a(z) `{name}` változó nem menthető változókönyvtárba: nevezze át úgy, hogy betűvel vagy `_` jellel kezdődjön, szóközök és a `-`, `_` és `.` jeltől eltérő szimbólumok nélkül +@msg {k} has control characters {k} vezérlőkaraktereket tartalmaz +@msg {k} has control characters or line breaks {k} vezérlőkaraktereket vagy sortöréseket tartalmaz @msg data set `{name}` holds a control character, which a variable library can't hold a(z) `{name}` adatkészlet vezérlőkaraktert tartalmaz, amelyet a változókönyvtár nem tartalmazhat @msg data set `{name}` starts or ends with white space, which a variable library can't hold a(z) `{name}` adatkészlet szóközzel kezdődik vagy végződik, amelyet a változókönyvtár nem tartalmazhat Make Graph Dynamic Diagram dinamikussá tétele @@ -6577,6 +6599,7 @@ axis Y Axis Y tengely @msg stops must be in order of `offset` a színátmenet-pontok `offset` szerint rendezve legyenek @msg the document has no pattern {pattern} a dokumentumban nincs {pattern} mintázat @msg the document has no swatch {n} a dokumentumban nincs {n} színminta +@msg `plates` requires a stop linked to a spot swatch a `plates` direktszín-színmintához kapcsolt színátmenet-pontot igényel @msg the output a kimenet @msg the path has no anchor points az útvonalnak nincsenek horgonypontjai @msg too many anchor points (limit {MAX_ANCHORS}) túl sok horgonypont (korlát: {MAX_ANCHORS}) @@ -7663,6 +7686,7 @@ breakText Glyphs Glifák @msg Image Trace failed: {e} A képvektorizálás nem sikerült: {e} @msg Image Trace finished, but its document was closed A képvektorizálás elkészült, de a dokumentumát bezárták @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again A képvektorizálás elkészült, de a kép vagy a beállításai közben megváltoztak: vektorizáld újra +@msg Image Trace finished, but its document was reverted: trace it again A képvektorizálás elkészült, de a dokumentumát visszaállították a mentett változatra: vektorizáld újra @msg Import SVG Filter reads SVG files, not SVGZ Az SVG-szűrő importálása SVG-fájlokat olvas, SVGZ-fájlokat nem @msg Invalid dimension offset Érvénytelen méretvonal-eltolás @msg Invalid point Érvénytelen pont @@ -7836,6 +7860,7 @@ breakText Glyphs Glifák @msg `names` must be an array of style names `names`: stílusnevek tömbje kell @msg `new` is a design name, or true `new`: diagramminta neve vagy true @msg `origin` must be [x, y] in points `origin`: [x, y] kell pontban megadva +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes`: [a, b, c, d] kell, mindegyik -1000 és 1000 között, és a·d - b·c legalább 0.001-gyel térjen el 0-tól @msg `path` must be a folder `path`: mappa kell @msg `path` must be a path object `path`: útvonalobjektum kell @msg `picks` must be an array (got {v}) `picks`: tömb kell (kapott érték: {v}) @@ -8241,7 +8266,7 @@ breakText Glyphs Glifák @msg column: true floats the whole column and takes no onto, above or below column: true az egész oszlopot lebegővé teszi, és nem adható meg mellette onto, above vagy below @msg command must be a shape.* command command: shape.* parancs kell @msg commands[{i}]: missing `command` commands[{i}]: hiányzik: `command` -@msg composer must be "singleLine" or "everyLine" composer: "singleLine" vagy "everyLine" kell +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer értéke "singleLine", "everyLine", "standardSingleLine" vagy "standardEveryLine" lehet @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes összetett alakzatba útvonalak, összetett útvonalak, csoportok, összetett alakzatok, szöveg, összemosások és burkolók kerülhetnek @msg compound {_1} must contain exactly one open path to join az összetett objektumnak ({_1}) pontosan egy összekapcsolható nyitott útvonalat kell tartalmaznia @msg connect needs a path other than the one extended connect: a meghosszabbított útvonaltól különböző útvonal kell diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 712148180d..c5ef1c53ac 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -928,6 +928,11 @@ NTSC Colors Warna NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Kebijakan RGB: + CMYK Policy: Kebijakan CMYK: + Preserve Embedded Profiles Pertahankan Profil Tersemat + Convert to Working Space Konversi ke Ruang Kerja + Preserve Numbers Pertahankan Angka CMYK CMYK Texture Tekstur Craquelure… Craquelure… @@ -1427,6 +1432,7 @@ lighting Light Cahaya Align to Artboard Ratakan ke artboard Align to Glyph Bounds Ratakan ke batas glif Align to Key Object Ratakan ke objek kunci + Align to Key Anchor Ratakan ke titik jangkar kunci Align to Selection Ratakan ke seleksi Align To: Ratakan ke: Cancel Key Object Batalkan objek kunci @@ -1576,6 +1582,8 @@ digits Malayalam Malayalam Kana Kana Numerals Angka Scale at Center Skalakan dari tengah + Delete Set Hapus set + Direct Entry Entri langsung Up/Down: Atas/bawah: Left/Right: Kiri/kanan: Tate-chu-yoko Up/Down Tate-chu-yoko atas/bawah @@ -1859,7 +1867,10 @@ digits Malayalam Malayalam Tabular Lining Lining tabular Tabular Oldstyle Oldstyle tabular Titling Alternates Alternatif judul - Every-line Composer Komposer tiap baris + Middle Eastern & South Asian Single-line Composer Komposer satu baris (Timur Tengah dan Asia Selatan) + Middle Eastern & South Asian Every-line Composer Komposer tiap baris (Timur Tengah dan Asia Selatan) + Standard Single-line Composer Komposer standar satu baris + Standard Every-line Composer Komposer standar tiap baris First-line Left Indent Indentasi kiri baris pertama Hyphenation… Pemenggalan kata… Justification… Justifikasi… @@ -1891,7 +1902,6 @@ digits Malayalam Malayalam East Asian Composition Settings Pengaturan komposisi Asia Timur Korean Composition Settings Pengaturan komposisi Korea Select a text object to edit its paragraph attributes. Pilih objek teks untuk mengedit atribut paragrafnya. - Single-line Composer Komposer satu baris Toggle Line Composer Alihkan komposer baris Space After Paragraph Spasi setelah paragraf Space Before Paragraph Spasi sebelum paragraf @@ -5850,6 +5860,14 @@ swatch Bright Violet Violet cerah Color wheel Roda warna First Artboard Artboard pertama Last Artboard Artboard terakhir + Status Bar Show Tampilkan di bilah status + Artboard Name Nama artboard + Current Tool Alat saat ini + Date and Time Tanggal dan waktu + Number of Undos Jumlah urungkan + Document Color Profile Profil warna dokumen + Undos Urungkan + Redos Ulangi Next Artboard Artboard berikutnya Previous Artboard Artboard sebelumnya Step up or down Naikkan atau turunkan bertahap @@ -6992,3 +7010,4 @@ breakText Glyphs Glif Nest Shapes… Susun Bentuk… Nest Shapes Susun Bentuk Roll Gulungan +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer harus "singleLine", "everyLine", "standardSingleLine", atau "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1d2e05bfb0..b0ef62f97a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -160,6 +160,7 @@ Align to Artboard Allinea alla tavola da disegno Align to Glyph Bounds Allinea ai limiti del glifo Align to Key Object Allinea all'oggetto chiave + Align to Key Anchor Allinea al punto di ancoraggio chiave Align to Page Allinea alla pagina Align to Path Allinea al tracciato Align to Selection Allinea alla selezione @@ -1085,7 +1086,8 @@ font-technology Variable Variabili Every (minutes) Ogni (minuti) Every option is at its default. Tutte le opzioni hanno il valore predefinito. Every option matches {base}. Tutte le opzioni corrispondono a {base}. - Every-line Composer Composizione su tutte le righe + Middle Eastern & South Asian Every-line Composer Composizione su tutte le righe (Medio Oriente e Asia meridionale) + Standard Every-line Composer Composizione standard su tutte le righe Exact dash lengths Lunghezze esatte dei trattini Exceptions (comma separated) Eccezioni (separate da virgole) Exclude Escludi @@ -1422,6 +1424,14 @@ file-filter Templates Modelli Previous Artboard Tavola da disegno precedente Next Artboard Tavola da disegno successiva Last Artboard Ultima tavola da disegno + Status Bar Show Mostra nella barra di stato + Artboard Name Nome tavola da disegno + Current Tool Strumento attivo + Date and Time Data e ora + Number of Undos Numero di annullamenti + Document Color Profile Profilo colore del documento + Undos Annullamenti + Redos Ripristini Go To Link Vai al collegamento GPL palettes keep solid colours only, as RGB. Le palette GPL conservano solo i colori pieni, in RGB. GPU Performance Prestazioni GPU @@ -3497,7 +3507,8 @@ digits Malayalam Malayalam Single Left Quotation Mark Virgoletta singola di apertura Single Quotes: Virgolette singole: Single Right Quotation Mark Virgoletta singola di chiusura - Single-line Composer Composizione su riga singola + Middle Eastern & South Asian Single-line Composer Composizione su riga singola (Medio Oriente e Asia meridionale) + Standard Single-line Composer Composizione standard su riga singola Toggle Line Composer Alterna composizione delle righe Sixth Space Spazio di un sesto Size Dimensione @@ -3833,6 +3844,8 @@ digits Malayalam Malayalam Kana Kana Numerals Numeri Scale at Center Ridimensiona dal centro + Delete Set Elimina set + Direct Entry Immissione diretta Up/Down: Su/giù: Left/Right: Sinistra/destra: Tate-chu-yoko Up/Down Tate-chu-yoko su/giù @@ -5446,6 +5459,11 @@ axis Both Entrambi @msg Imported library {name} Libreria {name} importata RGB: RGB: CMYK: CMYK: + RGB Policy: Criterio RGB: + CMYK Policy: Criterio CMYK: + Preserve Embedded Profiles Mantieni profili incorporati + Convert to Working Space Converti nello spazio di lavoro + Preserve Numbers Mantieni valori numerici Texture Texture Craquelure… Craquelé… Grain… Granulosità… @@ -6220,6 +6238,7 @@ axis Y Axis Asse Y @msg stops must be in order of `offset` i cursori colore devono essere in ordine di `offset` @msg the document has no pattern {pattern} il documento non ha il motivo {pattern} @msg the document has no swatch {n} il documento non ha il campione {n} +@msg `plates` requires a stop linked to a spot swatch `plates` richiede un cursore colore collegato a un campione di colore spot @msg the output l’output @msg the path has no anchor points il tracciato non ha punti di ancoraggio @msg too many anchor points (limit {MAX_ANCHORS}) troppi punti di ancoraggio (limite {MAX_ANCHORS}) @@ -7291,6 +7310,7 @@ breakText Glyphs Glifi @msg `answer` for this {kind} dialog must be one of: {_1} `answer` per questa finestra di dialogo {kind} deve essere uno tra: {_1} @msg `answers` has more than {MAX_ANSWERS} answers `answers` ha più di {MAX_ANSWERS} risposte @msg `answers` is larger than 1 MiB `answers` supera 1 MiB +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` è solo per un'esecuzione senza nessuno a cui chiedere: nell'app, la persona risponde a ogni finestra di dialogo @msg `answers` must be a list of answers `answers` deve essere un elenco di risposte @msg `artboard` must be the index of an artboard of the document `artboard` deve essere l'indice di una tavola da disegno del documento @msg `artboardsAt` must be [x, y] `artboardsAt` deve essere [x, y] @@ -7323,6 +7343,7 @@ breakText Glyphs Glifi @msg `name` must be a name that is not empty `name` deve essere un nome non vuoto @msg `name` must not be empty `name` non deve essere vuoto @msg `origin` must be [x, y] in points `origin` deve essere [x, y] in punti +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` deve essere [a, b, c, d], ciascuno compreso tra -1000 e 1000, con a·d - b·c distante almeno 0.001 da 0 @msg `picks` must be an array (got {v}) `picks` deve essere un array (ricevuto {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` deve contenere da 1 a {MAX_PICKS} scelte (ricevute {_1}) @msg `points` must hold from 2 to 100000 points `points` deve contenere da 2 a 100000 punti @@ -7690,6 +7711,7 @@ breakText Glyphs Glifi @msg Image Trace failed: {e} Ricalco immagine non riuscito: {e} @msg Image Trace finished, but its document was closed Ricalco immagine terminato, ma il suo documento è stato chiuso @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Ricalco immagine terminato, ma nel frattempo l'immagine o le sue impostazioni sono cambiate: ricalcala di nuovo +@msg Image Trace finished, but its document was reverted: trace it again Ricalco immagine terminato, ma il suo documento è stato ripristinato: ricalcala di nuovo @msg Invalid dimension offset Distanza della quota non valida @msg Invalid point Punto non valido @msg Join: a subpath went missing Unisci: un sottotracciato è andato perso @@ -8104,7 +8126,7 @@ breakText Glyphs Glifi @msg column: true floats the whole column and takes no onto, above or below column: true rende mobile l'intera colonna e non accetta onto, above o below @msg command must be a shape.* command command deve essere un comando shape.* @msg commands[{i}]: missing `command` commands[{i}]: `command` mancante -@msg composer must be "singleLine" or "everyLine" composer deve essere "singleLine" o "everyLine" +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer deve essere "singleLine", "everyLine", "standardSingleLine" o "standardEveryLine" @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes le forme composte accettano tracciati, tracciati composti, gruppi, forme composte, testo, fusioni e involucri @msg compound {_1} must contain exactly one open path to join il composto {_1} deve contenere esattamente un tracciato aperto da unire @msg connect end must be "start" or "end" end di connect deve essere "start" o "end" @@ -9035,7 +9057,8 @@ breakText Glyphs Glifi @msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) i file {kind} non hanno trasparenza, e il file avrebbe {what}: appiattiscila prima (object.flattenTransparency) @msg {kind} images can't be opened (see document.formats) le immagini {kind} non possono essere aperte (vedi document.formats) @msg {k} `{v}` is not one of the choices {k} `{v}` non è una delle scelte disponibili -@msg {k} has control characters{_1} {k} contiene caratteri di controllo{_1} +@msg {k} has control characters {k} contiene caratteri di controllo +@msg {k} has control characters or line breaks {k} contiene caratteri di controllo o interruzioni di riga @msg {k} is too long (at most {MAX_TEXT} characters) {k} è troppo lungo (al massimo {MAX_TEXT} caratteri) @msg {k} must be > 0 {k} deve essere > 0 @msg {k} must be a number (pt) or a length such as "210 mm" {k} deve essere un numero (pt) o una lunghezza come "210 mm" diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index cdf8b04d3c..ed4981b977 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1077,6 +1077,7 @@ digits Malayalam マラヤーラム数字 Align to Artboard アートボードに整列 Align to Glyph Bounds 字形の境界に整列 Align to Key Object キーオブジェクトに整列 + Align to Key Anchor キーアンカーポイントに整列 Align to Page ページに沿う Align to Path パスに沿う Align to Selection 選択範囲に整列 @@ -1687,7 +1688,10 @@ antiAlias Sharp シャープ Every (minutes) 間隔 (分) Every option is at its default. すべてのオプションが初期値です。 Every option matches {base}. すべてのオプションが {base} と同じです。 - Every-line Composer すべての行を一括処理 + Middle Eastern & South Asian Single-line Composer 1 行ずつ組む (中東・南アジア) + Middle Eastern & South Asian Every-line Composer すべての行を一括処理 (中東・南アジア) + Standard Single-line Composer 1 行ずつ組む (標準) + Standard Every-line Composer すべての行を一括処理 (標準) Exact dash lengths 破線の長さを正確に保つ Exceptions (comma separated) 例外 (カンマ区切り) Exclusion 除外 @@ -1884,6 +1888,14 @@ antiAlias Sharp シャープ Previous Artboard 前のアートボード Next Artboard 次のアートボード Last Artboard 最後のアートボード + Status Bar Show ステータスバーの表示 + Artboard Name アートボード名 + Current Tool 現在のツール + Date and Time 日付と時刻 + Number of Undos 取り消し回数 + Document Color Profile ドキュメントのカラープロファイル + Undos 取り消し + Redos やり直し Gradient Fill: click to apply, drag onto art グラデーションの塗り: クリックで適用、アートにドラッグ Gradient Mesh グラデーションメッシュ Gradient Tool グラデーションツール @@ -3281,7 +3293,6 @@ mark-style Japanese 日本式 Simulate Colored Paper 色紙をシミュレート Simulate Paper 紙色をシミュレート Single Quotes: 一重引用符: - Single-line Composer 1 行ずつ組む Toggle Line Composer 行の組版方式を切り替え Size Tile to Art タイルをアートワークのサイズに合わせる Move Tile with Art タイルをアートワークと一緒に移動 @@ -4161,6 +4172,8 @@ file-filter Templates テンプレート Kana かな Numerals 数字 Scale at Center 中心から拡大・縮小 + Delete Set セットを削除 + Direct Entry 直接入力 Up/Down: 上下: Left/Right: 左右: Tate-chu-yoko Up/Down 縦中横の上下 @@ -5296,6 +5309,7 @@ swatch Bright Violet ビビッドバイオレット @msg `name` must be the name of a script engine `name` はスクリプトエンジンの名前にしてください @msg the main script engine is new for each run and is never kept, so it cannot be dropped メインのスクリプトエンジンは実行ごとに新しく作られ、保持されないため、破棄できません @msg there is no script engine named {name} {name} という名前のスクリプトエンジンはありません +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` は尋ねる相手がいない実行専用です。アプリでは、各ダイアログに本人が回答します @msg Running a script スクリプトを実行中 @msg Copied the script report スクリプトのレポートをコピーしました @msg Copied the link info リンク情報をコピーしました @@ -5487,6 +5501,11 @@ swatch Bright Violet ビビッドバイオレット @msg Imported library {name} ライブラリ {name} を読み込みました RGB: RGB: CMYK: CMYK: + RGB Policy: RGB ポリシー: + CMYK Policy: CMYK ポリシー: + Preserve Embedded Profiles 埋め込まれたプロファイルを保持 + Convert to Working Space 作業用スペースに変換 + Preserve Numbers カラー値を保持 Texture テクスチャ Craquelure… クラッキング… Grain… 粒状… @@ -5933,6 +5952,8 @@ lighting Light ライト Save Variable Library… 変数ライブラリを保存… @msg a variable library holds text variables, and the document has none 変数ライブラリにはテキスト変数を保存しますが、このドキュメントにはテキスト変数がありません @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` 変数 `{name}` は変数ライブラリに保存できません: 英字または `_` で始まり、スペースや `-`、`_`、`.` 以外の記号を含まない名前に変更してください +@msg {k} has control characters {k} に制御文字が含まれています +@msg {k} has control characters or line breaks {k} に制御文字または改行が含まれています @msg data set `{name}` holds a control character, which a variable library can't hold データセット `{name}` に変数ライブラリに保存できない制御文字が含まれています @msg data set `{name}` starts or ends with white space, which a variable library can't hold データセット `{name}` の先頭または末尾に変数ライブラリに保存できない空白があります Make Graph Dynamic グラフを動的にする @@ -6286,6 +6307,7 @@ axis Y Axis Y 軸 @msg stops must be in order of `offset` 分岐点は `offset` の順に並べてください @msg the document has no pattern {pattern} ドキュメントにパターン {pattern} がありません @msg the document has no swatch {n} ドキュメントにスウォッチ {n} がありません +@msg `plates` requires a stop linked to a spot swatch `plates` には特色スウォッチにリンクされた分岐点が必要です @msg the output 出力 @msg the path has no anchor points パスにアンカーポイントがありません @msg too many anchor points (limit {MAX_ANCHORS}) アンカーポイントが多すぎます (上限 {MAX_ANCHORS}) @@ -6521,6 +6543,7 @@ axis Y Axis Y 軸 @msg `name` must be a name that is not empty `name` には空でない名前を指定してください @msg `name` must not be empty `name` を空にすることはできません @msg `origin` must be [x, y] in points `origin` にはポイント単位の [x, y] を指定してください +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` には [a, b, c, d] を指定してください (各値は -1000 から 1000、a·d - b·c は 0 から 0.001 以上離れた値) @msg `picks` must be an array (got {v}) `picks` には配列を指定してください (指定値: {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` には 1~{MAX_PICKS} 個の指定が必要です (指定数: {_1}) @msg `points` must hold from 2 to 100000 points `points` には 2~100000 個の点が必要です @@ -6889,6 +6912,7 @@ axis Y Axis Y 軸 @msg Image Trace failed: {e} 画像トレースに失敗しました: {e} @msg Image Trace finished, but its document was closed 画像トレースは完了しましたが、ドキュメントが閉じられています @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again 画像トレースは完了しましたが、その間に画像または設定が変更されました: もう一度トレースしてください +@msg Image Trace finished, but its document was reverted: trace it again 画像トレースは完了しましたが、ドキュメントは保存済みの状態に戻されました: もう一度トレースしてください @msg Invalid dimension offset 寸法のオフセットが無効です @msg Invalid point 点が無効です @msg Join: a subpath went missing 連結: サブパスが見つからなくなりました @@ -7303,7 +7327,7 @@ axis Y Axis Y 軸 @msg column: true floats the whole column and takes no onto, above or below column: true は列全体をフローティングにするため、onto、above、below は指定できません @msg command must be a shape.* command command には shape.* コマンドを指定してください @msg commands[{i}]: missing `command` commands[{i}]: `command` がありません -@msg composer must be "singleLine" or "everyLine" composer には "singleLine" または "everyLine" を指定してください +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer は "singleLine"、"everyLine"、"standardSingleLine"、"standardEveryLine" のいずれかにしてください @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes 複合シェイプに含められるのは、パス、複合パス、グループ、複合シェイプ、文字、ブレンド、エンベロープです @msg compound {_1} must contain exactly one open path to join 連結するには、複合パス {_1} にオープンパスがちょうど 1 つ含まれている必要があります @msg connect end must be "start" or "end" connect の end には "start" または "end" を指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a7b771b8b5..358960bd37 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -15,6 +15,2091 @@ # Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language names, # OpenType, the built-in workspace names and the bracketed perspective grid preset names. Keep # placeholders, ellipses and the leading/trailing spaces of fragments when editing. +# Georgian rows untranslated on main (catch-up, 2026-11-15) + Add as Single Graphic ერთ გრაფიკად დამატება + Expand as Wireframes კარკასებად გაშლა + Add art with Add as Single Graphic, or drop art here. დაამატეთ ნახატი ბრძანებით „ერთ გრაფიკად დამატება“ ან ჩამოაგდეთ ნახატი აქ. + Face მხარე + Graphics map onto the Plane, Extrude and Revolve object types. გრაფიკა თავსდება ობიექტის ტიპებზე „სიბრტყე“, „ამოწევა“ და „ბრუნვა“. + Line Gap სტრიქონებს შორის ღრეჩო + Manage Profiles პროფილების მართვა + Mapped graphics განთავსებული გრაფიკა + Materials მასალები + NEW DOCUMENT PROFILES ახალი დოკუმენტის პროფილები + Pick a graphic above to map it onto the object. აირჩიეთ გრაფიკა ზემოთ, რომ ობიექტზე განათავსოთ. + Remove Graphic გრაფიკის მოცილება + Replace the selected 3D objects with the edges of their faces, as stroked paths. მონიშნული 3D ობიექტების ჩანაცვლება მათი წახნაგების წიბოებით, კონტურიანი ტრაექტორიების სახით. + Select a 3D object to map the graphic onto it. მონიშნეთ 3D ობიექტი, რომ მასზე გრაფიკა განათავსოთ. + The selected objects have different graphics. მონიშნულ ობიექტებს სხვადასხვა გრაფიკა აქვს. + Word Gap სიტყვებს შორის ღრეჩო + Drop Script Engine სკრიპტის ძრავის მოცილება + Edit Paragraph Style აბზაცის სტილის რედაქტირება + Edit Profile პროფილის რედაქტირება + List Script Engines სკრიპტის ძრავების სია + Map Graphic გრაფიკის განთავსება + Move Object on Path ტრაექტორიაზე ობიექტის გადაადგილება + New Document Profiles ახალი დოკუმენტის პროფილები + Reset Profile პროფილის ჩამოყრა + Select Object on Path ტრაექტორიაზე ობიექტის მონიშვნა + Weather Icons ამინდის ხატულები +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions 'caller', 'callee' და 'arguments' მკაცრი რეჟიმის ფუნქციებში ვერ გამოიყენება +@msg 3D effects require finite coordinates within 100 million points 3D ეფექტებს სჭირდება სასრული კოორდინატები 100 მილიონი პუნქტის ფარგლებში +@msg 3D effects require one to ten lights 3D ეფექტებს სჭირდება ერთიდან ათამდე სინათლის წყარო +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. პროფილს მხოლოდ ერთი „ბრუნვის“ ეფექტი შეიძლება ჰქონდეს. სანაცვლოდ შეცვალეთ არსებული ეფექტის პარამეტრები. +@msg Add Points at Crossings: a path went missing წერტილების დამატება გადაკვეთებზე: ერთ-ერთი ტრაექტორია დაიკარგა +@msg Alignment must be one of the choices სწორება უნდა იყოს ერთ-ერთი შემოთავაზებული ვარიანტი +@msg BMP depth {_1}: 1, 4, 8, 16, 24 or 32 bits per pixel BMP-ის სიღრმე {_1}: 1, 4, 8, 16, 24 ან 32 ბიტი პიქსელზე +@msg BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer BMP-ის კოდირება ვერ მოხერხდა: {width} × {height} პიქსელი არ ეტევა ფორმატში ან პიქსელების ბუფერში +@msg Boolean.prototype.{name} was called on a value that is not a Boolean Boolean.prototype.{name} გამოძახებულია მნიშვნელობაზე, რომელიც Boolean არ არის +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB CMYK გამოსახულებები, რომელთა პიქსელური მნიშვნელობების CMYK-ში წაკითხვა ვერ მოხერხდა, RGB-ში იმპორტირდა +@msg CSS options: {e} CSS-ის პარამეტრები: {e} +@msg CSV has a header but no data rows CSV-ს აქვს სათაური, მაგრამ მონაცემთა სტრიქონები არ აქვს +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark CSV შეიცავს NUL ბაიტებს: შეინახეთ ის UTF-8-ად ან UTF-16-ად ბაიტების რიგის ნიშნით (BOM) +@msg CSV is empty CSV ცარიელია +@msg CSV is larger than {MAX_CSV_BYTES} bytes CSV {MAX_CSV_BYTES} ბაიტზე დიდია +@msg CSV is not UTF-16 CSV არ არის UTF-16 +@msg CSV is not UTF-8 CSV არ არის UTF-8 +@msg CSV would be larger than {MAX_CSV_BYTES} bytes CSV {MAX_CSV_BYTES} ბაიტზე დიდი იქნებოდა +@msg Cannot write to the location of “{name}”; choose where to save it იქ, სადაც „{name}“ მდებარეობს, ჩაწერა შეუძლებელია; აირჩიეთ, სად შეინახოთ +@msg Choose original artwork, not a dimension's generated paths აირჩიეთ საწყისი ნახატი და არა ზომის მიერ შექმნილი ტრაექტორიები +@msg Contour: select paths or shapes მოხაზულობა: მონიშნეთ ტრაექტორიები ან ფიგურები +@msg Contour: the Offset Path effect is not available მოხაზულობა: ეფექტი „ტრაექტორიის წანაცვლება“ მიუწვდომელია +@msg Create needs fills, strokes or both „შექმნას“ სჭირდება შევსებები, კონტურები ან ორივე +@msg DCS files can only be placed linked: use File › Place with Link on DCS ფაილების განთავსება მხოლოდ ბმულით შეიძლება: გამოიყენეთ ფაილი › განთავსება და ჩართეთ „ბმული“ +@msg DXF options: {e} DXF-ის პარამეტრები: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} DXF-ის მასშტაბი უნდა იყოს დადებითი რიცხვი, არაუმეტეს {_1}-ისა, და არა {scale} +@msg DXF {field} `{s}`: {allowed} DXF {field} `{s}`: {allowed} +@msg Date.prototype method called on a value that is not a Date object Date.prototype-ის მეთოდი გამოძახებულია მნიშვნელობაზე, რომელიც Date ობიექტი არ არის +@msg Dimension is locked or hidden ზომა ჩაკეტილი ან დამალულია +@msg Dimension mode does not match its source ზომის რეჟიმი არ შეესაბამება მის წყაროს +@msg Draw Inside needs exactly one selected path, compound path or text „შიგნით ხატვას“ სჭირდება ზუსტად ერთი მონიშნული ტრაექტორია, შედგენილი ტრაექტორია ან ტექსტი +@msg EPS Save writes all artwork: use document.export for selected-only EPS EPS-ად შენახვა მთელ ნახატს წერს: მხოლოდ მონიშნულის EPS-ისთვის გამოიყენეთ document.export +@msg EPS flattener: {e} EPS-ის გაბრტყელება: {e} +@msg EPS options: {e} EPS-ის პარამეტრები: {e} +@msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} +@msg Error.prototype.toString was called on a value that is not an object Error.prototype.toString გამოძახებულია მნიშვნელობაზე, რომელიც ობიექტი არ არის +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft „ეკრანებისთვის ექსპორტირება“ წერს png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai ან vectorcraft ფორმატებს +@msg Flare click coordinates must be finite numbers ბლიკის დაწკაპუნების კოორდინატები სასრული რიცხვები უნდა იყოს +@msg Function.prototype.toString was called on a value that is not a function Function.prototype.toString გამოძახებულია მნიშვნელობაზე, რომელიც ფუნქცია არ არის +@msg GIF encoding failed: the palette image is malformed GIF-ის კოდირება ვერ მოხერხდა: პალიტრის გამოსახულება დაზიანებულია +@msg GIF encoding failed: {e} GIF-ის კოდირება ვერ მოხერხდა: {e} +@msg HTML Text applies to object slices of type only „HTML ტექსტი“ მხოლოდ ტექსტური ობიექტების ნაჭრებზე ვრცელდება +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) „ობიექტების იდენტიფიკაცია“ დაყენებულია XML ID-ზე: სახელი უნდა იყოს სწორი XML ID (იწყებოდეს ასოთი, ქვედა ტირეთი ან ორწერტილით; ჰარების გარეშე) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) გამოსახულების ვექტორიზაცია ვერ მოხერხდა: შიდა შეცდომა: მისი ნაკადი შეჩერდა (გთხოვთ, შეგვატყობინოთ ამ ხარვეზის შესახებ) +@msg Image Trace failed: {e} გამოსახულების ვექტორიზაცია ვერ მოხერხდა: {e} +@msg Image Trace finished, but its document was closed გამოსახულების ვექტორიზაცია დასრულდა, მაგრამ მისი დოკუმენტი დაიხურა +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again გამოსახულების ვექტორიზაცია დასრულდა, მაგრამ ამასობაში გამოსახულება ან მისი პარამეტრები შეიცვალა: გაუშვით ვექტორიზაცია ხელახლა +@msg Import SVG Filter reads SVG files, not SVGZ „SVG ფილტრის იმპორტი“ კითხულობს SVG ფაილებს და არა SVGZ-ს +@msg Invalid dimension offset ზომის არასწორი წანაცვლება +@msg Invalid point არასწორი წერტილი +@msg JPEG colour profile: {e} JPEG-ის ფერის პროფილი: {e} +@msg JPEG encoding failed: pixel buffer doesn't match the image size JPEG-ის კოდირება ვერ მოხერხდა: პიქსელების ბუფერი გამოსახულების ზომას არ შეესაბამება +@msg JPEG encoding failed: {e} JPEG-ის კოდირება ვერ მოხერხდა: {e} +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (წანაცვლება {at}) +@msg JSON.stringify cannot write a structure that contains itself JSON.stringify ვერ ჩაწერს სტრუქტურას, რომელიც თავის თავს შეიცავს +@msg Join: a subpath went missing შეერთება: ერთ-ერთი ქვეტრაექტორია დაიკარგა +@msg Line Gap must be a number „სტრიქონებს შორის ღრეჩო“ უნდა იყოს რიცხვი +@msg Lock Workspace is not available in VectorCraft „სამუშაო სივრცის ჩაკეტვა“ VectorCraft-ში მიუწვდომელია +@msg Move Panel to New Window is not available: floating panels stay in the main window „პანელის ახალ ფანჯარაში გადატანა“ მიუწვდომელია: მცურავი პანელები მთავარ ფანჯარაში რჩება +@msg Namespace.prototype.toString needs a Namespace Namespace.prototype.toString-ს სჭირდება Namespace +@msg New Document profiles need the preferences folder, which this run doesn't have ახალი დოკუმენტის პროფილებს სჭირდება პარამეტრების საქაღალდე, რომელიც ამ გაშვებას არ აქვს +@msg Number.prototype.{name} was called on a value that is not a number Number.prototype.{name} გამოძახებულია მნიშვნელობაზე, რომელიც რიცხვი არ არის +@msg Object.create's prototype must be an object or null Object.create-ის პროტოტიპი უნდა იყოს ობიექტი ან null +@msg Object.{function} was given a value that is not an object Object.{function}-ს გადაეცა მნიშვნელობა, რომელიც ობიექტი არ არის +@msg Offset Path: the path is too complex to offset ტრაექტორიის წანაცვლება: ტრაექტორია წასანაცვლებლად ზედმეტად რთულია +@msg Offset Path: {e} ტრაექტორიის წანაცვლება: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported PDF-ის შიგთავსი არასრულია ან წამკითხველის ლიმიტებს აჭარბებს; დოკუმენტი არ იმპორტირდა +@msg PDF flattener: {e} PDF-ის გაბრტყელება: {e} +@msg PDF options: {e} PDF-ის პარამეტრები: {e} +@msg PNG encoding failed: pixel buffer doesn't match the image size PNG-ის კოდირება ვერ მოხერხდა: პიქსელების ბუფერი გამოსახულების ზომას არ შეესაბამება +@msg PNG encoding failed: the palette image is malformed PNG-ის კოდირება ვერ მოხერხდა: პალიტრის გამოსახულება დაზიანებულია +@msg PNG encoding failed: {e} PNG-ის კოდირება ვერ მოხერხდა: {e} +@msg PNG encoding failed: {width} × {height} pixels is not a valid image size PNG-ის კოდირება ვერ მოხერხდა: {width} × {height} პიქსელი გამოსახულების სწორი ზომა არ არის +@msg PSD colour profile: {e} PSD-ის ფერის პროფილი: {e} +@msg PSD encoding failed: the pixel buffer doesn't match the image size PSD-ის კოდირება ვერ მოხერხდა: პიქსელების ბუფერი გამოსახულების ზომას არ შეესაბამება +@msg QName.prototype.toString needs a QName QName.prototype.toString-ს სჭირდება QName +@msg Recolor Artwork is no longer open „ნახატის ხელახლა შეფერადება“ აღარ არის გახსნილი +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object RegExp.prototype.{name} გამოძახებულია მნიშვნელობაზე, რომელიც RegExp ობიექტი არ არის +@msg Revolve profile contains invalid coordinates ბრუნვის პროფილი არასწორ კოორდინატებს შეიცავს +@msg Revolve profile is too complex ბრუნვის პროფილი ზედმეტად რთულია +@msg Revolve projection is not finite ბრუნვის პროექცია სასრული არ არის +@msg Revolve requires finite profile coordinates within 100 million points ბრუნვას სჭირდება პროფილის სასრული კოორდინატები 100 მილიონი პუნქტის ფარგლებში +@msg Revolve surface is too complex; reduce Segments or simplify the profile ბრუნვის ზედაპირი ზედმეტად რთულია; შეამცირეთ სეგმენტები ან გაამარტივეთ პროფილი +@msg Revolve surface is too complex; simplify the profile ბრუნვის ზედაპირი ზედმეტად რთულია; გაამარტივეთ პროფილი +@msg Revolve visibility cannot split the projected boundary ბრუნვის ხილვადობა ვერ ყოფს პროეცირებულ საზღვარს +@msg Revolve visibility creates too many fragments ბრუნვის ხილვადობა ზედმეტად ბევრ ფრაგმენტს ქმნის +@msg Revolve visibility creates too many vector anchors ბრუნვის ხილვადობა ზედმეტად ბევრ ვექტორულ საყრდენ წერტილს ქმნის +@msg Revolve visibility exceeds its geometry work limit ბრუნვის ხილვადობა გეომეტრიული გამოთვლების ლიმიტს აჭარბებს +@msg Revolve visibility exceeds its spatial index limit ბრუნვის ხილვადობა სივრცითი ინდექსის ლიმიტს აჭარბებს +@msg Revolve visibility has too many boundary crossings ბრუნვის ხილვადობას საზღვრის ზედმეტად ბევრი გადაკვეთა აქვს +@msg Revolve visibility has too many input anchors ბრუნვის ხილვადობას ზედმეტად ბევრი შემავალი საყრდენი წერტილი აქვს +@msg Revolve visibility requires convex cap sectors ბრუნვის ხილვადობას სჭირდება თავსახურის ამოზნექილი სექტორები +@msg Revolve visibility requires finite projected faces ბრუნვის ხილვადობას სჭირდება სასრული პროეცირებული წახნაგები +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG არ არის UTF-8, UTF-16 ან ISO 8859-1 +@msg SVG options: {e} SVG-ის პარამეტრები: {e} +@msg Save for Web settings: {e} „ვებისთვის შენახვის“ პარამეტრები: {e} +@msg Save writes one artboard: name one, or export several with document.export შენახვა ერთ სამუშაო დაფას წერს: მიუთითეთ ერთი, ან რამდენიმის ექსპორტისთვის გამოიყენეთ document.export +@msg Select a dimension first ჯერ მონიშნეთ ზომა +@msg Select dimensions to update მონიშნეთ განსაახლებელი ზომები +@msg Select only dimensions მონიშნეთ მხოლოდ ზომები +@msg Select two or more objects, or a group მონიშნეთ ორი ან მეტი ობიექტი, ან ჯგუფი +@msg Shaper needs 1–64 source paths ფორმირებას სჭირდება 1–64 საწყისი ტრაექტორია +@msg Stencil Bridges: the selected art is too large or too detailed to bridge ტრაფარეტის ხიდები: მონიშნული ნახატი ხიდების დასამატებლად ზედმეტად დიდი ან დეტალურია +@msg String.prototype.toString was called on a value that is not a string String.prototype.toString გამოძახებულია მნიშვნელობაზე, რომელიც სტრიქონი არ არის +@msg Switch x/y is for scatter graphs „x/y-ის გაცვლა“ წერტილოვანი დიაგრამებისთვისაა +@msg TIFF colour profile: {e} TIFF-ის ფერის პროფილი: {e} +@msg TIFF encoding failed: the pixel buffer doesn't match the image size TIFF-ის კოდირება ვერ მოხერხდა: პიქსელების ბუფერი გამოსახულების ზომას არ შეესაბამება +@msg TIFF encoding failed: {e} TIFF-ის კოდირება ვერ მოხერხდა: {e} +@msg Targa depth {_1}: 16, 24 or 32 bits per pixel Targa-ს სიღრმე {_1}: 16, 24 ან 32 ბიტი პიქსელზე +@msg Targa encoding failed: the pixel buffer doesn't match the image size Targa-ს კოდირება ვერ მოხერხდა: პიქსელების ბუფერი გამოსახულების ზომას არ შეესაბამება +@msg Text options: {e} ტექსტის პარამეტრები: {e} +@msg The 3D projection is not finite 3D პროექცია სასრული არ არის +@msg The 3D shadow is too complex; turn Shadows off or simplify the art 3D ჩრდილი ზედმეტად რთულია; გამორთეთ ჩრდილები ან გაამარტივეთ ნახატი +@msg The shape contains invalid coordinates ფიგურა არასწორ კოორდინატებს შეიცავს +@msg The {name} profile could not be read, so the new document has the default settings: {reason} პროფილის „{name}“ წაკითხვა ვერ მოხერხდა, ამიტომ ახალ დოკუმენტს ნაგულისხმევი პარამეტრები აქვს: {reason} +@msg Too many Revolve faces for visibility evaluation ხილვადობის შესაფასებლად ბრუნვას ზედმეტად ბევრი წახნაგი აქვს +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time ტრეპინგი: ობიექტების {_1}-ზე მეტი წყვილი ტრეპინგისთვის საკმარისად ახლოს მდებარეობს, ან ზედმეტად ბევრ ობიექტს საერთო ჰორიზონტალური დიაპაზონი აქვს; ერთდროულად ნაკლებ ობიექტზე გამოიყენეთ ტრეპინგი +@msg VectorCraft reads at most {ASE_MAX_ENTRIES} colors and groups from a swatch exchange file, and this library has {entries} VectorCraft ფერის ნიმუშების გაცვლის ფაილიდან კითხულობს მაქსიმუმ {ASE_MAX_ENTRIES} ფერსა და ჯგუფს, ამ ბიბლიოთეკაში კი არის {entries} +@msg WebP encoding failed: {e} WebP-ის კოდირება ვერ მოხერხდა: {e} +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off ნარჩენის მოსაცილებელი ჩარჩო და ხაზები: ნახატის გარეთ ნარჩენის მოსაცილებელი არცერთი ხაზი არ ეტევა, ნარჩენის ჩარჩო კი გამორთულია +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} +@msg \\ at end of pattern \\ შაბლონის ბოლოს +@msg `alignOn` cannot be empty `alignOn` ცარიელი ვერ იქნება +@msg `alignOn` must be a string `alignOn` უნდა იყოს სტრიქონი +@msg `align` is start, center or end `align` არის start, center ან end +@msg `align` must be a string `align` უნდა იყოს სტრიქონი +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` უნდა იყოს [subpath, anchor] წყვილების მასივი +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` უნდა ჩამოთვლიდეს საყრდენი წერტილების ინდექსებს (0, 1, 2…) და არა {k} +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` უნდა ჩამოთვლიდეს საყრდენი წერტილების ინდექსებს (0, 1, 2…) და არა {v} +@msg `answer` for this {kind} dialog must be one of: {_1} დიალოგისთვის (ტიპი: {kind}) `answer` უნდა იყოს ერთ-ერთი: {_1} +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg `answers` has more than {MAX_ANSWERS} answers `answers` შეიცავს {MAX_ANSWERS}-ზე მეტ პასუხს +@msg `answers` is larger than 1 MiB `answers` 1 MiB-ზე დიდია +@msg `answers` must be a list of answers `answers` უნდა იყოს პასუხების სია +@msg `artboard` must be the index of an artboard of the document `artboard` უნდა იყოს დოკუმენტის სამუშაო დაფის ინდექსი +@msg `artboardsAt` must be [x, y] `artboardsAt` უნდა იყოს [x, y] +@msg `artboards` must be a list of artboard indices `artboards` უნდა იყოს სამუშაო დაფების ინდექსების სია +@msg `artboards` names an artboard the document does not have `artboards` მიუთითებს სამუშაო დაფას, რომელიც დოკუმენტს არ აქვს +@msg `artboards` names an artboard twice `artboards` ერთსა და იმავე სამუშაო დაფას ორჯერ მიუთითებს +@msg `asset` is the asset id (see assets.list) `asset` არის რესურსის id (იხ. assets.list) +@msg `at` must be [x, y] (got {at}) `at` უნდა იყოს [x, y] (მიღებულია {at}) +@msg `at` must be a byte offset `at` უნდა იყოს ბაიტური წანაცვლება +@msg `at` must be a point [x, y] `at` უნდა იყოს წერტილი [x, y] +@msg `autoLeading` must be a number `autoLeading` უნდა იყოს რიცხვი +@msg `background` must be "", "matte" or #rrggbb `background` უნდა იყოს "", "matte" ან #rrggbb +@msg `beside` needs `side` `beside`-ს სჭირდება `side` +@msg `burasagari` must be "none", "standard" or "forced" `burasagari` უნდა იყოს "none", "standard" ან "forced" +@msg `center` must be [x, y] `center` უნდა იყოს [x, y] +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` უნდა იყოს "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" ან "icfBottom" +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection` უნდა იყოს "default", "leftToRight" ან "rightToLeft" +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` `clearStroke`-ს სჭირდება `stroke: false` და `ids: []`, ხოლო `stop` არ უნდა იყოს მითითებული +@msg `clear` must be a list of type object ids, not {v} `clear` უნდა იყოს ტექსტური ობიექტების id-ების სია და არა {v} +@msg `clear` must list type objects; {_1} is not one `clear` უნდა ჩამოთვლიდეს ტექსტურ ობიექტებს; {_1} ასეთი არ არის +@msg `clear` must not list `id` `clear` არ უნდა შეიცავდეს `id`-ს +@msg `code` must be a whole number `code` უნდა იყოს მთელი რიცხვი +@msg `colorMode` must be one of rgb, cmyk `colorMode` უნდა იყოს ერთ-ერთი: rgb, cmyk +@msg `color` needs a `rule` to make the theme from `color`-ს სჭირდება `rule`, რომლითაც თემა შეიქმნება +@msg `colors` does not apply to the {name} reduction `colors` არ ვრცელდება ფერების შემცირების მეთოდზე „{name}“ +@msg `colors` is empty `colors` ცარიელია +@msg `colors` must be a whole number from 2 to 256 `colors` უნდა იყოს მთელი რიცხვი 2-დან 256-მდე +@msg `colors` must be at least 9 for the uniform reduction `colors` ერთგვაროვანი შემცირებისთვის მინიმუმ 9 უნდა იყოს +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors` უნდა იყოს 1-სა და {MAX_THEME_COLORS}-ს შორის (მიღებულია {n}) +@msg `columnType` is vertical, uniform, repeating or sliding `columnType` არის vertical, uniform, repeating ან sliding +@msg `columns` must be a whole number 1..50 `columns` უნდა იყოს მთელი რიცხვი 1..50 +@msg `commands` must not be empty `commands` არ უნდა იყოს ცარიელი +@msg `contents` goes with `to` `contents` გამოიყენება `to`-სთან ერთად +@msg `corners` must be sharp, round or square, not `{other}` `corners` უნდა იყოს sharp, round ან square და არა `{other}` +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners` უნდა ჩამოთვლიდეს საყრდენი წერტილების ინდექსებს (0, 1, 2…) და არა {v} +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count` უნდა იყოს მთელი რიცხვი 0-დან {MAX_COUNT}-მდე +@msg `count` must be a whole number from 1 to 100 `count` უნდა იყოს მთელი რიცხვი 1-დან 100-მდე +@msg `dataBase64` is larger than the module limit `dataBase64` მოდულის ლიმიტზე დიდია +@msg `dataBase64` is not valid base64 `dataBase64` სწორი base64 არ არის +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly` ვერ გამოიყენება `ids`-თან, `reverse`-თან ან `append`-თან ერთად +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` და `childLimit` შეჯამებას ზღუდავს: გადაეცით `summary: true` +@msg `design` is a design name, or null for plain columns `design` არის დიზაინის სახელი, ან null — ჩვეულებრივი სვეტებისთვის +@msg `design` is a design name, or null for the default marker `design` არის დიზაინის სახელი, ან null — ნაგულისხმევი მარკერისთვის +@msg `destinationFolder` is required when destination is `folder` `destinationFolder` აუცილებელია, როცა destination არის `folder` +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination` უნდა იყოს `none`, `saveAndClose` ან `folder` +@msg `diameter` must be from 0.1 to 2500 pt `diameter` უნდა იყოს 0.1-დან 2500 pt-მდე +@msg `digits` must be one of {_1} `digits` უნდა იყოს ერთ-ერთი: {_1} +@msg `dimImages` is a percentage or false `dimImages` არის პროცენტი ან false +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` უნდა იყოს "auto", "leftToRight" ან "rightToLeft" +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount` უნდა იყოს რიცხვი 0-დან 100-მდე +@msg `each` is the value one design stands for, more than 0 `each` არის მნიშვნელობა, რომელსაც ერთი დიზაინი გამოხატავს; 0-ზე მეტი +@msg `end` must be "start" or "end" `end` უნდა იყოს "start" ან "end" +@msg `ends` applies to joining exactly two paths `ends` ვრცელდება ზუსტად ორი ტრაექტორიის შეერთებაზე +@msg `event` must be one of {_1} `event` უნდა იყოს ერთ-ერთი: {_1} +@msg `every` must be "all", "odd" or "even", not {v} `every` უნდა იყოს "all", "odd" ან "even" და არა {v} +@msg `expand` must be true or false `expand` უნდა იყოს true ან false +@msg `fidelity` must be from {lo} to {hi} pt `fidelity` უნდა იყოს {lo}-დან {hi} pt-მდე +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` უნდა იყოს შრიფტის ფაილების 1-დან 1000-მდე გზა, რომლებიც ბოლო text.findFontFiles ძიებამ იპოვა +@msg `focal` and `radius` cannot be used together `focal` და `radius` ერთად ვერ გამოიყენება +@msg `focal` must be an array of [subpath, anchor] pairs `focal` უნდა იყოს [subpath, anchor] წყვილების მასივი +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` მიუთითებს [{si}, {ai}]-ს, რომელიც ტრაექტორიის საყრდენი წერტილი არ არის +@msg `folder` must be an absolute folder path `folder` უნდა იყოს საქაღალდის აბსოლუტური გზა +@msg `fontAxes` must be an object of axis tags and values, such as {{"wght": 650}} `fontAxes` უნდა იყოს ღერძების ტეგებისა და მნიშვნელობების ობიექტი, მაგალითად {{"wght": 650}} +@msg `format` must be a string `format` უნდა იყოს სტრიქონი +@msg `format` must be one of {_1} `format` უნდა იყოს ერთ-ერთი: {_1} +@msg `fractions` is chop or scale `fractions` არის chop ან scale +@msg `from` and `to` must differ `from` და `to` ერთმანეთისგან უნდა განსხვავდებოდეს +@msg `from` has an empty frame `from`-ს ცარიელი ჩარჩო აქვს +@msg `from` must be a non-negative integer `from` უნდა იყოს არაუარყოფითი მთელი რიცხვი +@msg `from` must be area type or type on a path `from` უნდა იყოს არის ტექსტი ან ტექსტი ტრაექტორიაზე +@msg `gid` must be a glyph id, not {v} `gid` უნდა იყოს გლიფის id და არა {v} +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` ეკუთვნის ერთ სიმბოლოს და არა სიმბოლოს სტილს (მას text.setGlyphAlternate აყენებს) +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate` იღებს მხოლოდ null-ს, რომელიც გლიფის არჩევანს აუქმებს (არჩევანს text.setGlyphAlternate ქმნის) +@msg `group` must be one of {_1} `group` უნდა იყოს ერთ-ერთი: {_1} +@msg `group` must be the Objects on Path group of `member` and `target` `group` უნდა იყოს `member`-ისა და `target`-ის ტრაექტორიაზე ობიექტების ჯგუფი +@msg `guide` must be a guide index (guide.list) `guide` უნდა იყოს დამხმარე ხაზის ინდექსი (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign` უნდა იყოს default, left, center ან right +@msg `id` is an object id, not {v} `id` ობიექტის id უნდა იყოს და არა {v} +@msg `id` must be a node id `id` უნდა იყოს კვანძის id +@msg `id` must be an object id, not {v} `id` უნდა იყოს ობიექტის id და არა {v} +@msg `ids` must name Objects on Path groups or objects in them `ids` უნდა მიუთითებდეს ტრაექტორიაზე ობიექტების ჯგუფებს ან მათში შემავალ ობიექტებს +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` იღებს მაქსიმუმ {MAX_INSPECT_IDS} id-ს +@msg `imageMap` must be none, rectangle or polygon `imageMap` უნდა იყოს none, rectangle ან polygon +@msg `includeArtboardFill` must be true or false `includeArtboardFill` უნდა იყოს true ან false +@msg `includeSubdirs` must be true or false `includeSubdirs` უნდა იყოს true ან false +@msg `index` must be a point index `index` უნდა იყოს წერტილის ინდექსი +@msg `index` must be a whole number `index` უნდა იყოს მთელი რიცხვი +@msg `index` must be a whole number or null `index` უნდა იყოს მთელი რიცხვი ან null +@msg `indices` must be a non-empty array of item indices `indices` უნდა იყოს ელემენტების ინდექსების არაცარიელი მასივი +@msg `item` needs `index` `item`-ს სჭირდება `index` +@msg `items` is a list of {{kind, item}} `items` არის {{kind, item}} ჩანაწერების სია +@msg `items` is empty `items` ცარიელია +@msg `items` must be a list of {{id, anchors?}}, not {v} `items` უნდა იყოს {{id, anchors?}} ჩანაწერების სია და არა {v} +@msg `items` must be a list of {{id, points}} `items` უნდა იყოს {{id, points}} ჩანაწერების სია +@msg `items` must be an array of {{id, anchors}} `items` უნდა იყოს {{id, anchors}} ჩანაწერების მასივი +@msg `items` must be an array of {{id, corners?}}, not {v} `items` უნდა იყოს {{id, corners?}} ჩანაწერების მასივი და არა {v} +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` უნდა იყოს "none", "short", "medium", "long" ან "stylistic" +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore` ეკუთვნის სტრიქონის პირველ სიმბოლოს და არა სიმბოლოს სტილს (მას text.setRangeStyle აყენებს) +@msg `kind` is one of {_1} `kind` არის ერთ-ერთი: {_1} +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` უნდა იყოს "round", "invertedRound" ან "chamfer" და არა {v} +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind` უნდა იყოს `text`, `visibility`, `image` ან `graph` +@msg `kind` must be a string `kind` უნდა იყოს სტრიქონი +@msg `kind` must be image, noImage or htmlText `kind` უნდა იყოს image, noImage ან htmlText +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType` უნდა იყოს "pushInFirst", "pushOutFirst" ან "pushOutOnly" +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` უნდა იყოს "none", "hard" ან "soft" +@msg `label` must be a non-empty name of at most 200 characters `label` უნდა იყოს არაცარიელი სახელი, არაუმეტეს 200 სიმბოლოსი +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` უნდა იყოს ენის სახელი ან ტეგი, მაგალითად "Turkish" ან "tr" +@msg `leader` must be a string `leader` უნდა იყოს სტრიქონი +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` უნდა იყოს "romanBaseline" ან "emBoxTop" +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` უნდა იყოს 0.1-დან 5000-მდე, ან null — ავტომატურისთვის +@msg `length` must be a number of at most 1000000 pt either way `length` უნდა იყოს რიცხვი, ნებისმიერი მიმართულებით არაუმეტეს 1000000 pt-ისა +@msg `line` must be true or false `line` უნდა იყოს true ან false +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max` უნდა იყოს 1-დან {MAX_SUGGESTIONS}-მდე +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) ვერ იქნება `maxAngle`-ზე ({max_angle}°) მეტი +@msg `mode` must be "handles", "straight" or "smooth" `mode` უნდა იყოს "handles", "straight" ან "smooth" +@msg `mode` must be "straight", "bezier" or "arc" `mode` უნდა იყოს "straight", "bezier" ან "arc" +@msg `mode` must be one of {_1} `mode` უნდა იყოს ერთ-ერთი: {_1} +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` უნდა იყოს "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" ან "lineEndHalf" +@msg `name` must be a name that is not empty `name` უნდა იყოს არაცარიელი სახელი +@msg `name` must not be empty `name` არ უნდა იყოს ცარიელი +@msg `names` is a list of preset names `names` არის პრესეტების სახელების სია +@msg `names` lists preset names `names` ჩამოთვლის პრესეტების სახელებს +@msg `names` must be a list of names `names` უნდა იყოს სახელების სია +@msg `names` must be a list of swatch names `names` უნდა იყოს ფერის ნიმუშების სახელების სია +@msg `names` must be an array of style names `names` უნდა იყოს სტილების სახელების მასივი +@msg `new` is a design name, or true `new` არის დიზაინის სახელი ან true +@msg `origin` must be [x, y] in points `origin` უნდა იყოს [x, y] პუნქტებში +@msg `path` must be a folder `path` უნდა იყოს საქაღალდე +@msg `path` must be a path object `path` უნდა იყოს ტრაექტორიის ობიექტი +@msg `picks` must be an array (got {v}) `picks` უნდა იყოს მასივი (მიღებულია {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` უნდა შეიცავდეს 1-დან {MAX_PICKS}-მდე არჩევანს (მიღებულია {_1}) +@msg `pivot` must be a whole number from 0 to 8 `pivot` უნდა იყოს მთელი რიცხვი 0-დან 8-მდე +@msg `points` must be a list of [x, y] points `points` უნდა იყოს [x, y] წერტილების სია +@msg `points` must be a list of point indexes `points` უნდა იყოს წერტილების ინდექსების სია +@msg `points` must be an array of point indices `points` უნდა იყოს წერტილების ინდექსების მასივი +@msg `points` must be point indexes `points` უნდა იყოს წერტილების ინდექსები +@msg `points` must hold from 2 to 100000 points `points` უნდა შეიცავდეს 2-დან 100000-მდე წერტილს +@msg `port` must be "in" or "out" `port` უნდა იყოს "in" ან "out" +@msg `position` is above or below `position` არის above ან below +@msg `preset` must be a string `preset` უნდა იყოს სტრიქონი +@msg `radius` must be 0 or more `radius` უნდა იყოს 0 ან მეტი +@msg `ratio` must be from 0.1 to 0.5 `ratio` უნდა იყოს 0.1-დან 0.5-მდე +@msg `rect` must be [x, y, width, height] `rect` უნდა იყოს [x, y, width, height] +@msg `rect` must be [x, y, width, height] with a positive size `rect` უნდა იყოს [x, y, width, height] დადებითი ზომით +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` უნდა იყოს [x0, y0, x1, y1] (მიღებულია {r}) +@msg `relation` must be overlap, inside or outside `relation` უნდა იყოს overlap, inside ან outside +@msg `replace` must be a string `replace` უნდა იყოს სტრიქონი +@msg `reversed` must be true or false `reversed` უნდა იყოს true ან false +@msg `rotate` must be a finite number `rotate` უნდა იყოს სასრული რიცხვი +@msg `rows` must be lists of numbers or nulls, not {r} `rows` უნდა იყოს რიცხვების ან null-ების სიები და არა {r} +@msg `rule` must be nonZero or evenOdd `rule` უნდა იყოს nonZero ან evenOdd +@msg `scale` must be a number from 0.001 to 1000 `scale` უნდა იყოს რიცხვი 0.001-დან 1000-მდე +@msg `scale` must be a number in (0, 100] `scale` უნდა იყოს რიცხვი (0, 100] შუალედში +@msg `scope.{key}` must be a non-empty array `scope.{key}` უნდა იყოს არაცარიელი მასივი +@msg `scope` must be "selection" or "document", not {other} `scope` უნდა იყოს "selection" ან "document" და არა {other} +@msg `script` must be a string `script` უნდა იყოს სტრიქონი +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` არის სერიების ინდექსების არაცარიელი სია +@msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes`-ს სჭირდება `type` ან `valueAxis` +@msg `shift` must be a number of points `shift` უნდა იყოს რიცხვი პუნქტებში +@msg `showCenter` must be true or false `showCenter` უნდა იყოს true ან false +@msg `side` must be "left", "right", "top" or "bottom" `side` უნდა იყოს "left", "right", "top" ან "bottom" +@msg `side` places the artboard: leave out x, y, width and height `side` სამუშაო დაფას თავად განათავსებს: გამოტოვეთ x, y, width და height +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` უნდა იყოს "fullJustify", "alignLeft", "alignCenter" ან "alignRight" +@msg `size` is a percentage of the base text's size, 1 to 100 `size` არის საბაზისო ტექსტის ზომის პროცენტი, 1-დან 100-მდე +@msg `source` and `target` are the same object `source` და `target` ერთი და იგივე ობიექტია +@msg `source` is the only path in series group `target` `source` ერთადერთი ტრაექტორიაა სერიის ჯგუფში `target` +@msg `source` must be `folder` or `datasets` `source` უნდა იყოს `folder` ან `datasets` +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing` ჩარჩოში {MAX_COUNT}-ზე მეტ ხაზს ქმნის +@msg `spacing` must be a number `spacing` უნდა იყოს რიცხვი +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` უნდა იყოს რიცხვი პუნქტებში (0 ან მეტი) ან "auto" +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing` უნდა იყოს 0-ზე მეტი და არაუმეტეს {MAX_DISTANCE} pt-ისა +@msg `start` must be a finite number `start` უნდა იყოს სასრული რიცხვი +@msg `starts` must be an array of anchor indices (or nulls) `starts` უნდა იყოს საყრდენი წერტილების ინდექსების (ან null-ების) მასივი +@msg `status` must be linked, embedded, missing or modified, or a list of them `status` უნდა იყოს linked, embedded, missing ან modified, ან მათი სია +@msg `stop` must be a stop index `stop` უნდა იყოს გადასვლის წერტილის ინდექსი +@msg `story` styles the whole story: give no `start` or `end` `story` მთელ ტექსტს აფორმებს: გამოტოვეთ `start` და `end` +@msg `story` takes the whole story: give no `start` or `end` `story` მთელ ტექსტს იღებს: გამოტოვეთ `start` და `end` +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` ვრცელდება კონტურებზე (stroke: true) +@msg `style` must be roman or japanese `style` უნდა იყოს roman ან japanese +@msg `subpaths` must be two integer indices `subpaths` უნდა იყოს ორი მთელრიცხვა ინდექსი +@msg `symbol` must be a symbol name `symbol` უნდა იყოს სიმბოლოს სახელი +@msg `target` must be "object" or "contents", not {v} `target` უნდა იყოს "object" ან "contents" და არა {v} +@msg `target` must be an object of the same Objects on Path group as `member` `target` უნდა იყოს ობიექტი იმავე ტრაექტორიაზე ობიექტების ჯგუფიდან, რომელშიც `member` არის +@msg `tint` applies to global and spot colours `tint` ვრცელდება გლობალურ და სპოტ-ფერებზე +@msg `tint` goes with a global or spot `swatch` `tint` გამოიყენება გლობალური ან სპოტ-ფერის `swatch`-თან ერთად +@msg `tint` must be a number (%) `tint` უნდა იყოს რიცხვი (%) +@msg `tint` needs a global or spot colour `tint`-ს სჭირდება გლობალური ან სპოტ-ფერი +@msg `to` is already threaded `to` უკვე დაკავშირებულია +@msg `to` is the design's new name `to` არის დიზაინის ახალი სახელი +@msg `to` must be [x, y] `to` უნდა იყოს [x, y] +@msg `to` must be a closed path, area type or type on a path other than `from` `to` უნდა იყოს შეკრული ტრაექტორია, არის ტექსტი ან ტექსტი ტრაექტორიაზე, `from`-ისგან განსხვავებული +@msg `to` must be a whole number `to` უნდა იყოს მთელი რიცხვი +@msg `to` takes the rows of one object's stack, not aligned rows `to` იღებს ერთი ობიექტის დასტის სტრიქონებს და არა გასწორებულ სტრიქონებს +@msg `tolerance` must be from 0 to 100 pt `tolerance` უნდა იყოს 0-დან 100 pt-მდე +@msg `useLab` must be true or false `useLab` უნდა იყოს true ან false +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` უნდა იყოს default, top, middle, baseline ან bottom +@msg `values` must be a list `values` უნდა იყოს სია +@msg `values` must be an object `values` უნდა იყოს ობიექტი +@msg `values` must name hex, rgb, cmyk, lab or gray `values` უნდა შეიცავდეს hex, rgb, cmyk, lab ან gray მნიშვნელობას +@msg `values` names a kind twice `values` ერთსა და იმავე სახეობას ორჯერ მიუთითებს +@msg `version` must be 0 or {_1} `version` უნდა იყოს 0 ან {_1} +@msg `version` must be 0 or {newest} `version` უნდა იყოს 0 ან {newest} +@msg `view` is smallThumb, mediumThumb, largeThumb, smallList or largeList `view` არის smallThumb, mediumThumb, largeThumb, smallList ან largeList +@msg `warichuAlign` must be one of {_1} `warichuAlign` უნდა იყოს ერთ-ერთი: {_1} +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines` უნდა იყოს მთელი რიცხვი {_1}-დან {_2}-მდე +@msg `weight` must be from 0.01 to 1000 pt `weight` უნდა იყოს 0.01-დან 1000 pt-მდე +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights` უნდა იყოს {_1} რიცხვის სია, თითო სტრიქონზე ერთი (მიღებულია {v}) +@msg `xml` must be a string `xml` უნდა იყოს სტრიქონი +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}`-ის ჩანაწერი {n} აბზაცს ({_1}, ბაიტი {start}) სხვა შეწევებს ანიჭებს, ვიდრე მანამდე მყოფი ჩანაწერი +@msg `{KEY}` entry {n} must be an object `{KEY}`-ის ჩანაწერი {n} უნდა იყოს ობიექტი +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}`-ის ჩანაწერი {n} არც `leftIndent`-ს აყენებს და არც `firstLineIndent`-ს +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}`-ის ჩანაწერი {n}: `id` უნდა იყოს ობიექტის id +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}`-ის ჩანაწერი {n}: `start` უნდა იყოს ბაიტური წანაცვლება +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}`-ის ჩანაწერი {n}: `{k}` უნდა იყოს რიცხვი -{MAX_PARA}-დან {MAX_PARA} pt-მდე +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}`-ის ჩანაწერი {n}: ობიექტში {_1} ბაიტზე {start} არცერთი აბზაცი არ იწყება +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}`-ის ჩანაწერი {n}: უცნობი გასაღები `{k}` +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}`-ის ჩანაწერი {n}: {_1} ტექსტური ობიექტი არ არის +@msg `{KEY}` is empty `{KEY}` ცარიელია +@msg `{KEY}` must be a list `{KEY}` უნდა იყოს სია +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` იღებს მაქსიმუმ {MAX_PARAGRAPH_INDENTS} ჩანაწერს (მიღებულია {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}`-თან ერთად `{other}` ვერ გამოიყენება +@msg `{_1}` can't be edited or removed `{_1}`: რედაქტირება და მოცილება შეუძლებელია +@msg `{_1}` is built in and always listed `{_1}` ჩაშენებულია და სიაში ყოველთვის ჩანს +@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` არ არის შრიფტის ფაილი, რომელიც ბოლო text.findFontFiles ძიებამ იპოვა +@msg `{_1}` would be overwritten by the package: choose a different folder or name `{_1}` შეფუთვით გადაიწერებოდა: აირჩიეთ სხვა საქაღალდე ან სახელი +@msg `{base}` is larger than {_1} MB `{base}` {_1} MB-ზე დიდია +@msg `{base}` is not in the DCS file's folder `{base}` DCS ფაილის საქაღალდეში არ არის +@msg `{file}` has no art to place `{file}`: განსათავსებელი ნახატი არ არის +@msg `{file}` is over {_1} MB `{file}` {_1} MB-ს აღემატება +@msg `{f}` belongs to a VectorCraft that is running `{f}` ეკუთვნის გაშვებულ VectorCraft-ს +@msg `{f}` is the copy of a document open now `{f}` ახლა გახსნილი დოკუმენტის ასლია +@msg `{id}` is no writable format (see document.formats) `{id}` ჩაწერადი ფორმატი არ არის (იხ. document.formats) +@msg `{id}` isn't a loaded library `{id}` ჩატვირთული ბიბლიოთეკა არ არის +@msg `{key}` has no colours `{key}`: ფერები არ არის +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` {MAX_EDIT_WORDS}-ზე მეტ სიტყვას შეიცავს +@msg `{key}` is a design name `{key}` არის დიზაინის სახელი +@msg `{key}` is a design name or a list of them `{key}` არის დიზაინის სახელი ან მათი სია +@msg `{key}` is longer than {MAX_NAME} characters `{key}` {MAX_NAME} სიმბოლოზე გრძელია +@msg `{key}` is out of range `{key}` დიაპაზონს სცდება +@msg `{key}` must be 0 or more `{key}` უნდა იყოს 0 ან მეტი +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` უნდა იყოს [min, desired, max] ან {{min?, desired?, max?}}, პროცენტებში +@msg `{key}` must be [x, y] numbers `{key}` უნდა იყოს [x, y] რიცხვები +@msg `{key}` must be a list of words `{key}` უნდა იყოს სიტყვების სია +@msg `{key}` must be a list, not {v} `{key}` უნდა იყოს სია და არა {v} +@msg `{key}` must be a non-negative integer `{key}` უნდა იყოს არაუარყოფითი მთელი რიცხვი +@msg `{key}` must be a number or null, not {v} `{key}` უნდა იყოს რიცხვი ან null და არა {v} +@msg `{key}` must be a number {_1}..{_2} `{key}` უნდა იყოს რიცხვი {_1}..{_2} +@msg `{key}` must be a whole number of 0 or more `{key}` უნდა იყოს მთელი რიცხვი, 0 ან მეტი +@msg `{key}` must be a whole number of at least 0, not {n} `{key}` უნდა იყოს მთელი რიცხვი, არანაკლებ 0, და არა {n} +@msg `{key}` must be an appearance item index (paint order) or null `{key}` უნდა იყოს იერსახის ელემენტის ინდექსი (დახატვის რიგით) ან null +@msg `{key}` must be an array of asset ids (see assets.list) `{key}` უნდა იყოს რესურსების id-ების მასივი (იხ. assets.list) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` უნდა იყოს 0-დან 180 გრადუსამდე და არა {a} +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}` უნდა იყოს 0-დან {MAX_DISTANCE} pt-მდე +@msg `{key}` must be from 0.01 to 1000 pt `{key}` უნდა იყოს 0.01-დან 1000 pt-მდე +@msg `{key}` must be from {_1} to {shift_max} `{key}` უნდა იყოს {_1}-დან {shift_max}-მდე +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}` უნდა იყოს {lo}-დან {hi}-მდე და არა {v} +@msg `{key}` must be one of auto, always, never `{key}` უნდა იყოს ერთ-ერთი: auto, always, never +@msg `{key}` must be one of miter, round, bevel `{key}` უნდა იყოს ერთ-ერთი: miter, round, bevel +@msg `{key}` must be one word of at most {_1} characters `{key}` უნდა იყოს ერთი სიტყვა, არაუმეტეს {_1} სიმბოლოსი +@msg `{key}` must hold single words of at most {_1} characters `{key}` უნდა შეიცავდეს ცალკეულ სიტყვებს, თითოეული არაუმეტეს {_1} სიმბოლოსი +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}` უნდა მიუთითებდეს ობიექტს, რომელიც ტრაექტორიაზე ობიექტების ჯგუფის ტრაექტორიაზეა მიმაგრებული +@msg `{key}` names an object that is hidden or locked `{key}` მიუთითებს ობიექტს, რომელიც დამალული ან ჩაკეტილია +@msg `{key}`: not a colour: {v} `{key}`: ფერი არ არის: {v} +@msg `{k}.family` is required `{k}.family` აუცილებელია +@msg `{k}` is a number 0..1 `{k}` არის რიცხვი 0..1 +@msg `{k}` is true or false `{k}` არის true ან false +@msg `{k}` isn't an export setting: {_1} `{k}` ექსპორტის პარამეტრი არ არის: {_1} +@msg `{k}` must be a byte offset `{k}` უნდა იყოს ბაიტური წანაცვლება +@msg `{k}` must be a number `{k}` უნდა იყოს რიცხვი +@msg `{k}` must be a number of points `{k}` უნდა იყოს რიცხვი პუნქტებში +@msg `{k}` must be a string `{k}` უნდა იყოს სტრიქონი +@msg `{k}` must be a whole number `{k}` უნდა იყოს მთელი რიცხვი +@msg `{k}` must be from {lo} to {hi} for {key} {key}: `{k}` უნდა იყოს {lo}-დან {hi}-მდე +@msg `{k}` must be {what}, not {v} `{k}` უნდა იყოს {what} და არა {v} +@msg `{l}` isn't a global or spot colour `{l}` გლობალური ან სპოტ-ფერი არ არის +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` ბიტმაპი არ არის: გამოიყენეთ clipboard.importSvg, importPdf ან importText +@msg `{name}` can't be deleted `{name}`: წაშლა შეუძლებელია +@msg `{name}` can't be duplicated `{name}`: დუბლირება შეუძლებელია +@msg `{name}` can't be edited `{name}`: რედაქტირება შეუძლებელია +@msg `{name}` can't be edited this way `{name}`: ამგვარად რედაქტირება შეუძლებელია +@msg `{name}` can't be renamed `{name}`: სახელის შეცვლა შეუძლებელია +@msg `{name}` can't name a folder `{name}` საქაღალდის სახელად ვერ გამოდგება +@msg `{name}` has no art to place `{name}`: განსათავსებელი ნახატი არ არის +@msg `{name}` has no text to place `{name}`: განსათავსებელი ტექსტი არ არის +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text `{name}` Word-ის დოკუმენტში არ არის UTF-8 ან UTF-16 ტექსტი +@msg `{name}` in the Word document is not well-formed XML: {e} `{name}` Word-ის დოკუმენტში სწორად აგებული XML არ არის: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep `{name}` Word-ის დოკუმენტში შეიცავს {MAX_NESTING} დონეზე ღრმად ჩადგმულ ელემენტებს +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` ჩაშენებული პრესეტია და მხოლოდ წასაკითხია: შეინახეთ სხვა სახელით +@msg `{name}` is a built-in preset: save under another name `{name}` ჩაშენებული პრესეტია: შეინახეთ სხვა სახელით +@msg `{name}` is a built-in profile and stays `{name}` ჩაშენებული პროფილია და ყოველთვის რჩება +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` ცალკე ფაილია, რომლის პოვნაც მხოლოდ DCS ფაილის გვერდით შეიძლება +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` არის ფერის `{base}` ტონირება: დაარედაქტირეთ `{base}` (ტონირების ფერის ნიმუშს მხოლოდ ახალი სახელის მინიჭება შეიძლება) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` სამუშაო ფერთა სივრცეა: ჯერ edit.colorSettings-ით სხვა აირჩიეთ +@msg `{name}` is built in and stays `{name}` ჩაშენებულია და ყოველთვის რჩება +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` იღებს მხოლოდ საკუთარი სახეობის საღებავს (ფერს, გრადიენტს ან პატერნს) +@msg `{name}`: Minimum must not exceed Desired, nor Desired Maximum `{name}`: მინიმალური არ უნდა აღემატებოდეს სასურველს, ხოლო სასურველი — მაქსიმალურს +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{n}` can't be deleted `{n}`: წაშლა შეუძლებელია +@msg `{n}` can't be merged `{n}`: შერწყმა შეუძლებელია +@msg `{n}` can't be moved `{n}`: გადატანა შეუძლებელია +@msg `{n}` is not a colour swatch `{n}` ფერის ნიმუში არ არის +@msg `{n}` isn't a solid colour `{n}` მყარი ფერი არ არის +@msg `{n}`: colour groups hold solid colours only `{n}`: ფერთა ჯგუფები მხოლოდ მყარ ფერებს შეიცავს +@msg `{other}` doesn't trace an image `{other}` გამოსახულების ვექტორიზაციას არ ახდენს +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: ნაბიჯის მიმართვებს {_1} MB-ზე მეტი მნიშვნელობა შემოაქვს +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` ღერძის ტეგი არ არის: ტეგი ოთხი სიმბოლოსგან შედგება, მაგალითად "wght" +@msg `{to}` is empty or already used `{to}` ცარიელია ან უკვე გამოიყენება +@msg `{u}` is no Unicode character `{u}` Unicode სიმბოლო არ არის +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` {MAX_TEXT} ბაიტზე გრძელია +@msg `{what}` must be text `{what}` უნდა იყოს ტექსტი +@msg `{word}` isn't in the custom dictionary `{word}` მომხმარებლის ლექსიკონში არ არის +@msg a CMap has too many mappings CMap-ს ზედმეტად ბევრი შესაბამისობა აქვს +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard DXF ნახაზს ერთი საწყისი წერტილი აქვს: თითო სამუშაო დაფაზე ცალკე ნახაზისთვის გადაეცით useArtboards: true +@msg a PDF presets file with over {_1} presets PDF-ის პრესეტების ფაილი {_1}-ზე მეტი პრესეტით +@msg a \\u escape needs four hexadecimal digits \\u ესკეიპ-მიმდევრობას ოთხი თექვსმეტობითი ციფრი სჭირდება +@msg a batch preview cannot switch documents პაკეტური წინასწარი ნახვა დოკუმენტებს ვერ გადართავს +@msg a brush named `{nn}` exists ფუნჯი სახელით `{nn}` უკვე არსებობს +@msg a comma in an XPath expression must separate function arguments XPath გამოსახულებაში მძიმე ფუნქციის არგუმენტებს უნდა გამოყოფდეს +@msg a container of the listed objects is gone ჩამოთვლილი ობიექტების ერთ-ერთი კონტეინერი აღარ არსებობს +@msg a content stream can't be read შიგთავსის ნაკადი ვერ იკითხება +@msg a content stream isn't a stream შიგთავსის ნაკადი სინამდვილეში ნაკადი არ არის +@msg a design holds at most {_1} objects დიზაინი შეიცავს მაქსიმუმ {_1} ობიექტს +@msg a design needs a name დიზაინს სახელი სჭირდება +@msg a dictionary grew too large ლექსიკონი ზედმეტად გაიზარდა +@msg a document holds at most {_1} SVG filters დოკუმენტი შეიცავს მაქსიმუმ {_1} SVG ფილტრს +@msg a document keeps at most {_1} graph designs დოკუმენტი ინახავს მაქსიმუმ {_1} დიაგრამის დიზაინს +@msg a document keeps at most {_1} saved selections დოკუმენტი ინახავს მაქსიმუმ {_1} შენახულ მონიშვნას +@msg a document keeps at most {_1} views დოკუმენტი ინახავს მაქსიმუმ {_1} ხედს +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files დოკუმენტს შეიძლება ჰქონდეს მაქსიმუმ {MAX_SVG_SCRIPTS} დაკავშირებული JavaScript ფაილი +@msg a document with unsaved changes has no recovery copy შეუნახავი ცვლილებების მქონე დოკუმენტს აღდგენის ასლი არ აქვს +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes feConvolveMatrix ფილტრის რიგი არის {x}×{y}, რაც usvg-ის მიერ ბირთვის ზომის გამოთვლისას გადავსებას იწვევს +@msg a font's data is too large შრიფტის მონაცემები ზედმეტად დიდია +@msg a font's subroutines are too large შრიფტის ქვეპროგრამები ზედმეტად დიდია +@msg a function declaration without a name ფუნქციის გამოცხადება სახელის გარეშე +@msg a gradient preset is already called {n} გრადიენტის პრესეტი სახელით {n} უკვე არსებობს +@msg a graph has at most {_1} series, so it transposes with at most that many categories დიაგრამას აქვს მაქსიმუმ {_1} სერია, ამიტომ ტრანსპოზიცია შესაძლებელია მაქსიმუმ ამდენი კატეგორიით +@msg a host value has too many entries ჰოსტის მნიშვნელობას ზედმეტად ბევრი ჩანაწერი აქვს +@msg a host value is nested too deep ჰოსტის მნიშვნელობა ზედმეტად ღრმადაა ჩადგმული +@msg a layer can't be merged into its own sublayer შრე საკუთარ ქვეშრეს ვერ შეერწყმება +@msg a layer's form box isn't an array შრის ფორმის საზღვრების ჩარჩო მასივი არ არის +@msg a layer's form can't be read შრის ფორმა ვერ იკითხება +@msg a layer's form has no box შრის ფორმას საზღვრების ჩარჩო არ აქვს +@msg a layer's form has no dictionary შრის ფორმას ლექსიკონი არ აქვს +@msg a legend draws at most {MAX_CHIPS} swatches ლეგენდა ხატავს მაქსიმუმ {MAX_CHIPS} ფერის ნიმუშს +@msg a line can't run from a point to itself ხაზი წერტილიდან იმავე წერტილამდე ვერ გაივლის +@msg a line needs at least two points ხაზს სჭირდება მინიმუმ ორი წერტილი +@msg a name names one asset: give multiple: false, or one object სახელი ერთ რესურსს აღნიშნავს: მიუთითეთ multiple: false ან ერთი ობიექტი +@msg a namespace declaration uses a reserved prefix or URI სახელთა სივრცის გამოცხადება დაჯავშნილ პრეფიქსს ან URI-ს იყენებს +@msg a namespace qualifier is undefined სახელთა სივრცის კვალიფიკატორი განსაზღვრული არ არის +@msg a namespace with an empty URI cannot have a prefix ცარიელი URI-ის მქონე სახელთა სივრცეს პრეფიქსი არ შეიძლება ჰქონდეს +@msg a number cannot be read რიცხვის წაკითხვა ვერ ხერხდება +@msg a number has no digits რიცხვს ციფრები არ აქვს +@msg a number's exponent has no digits რიცხვის ხარისხის მაჩვენებელს ციფრები არ აქვს +@msg a number's fraction has no digits რიცხვის წილად ნაწილს ციფრები არ აქვს +@msg a page can't be read გვერდის წაკითხვა ვერ ხერხდება +@msg a page object is used by something else გვერდის ობიექტს სხვა რამ იყენებს +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) გვერდის ესკიზი უნდა შედგებოდეს RGB პიქსელებისგან, თითო მხარე 1-დან {THUMBNAIL_SIZE}-მდე (მიღებულია {w} × {h}) +@msg a pattern layer doesn't end what it starts პატერნის შრე არ ასრულებს იმას, რასაც იწყებს +@msg a pattern layer's colors hold something else პატერნის შრის ფერები სხვა რამეს შეიცავს +@msg a pattern named `{n}` exists პატერნი სახელით `{n}` უკვე არსებობს +@msg a plug-in with id {_1} is already installed პლაგინი ID-ით {_1} უკვე დაყენებულია +@msg a preset named `{n}` exists პრესეტი სახელით `{n}` უკვე არსებობს +@msg a print presets file with over {_1} presets ბეჭდვის პრესეტების ფაილი {_1}-ზე მეტი პრესეტით +@msg a profile is already called {n} პროფილი სახელით {n} უკვე არსებობს +@msg a property descriptor cannot have both a value or writable and a get or set თვისების დესკრიპტორს ერთდროულად ვერ ექნება value ან writable და get ან set +@msg a property descriptor must be an object თვისების დესკრიპტორი უნდა იყოს ობიექტი +@msg a property's {name} must be a function or undefined თვისების {name} უნდა იყოს ფუნქცია ან undefined +@msg a row can't go into itself სტრიქონი საკუთარ თავში ვერ მოთავსდება +@msg a series goes on the left or the right value axis სერია თავსდება მარცხენა ან მარჯვენა მნიშვნელობის ღერძზე +@msg a start must be an anchor index ≥ 0 or null საწყისი უნდა იყოს საყრდენი წერტილის ინდექსი ≥ 0 ან null +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied მასში არსებული ნაკადი დეკოდირებისას 256 MB-ს გადააჭარბებდა, ან მისი დეკოდირების პარამეტრების გამოყენება ვერ ხერხდება +@msg a string holds a control character სტრიქონი მმართველ სიმბოლოს შეიცავს +@msg a string holds an escape sequence JSON does not have სტრიქონი შეიცავს escape-მიმდევრობას, რომელიც JSON-ში არ არსებობს +@msg a string is not closed სტრიქონი არ არის დახურული +@msg a style named `{name}` already exists სტილი სახელით `{name}` უკვე არსებობს +@msg a style named `{to}` already exists სტილი სახელით `{to}` უკვე არსებობს +@msg a swatch exchange block is too long ფერის ნიმუშების გაცვლის ბლოკი ზედმეტად გრძელია +@msg a swatch exchange name is too long ფერის ნიმუშების გაცვლის სახელი ზედმეტად გრძელია +@msg a swatch needs a colour, gradient or pattern ფერის ნიმუშს სჭირდება ფერი, გრადიენტი ან პატერნი +@msg a symbol named `{n}` already exists სიმბოლო სახელით `{n}` უკვე არსებობს +@msg a theme has 1 to {THEME_SIZE} colours თემაში 1-დან {THEME_SIZE}-მდე ფერია +@msg a theme is already called {name} (replace: "{name}" overwrites it) თემა სახელით {name} უკვე არსებობს (replace: "{name}" მას გადააწერს) +@msg a toolbar name has at most {MAX_NAME} characters ხელსაწყოთა ზოლის სახელი შეიძლება შეიცავდეს არაუმეტეს {MAX_NAME} სიმბოლოს +@msg a view named `{_1}` exists ხედი სახელით `{_1}` უკვე არსებობს +@msg a view named `{name}` exists ხედი სახელით `{name}` უკვე არსებობს +@msg a workspace keeps at most {MAX} toolbars სამუშაო სივრცე ინახავს არაუმეტეს {MAX} ხელსაწყოთა ზოლს +@msg a {_1} series can't go in a {_2} graph {_1} ტიპის სერია ვერ მოთავსდება {_2} ტიპის დიაგრამაში +@msg a {sw} × {sh} image can't be resized to {width} × {height} pixels {sw} × {sh} გამოსახულების ზომის შეცვლა {width} × {height} პიქსელამდე ვერ ხერხდება +@msg a {width} × {height} image is too large {width} × {height} გამოსახულება ზედმეტად დიდია +@msg a {width} × {height} image: {e} {width} × {height} გამოსახულება: {e} +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (PDF-თან თავსებადი, ნაგულისხმევი) ან "7" (ძველი .ai, ვერსია 7) +@msg already editing an opacity mask გაუმჭვირვალობის ნიღაბი უკვე რედაქტირდება +@msg already editing another object's opacity mask უკვე რედაქტირდება სხვა ობიექტის გაუმჭვირვალობის ნიღაბი +@msg already in pattern editing mode პატერნის რედაქტირების რეჟიმი უკვე ჩართულია +@msg an SVG filter is named by its id: give `xml` with `name` SVG ფილტრის სახელი მისი id-ია: მიუთითეთ `xml` `name`-თან ერთად +@msg an XML element has two attributes with the same name XML ელემენტს ერთი და იმავე სახელის ორი ატრიბუტი აქვს +@msg an XML value cannot be inserted into itself or its descendants XML მნიშვნელობის ჩასმა საკუთარ თავში ან თავის შთამომავლებში შეუძლებელია +@msg an XML value's children cannot be assigned by position XML მნიშვნელობის შვილობილი ელემენტების მინიჭება პოზიციით შეუძლებელია +@msg an XML value's children cannot be deleted by position XML მნიშვნელობის შვილობილი ელემენტების წაშლა პოზიციით შეუძლებელია +@msg an XMLList converts to XML only when it has one item XMLList XML-ად მხოლოდ მაშინ გარდაიქმნება, როცა ერთი ელემენტი აქვს +@msg an XPath node test is missing XPath-ის კვანძის ტესტი აკლია +@msg an XPath operand is missing XPath-ის ოპერანდი აკლია +@msg an XPath operator is missing XPath-ის ოპერატორი აკლია +@msg an XPath predicate or step cannot follow here XPath-ის პრედიკატი ან ნაბიჯი აქ ვერ მოჰყვება +@msg an XPath predicate or step follows a value that is not a node-set XPath-ის პრედიკატი ან ნაბიჯი მოსდევს მნიშვნელობას, რომელიც კვანძების სიმრავლე არ არის +@msg an XPath step is missing XPath-ის ნაბიჯი აკლია +@msg an artboard is listed twice სამუშაო დაფა ორჯერაა ჩამოთვლილი +@msg an empty artboard has no width or height to scale to ცარიელ სამუშაო დაფას არ აქვს სიგანე ან სიმაღლე, რომელზეც მასშტაბირება შეიძლება +@msg an image could not be written: {e} გამოსახულების ჩაწერა ვერ მოხერხდა: {e} +@msg an image couldn't be decoded and is missing from the picture გამოსახულების დეკოდირება ვერ მოხერხდა და ის ნახატს აკლია +@msg an image is too large for DXF and was left out გამოსახულება DXF-ისთვის ზედმეტად დიდია და გამოტოვებულია +@msg an image of {_1} MB, at most {_2} MB გამოსახულება {_1} MB-ია, დასაშვებია არაუმეტეს {_2} MB +@msg an image of {declared_w} × {declared_h} pixels is too large to draw {declared_w} × {declared_h} პიქსელის გამოსახულება დასახატად ზედმეტად დიდია +@msg an image of {width} × {height} pixels is too large to draw {width} × {height} პიქსელის გამოსახულება დასახატად ზედმეტად დიდია +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) {w} × {h} პიქსელის გამოსახულება გარდასაქმნელად ზედმეტად დიდია (არაუმეტეს {_1} პიქსელი) +@msg an image that couldn't be read was left out გამოსახულება, რომლის წაკითხვაც ვერ მოხერხდა, გამოტოვებულია +@msg an index must be a whole number ინდექსი უნდა იყოს მთელი რიცხვი +@msg an inline graphic has no glyphs ტექსტში ჩასმულ გრაფიკას გლიფები არ აქვს +@msg an invisible text form can't be read უხილავი ტექსტის ფორმის წაკითხვა ვერ ხერხდება +@msg an object is missing ობიექტი აკლია +@msg an object's header is missing ობიექტის სათაური აკლია +@msg an overprinting form can't be read ზედდაბეჭდვის ფორმის წაკითხვა ვერ ხერხდება +@msg an unread object has no readable bounds წაუკითხავ ობიექტს წაკითხვადი საზღვრები არ აქვს +@msg an unread object holds a frame of threaded type წაუკითხავი ობიექტი დაკავშირებული ტექსტის ჩარჩოს შეიცავს +@msg an unread object is missing from its layer წაუკითხავი ობიექტი თავის შრეს აკლია +@msg anchor `smooth` must be true or false საყრდენი წერტილის `smooth` უნდა იყოს true ან false +@msg anchor `{key}` must be [x, y] საყრდენი წერტილის `{key}` უნდა იყოს [x, y] +@msg anchor `{key}` must be a number საყრდენი წერტილის `{key}` უნდა იყოს რიცხვი +@msg angle and roundness must be finite კუთხე და სიმრგვალე უნდა იყოს სასრული +@msg angles must be a list as long as pins (degrees or null) angles უნდა იყოს pins-ის სიგრძის სია (გრადუსები ან null) +@msg appearance must be flat|center|edge appearance უნდა იყოს flat|center|edge +@msg apply is "fill" or "stroke", not `{other}` apply არის "fill" ან "stroke" და არა `{other}` +@msg apply was called on a value that is not a function apply გამოძახებულია მნიშვნელობაზე, რომელიც ფუნქცია არ არის +@msg area must be a closed polygon of 3 to 100000 [x, y] points area უნდა იყოს შეკრული მრავალკუთხედი 3-დან 100000-მდე [x, y] წერტილით +@msg area type in a thread cannot use Dynamic Text: {frames} დაკავშირებულ ტექსტში არის ტექსტი ვერ გამოიყენებს დინამიკურ ტექსტს: {frames} +@msg area type needs a path that encloses an area არის ტექსტს სჭირდება ტრაექტორია, რომელიც არეს შემოსაზღვრავს +@msg area type {_1} is named twice არის ტექსტი {_1} ორჯერაა დასახელებული +@msg array literal too long მასივის ლიტერალი ზედმეტად გრძელია +@msg arrowAlign must be extend|tip, got {o} arrowAlign უნდა იყოს extend|tip, მიღებულია {o} +@msg arrowScale must be [start, end] arrowScale უნდა იყოს [start, end] +@msg artboard is a 0-based index artboard არის 0-დან დაწყებული ინდექსი +@msg artboard must be a 0-based index artboard უნდა იყოს 0-დან დაწყებული ინდექსი +@msg artboard {i} doesn't print სამუშაო დაფა {i} არ იბეჭდება +@msg artboardLayout must be {{layout?, columns?, spacing?, rightToLeft?}} artboardLayout უნდა იყოს {{layout?, columns?, spacing?, rightToLeft?}} +@msg artboardLayout.columns must be 1 or more artboardLayout.columns უნდა იყოს 1 ან მეტი +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout უნდა იყოს gridByRow, gridByColumn, row ან column +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft უნდა იყოს true ან false +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing უნდა იყოს 0–{MAX_SPACING} pt +@msg artboards must be a non-empty list artboards უნდა იყოს არაცარიელი სია +@msg artboards must be a number artboards უნდა იყოს რიცხვი +@msg asset `{_1}` has no art to export რესურსს `{_1}` ექსპორტისთვის ნახატი არ აქვს +@msg assignment to a constant binding მუდმივ ბმას მნიშვნელობა ვერ მიენიჭება +@msg at least one row that isn't excluded needs a weight above 0 მინიმუმ ერთ გამოურიცხავ სტრიქონს 0-ზე მეტი წილი სჭირდება +@msg at most {MAX_KEYWORDS} keywords არაუმეტეს {MAX_KEYWORDS} საკვანძო სიტყვა +@msg at most {MAX_MATCHES} matches are kept ინახება არაუმეტეს {MAX_MATCHES} დამთხვევა +@msg at most {MAX_POINTS} points არაუმეტეს {MAX_POINTS} წერტილი +@msg at most {MAX_QUEUE} files არაუმეტეს {MAX_QUEUE} ფაილი +@msg at must be [x, y] at უნდა იყოს [x, y] +@msg at/rect lie outside the canvas at/rect ტილოს გარეთაა +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute უნდა იყოს size|leading|tracking|kerning|baselineShift +@msg attrs must be an object attrs უნდა იყოს ობიექტი +@msg automation path rejected: the folder's name isn't valid text ავტომატიზაციის გზა უარყოფილია: საქაღალდის სახელი სწორი ტექსტი არ არის +@msg automation path rejected: {_1} isn't valid text ავტომატიზაციის გზა უარყოფილია: {_1} სწორი ტექსტი არ არის +@msg automation path rejected: {why}: {path} ავტომატიზაციის გზა უარყოფილია: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis უნდა იყოს "vertical", "horizontal" ან კუთხე გრადუსებში და არა `{given}` +@msg backgroundContents must be transparent or white backgroundContents უნდა იყოს transparent ან white +@msg bad AES key არასწორი AES გასაღები +@msg bad `runs`: {e} არასწორი `runs`: {e} +@msg bad arrowScale არასწორი arrowScale +@msg bad base64 არასწორი base64 +@msg bad color {c} არასწორი ფერი {c} +@msg bad colour არასწორი ფერი +@msg bad colour in `colors` `colors`: არასწორი ფერი +@msg bad colour {v} არასწორი ფერი {v} +@msg bad colour {v} in groupColors groupColors: არასწორი ფერი {v} +@msg bad cross-reference entry ჯვარედინი მიმართვების არასწორი ჩანაწერი +@msg bad cross-reference table ჯვარედინი მიმართვების არასწორი ცხრილი +@msg bad current colour {v} არასწორი მიმდინარე ფერი {v} +@msg bad dataBase64 არასწორი dataBase64 +@msg bad edit არასწორი ცვლილება +@msg bad fill {v} არასწორი შევსება {v} +@msg bad fit: {e} არასწორი მორგება: {e} +@msg bad model {other} (keep, new or document) არასწორი მოდელი {other} (keep, new ან document) +@msg bad new colour {v} არასწორი ახალი ფერი {v} +@msg bad object არასწორი ობიექტი +@msg bad object number ობიექტის არასწორი ნომერი +@msg bad offset არასწორი წანაცვლება +@msg bad path data: {e} ტრაექტორიის არასწორი მონაცემები: {e} +@msg bad point {v}: expected [x, y] არასწორი წერტილი {v}: მოსალოდნელია [x, y] +@msg bad point {v}: expected [x, y] or [x, y, pressure] არასწორი წერტილი {v}: მოსალოდნელია [x, y] ან [x, y, pressure] +@msg bad reference არასწორი მიმართვა +@msg bad trailer size ტრეილერის არასწორი ზომა +@msg bad {k} colour {k}: არასწორი ფერი +@msg batches cannot nest პაკეტები ერთმანეთში ვერ ჩაიდგმება +@msg bind something to a variable first ჯერ ცვლადს რამე მიაბით +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed უნდა იყოს 0–{MAX_BLEED} pt: რიცხვი, [top, bottom, left, right] ან {{top, bottom, left, right}} +@msg blend must be a mode name blend უნდა იყოს რეჟიმის სახელი +@msg both sides of an XPath | must select nodes XPath-ის | ოპერატორის ორივე მხარემ კვანძები უნდა აირჩიოს +@msg brush must be a name or null brush უნდა იყოს სახელი ან null +@msg by must be a number by უნდა იყოს რიცხვი +@msg can't add page thumbnails: {why} გვერდების ესკიზების დამატება ვერ ხერხდება: {why} +@msg can't add the editing data for other apps: {why} სხვა აპებისთვის რედაქტირების მონაცემების დამატება ვერ ხერხდება: {why} +@msg can't edit the written PDF: {why} ჩაწერილი PDF-ის რედაქტირება ვერ ხერხდება: {why} +@msg can't encrypt the PDF: {why} PDF-ის დაშიფვრა ვერ ხერხდება: {why} +@msg can't linearise the PDF: {why} PDF-ის ლინეარიზაცია ვერ ხერხდება: {why} +@msg can't open `{_1}`: {_2} `{_1}` ვერ იხსნება: {_2} +@msg can't open `{_1}`: {e} `{_1}` ვერ იხსნება: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) `{name}` ვერ იხსნება: VectorCraft ამ ფორმატს არ კითხულობს (იხ. document.formats) +@msg can't open “{name}”: {e} „{name}“ ვერ იხსნება: {e} +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) `{name}` ვერ განთავსდება: VectorCraft ამ ფორმატს არ კითხულობს (იხ. document.formats) +@msg can't read back the written PDF: {e} ჩაწერილი PDF-ის ხელახლა წაკითხვა ვერ ხერხდება: {e} +@msg can't read this Word document: {why} ამ Word დოკუმენტის წაკითხვა ვერ ხერხდება: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp გამოსახულებების .{e} ფორმატში ჩაწერა შეუძლებელია: გამოიყენეთ .png, .jpg, .gif, .webp, .tif ან .bmp +@msg can't write PDF layers, overprint or invisible text: {why} PDF შრეების, ზედდაბეჭდვის ან უხილავი ტექსტის ჩაწერა ვერ ხერხდება: {why} +@msg can't write the file's copyright: {why} ფაილის საავტორო უფლების მონაცემების ჩაწერა ვერ ხერხდება: {why} +@msg can't write the file's modification date: {why} ფაილის ცვლილების თარიღის ჩაწერა ვერ ხერხდება: {why} +@msg can't write the pages' user units: {why} გვერდების მომხმარებლის ერთეულების ჩაწერა ვერ ხერხდება: {why} +@msg canceled გაუქმდა +@msg cannot align zip entries to {align} bytes zip-ის ჩანაწერების {align} ბაიტზე სწორება შეუძლებელია +@msg cannot convert the object to a primitive value ობიექტის პრიმიტიულ მნიშვნელობად გარდაქმნა შეუძლებელია +@msg cannot convert undefined or null to an object undefined-ის ან null-ის ობიექტად გარდაქმნა შეუძლებელია +@msg cannot distort a zero-size or non-finite bounding box ნულოვანი ზომის ან არასასრული შემომსაზღვრელი ჩარჩოს დეფორმაცია შეუძლებელია +@msg cannot move an object into itself ობიექტის საკუთარ თავში გადატანა შეუძლებელია +@msg cannot open the automation {kind} root `{_1}`: {e} ავტომატიზაციის ძირეული საქაღალდის ({kind}) `{_1}` გახსნა ვერ ხერხდება: {e} +@msg cannot read profile: {0} პროფილის წაკითხვა ვერ ხერხდება: {0} +@msg case must be upper|lower|title|sentence case უნდა იყოს upper|lower|title|sentence +@msg category `{c}`: one of {_1} კატეგორია `{c}`: დასაშვებია ერთ-ერთი: {_1} +@msg center must be [x, y] center უნდა იყოს [x, y] +@msg character dimensions cannot be represented with the source text transform სიმბოლოების ზომები წყარო ტექსტის გარდაქმნით ვერ გამოისახება +@msg character dimensions cannot be represented with the target text transform სიმბოლოების ზომები სამიზნე ტექსტის გარდაქმნით ვერ გამოისახება +@msg choose at least one artboard აირჩიეთ მინიმუმ ერთი სამუშაო დაფა +@msg choose at least one status აირჩიეთ მინიმუმ ერთი სტატუსი +@msg clip must be true or false clip უნდა იყოს true ან false +@msg clippingMask must be true or false clippingMask უნდა იყოს true ან false +@msg closed must be a boolean closed უნდა იყოს ლოგიკური მნიშვნელობა +@msg collapsed must be true or false collapsed უნდა იყოს true ან false +@msg color book version {version} isn't supported (only 1) ფერთა წიგნის ვერსია {version} მხარდაჭერილი არ არის (მხოლოდ 1) +@msg color books in color space {other} aren't supported (only RGB, CMYK and Lab) ფერთა წიგნები ფერთა სივრცეში {other} მხარდაჭერილი არ არის (მხოლოდ RGB, CMYK და Lab) +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: RGB ან CMYK პროფილი სახელით „{profile}“ არ არსებობს +@msg colorMode must be rgb or cmyk, not `{m}` colorMode უნდა იყოს rgb ან cmyk და არა `{m}` +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel უნდა იყოს {own} (დოკუმენტის რეჟიმი), grayscale ან bitmap +@msg colour groups can't go into colour groups ფერთა ჯგუფები ფერთა ჯგუფებში ვერ მოთავსდება +@msg colour, mode and spot apply to solid-colour swatches only ფერი, რეჟიმი და სპოტ-ფერი მხოლოდ მყარი ფერის ნიმუშებს ეხება +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read სვეტი {column}: `{cell}` დიაგრამის მონაცემთა ფაილების `%` სვეტია, ასეთი ფაილები კი არ იკითხება +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable სვეტი {column}: `{cell}` ასახელებს ცვლადს `{name}`, რომელიც {_1} ტიპისაა +@msg column {column}: `{cell}` names no variable სვეტი {column}: `{cell}` არცერთ ცვლადს არ ასახელებს +@msg column {column}: the variable name is longer than {MAX_NAME} characters სვეტი {column}: ცვლადის სახელი {MAX_NAME} სიმბოლოზე გრძელია +@msg column: true floats the whole column and takes no onto, above or below column: true მთელ სვეტს მცურავს ხდის და არ იღებს onto, above ან below პარამეტრებს +@msg command must be a shape.* command command უნდა იყოს shape.* ბრძანება +@msg commands[{i}]: missing `command` commands[{i}]: აკლია `command` +@msg composer must be "singleLine" or "everyLine" composer უნდა იყოს "singleLine" ან "everyLine" +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes შედგენილი ფიგურები იღებს ტრაექტორიებს, შედგენილ ტრაექტორიებს, ჯგუფებს, შედგენილ ფიგურებს, ტექსტს, გადასვლებს და გარსებს +@msg compound {_1} must contain exactly one open path to join შედგენილი {_1} შესაერთებლად ზუსტად ერთ ღია ტრაექტორიას უნდა შეიცავდეს +@msg connect end must be "start" or "end" connect-ის end უნდა იყოს "start" ან "end" +@msg connect needs a path other than the one extended connect-ს სჭირდება ტრაექტორია, რომელიც გაგრძელებულისგან განსხვავდება +@msg connect needs id connect-ს სჭირდება id +@msg connect path has no open end connect-ის ტრაექტორიას ღია ბოლო არ აქვს +@msg continuation must be an array of path chunks continuation უნდა იყოს ტრაექტორიის ნაწილების მასივი +@msg continuation requires points continuation-ს სჭირდება წერტილები +@msg copy needs path: the file to copy into the document's folder copy-ს სჭირდება path: ფაილი, რომელიც დოკუმენტის საქაღალდეში დაკოპირდება +@msg copy takes images only: relink placed documents where their files are copy მხოლოდ გამოსახულებებს იღებს: განთავსებული დოკუმენტები ხელახლა დააკავშირეთ იქ, სადაც მათი ფაილებია +@msg copying a file isn't available on the web ფაილის კოპირება ვებში მიუწვდომელია +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus უნდა იყოს unknown, copyrighted ან publicDomain +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl უნდა იყოს URL (ჰარების გარეშე, არაუმეტეს 2048 სიმბოლო) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners უნდა იყოს 4 [x, y] წერტილი (TL, TR, BR, BL) +@msg counterPer `{c}`: item or file counterPer `{c}`: item ან file +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: art, trim, bleed და media ჩარჩოები მხოლოდ PDF გვერდებს აქვს; გამოიყენეთ "crop" ან "bounding" +@msg crop rectangle doesn't overlap the image ჩამოჭრის მართკუთხედი გამოსახულებას არ კვეთს +@msg cross-reference and object streams aren't supported ჯვარედინი მიმართვებისა და ობიექტების ნაკადები მხარდაჭერილი არ არის +@msg damaged compressed data: {e} დაზიანებული შეკუმშული მონაცემები: {e} +@msg dash lengths must be numbers, got {v} ტირეების სიგრძეები უნდა იყოს რიცხვები, მიღებულია {v} +@msg dash must be a list of lengths or null, got {v} dash უნდა იყოს სიგრძეების სია ან null, მიღებულია {v} +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) მონაცემთა გასაღები `{k}`: ასოები, ციფრები, -, _ და . (არა name ან vc-…) +@msg data must be an object of key: value, not {v} data უნდა იყოს key: value ობიექტი და არა {v} +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare მონაცემთა ნაკრები `{name}` შეიცავს მნიშვნელობას ცვლადისთვის `{var}`, რომელსაც ბიბლიოთეკა არ აცხადებს +@msg data set `{name}` holds two values for `{var}` მონაცემთა ნაკრები `{name}` შეიცავს ცვლადის `{var}` ორ მნიშვნელობას +@msg data set `{name}` is declared twice მონაცემთა ნაკრები `{name}` ორჯერაა გამოცხადებული +@msg data takes at most {MAX_DATA} keys at once data ერთდროულად იღებს არაუმეტეს {MAX_DATA} გასაღებს +@msg dataset `{name}` already exists მონაცემთა ნაკრები `{name}` უკვე არსებობს +@msg dataset `{to}` already exists მონაცემთა ნაკრები `{to}` უკვე არსებობს +@msg date `{d}`: YYYY-MM-DD თარიღი `{d}`: YYYY-MM-DD +@msg decoded data is too large დეკოდირებული მონაცემები ზედმეტად დიდია +@msg define a variable first ჯერ განსაზღვრეთ ცვლადი +@msg detect must be true or false detect უნდა იყოს true ან false +@msg direct-select anchor points of a path პირდაპირი მონიშვნით მონიშნეთ ტრაექტორიის საყრდენი წერტილები +@msg distance must be > 0 distance უნდა იყოს > 0 +@msg dx and dy give the arrow's direction dx და dy ისრის მიმართულებას განსაზღვრავს +@msg dx and dy must be numbers dx და dy უნდა იყოს რიცხვები +@msg dxf fitTo must be [width, height], positive dxf fitTo უნდა იყოს [width, height], დადებითი +@msg dxf must be an object of DXF import options dxf უნდა იყოს DXF იმპორტის პარამეტრების ობიექტი +@msg dxf scale must be a positive number up to {_1}, not {s} dxf scale უნდა იყოს დადებითი რიცხვი {_1}-მდე და არა {s} +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: მიუთითეთ ერთეული, მაგალითად mm, cm, in ან pt +@msg dxf: {e} dxf: {e} +@msg each format is an object {{format, scale?, suffix?}} თითოეული ფორმატი არის ობიექტი {{format, scale?, suffix?}} +@msg each item needs an id თითოეულ ელემენტს სჭირდება id +@msg each match needs `kind` თითოეულ დამთხვევას სჭირდება `kind` +@msg each match needs a `name` თითოეულ დამთხვევას სჭირდება `name` +@msg each of `fonts` is {{family, style?}}; 1 to 1000 of them `fonts`-ის თითოეული ჩანაწერი არის {{family, style?}}; 1-დან 1000-მდე ჩანაწერი +@msg each of `items` is {{id, corners?}} with an object id, not {it} `items`-ის თითოეული ჩანაწერი არის {{id, corners?}} ობიექტის id-ით და არა {it} +@msg each of `items` is {{kind, item}} `items`-ის თითოეული ჩანაწერი არის {{kind, item}} +@msg each path chunk must contain 1 to 4096 points ტრაექტორიის თითოეული ნაწილი უნდა შეიცავდეს 1-დან 4096-მდე წერტილს +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) თითოეული არჩევანი არის {{at: [x, y]}} ან {{rect: [x0, y0, x1, y1]}} (მიღებულია {v}) +@msg each row needs `from` თითოეულ სტრიქონს სჭირდება `from` +@msg each stop needs a position თითოეულ გადასვლის წერტილს სჭირდება პოზიცია +@msg each subpath needs an anchors array თითოეულ ქვეტრაექტორიას სჭირდება anchors მასივი +@msg each weight must be a number of 0 or more (got {v}) თითოეული წილი უნდა იყოს 0 ან მეტი რიცხვი (მიღებულია {v}) +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType უნდა იყოს easeIn, easeOut, easeInOut ან easeOutIn +@msg editingData must be true or false editingData უნდა იყოს true ან false +@msg editingData must be true, false or "always" editingData უნდა იყოს true, false ან "always" +@msg edits overlap ცვლილებები ერთმანეთს ფარავს +@msg effects of objects whose appearances differ can't be moved together განსხვავებული იერსახის მქონე ობიექტების ეფექტები ერთად ვერ გადაიტანება +@msg empty header in column {_1} სვეტი {_1}: ცარიელი სათაური +@msg encoding `{_1}`: utf8 or utf16 encoding `{_1}`: utf8 ან utf16 +@msg ends must be two of "first" and "last" ends უნდა შეიცავდეს ორ მნიშვნელობას "first"-ისა და "last"-ის სიიდან +@msg every numbered name for the result is taken შედეგისთვის ყველა დანომრილი სახელი დაკავებულია +@msg expanded SVG styles exceed the import size limit გაშლილი SVG სტილები იმპორტის ზომის ლიმიტს აჭარბებს +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes ექსპორტის პრესეტების ფაილი {MAX_PRESET_FILE_BYTES} ბაიტზე დიდია +@msg exportText must be editable or appearance exportText უნდა იყოს editable ან appearance +@msg exportType must be movieClip or graphic, not `{s}` exportType უნდა იყოს movieClip ან graphic და არა `{s}` +@msg extend needs id extend-ს სჭირდება id +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges ჩამოთვლის არაუმეტეს {MAX_FEATURE_CHANGES} ტეგს +@msg featureChanges must be a list of tags featureChanges უნდა იყოს ტეგების სია +@msg features must be a list of tags features უნდა იყოს ტეგების სია +@msg fidelity must be 0..100 fidelity უნდა იყოს 0..100 +@msg file name too long to zip: {_1} ფაილის სახელი zip-ში ჩასამატებლად ზედმეტად გრძელია: {_1} +@msg file name too long to zip: {name} ფაილის სახელი zip-ში ჩასამატებლად ზედმეტად გრძელია: {name} +@msg files must be {{name, dataBase64}} files უნდა იყოს {{name, dataBase64}} +@msg finally table {t} out of range finally ცხრილი {t} დიაპაზონს გარეთაა +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit უნდა იყოს none|autoHeight|shrinkText|block, მიღებულია {id} +@msg flags cannot be given with a RegExp object RegExp ობიექტთან ერთად ალმების მითითება შეუძლებელია +@msg flattener: {e} გაბრტყელება: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low ან შენახული პრესეტი (იხ. flattener.presets.list) +@msg flipProfile must be along|across, got {o} flipProfile უნდა იყოს along|across, მიღებულია {o} +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item ან itemFormat +@msg folders deeper than {MAX_BATCH_DEPTH} levels {MAX_BATCH_DEPTH} დონეზე ღრმა საქაღალდეები +@msg folders deeper than {MAX_DEPTH} levels {MAX_DEPTH} დონეზე ღრმა საქაღალდეები +@msg font family `{family}` is not available შრიფტის ოჯახი `{family}` მიუწვდომელია +@msg font family `{tf}` is not available შრიფტის ოჯახი `{tf}` მიუწვდომელია +@msg format `{f}`: pdf or postscript format `{f}`: pdf ან postscript +@msg format `{f}`: text or json format `{f}`: text ან json +@msg format is `{format}` ფორმატია `{format}` +@msg formats must be an array of rows, not {v} formats უნდა იყოს სტრიქონების მასივი და არა {v} +@msg freeform line {i}: {e} freeform-ის ხაზი {i}: {e} +@msg freeform point {i}: {e} freeform-ის წერტილი {i}: {e} +@msg frequency must be between 1 and 300 lines per inch frequency უნდა იყოს 1-დან 300-მდე ხაზი დუიმზე +@msg from and to must be finite from და to უნდა იყოს სასრული +@msg from must be [x0, y0, x1, y1] from უნდა იყოს [x0, y0, x1, y1] +@msg function declaration index out of range ფუნქციის გამოცხადების ინდექსი დიაპაზონს გარეთაა +@msg give `before` or `after`, not both მიუთითეთ `before` ან `after`, ორივე არა +@msg give `char`, `text`, `code` or `unicode` მიუთითეთ `char`, `text`, `code` ან `unicode` +@msg give `colors`, or `color` and `rule` მიუთითეთ `colors`, ან `color` და `rule` +@msg give `count` or `spacing`, not both მიუთითეთ `count` ან `spacing`, ორივე არა +@msg give `event`, such as onclick მიუთითეთ `event`, მაგალითად onclick +@msg give `file`, the JavaScript file's path or URL მიუთითეთ `file`, JavaScript ფაილის გზა ან URL +@msg give `fill` and/or `stroke` მიუთითეთ `fill` და/ან `stroke` +@msg give `fill`, `stroke` or both მიუთითეთ `fill`, `stroke` ან ორივე +@msg give `folder` მიუთითეთ `folder` +@msg give `fonts`, or open a document მიუთითეთ `fonts` ან გახსენით დოკუმენტი +@msg give `from` or `indices`, not both მიუთითეთ `from` ან `indices`, ორივე არა +@msg give `id` (no text is being edited) მიუთითეთ `id` (არცერთი ტექსტი არ რედაქტირდება) +@msg give `id` and `anchors`, or `items`, not both მიუთითეთ `id` და `anchors`, ან `items`, ორივე არა +@msg give `id` or `guide`, not both მიუთითეთ `id` ან `guide`, ორივე არა +@msg give `length` or `to` მიუთითეთ `length` ან `to` +@msg give `matches`: [{{kind, name, target}}] მიუთითეთ `matches`: [{{kind, name, target}}] +@msg give `name` (or edit a pattern) მიუთითეთ `name` (ან დაარედაქტირეთ პატერნი) +@msg give `name` or `names` მიუთითეთ `name` ან `names` +@msg give `name`, an SVG filter's name მიუთითეთ `name`, SVG ფილტრის სახელი +@msg give `path` (a .wasm file) or `dataBase64` (the module) მიუთითეთ `path` (.wasm ფაილი) ან `dataBase64` (მოდული) +@msg give `path`, `data` or `dataBase64` მიუთითეთ `path`, `data` ან `dataBase64` +@msg give `path`, `data`, `dataBase64` or `csv` მიუთითეთ `path`, `data`, `dataBase64` ან `csv` +@msg give `path`, `xml` or `dataBase64` მიუთითეთ `path`, `xml` ან `dataBase64` +@msg give `path`, an SVG file, or `svg`, SVG text მიუთითეთ `path`, SVG ფაილი, ან `svg`, SVG ტექსტი +@msg give `script`, the JavaScript the event runs ("" removes the event) მიუთითეთ `script`, JavaScript, რომელსაც მოვლენა უშვებს ("" მოვლენას შლის) +@msg give `text` (the reading) or `remove` მიუთითეთ `text` (წაკითხვის ტექსტი) ან `remove` +@msg give `x` and `y` (and a `tolerance` of 0 or more) მიუთითეთ `x` და `y` (და `tolerance`, 0 ან მეტი) +@msg give `xml`, one SVG element მიუთითეთ `xml`, ერთი SVG ელემენტი +@msg give `{key}` or `{other}`, not both მიუთითეთ `{key}` ან `{other}`, ორივე არა +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments მიუთითეთ რეჟიმი ან r/g/b, c/m/y/k, gray ან tint კორექციები +@msg give a name, or paint the active proxy with a saved preset მიუთითეთ სახელი ან აქტიური შევსება/კონტური შენახული პრესეტით შეღებეთ +@msg give a name, or select a stroke with a saved profile მიუთითეთ სახელი ან მონიშნეთ კონტური შენახული პროფილით +@msg give a pattern name or select an object painted with a pattern მიუთითეთ პატერნის სახელი ან მონიშნეთ პატერნით შეღებილი ობიექტი +@msg give at least one of clip, invert, disabled, linked მიუთითეთ მინიმუმ ერთი: clip, invert, disabled, linked +@msg give color, none, swatch or gradient მიუთითეთ color, none, swatch ან gradient +@msg give dataBase64 მიუთითეთ dataBase64 +@msg give either at (a click) or area (a path drawn around the overlaps) მიუთითეთ ან at (დაწკაპუნება), ან area (გადაფარვების გარშემო დახატული ტრაექტორია) +@msg give exactly one of `at`, `rect` and `points` მიუთითეთ ზუსტად ერთი: `at`, `rect` ან `points` +@msg give exactly one of `path`, `data` and `dataBase64` მიუთითეთ ზუსტად ერთი: `path`, `data` ან `dataBase64` +@msg give formats or a preset, not both მიუთითეთ formats ან პრესეტი, ორივე არა +@msg give index or indexes, not both მიუთითეთ index ან indexes, ორივე არა +@msg give newName or delete მიუთითეთ newName ან delete +@msg give offset or by მიუთითეთ offset ან by +@msg give one element მიუთითეთ ერთი ელემენტი +@msg give one of `to`, `rect` or `at` მიუთითეთ ერთ-ერთი: `to`, `rect` ან `at` +@msg give one of assets, layers, selection or fullDocument მიუთითეთ ერთ-ერთი: assets, layers, selection ან fullDocument +@msg give only one of onto, above, below, icon and a column place მიუთითეთ მხოლოდ ერთი: onto, above, below, icon ან სვეტში მდებარეობა +@msg give opacity or blend მიუთითეთ opacity ან blend +@msg give origin: [x, y], or reset: true მიუთითეთ origin: [x, y] ან reset: true +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note მიუთითეთ overprintFill, overprintStroke, showCenter, imageMap, url ან note +@msg give path (a file) or folder მიუთითეთ path (ფაილი) ან folder +@msg give path, or name and dataBase64 მიუთითეთ path, ან name და dataBase64 +@msg give paths or files მიუთითეთ paths ან files +@msg give rows, rowHeight, columns or columnWidth მიუთითეთ rows, rowHeight, columns ან columnWidth +@msg give svg, or dataBase64 of an SVG or SVGZ file მიუთითეთ svg, ან SVG ან SVGZ ფაილის dataBase64 +@msg give text მიუთითეთ text +@msg give the asset a name რესურსს სახელი მიეცით +@msg give the preset a `name` პრესეტს მიეცით `name` +@msg give the preset a name პრესეტს სახელი მიეცით +@msg give the preset a name (1–64 characters) პრესეტს სახელი მიეცით (1–64 სიმბოლო) +@msg give the settings to change, e.g. {{"formats": [{{"format": "png", "scale": "2x"}}]}} მიუთითეთ შესაცვლელი პარამეტრები, მაგ. {{"formats": [{{"format": "png", "scale": "2x"}}]}} +@msg give the style a name სტილს სახელი მიეცით +@msg give two or more style `names` მიუთითეთ ორი ან მეტი სტილის `names` +@msg give two or more swatches მიუთითეთ ორი ან მეტი ფერის ნიმუში +@msg give x, y, or a handle's length or angle მიუთითეთ x, y, ან სახელურის სიგრძე ან კუთხე +@msg give {{key, value}} or {{values}} მიუთითეთ {{key, value}} ან {{values}} +@msg global must be true or false global უნდა იყოს true ან false +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) გლიფი {_1} სიმბოლოს {ch} სტილით არ იხატება (მაგალითად, მთავრული, პატარა მთავრული ან ციფრების სისტემა) +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} გლიფი {g} არ არის სიმბოლოს {ch} ალტერნატივა შრიფტში {_1} {_2} +@msg gradient must be "objects" or "mesh" gradient უნდა იყოს "objects" ან "mesh" +@msg grays must be "profile" or "black", not `{g}` grays უნდა იყოს "profile" ან "black" და არა `{g}` +@msg grayscale profiles can't be working spaces yet ნაცრისფერი გრადაციების პროფილები სამუშაო ფერთა სივრცედ ჯერ ვერ გამოიყენება +@msg gridColors must be a preset name or two colours gridColors უნდა იყოს პრესეტის სახელი ან ორი ფერი +@msg gridSize must be small, medium or large gridSize უნდა იყოს small, medium ან large +@msg guide {g} is not selected დამხმარე ხაზი {g} მონიშნული არ არის +@msg handle must be 0..3 handle უნდა იყოს 0..3 +@msg handler at {_1} expects {_2} values but its range starts with {d} დამმუშავებელი პოზიციაზე {_1} ელოდება {_2} მნიშვნელობას, მაგრამ მისი დიაპაზონი სიღრმით {d} იწყება +@msg handler depth too large დამმუშავებლის სიღრმე ზედმეტად დიდია +@msg handler range {_1}..{_2} out of range დამმუშავებლის დიაპაზონი {_1}..{_2} დასაშვებ ფარგლებს გარეთაა +@msg hiddenLayers must be true or false hiddenLayers უნდა იყოს true ან false +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (ნაგულისხმევი), "omit" ან "visible" +@msg highlight a layer to merge into გამოკვეთეთ შრე, რომელშიც უნდა მოხდეს შერწყმა +@msg highlight two or more rows in one layer or group გამოკვეთეთ ორი ან მეტი სტრიქონი ერთ შრეში ან ჯგუფში +@msg highlight two or more rows to merge შესარწყმელად გამოკვეთეთ ორი ან მეტი სტრიქონი +@msg hvcC hvcC +@msg icon docks a panel in the icon column: use window.panel.dock icon პანელს ხატულების სვეტში ამაგრებს: გამოიყენეთ window.panel.dock +@msg id must be a dimension id id უნდა იყოს ზომის id +@msg image has no bounds გამოსახულებას საზღვრები არ აქვს +@msg image {_1} is linked already გამოსახულება {_1} უკვე დაკავშირებულია +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client ან server +@msg images in a spot ink whose samples couldn't be read were imported in RGB სპოტ-მელნის გამოსახულებები, რომელთა პიქსელური მნიშვნელობების წაკითხვა ვერ მოხერხდა, RGB-ში იმპორტირდა +@msg images past the most pixels one file may hold were left out გამოსახულებები, რომლებიც ერთ ფაილში დასაშვებ პიქსელების მაქსიმუმს აჭარბებს, გამოტოვებულია +@msg images too large to read were left out წასაკითხად ზედმეტად დიდი გამოსახულებები გამოტოვებულია +@msg index out of range ინდექსი დიაპაზონს გარეთაა +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes შეიძლება შეიცავდეს არაუმეტეს {MAX_GUIDE_INDEXES} ჩანაწერს +@msg indexes must be an array of guide indexes indexes უნდა იყოს დამხმარე ხაზების ინდექსების მასივი +@msg input contains NaN or infinite coordinates შეყვანილი მონაცემები შეიცავს NaN ან უსასრულო კოორდინატებს +@msg input path could not be closed შემავალი ტრაექტორიის შეკვრა ვერ მოხერხდა +@msg instances must be 1..1000 instances უნდა იყოს 1..1000 +@msg instruction out of range ინსტრუქცია დიაპაზონს გარეთაა +@msg instruction {pc} ({op}) pops below the stack ინსტრუქცია {pc} ({op}) სტეკიდან იმაზე მეტს იღებს, ვიდრე მასშია +@msg instruction {pc} reached with stack depths {seen} and {d} ინსტრუქცია {pc} მიღწეულია სტეკის სიღრმეებით {seen} და {d} +@msg invalid ICC profile: {0} არასწორი ICC პროფილი: {0} +@msg invalid `color` არასწორი `color` +@msg invalid anchor index {v}: expected a non-negative integer საყრდენი წერტილის არასწორი ინდექსი {v}: მოსალოდნელია არაუარყოფითი მთელი რიცხვი +@msg invalid anchor reference {v}: expected [subpath, anchor] საყრდენი წერტილის არასწორი მიმართვა {v}: მოსალოდნელია [subpath, anchor] +@msg invalid angle არასწორი კუთხე +@msg invalid array length მასივის არასწორი სიგრძე +@msg invalid brush: {e} არასწორი ფუნჯი: {e} +@msg invalid color არასწორი ფერი +@msg invalid colour {c} არასწორი ფერი {c} +@msg invalid delta არასწორი ნაზრდი +@msg invalid group არასწორი ჯგუფი +@msg invalid object id {v}: expected a non-negative integer ობიექტის არასწორი id {v}: მოსალოდნელია არაუარყოფითი მთელი რიცხვი +@msg invalid point არასწორი წერტილი +@msg invalid regular expression flag '{_1}' რეგულარული გამოსახულების არასწორი ალამი '{_1}' +@msg invalid regular expression: {m} არასწორი რეგულარული გამოსახულება: {m} +@msg invalid scale {scale} არასწორი მასშტაბი {scale} +@msg invalid size არასწორი ზომა +@msg invalid t არასწორი t +@msg invalid time value დროის არასწორი მნიშვნელობა +@msg invalid tolerance არასწორი დაშვება +@msg is a confirm's answer, whose `value` must be true or false არის confirm-ის პასუხი, რომლის `value` უნდა იყოს true ან false +@msg is a prompt's answer, whose `value` must be a string or null არის prompt-ის პასუხი, რომლის `value` უნდა იყოს სტრიქონი ან null +@msg is an alert's answer, whose `value` must be "ok" არის alert-ის პასუხი, რომლის `value` უნდა იყოს "ok" +@msg is longer than 16 KiB 16 KiB-ზე გრძელია +@msg it draws its image through a clipping path ის თავის გამოსახულებას მომჭრელი ტრაექტორიით ხატავს +@msg it draws no image ის გამოსახულებას არ ხატავს +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes ის {MAX_DICTIONARY_FILE_BYTES} ბაიტზე დიდია +@msg it isn't a file ეს ფაილი არ არის +@msg it names {_1} plates, and at most {MAX_PLATES} are read ის {_1} ფირფიტას ასახელებს, იკითხება კი არაუმეტეს {MAX_PLATES} +@msg its art is too long or too detailed for a head მისი ნახატი ისრის წვერისთვის ზედმეტად გრძელი ან დეტალურია +@msg its art paints nothing მისი ნახატი არაფერს ხატავს +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) მისი ყველა სამუშაო დაფა ტილოს გარეთაა (სათავიდან {_1} pt-ზე მეტი მანძილით) +@msg its clipping masks are nested too deeply მისი მომჭრელი ნიღბები ზედმეტად ღრმადაა ჩადგმული +@msg its image is too large to read მისი გამოსახულება წასაკითხად ზედმეტად დიდია +@msg its localized names take more than {_1} KB მისი ლოკალიზებული სახელები {_1} KB-ზე მეტს იკავებს +@msg its metadata has no description მის მეტამონაცემებში აღწერა არ არის +@msg its metadata has no direct length მის მეტამონაცემებში პირდაპირი სიგრძე არ არის +@msg its objects are nested too deep ({e}) მისი ობიექტები ზედმეტად ღრმადაა ჩადგმული ({e}) +@msg its plates are too large: more than {MAX_SAMPLES} pixels together მისი ფირფიტები ზედმეტად დიდია: ჯამში {MAX_SAMPLES} პიქსელზე მეტი +@msg its plates are too large: {_1} plates of {w} × {h} pixels მისი ფირფიტები ზედმეტად დიდია: {_1} ფირფიტა, თითო {w} × {h} პიქსელი +@msg its plates can't be kept together მისი ფირფიტების ერთად შენარჩუნება შეუძლებელია +@msg its shapes could not be combined into one მისი ფიგურების ერთში გაერთიანება ვერ მოხერხდა +@msg its tag table lists the `{_1}` tag more than once მის ტეგების ცხრილში ტეგი `{_1}` ერთზე მეტჯერაა ჩამოთვლილი +@msg join two different paths შეაერთეთ ორი სხვადასხვა ტრაექტორია +@msg jump to {pc}, past the end გადასვლა ბოლოს მიღმა: {pc} +@msg justify must be auto|left|center|right|justifyAll justify უნდა იყოს auto|left|center|right|justifyAll +@msg kbps must be a positive number, not {kbps} kbps უნდა იყოს დადებითი რიცხვი და არა {kbps} +@msg keywords must be a list of words or a comma-separated string keywords უნდა იყოს სიტყვების სია ან მძიმეებით გამოყოფილი სტრიქონი +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords უნდა შეიცავდეს არაუმეტეს {MAX_KEYWORD} სიმბოლოს, მმართველი სიმბოლოების გარეშე +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind უნდა იყოს 1, 2 ან 3, ან მიუთითეთ პრესეტის `name` (იხ. perspective.presets.list) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind უნდა იყოს graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle ან paraStyle +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout უნდა იყოს "on", "off", "neutral" ან ლოგიკური მნიშვნელობა და არა {v} +@msg layer `{name}` has no art to export შრეს `{name}` ექსპორტისთვის ნახატი არ აქვს +@msg layer must name a layer or sublayer layer უნდა ასახელებდეს შრეს ან ქვეშრეს +@msg layers can't be intertwined: select the objects in them შრეების გადახლართვა შეუძლებელია: მონიშნეთ მათში არსებული ობიექტები +@msg layers go only at the top level or in layers, objects in layers and groups შრეები თავსდება მხოლოდ ზედა დონეზე ან შრეებში, ობიექტები — შრეებსა და ჯგუფებში +@msg layers must be true or false layers უნდა იყოს true ან false +@msg layout must be gridByRow, gridByColumn, row or column layout უნდა იყოს gridByRow, gridByColumn, row ან column +@msg leading must be a number or "auto" leading უნდა იყოს რიცხვი ან "auto" +@msg left/right must be non-negative widths left/right უნდა იყოს არაუარყოფითი სიგანეები +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding უნდა იყოს ერთ-ერთი: {_1} +@msg level `{t}`: 2 or 3 level `{t}`: 2 ან 3 +@msg line {line}: more than {MAX_CSV_COLS} columns სტრიქონი {line}: {MAX_CSV_COLS}-ზე მეტი სვეტი +@msg line {line}: unclosed quote სტრიქონი {line}: დაუხურავი ბრჭყალი +@msg line {line}: value for `{var}` must match its variable kind სტრიქონი {line}: ცვლადის `{var}` მნიშვნელობა მის ტიპს უნდა შეესაბამებოდეს +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf ან crlf +@msg loading profiles from disk isn't available in the browser პროფილების დისკიდან ჩატვირთვა ბრაუზერში მიუწვდომელია +@msg localize: the first argument must be a localization object or a string localize: პირველი არგუმენტი უნდა იყოს ლოკალიზაციის ობიექტი ან სტრიქონი +@msg margin: a {m} pt margin leaves no imageable area on the paper margin: {m} pt ველი ქაღალდზე დასაბეჭდ არეს აღარ ტოვებს +@msg matrix must be finite and invertible, depth finite matrix უნდა იყოს სასრული და შებრუნებადი, depth — სასრული +@msg matte: {e} matte: {e} +@msg member is for order front, forward, backward or back member მხოლოდ order-ის front, forward, backward ან back მნიშვნელობებისთვისაა +@msg missing `artboards` აკლია `artboards` +@msg missing `at` [x, y] აკლია `at` [x, y] +@msg missing `attrs` აკლია `attrs` +@msg missing `color` or `opacity` აკლია `color` ან `opacity` +@msg missing `colors` აკლია `colors` +@msg missing `commands` აკლია `commands` +@msg missing `destinationFolder` აკლია `destinationFolder` +@msg missing `effect` id აკლია `effect` id +@msg missing `family` აკლია `family` +@msg missing `find` აკლია `find` +@msg missing `gid` (a glyph id, or null for the default glyph) აკლია `gid` (გლიფის id, ან null ნაგულისხმევი გლიფისთვის) +@msg missing `group` აკლია `group` +@msg missing `id` აკლია `id` +@msg missing `id`: give one, or select one type object აკლია `id`: მიუთითეთ ან მონიშნეთ ერთი ტექსტური ობიექტი +@msg missing `index` (a point index, or null to clear) აკლია `index` (წერტილის ინდექსი, ან null გასასუფთავებლად) +@msg missing `index` (a stop index, or null to clear) აკლია `index` (გადასვლის წერტილის ინდექსი, ან null გასასუფთავებლად) +@msg missing `index` (an item index or null) აკლია `index` (ელემენტის ინდექსი ან null) +@msg missing `item` აკლია `item` +@msg missing `library` აკლია `library` +@msg missing `library` (see graphicStyle.libraries) აკლია `library` (იხ. graphicStyle.libraries) +@msg missing `library` (see swatch.library.list) აკლია `library` (იხ. swatch.library.list) +@msg missing `map` (rows [{{from, to}}] or {{current: new}}) აკლია `map` (სტრიქონები [{{from, to}}] ან {{current: new}}) +@msg missing `matrix` [a,b,c,d,e,f] აკლია `matrix` [a,b,c,d,e,f] +@msg missing `name` (see gradient.presets.list) აკლია `name` (იხ. gradient.presets.list) +@msg missing `offset` (a distance in points) აკლია `offset` (მანძილი პუნქტებში) +@msg missing `path` აკლია `path` +@msg missing `path` (a path object id) აკლია `path` (ტრაექტორია-ობიექტის id) +@msg missing `picks` აკლია `picks` +@msg missing `rule` აკლია `rule` +@msg missing `source` id აკლია `source` id +@msg missing `start` (the byte offset of a character) აკლია `start` (სიმბოლოს ბაიტური წანაცვლება) +@msg missing `status`: linked, embedded, missing or modified აკლია `status`: linked, embedded, missing ან modified +@msg missing `target` id აკლია `target` id +@msg missing `text` აკლია `text` +@msg missing `to` აკლია `to` +@msg missing `to` (an index) აკლია `to` (ინდექსი) +@msg missing `to`: a non-negative integer აკლია `to`: არაუარყოფითი მთელი რიცხვი +@msg missing `type` აკლია `type` +@msg missing `value` აკლია `value` +@msg missing `word` აკლია `word` +@msg missing `{key}` აკლია `{key}` +@msg missing `{k}` აკლია `{k}` +@msg missing anchor აკლია საყრდენი წერტილი +@msg missing anchors აკლია საყრდენი წერტილები +@msg missing anchors [[subpath, anchor]…] აკლია საყრდენი წერტილები [[subpath, anchor]…] +@msg missing array `{key}` აკლია მასივი `{key}` +@msg missing case აკლია case +@msg missing color აკლია ფერი +@msg missing commands აკლია ბრძანებები +@msg missing destination folder აკლია დანიშნულების საქაღალდე +@msg missing from აკლია from +@msg missing group აკლია ჯგუფი +@msg missing id or ids აკლია id ან ids +@msg missing ids აკლია ids +@msg missing indexes აკლია ინდექსები +@msg missing matrix [a,b,c,d,e,f] აკლია მატრიცა [a,b,c,d,e,f] +@msg missing name აკლია სახელი +@msg missing op აკლია op +@msg missing or invalid `color` აკლია ან არასწორია `color` +@msg missing or unknown `tool` აკლია ან უცნობია `tool` +@msg missing point აკლია წერტილი +@msg missing point [x,y] აკლია წერტილი [x,y] +@msg missing point `{key}` ([x, y]) აკლია წერტილი `{key}` ([x, y]) +@msg missing points აკლია წერტილები +@msg missing points [[x,y]…] აკლია წერტილები [[x,y]…] +@msg missing segment აკლია სეგმენტი +@msg missing stops აკლია გადასვლის წერტილები +@msg missing subpaths აკლია ქვეტრაექტორიები +@msg missing target აკლია სამიზნე +@msg missing to აკლია to +@msg missing x, y აკლია x, y +@msg mode is set, add or toggle, not `{other}` mode არის set, add ან toggle და არა `{other}` +@msg mode must be "cmyk" or "rgb" mode უნდა იყოს "cmyk" ან "rgb" +@msg mode must be area|onPath, got {o} mode უნდა იყოს area|onPath, მიღებულია {o} +@msg more than 10000 linked files: package dependencies in smaller parts 10000-ზე მეტი დაკავშირებული ფაილი: შეფუთეთ დამოკიდებულებები უფრო მცირე ნაწილებად +@msg more than 256 commands 256-ზე მეტი ბრძანება +@msg more than {MAX_BATCH_FILES} data sets {MAX_BATCH_FILES}-ზე მეტი მონაცემთა ნაკრები +@msg more than {MAX_BATCH_FILES} files {MAX_BATCH_FILES}-ზე მეტი ფაილი +@msg more than {MAX_CSV_ROWS} data rows {MAX_CSV_ROWS}-ზე მეტი მონაცემთა სტრიქონი +@msg more than {MAX_FILES} images {MAX_FILES}-ზე მეტი გამოსახულება +@msg more than {MAX_IMPORT} presets {MAX_IMPORT}-ზე მეტი პრესეტი +@msg moved must be [[x,y]…] moved უნდა იყოს [[x,y]…] +@msg must be an object with `kind` and `value` უნდა იყოს ობიექტი `kind`-ით და `value`-თი +@msg must have `kind` alert, confirm or prompt `kind` უნდა იყოს alert, confirm ან prompt +@msg must have exactly `kind` and `value` უნდა ჰქონდეს ზუსტად `kind` და `value` +@msg name (a toolbar's name) is required საჭიროა name (ხელსაწყოთა ზოლის სახელი) +@msg name (a toolbar's name) must be a string name (ხელსაწყოთა ზოლის სახელი) უნდა იყოს სტრიქონი +@msg name must be a toolbar's name name უნდა იყოს ხელსაწყოთა ზოლის სახელი +@msg names is empty names ცარიელია +@msg names must be an array of symbol names names უნდა იყოს სიმბოლოების სახელების მასივი +@msg need `anchor` or `segment` + `t` საჭიროა `anchor` ან `segment` + `t` +@msg need at least one anchor საჭიროა ერთი საყრდენი წერტილი მაინც +@msg need at least one point საჭიროა ერთი წერტილი მაინც +@msg need at least two points საჭიროა ორი წერტილი მაინც +@msg needs a closing quote after the name სახელის შემდეგ საჭიროა დამხურავი ბრჭყალი +@msg needs a name საჭიროა სახელი +@msg needs a name of at most 64 bytes საჭიროა სახელი, არაუმეტეს 64 ბაიტისა +@msg needs a name of letters, digits and _, or a quoted name საჭიროა სახელი ასოებით, ციფრებით და _-ით, ან ბრჭყალებში ჩასმული სახელი +@msg needs a name without control characters საჭიროა სახელი მმართველი სიმბოლოების გარეშე +@msg needs points საჭიროა წერტილები +@msg nesting too deep ჩადგმა ზედმეტად ღრმაა +@msg nineSlice must be true or false nineSlice უნდა იყოს true ან false +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides უნდა იყოს [left, top, right, bottom], თითოეული -16..16, სადაც left + right < 1 და top + bottom < 1 +@msg no HP-GL format HP-GL ფორმატი არ არის +@msg no New Document profile called `{shown}` ({_1}) ახალი დოკუმენტის პროფილი სახელით `{shown}` არ არის ({_1}) +@msg no PDF preset named `{n}` PDF პრესეტი სახელით `{n}` არ არის +@msg no SVG filter at index {k} SVG ფილტრი ინდექსით {k} არ არის +@msg no action set is current მიმდინარე მოქმედებების ნაკრები არ არის +@msg no active data set აქტიური მონაცემთა ნაკრები არ არის +@msg no appearance item {index} იერსახის ელემენტი {index} არ არის +@msg no arrowheads file here აქ ისრის წვერების ფაილი არ არის +@msg no artboard {_1} სამუშაო დაფა {_1} არ არსებობს +@msg no artboard {artboard} (0-based; the document has {_1}) სამუშაო დაფა {artboard} არ არსებობს (0-დან ითვლება; დოკუმენტს აქვს {_1}) +@msg no asset {id} რესურსი {id} არ არის +@msg no asset {id} (see assets.list) რესურსი {id} არ არის (იხ. assets.list) +@msg no assets to export საექსპორტო რესურსები არ არის +@msg no assets yet: collect art for export first (assets.add) რესურსები ჯერ არ არის: ჯერ შეაგროვეთ ნახატი ექსპორტისთვის (assets.add) +@msg no brush named `{n}` ფუნჯი სახელით `{n}` არ არის +@msg no character at byte {start} ბაიტზე {start} სიმბოლო არ არის +@msg no character has code {code} სიმბოლო კოდით {code} არ არის +@msg no colour ფერი არ არის +@msg no colour group `{group}` ფერების ჯგუფი `{group}` არ არის +@msg no colour group `{name}` ფერების ჯგუფი `{name}` არ არის +@msg no custom arrowhead {name} (see stroke.arrowheads.list) მომხმარებლის ისრის წვერი {name} არ არის (იხ. stroke.arrowheads.list) +@msg no data sets მონაცემთა ნაკრებები არ არის +@msg no dataset named `{from}` მონაცემთა ნაკრები სახელით `{from}` არ არის +@msg no dataset named `{name}` მონაცემთა ნაკრები სახელით `{name}` არ არის +@msg no datasets მონაცემთა ნაკრებები არ არის +@msg no design named `{name}` დიზაინი სახელით `{name}` არ არის +@msg no design named `{n}` დიზაინი სახელით `{n}` არ არის +@msg no design named `{old}` დიზაინი სახელით `{old}` არ არის +@msg no encoder for {_1} yet კოდერი ჯერ არ არის: {_1} +@msg no export preset `{name}` (see document.exportPresets.list) ექსპორტის პრესეტი `{name}` არ არის (იხ. document.exportPresets.list) +@msg no file to open გასახსნელი ფაილი არ არის +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder საქაღალდე არ არის: მიუთითეთ `path` ან დააყენეთ პარამეტრები › პლაგინები & სამუშაო დისკები › დამატებითი პლაგინები +@msg no fonts to look for: they are all available მოსაძებნი შრიფტები არ არის: ყველა ხელმისაწვდომია +@msg no formats to export საექსპორტო ფორმატები არ არის +@msg no gradient preset `{name}` (see gradient.presets.list) გრადიენტის პრესეტი `{name}` არ არის (იხ. gradient.presets.list) +@msg no graphic style `{n}` in `{_1}` გრაფიკული სტილი `{n}` არ არის ბიბლიოთეკაში `{_1}` +@msg no graphic style library `{key}` (see graphicStyle.libraries) გრაფიკული სტილების ბიბლიოთეკა `{key}` არ არის (იხ. graphicStyle.libraries) +@msg no guide selected დამხმარე ხაზი მონიშნული არ არის +@msg no guide selected (or give an index) დამხმარე ხაზი მონიშნული არ არის (ან მიუთითეთ ინდექსი) +@msg no guide {i} დამხმარე ხაზი {i} არ არის +@msg no images in the folder საქაღალდეში გამოსახულებები არ არის +@msg no instructions ინსტრუქციები არ არის +@msg no item at index {_1} ინდექსზე {_1} ელემენტი არ არის +@msg no item at index {from} ინდექსზე {from} ელემენტი არ არის +@msg no item at one of the indices {idx} ერთ-ერთ ინდექსზე ელემენტი არ არის: {idx} +@msg no layer შრე არ არის +@msg no layer to rename სახელის შესაცვლელი შრე არ არის +@msg no layer {id} (see layer.list) შრე {id} არ არის (იხ. layer.list) +@msg no layers to export საექსპორტო შრეები არ არის +@msg no library `{key}` ბიბლიოთეკა `{key}` არ არის +@msg no metafile format metafile ფორმატი არ არის +@msg no object {_1} ობიექტი {_1} არ არის +@msg no objects ობიექტები არ არის +@msg no parent layer მშობელი შრე არ არის +@msg no perspective grid preset named `{b}` (see perspective.presets.list) პერსპექტივის ბადის პრესეტი სახელით `{b}` არ არის (იხ. perspective.presets.list) +@msg no perspective grid preset named `{n}` პერსპექტივის ბადის პრესეტი სახელით `{n}` არ არის +@msg no point selected (give `index`) წერტილი მონიშნული არ არის (მიუთითეთ `index`) +@msg no preset called `{from}` (see file.newPresets) პრესეტი სახელით `{from}` არ არის (იხ. file.newPresets) +@msg no preset called `{name}` (see file.newPresets) პრესეტი სახელით `{name}` არ არის (იხ. file.newPresets) +@msg no preset named `{n}` პრესეტი სახელით `{n}` არ არის +@msg no print preset named `{b}` (see print.presets.list) ბეჭდვის პრესეტი სახელით `{b}` არ არის (იხ. print.presets.list) +@msg no print preset named `{name}` (presets: {_1}) ბეჭდვის პრესეტი სახელით `{name}` არ არის (პრესეტები: {_1}) +@msg no print preset named `{n}` ბეჭდვის პრესეტი სახელით `{n}` არ არის +@msg no profile named `{name}` პროფილი სახელით `{name}` არ არის +@msg no random numbers ({e}) შემთხვევითი რიცხვები მიუწვდომელია ({e}) +@msg no readable files in the folder საქაღალდეში წაკითხვადი ფაილები არ არის +@msg no recovery copy `{f}` (see file.recovery.list) აღდგენის ასლი `{f}` არ არის (იხ. file.recovery.list) +@msg no rows სტრიქონები არ არის +@msg no saved PDF preset named `{name}` შენახული PDF პრესეტი სახელით `{name}` არ არის +@msg no saved export preset `{name}` შენახული ექსპორტის პრესეტი `{name}` არ არის +@msg no saved gradient preset called {name} შენახული გრადიენტის პრესეტი სახელით {name} არ არის +@msg no saved gradient presets შენახული გრადიენტის პრესეტები არ არის +@msg no saved preset called `{name}` შენახული პრესეტი სახელით `{name}` არ არის +@msg no saved presets to export (name [Default] in `names` to export it) საექსპორტო შენახული პრესეტები არ არის ([Default]-ის ექსპორტისთვის ჩაწერეთ ის `names`-ში) +@msg no saved presets to export (name built-in ones in `names`) საექსპორტო შენახული პრესეტები არ არის (ჩაშენებულებისთვის ჩაწერეთ მათი სახელები `names`-ში) +@msg no saved print preset named `{name}` შენახული ბეჭდვის პრესეტი სახელით `{name}` არ არის +@msg no saved profile called {name} შენახული პროფილი სახელით {name} არ არის +@msg no saved selection `{name}` შენახული მონიშვნა `{name}` არ არის +@msg no saved theme called {name} (see colorTheme.list) შენახული თემა სახელით {name} არ არის (იხ. colorTheme.list) +@msg no saved views შენახული ხედები არ არის +@msg no selected or given object has a visible 3D effect არცერთ მონიშნულ ან მითითებულ ობიექტს ხილული 3D ეფექტი არ აქვს +@msg no series {i} სერია {i} არ არის +@msg no slice {n} in the image (see slice.list) გამოსახულებაში ნაჭერი {n} არ არის (იხ. slice.list) +@msg no source art საწყისი ნახატი არ არის +@msg no stop {i} (the gradient has {n}) გადასვლის წერტილი {i} არ არის (გრადიენტს აქვს {n}) +@msg no style `{name}` სტილი `{name}` არ არის +@msg no such artboard {v} ასეთი სამუშაო დაფა არ არის: {v} +@msg no such assets (see assets.list) ასეთი რესურსები არ არის (იხ. assets.list) +@msg no such frame anchor, or the frame would reach past the canvas ჩარჩოს ასეთი საყრდენი წერტილი არ არის, ან ჩარჩო ტილოს გასცდებოდა +@msg no such objects ასეთი ობიექტები არ არის +@msg no such path anchor, or the path would reach past the canvas ტრაექტორიის ასეთი საყრდენი წერტილი არ არის, ან ტრაექტორია ტილოს გასცდებოდა +@msg no such rows ასეთი სტრიქონები არ არის +@msg no swatch `{keep}` ფერის ნიმუში `{keep}` არ არის +@msg no swatch `{n}` ფერის ნიმუში `{n}` არ არის +@msg no swatch `{n}` (swatches and colour groups move separately) ფერის ნიმუში `{n}` არ არის (ფერის ნიმუშები და ფერების ჯგუფები ცალ-ცალკე გადაადგილდება) +@msg no swatch library `{key}` (see swatch.library.list) ფერის ნიმუშების ბიბლიოთეკა `{key}` არ არის (იხ. swatch.library.list) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) ფერის ნიმუშების ბიბლიოთეკა `{key}` არ არის (იხ. swatch.library.list; "document": დოკუმენტის ფერის ნიმუშები) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) ფერის ნიმუშების ბიბლიოთეკა `{key}` არ არის (იხ. swatch.library.list; "{DOCUMENT_SWATCHES}": დოკუმენტის ფერის ნიმუშები) +@msg no swatch named `{n}` ფერის ნიმუში სახელით `{n}` არ არის +@msg no swatch or colour group `{n}` ფერის ნიმუში ან ფერების ჯგუფი `{n}` არ არის +@msg no swatch or colour group `{n}` in `{_1}` ფერის ნიმუში ან ფერების ჯგუფი `{n}` არ არის ბიბლიოთეკაში `{_1}` +@msg no symbol named `{_1}` სიმბოლო სახელით `{_1}` არ არის +@msg no symbol named `{name}` სიმბოლო სახელით `{name}` არ არის +@msg no text objects selected ტექსტური ობიექტები მონიშნული არ არის +@msg no toolbar named `{_1}` ხელსაწყოთა ზოლი სახელით `{_1}` არ არის +@msg no toolbar named `{name}` ხელსაწყოთა ზოლი სახელით `{name}` არ არის +@msg no tracing preset `{preset}` (see imageTrace.presets) ვექტორიზაციის პრესეტი `{preset}` არ არის (იხ. imageTrace.presets) +@msg no user library folder here (save with a path, or without one to get the data) აქ მომხმარებლის ბიბლიოთეკის საქაღალდე არ არის (შეინახეთ გზის მითითებით, ან მის გარეშე, მონაცემების მისაღებად) +@msg no variable named `{from}` ცვლადი სახელით `{from}` არ არის +@msg no variable named `{name}` ცვლადი სახელით `{name}` არ არის +@msg no variable named `{var}` ცვლადი სახელით `{var}` არ არის +@msg no variable named `{v}` ცვლადი სახელით `{v}` არ არის +@msg no view named `{name}` ხედი სახელით `{name}` არ არის +@msg node {_1} already has the text variable `{_2}` კვანძს {_1} უკვე აქვს ტექსტური ცვლადი `{_2}` +@msg node {_1} is not a graph კვანძი {_1} დიაგრამა არ არის +@msg node {_1} is not an image კვანძი {_1} გამოსახულება არ არის +@msg node {_1} is not type კვანძი {_1} ტექსტი არ არის +@msg not a DCS file ეს არ არის DCS ფაილი +@msg not a PDF presets file (format `{_1}`) ეს არ არის PDF პრესეტების ფაილი (ფორმატი `{_1}`) +@msg not a PDF presets file: {e} ეს არ არის PDF პრესეტების ფაილი: {e} +@msg not a RegExp object ეს არ არის RegExp ობიექტი +@msg not a Word document: its WordDocument stream doesn't start with a file information block ეს არ არის Word-ის დოკუმენტი: მისი WordDocument ნაკადი ფაილის საინფორმაციო ბლოკით არ იწყება +@msg not a Word document: its compound file has no WordDocument stream ეს არ არის Word-ის დოკუმენტი: მის შედგენილ ფაილს WordDocument ნაკადი არ აქვს +@msg not a Word document: its main part is not a WordprocessingML document ეს არ არის Word-ის დოკუმენტი: მისი მთავარი ნაწილი WordprocessingML დოკუმენტი არ არის +@msg not a Word document: its package has no main document part ეს არ არის Word-ის დოკუმენტი: მის პაკეტს დოკუმენტის მთავარი ნაწილი არ აქვს +@msg not a closed path ეს არ არის შეკრული ტრაექტორია +@msg not a flattener presets file (format `{_1}`) ეს არ არის გამჭვირვალობის გაბრტყელების პრესეტების ფაილი (ფორმატი `{_1}`) +@msg not a flattener presets file: {e} ეს არ არის გამჭვირვალობის გაბრტყელების პრესეტების ფაილი: {e} +@msg not a graphic style library: {e} ეს არ არის გრაფიკული სტილების ბიბლიოთეკა: {e} +@msg not a layer or group ეს არ არის შრე ან ჯგუფი +@msg not a library file: {e} ეს არ არის ბიბლიოთეკის ფაილი: {e} +@msg not a mesh envelope point ეს არ არის გარსის ბადის წერტილი +@msg not a perspective grid presets file (format `{_1}`) ეს არ არის პერსპექტივის ბადის პრესეტების ფაილი (ფორმატი `{_1}`) +@msg not a perspective grid presets file: {e} ეს არ არის პერსპექტივის ბადის პრესეტების ფაილი: {e} +@msg not a print presets file (format `{_1}`) ეს არ არის ბეჭდვის პრესეტების ფაილი (ფორმატი `{_1}`) +@msg not a print presets file: {e} ეს არ არის ბეჭდვის პრესეტების ფაილი: {e} +@msg not a regular expression ეს არ არის რეგულარული გამოსახულება +@msg not a stored or deflated central record ეს არ არის შეუკუმშავი (stored) ან deflate-ით შეკუმშული ცენტრალური ჩანაწერი +@msg not a swatch library (.vcswatches or .gpl) ეს არ არის ფერის ნიმუშების ბიბლიოთეკა (.vcswatches ან .gpl) +@msg not a swatch library (format `{_1}`) ეს არ არის ფერის ნიმუშების ბიბლიოთეკა (ფორმატი `{_1}`) +@msg not a swatch library: {e} ეს არ არის ფერის ნიმუშების ბიბლიოთეკა: {e} +@msg not a valid SVGZ file: {e} ეს არ არის სწორი SVGZ ფაილი: {e} +@msg not an action set file ({e}) ეს არ არის მოქმედებების ნაკრების ფაილი ({e}) +@msg not an action set: the text doesn't start with /version and a number ეს არ არის მოქმედებების ნაკრები: ტექსტი არ იწყება /version-ით და რიცხვით +@msg not an image VectorCraft reads (see document.formats) ეს არ არის გამოსახულება, რომელსაც VectorCraft კითხულობს (იხ. document.formats) +@msg not area type (text in a frame) outside perspective ეს არ არის არის ტექსტი (ტექსტი ჩარჩოში) პერსპექტივის გარეთ +@msg not editing an opacity mask გაუმჭვირვალობის ნიღბის რედაქტირება არ მიმდინარეობს +@msg not type on a path outside perspective ეს არ არის ტრაექტორიაზე ტექსტი პერსპექტივის გარეთ +@msg nothing prints არაფერი იბეჭდება +@msg nothing to bind მისაბმელი არაფერია +@msg nothing to change შესაცვლელი არაფერია +@msg nothing to change: give dx, dy, hScale, vScale or rotation შესაცვლელი არაფერია: მიუთითეთ dx, dy, hScale, vScale ან rotation +@msg nothing to crop ჩამოსაჭრელი არაფერია +@msg nothing to export ექსპორტისთვის არაფერია +@msg nothing to export: the document has no visible art ექსპორტისთვის არაფერია: დოკუმენტს ხილული ნახატი არ აქვს +@msg nothing to mark ნიშნების დასასმელი არაფერია +@msg nothing to print: every artboard picked is blank დასაბეჭდი არაფერია: ყველა არჩეული სამუშაო დაფა ცარიელია +@msg nothing to print: the document has no art that prints დასაბეჭდი არაფერია: დოკუმენტს დასაბეჭდი ნახატი არ აქვს +@msg nothing to repeat გასამეორებელი არაფერია +@msg object is not a path ობიექტი ტრაექტორია არ არის +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object ობიექტის გამჭვირვალობა მონიშნულ სიმბოლოებზე ვერ გადავა; გამორთეთ „გამჭვირვალობა“ პიპეტის პარამეტრებში ან მონიშნეთ მთელი ობიექტი +@msg object {_1} can't be set as point type ობიექტი {_1} წერტილოვან ტექსტად ვერ დაყენდება +@msg object {_1} has lost its file's contents: relink it ობიექტმა {_1} დაკარგა თავისი ფაილის შიგთავსი: ხელახლა დააკავშირეთ +@msg object {_1} has no path ობიექტს {_1} ტრაექტორია არ აქვს +@msg object {_1} has no size ობიექტს {_1} ზომა არ აქვს +@msg object {_1} is hidden or locked, or on a locked artboard ობიექტი {_1} დამალული ან ჩაკეტილია, ან ჩაკეტილ სამუშაო დაფაზეა +@msg object {_1} is not a placed document ობიექტი {_1} განთავსებული დოკუმენტი არ არის +@msg object {_1} is not an Intertwine group ობიექტი {_1} გადახლართვის ჯგუფი არ არის +@msg object {_1} is not an image ობიექტი {_1} გამოსახულება არ არის +@msg object {_1} is not an image or a linked file ობიექტი {_1} არც გამოსახულებაა და არც დაკავშირებული ფაილი +@msg object {_1} is not an image or a placed document ობიექტი {_1} არც გამოსახულებაა და არც განთავსებული დოკუმენტი +@msg object {_1} is not in this Intertwine group ობიექტი {_1} ამ გადახლართვის ჯგუფში არ არის +@msg object {_1} needs a 3D effect ობიექტს {_1} სჭირდება 3D ეფექტი +@msg object {_1} needs a 3D effect at index {index} ობიექტს {_1} სჭირდება 3D ეფექტი ინდექსზე {index} +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic ობიექტი {_1}: ღრუ ამოწევასა და წაკვეთას თავსახურები არ აქვს: გრაფიკის განსათავსებლად ჩართეთ „თავსახური“ +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front ობიექტი {_1}: 0 სიღრმისას ობიექტს ერთი მხარე აქვს: მისი გრაფიკა წინა მხარეზე განათავსეთ +@msg object {_1}: {_2} ობიექტი {_1}: {_2} +@msg object {_1}: {e} ობიექტი {_1}: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams ობიექტი {_1}: ეფექტს „{name}“ ინდექსზე {index} განათების პრესეტები არ აქვს; მისი სინათლის წყაროები დააყენეთ effect.setParams-ით +@msg object {id} has anchors 0–{_1} for `corners`, not {past} ობიექტს {id} `corners`-ისთვის აქვს საყრდენი წერტილები 0–{_1} და არა {past} +@msg object {id} has {n} anchors, so no anchor {past} ობიექტს {id} აქვს {n} საყრდენი წერტილი, ამიტომ საყრდენი წერტილი {past} არ არსებობს +@msg objects must be none, move or copy objects უნდა იყოს none, move ან copy +@msg objects were left out ზოგიერთი ობიექტი გამოტოვებულია +@msg offset out of range წანაცვლება დიაპაზონს გარეთაა +@msg on must be true or false on უნდა იყოს true ან false +@msg only groups, layers, type and images have a Contents (Characters) row შიგთავსის (სიმბოლოების) სტრიქონი მხოლოდ ჯგუფებს, შრეებს, ტექსტსა და გამოსახულებებს აქვს +@msg only interior mesh lines can be deleted შეიძლება მხოლოდ ბადის შიდა ხაზების წაშლა +@msg only solid colours can be added (not gradients or patterns) შეიძლება მხოლოდ მყარი ფერების დამატება (არა გრადიენტების ან პატერნების) +@msg only solid-colour swatches can be spot colours სპოტ-ფერი შეიძლება იყოს მხოლოდ მყარი ფერის ნიმუში +@msg 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 Affinity-ის ამ ფაილიდან მხოლოდ ჩაშენებული წინასწარი ხედის წაკითხვა მოხერხდა: გაფრთხილებასთან ერთად მის სანახავად გამოიყენეთ ფაილი › გახსნა, ან ჯერ Affinity-დან გააკეთეთ SVG-ის ან PDF-ის ექსპორტი +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning Affinity-ის ამ ფაილიდან მხოლოდ ჩაშენებული წინასწარი ხედის წაკითხვა მოხერხდა; გაფრთხილებასთან ერთად მის სანახავად გამოიყენეთ ფაილი › გახსნა +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon და სვეტის ადგილი ეკუთვნის window.panel.float-სა და window.panel.dock-ს +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto, above, below, icon და სვეტის ადგილი არ იღებს anchor, side, zone ან before +@msg onto, above, below, icon and a column place take no x, y, width or height onto, above, below, icon და სვეტის ადგილი არ იღებს x, y, width ან height +@msg opacity must be a number opacity უნდა იყოს რიცხვი +@msg open a document to look for its linked files გახსენით დოკუმენტი მისი დაკავშირებული ფაილების მოსაძებნად +@msg operand too large ოპერანდი ზედმეტად დიდია +@msg options must be an object (see file.formatOptions) options უნდა იყოს ობიექტი (იხ. file.formatOptions) +@msg order is a whole number from 0 to {others}, not `{v}` order არის მთელი რიცხვი 0-დან {others}-მდე და არა `{v}` +@msg order must be leftToRight or rightToLeft order უნდა იყოს leftToRight ან rightToLeft +@msg orientation must be portrait or landscape, not `{o}` orientation უნდა იყოს portrait ან landscape და არა `{o}` +@msg outlineText {v}: true or false outlineText {v}: true ან false +@msg output intent profile: {e} გამოტანის მიზნის პროფილი: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: RGB ან CMYK პროფილი სახელით „{name}“ არ არის +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: {_1} ფაილები CMYK-ია, „{name}“ კი RGB პროფილია +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: არცერთი მელანი დასაბეჭდად არ არის დაყენებული +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: პროფილი სახელით „{name}“ არ არის, {std} გამოტანის მიზანი კი თავის პროფილს ჩააშენებს +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: პროფილი სახელით „{name}“ არ არის: {std} გამოტანის მიზანი ან ჩააშენებს პროფილს, ან ასახელებს რეგისტრირებულ ბეჭდვის პირობას (outputConditionId და registry) +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: {std} ფაილები CMYK-ში იბეჭდება, „{name}“ კი RGB პროფილია +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: „{name}“ ნაცრისფრის პროფილია და {std} გამოტანის მიზანი ვერ იქნება +@msg overlap: {o} pt is half a tile or more overlap: {o} pt ფილის ნახევარი ან მეტია +@msg overlapping Shaper paths must be in the same layer or group გადაფარული ფორმირების ტრაექტორიები ერთ შრეში ან ჯგუფში უნდა იყოს +@msg overlapping edits ცვლილებები ერთმანეთს ფარავს +@msg page art conflicts with image data for {key} გვერდის ნახატი ეწინააღმდეგება გამოსახულების მონაცემებს: {key} +@msg page art conflicts with pattern {name} გვერდის ნახატი ეწინააღმდეგება პატერნს: {name} +@msg page art conflicts with swatch {name} გვერდის ნახატი ეწინააღმდეგება ფერის ნიმუშს: {name} +@msg page art is missing image data for {key} გვერდის ნახატს აკლია გამოსახულების მონაცემები: {key} +@msg page art is missing pattern {name} გვერდის ნახატს აკლია პატერნი: {name} +@msg page art is missing swatch {name} გვერდის ნახატს აკლია ფერის ნიმუში: {name} +@msg page must be a whole number from 1 page უნდა იყოს მთელი რიცხვი 1-დან +@msg page thumbnail: {e} გვერდის ესკიზი: {e} +@msg page {page} of the PDF has no objects PDF-ის გვერდზე {page} ობიექტები არ არის +@msg page {page}: `{file}` has {n} {artboards} გვერდი {page}: `{file}` შეიცავს {n} {artboards} +@msg page {page}: `{name}` has {n} {pages} გვერდი {page}: `{name}` შეიცავს {n} {pages} +@msg pages must be a range such as "2-3" or a page number, not {v} pages უნდა იყოს დიაპაზონი, მაგალითად "2-3", ან გვერდის ნომერი და არა {v} +@msg pages: {e} pages: {e} +@msg panel position must be finite პანელის პოზიცია სასრული უნდა იყოს +@msg parameters must be an object parameters უნდა იყოს ობიექტი +@msg params must be an object params უნდა იყოს ობიექტი +@msg passwords can have up to {MAX_PASSWORD} bytes პაროლი შეიძლება იყოს არაუმეტეს {MAX_PASSWORD} ბაიტისა +@msg path chunks must share their boundary point ტრაექტორიის ნაწილებს საერთო სასაზღვრო წერტილი უნდა ჰქონდეს +@msg path has no open end ტრაექტორიას ღია ბოლო არ აქვს +@msg paths must be strings paths უნდა იყოს სტრიქონები +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false-ს სჭირდება, რომ ყველა სამუშაო დაფამ რედაქტირებადი ნახატი შეინარჩუნოს +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false pdfCompatible: false-ს სჭირდება, რომ სამუშაო დაფებმა რედაქტირებადი ნახატი შეინარჩუნონ, როცა useArtboards არის false +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single ან multiple +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom არის "artwork" ან "swatches" და არა {v} +@msg pins and moved must be non-empty lists of the same length pins და moved უნდა იყოს ერთი სიგრძის არაცარიელი სიები +@msg pins must be [[x,y]…] pins უნდა იყოს [[x,y]…] +@msg pins must be finite pins სასრული უნდა იყოს +@msg place is above, below or inside, not `{other}` place არის above, below ან inside და არა `{other}` +@msg plane must be left|right|ground plane უნდა იყოს left|right|ground +@msg plane must be left|right|ground|none plane უნდა იყოს left|right|ground|none +@msg point is outside the mesh წერტილი ბადის გარეთაა +@msg point x must be a number წერტილის x უნდა იყოს რიცხვი +@msg point y must be a number წერტილის y უნდა იყოს რიცხვი +@msg points must be [t, left, right] with non-negative widths points უნდა იყოს [t, left, right] არაუარყოფითი სიგანეებით +@msg points must be [x,y] pairs points უნდა იყოს [x,y] წყვილები +@msg points must be a non-empty [[x,y,pressure?]…] list points უნდა იყოს არაცარიელი სია [[x,y,pressure?]…] +@msg points must be a non-empty list of finite [x,y] points უნდა იყოს სასრული [x,y]-ების არაცარიელი სია +@msg points must be an array or null points უნდა იყოს მასივი ან null +@msg points must be finite points სასრული უნდა იყოს +@msg points must be finite [x,y] pairs points უნდა იყოს სასრული [x,y] წყვილები +@msg points must be finite canvas points points უნდა იყოს ტილოს სასრული წერტილები +@msg points must contain 1–4096 coordinates points უნდა შეიცავდეს 1–4096 კოორდინატს +@msg position must be normal, superscript or subscript position უნდა იყოს normal, superscript ან subscript +@msg position must be topLeft, topRight, bottomLeft or bottomRight position უნდა იყოს topLeft, topRight, bottomLeft ან bottomRight +@msg positions must be between 0 and 100000 pt პოზიციები უნდა იყოს 0-სა და 100000 pt-ს შორის +@msg ppi must be a positive number, not {ppi} ppi უნდა იყოს დადებითი რიცხვი და არა {ppi} +@msg ppi must be between 1 and 2400 ppi უნდა იყოს 1-სა და 2400-ს შორის +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}` არ არის transforms|bounds|fileDimensions|fit|fill +@msg preserveShape must be clippingMask|transparency preserveShape უნდა იყოს clippingMask|transparency +@msg preset `{id}`: mobile or density პრესეტი `{id}`: mobile ან density +@msg preset must be standard, diffuse, topLeft or right preset უნდა იყოს standard, diffuse, topLeft ან right +@msg preset must be the name of a print preset preset უნდა იყოს ბეჭდვის პრესეტის სახელი +@msg previewMode must be default, pixel or overprint previewMode უნდა იყოს default, pixel ან overprint +@msg print settings: {e} ბეჭდვის პარამეტრები: {e} +@msg procedures nest too deeply პროცედურები ზედმეტად ღრმადაა ჩადგმული +@msg procedures nested too deeply პროცედურები ზედმეტად ღრმადაა ჩადგმული +@msg profile colour space {other} is not RGB, CMYK or Gray პროფილის ფერთა სივრცე {other} არ არის RGB, CMYK ან Gray +@msg quotes must be {{double?, single?}} quotes უნდა იყოს {{double?, single?}} +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} უნდა იყოს გამხსნელი და დამხურავი ბრჭყალი, მაგ. "“”" +@msg range out of order in character class სიმბოლოთა კლასში დიაპაზონი არასწორი რიგითაა +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi უნდა იყოს 1–2400 (72 ეკრანი, 150 საშუალო, 300 მაღალი) +@msg rating must be a whole number from 0 to {MAX_RATING} rating უნდა იყოს მთელი რიცხვი 0-დან {MAX_RATING}-მდე +@msg rect and {k} contradict each other: give the area or the artboards rect და {k} ერთმანეთს ეწინააღმდეგება: მიუთითეთ არე ან სამუშაო დაფები +@msg rect applies to raster formats, not {_1} rect ეხება რასტრულ ფორმატებს და არა {_1} +@msg rect is [x, y, width, height] in points rect არის [x, y, width, height] პუნქტებში +@msg rect must be [x, y, width, height] rect უნდა იყოს [x, y, width, height] +@msg rect must be [x, y, width, height] with a positive width and height rect უნდა იყოს [x, y, width, height] დადებითი სიგანითა და სიმაღლით +@msg rect needs finite numbers and a positive width and height rect-ს სჭირდება სასრული რიცხვები და დადებითი სიგანე და სიმაღლე +@msg redraw needs id redraw-ს სჭირდება id +@msg redraw within must be a finite distance of 0 or more redraw within უნდა იყოს 0 ან მეტი სასრული მანძილი +@msg reduce of an empty array with no initial value reduce ცარიელ მასივზე საწყისი მნიშვნელობის გარეშე შეუძლებელია +@msg registration must be 0..8 registration უნდა იყოს 0..8 +@msg regular expression flag '{_1}' given twice რეგულარული გამოსახულების ალამი '{_1}' ორჯერაა მითითებული +@msg regular expression too large რეგულარული გამოსახულება ზედმეტად დიდია +@msg release a layer or a group შრეებად გასათავისუფლებლად მიუთითეთ შრე ან ჯგუფი +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace ინარჩუნებს ჩანაცვლებული ობიექტის ადგილს და გარდაქმნას: ამოიღეთ template, at და rect +@msg replace: select the one object to replace replace: მონიშნეთ ერთი ჩასანაცვლებელი ობიექტი +@msg resolution must be a number or screen|medium|high resolution უნდა იყოს რიცხვი ან screen|medium|high +@msg resolution must be between 1 and 2400 ppi გარჩევადობა უნდა იყოს 1-სა და 2400 ppi-ს შორის +@msg rotation must be a number of degrees rotation უნდა იყოს გრადუსების რიცხვი +@msg rotation must be finite rotation სასრული უნდა იყოს +@msg rows and cols must be 1..50 rows და cols უნდა იყოს 1..50 +@msg rows and columns must be 1..500 სტრიქონები და სვეტები უნდა იყოს 1..500 +@msg rows of objects whose appearances differ can't be reordered together სხვადასხვა იერსახის მქონე ობიექტების სტრიქონების ერთად გადალაგება შეუძლებელია +@msg sampled text would exceed the canvas (coordinates out of range) აღებული ტექსტი ტილოს გასცდებოდა (კოორდინატები დიაპაზონს გარეთაა) +@msg save the document first: Package collects a saved document ჯერ შეინახეთ დოკუმენტი: შეფუთვა შენახულ დოკუმენტს აგროვებს +@msg save the document first: copy puts the file in its folder ჯერ შეინახეთ დოკუმენტი: copy ფაილს მის საქაღალდეში აკოპირებს +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: კოეფიციენტი, მაგალითად 2x, სიგანე (100w), სიმაღლე (100h) ან გარჩევადობა (72ppi) +@msg scale must be a number above 0, at most 64 scale უნდა იყოს 0-ზე მეტი რიცხვი, არაუმეტეს 64-ისა +@msg scale must be non-zero scale ნულისგან განსხვავებული უნდა იყოს +@msg scope must be selection or all (got {other}) scope უნდა იყოს selection ან all (მიღებულია {other}) +@msg scribble is too long ჯღაბნა ზედმეტად გრძელია +@msg script file is not UTF-8 text: {value} სკრიპტის ფაილი UTF-8 ტექსტი არ არის: {value} +@msg segment does not exist სეგმენტი არ არსებობს +@msg select a Shaper group მონიშნეთ ფორმირების ჯგუფი +@msg select a blend to set easing or color shift შერბილების ან ფერის წანაცვლების დასაყენებლად მონიშნეთ გადასვლა +@msg select a compound shape მონიშნეთ შედგენილი ფიგურა +@msg select a graph მონიშნეთ დიაგრამა +@msg select a group or layer with visible, unlocked members მონიშნეთ ჯგუფი ან შრე ხილული, ჩაუკეტავი წევრებით +@msg select a layer მონიშნეთ შრე +@msg select a text object მონიშნეთ ტექსტური ობიექტი +@msg select an SVG filter მონიშნეთ SVG ფილტრი +@msg select an image (or an Image Trace group) მონიშნეთ გამოსახულება (ან გამოსახულების ვექტორიზაციის ჯგუფი) +@msg select an image, or pass id მონიშნეთ გამოსახულება ან მიუთითეთ id +@msg select an object (or pass `id`) მონიშნეთ ობიექტი (ან მიუთითეთ `id`) +@msg select an object with a 3D effect მონიშნეთ 3D ეფექტის მქონე ობიექტი +@msg select an object with a 3D effect, or give its id in ids მონიშნეთ 3D ეფექტის მქონე ობიექტი ან მიუთითეთ მისი id ids-ში +@msg select an object with a freeform gradient მონიშნეთ თავისუფალი ფორმის გრადიენტის მქონე ობიექტი +@msg select an object with an opacity mask მონიშნეთ გაუმჭვირვალობის ნიღბის მქონე ობიექტი +@msg select area type მონიშნეთ არის ტექსტი +@msg select area type (text in a frame) მონიშნეთ არის ტექსტი (ტექსტი ჩარჩოში) +@msg select area type and closed paths (at least one area type object) მონიშნეთ არის ტექსტი და შეკრული ტრაექტორიები (არის ტექსტის ერთი ობიექტი მაინც) +@msg select area type whose first line can fit its frame მონიშნეთ არის ტექსტი, რომლის პირველი სტრიქონიც მის ჩარჩოში ეტევა +@msg select artwork to add its colours მონიშნეთ ნახატი მისი ფერების დასამატებლად +@msg select artwork to make a group of its colours მონიშნეთ ნახატი მისი ფერებისგან ჯგუფის შესაქმნელად +@msg select artwork to recolour, or give a colour `group` მონიშნეთ ხელახლა შესაფერადებელი ნახატი ან მიუთითეთ ფერების ჯგუფი (`group`) +@msg select exactly two path ends to join შესაერთებლად მონიშნეთ ტრაექტორიის ზუსტად ორი ბოლო +@msg select members of a compound shape მონიშნეთ შედგენილი ფიგურის წევრები +@msg select objects or give ids მონიშნეთ ობიექტები ან მიუთითეთ ids +@msg select objects to align to the guide მონიშნეთ დამხმარე ხაზზე გასასწორებელი ობიექტები +@msg select one Intertwine group მონიშნეთ ერთი გადახლართვის ჯგუფი +@msg select one image or pass id მონიშნეთ ერთი გამოსახულება ან მიუთითეთ id +@msg select one layer, group or object to rename სახელის შესაცვლელად მონიშნეთ ერთი შრე, ჯგუფი ან ობიექტი +@msg select paths მონიშნეთ ტრაექტორიები +@msg select paths or compound paths მონიშნეთ ტრაექტორიები ან შედგენილი ტრაექტორიები +@msg select rows or objects to collect მონიშნეთ შესაგროვებელი სტრიქონები ან ობიექტები +@msg select something to export მონიშნეთ ის, რისი ექსპორტიც გსურთ +@msg select something to export (or turn off selectedOnly) მონიშნეთ ის, რისი ექსპორტიც გსურთ (ან გამორთეთ selectedOnly) +@msg select text to redefine the style from მონიშნეთ ტექსტი, რომლითაც სტილი ხელახლა განისაზღვრება +@msg select the art and, on top of it, the mask object მონიშნეთ ნახატი და მის ზემოთ ნიღბის ობიექტი +@msg select the art for the design მონიშნეთ ნახატი დიზაინისთვის +@msg select the art for the pattern tile მონიშნეთ ნახატი პატერნის ფილისთვის +@msg select the art to add first ჯერ მონიშნეთ დასამატებელი ნახატი +@msg select the art to collect (or give its ids) მონიშნეთ შესაგროვებელი ნახატი (ან მიუთითეთ მისი ids) +@msg select the art to weed მონიშნეთ ნახატი ნარჩენის მოსაცილებლად +@msg select the banner, or mark every artboard მონიშნეთ ბანერი ან ნიშნები დაუსვით ყველა სამუშაო დაფას +@msg select the content and a path on top მონიშნეთ შიგთავსი და მის ზემოთ ტრაექტორია +@msg select the images to relink, or pass ids მონიშნეთ ხელახლა დასაკავშირებელი გამოსახულებები ან მიუთითეთ ids +@msg select the images, or pass ids მონიშნეთ გამოსახულებები ან მიუთითეთ ids +@msg select the linked images or placed documents, or pass ids მონიშნეთ დაკავშირებული გამოსახულებები ან განთავსებული დოკუმენტები, ან მიუთითეთ ids +@msg select the object to copy from მონიშნეთ ობიექტი, საიდანაც დაკოპირდება +@msg select the objects (or give their ids) მონიშნეთ ობიექტები (ან მიუთითეთ მათი ids) +@msg select the objects to attach to the path მონიშნეთ ტრაექტორიაზე მისამაგრებელი ობიექტები +@msg select the objects to bind მონიშნეთ მისაბმელი ობიექტები +@msg select two or more objects to intertwine გადასახლართად მონიშნეთ ორი ან მეტი ობიექტი +@msg select two or more slices to combine გასაერთიანებლად მონიშნეთ ორი ან მეტი ნაჭერი +@msg select type on a path მონიშნეთ ტრაექტორიაზე ტექსტი +@msg select type on a path or area type მონიშნეთ ტრაექტორიაზე ტექსტი ან არის ტექსტი +@msg selection `{m}`: one or each selection `{m}`: one ან each +@msg settings is an object of document.exportForScreens params settings არის document.exportForScreens-ის პარამეტრების ობიექტი +@msg settings must be an object settings უნდა იყოს ობიექტი +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all, missing, modified ან embedded +@msg show must be true or false show უნდა იყოს true ან false +@msg side is left or right, not `{v}` side არის left ან right და არა `{v}` +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size უნდა იყოს "small", "medium", "large" ან პუნქტების რიცხვი 0-ზე მეტი, არაუმეტეს {_1} +@msg size must be positive ზომა დადებითი უნდა იყოს +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode არის "fixed" ან "pressure" და არა `{other}` +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode არის "fixed" ან "pressure" და არა {other} +@msg sizeVariation must be 0 or more sizeVariation უნდა იყოს 0 ან მეტი +@msg smallCaps must be true or false smallCaps უნდა იყოს true ან false +@msg sort `{x}`: name, kind or status sort `{x}`: name, kind ან status +@msg source paths are too large for Shaper საწყისი ტრაექტორიები ფორმირებისთვის ზედმეტად დიდია +@msg source text transform is collapsed or unrepresentable საწყისი ტექსტის გარდაქმნა გადაგვარებულია ან ვერ გამოისახება +@msg spot, mode and group apply to solid colours only spot, mode და group ეხება მხოლოდ მყარ ფერებს +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked, embedded, missing ან modified +@msg steps must be 1..1000 steps უნდა იყოს 1..1000 +@msg steps must be 1..{MAX_STEPS} steps უნდა იყოს 1..{MAX_STEPS} +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) გადასვლის წერტილი {i}: `plates` უნდა იყოს ოთხი რიცხვი 0-დან 1-მდე (C, M, Y, K) +@msg stop {i}: {e} გადასვლის წერტილი {i}: {e} +@msg stopped before the archive was finished შეჩერდა არქივის დასრულებამდე +@msg strokeMode must be within|along|across, got {m} strokeMode უნდა იყოს within|along|across, მიღებულია {m} +@msg strokeOptions must be an object of stroke.set options strokeOptions უნდა იყოს stroke.set-ის პარამეტრების ობიექტი +@msg strokeWidth must be a positive number of pixels strokeWidth უნდა იყოს პიქსელების დადებითი რიცხვი +@msg subpath {si} is not an open subpath ქვეტრაექტორია {si} ღია ქვეტრაექტორია არ არის +@msg subpaths required when joining the same path ერთი და იმავე ტრაექტორიის შეერთებისას საჭიროა subpaths +@msg swatch exchange version {major}.{minor} isn't supported (only 1.x) ფერის ნიმუშების გაცვლის ვერსია {major}.{minor} მხარდაჭერილი არ არის (მხოლოდ 1.x) +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict უნდა იყოს "merge" ან "add" და არა {v} +@msg table must be true or false table უნდა იყოს true ან false +@msg takes one name იღებს ერთ სახელს +@msg target text transform is collapsed or unrepresentable სამიზნე ტექსტის გარდაქმნა გადაგვარებულია ან ვერ გამოისახება +@msg text must be {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} text უნდა იყოს {{characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}} +@msg text transform is collapsed or unrepresentable ტექსტის გარდაქმნა გადაგვარებულია ან ვერ გამოისახება +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces უნდა იყოს მთელი რიცხვი 2-დან 100-მდე +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: გამოიყენეთ "{_1}" ან "{_2}" +@msg textureImage: give {{path}} or {{name, dataBase64}} of an image file, or a texture as effect.list gives it textureImage: მიუთითეთ გამოსახულების ფაილის {{path}} ან {{name, dataBase64}}, ან ტექსტურა ისე, როგორც effect.list იძლევა +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices ეს {MAX_PIECES}-ზე მეტ ნაჭერს ქმნის +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer ეს ზედმეტად ბევრ ნაწილს (მაქსიმუმ 100000) ან ტირეების ნაბიჯს ქმნის: გაამარტივეთ ტირეების ნიმუში ან გაზარდეთ ტირეების სიგრძე +@msg that object is not in the overlap ეს ობიექტი გადაფარვაში არ არის +@msg the Affinity document has no page with a size Affinity-ის დოკუმენტს ზომის მქონე გვერდი არ აქვს +@msg the CMYK image couldn't be written CMYK გამოსახულების ჩაწერა ვერ მოხერხდა +@msg the Layers panel is not shown შრეების პანელი არ ჩანს +@msg the Layers panel's search, filter or Show Layers Only hides its row შრეების პანელის ძიება, ფილტრი ან „მხოლოდ შრეების ჩვენება“ მის სტრიქონს მალავს +@msg the Objects on Path group is hidden or locked ტრაექტორიაზე ობიექტების ჯგუფი დამალული ან ჩაკეტილია +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected PDF-ის უსაფრთხოების პარამეტრები პაროლების გარეშეა შენახული: კვლავ მიუთითეთ openPassword ან permissionsPassword, ან დაუცველი ექსპორტისთვის მიუთითეთ security passwordRequired-ის გარეშე +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) RTF ფაილს ზედმეტად ბევრი აბზაცი ან ფორმატირების ცვლილება აქვს ტექსტად განსათავსებლად (მაქსიმუმ {MAX_PARAGRAPHS} აბზაცი და {MAX_RUNS} ფრაგმენტი) +@msg the RTF file is too large to place as type (at most {_1} MB) RTF ფაილი ზედმეტად დიდია ტექსტად განსათავსებლად (მაქსიმუმ {_1} MB) +@msg the SVG expands to more than {0} nested SVG images SVG იშლება {0}-ზე მეტ ჩადგმულ SVG გამოსახულებად +@msg the SVG expands to more than {0} objects SVG იშლება {0}-ზე მეტ ობიექტად +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes SVG ფაილი {MAX_IMPORT_BYTES} ბაიტზე დიდია +@msg the SVG has no drawable objects SVG-ს დასახატი ობიექტები არ აქვს +@msg the SVGZ file unpacks to more than {_1} MB SVGZ ფაილი გაშლისას {_1} MB-ს აჭარბებს +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place Word-ის დოკუმენტს თვისებების {MAX_PAGES}-ზე მეტი გვერდი აქვს, განსათავსებლად ზედმეტად ბევრი +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place Word-ის დოკუმენტს სიმბოლოს ან აბზაცის თვისებების {MAX_RUNS}-ზე მეტი ფრაგმენტი აქვს, განსათავსებლად ზედმეტად ბევრი +@msg the Word document has more than {_1} MB of formatting properties, too many to place Word-ის დოკუმენტს {_1} MB-ზე მეტი მოცულობის ფორმატირების თვისებები აქვს, განსათავსებლად ზედმეტად ბევრი +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) Word-ის დოკუმენტს ზედმეტად ბევრი ტექსტი ან ფორმატირება აქვს ტექსტად განსათავსებლად (მაქსიმუმ {MAX_PARAGRAPHS} აბზაცი, {MAX_RUNS} ფრაგმენტი და {_1} MB ტექსტი) +@msg the Word document has too much text to place as type (at most {_1} MB) Word-ის დოკუმენტს ზედმეტად ბევრი ტექსტი აქვს ტექსტად განსათავსებლად (მაქსიმუმ {_1} MB) +@msg the Word document is encrypted or protected with a password; save it without one to place it Word-ის დოკუმენტი დაშიფრული ან პაროლით დაცულია; განსათავსებლად შეინახეთ ის პაროლის გარეშე +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) Word-ის დოკუმენტი ზედმეტად დიდია განსათავსებლად (ნაკადი {name} {_1} MB-ზე მეტს შეიცავს) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off Word-ის დოკუმენტი ბოლოს სწრაფი შენახვით არის შენახული, რასაც VectorCraft ვერ კითხულობს; ხელახლა შეინახეთ ის გამორთული სწრაფი შენახვით +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document Word-ის დოკუმენტი შენახულია Word 6.0-ით ან Word 95-ით, რომელსაც VectorCraft ვერ კითხულობს; შეინახეთ ის Word 97-2003 დოკუმენტად +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place Word-ის დოკუმენტის ტექსტი {MAX_PIECES}-ზე მეტი ნაწილისგან შედგება, განსათავსებლად ზედმეტად ბევრი +@msg the XML operation needs an XML or XMLList value XML ოპერაციას XML ან XMLList მნიშვნელობა სჭირდება +@msg the XPath expression does not evaluate to a node-set XPath გამოსახულების შედეგი კვანძების სიმრავლე არ არის +@msg the XPath expression ends early XPath გამოსახულება ნაადრევად მთავრდება +@msg the XPath expression has an unmatched ) XPath გამოსახულებაში დაუწყვილებელი ) არის +@msg the XPath expression has an unmatched ] XPath გამოსახულებაში დაუწყვილებელი ] არის +@msg the XPath expression has an unmatched closing bracket XPath გამოსახულებაში დაუწყვილებელი დამხურავი ფრჩხილი არის +@msg the XPath expression selects a namespace node, which is not an XML value XPath გამოსახულება სახელთა სივრცის კვანძს ირჩევს, რომელიც XML მნიშვნელობა არ არის +@msg the XPath expression selects the root node, which is not an XML value XPath გამოსახულება ძირეულ კვანძს ირჩევს, რომელიც XML მნიშვნელობა არ არის +@msg the XPath function {name} needs a node-set XPath ფუნქციას {name} კვანძების სიმრავლე სჭირდება +@msg the XPath function {name} takes the wrong number of arguments XPath ფუნქციას {name} არგუმენტების არასწორი რაოდენობა გადაეცა +@msg the XPath name functions need a node-set XPath-ის სახელის ფუნქციებს კვანძების სიმრავლე სჭირდება +@msg the XPath prefix {shown} is not bound to a namespace XPath პრეფიქსი {shown} არცერთ სახელთა სივრცესთან არ არის მიბმული +@msg the XPath variable ${shown} is not bound XPath ცვლადი ${shown} არ არის მიბმული +@msg the action set ids are not a list მოქმედებების ნაკრების იდენტიფიკატორები სია არ არის +@msg the active paint is not a gradient აქტიური საღებავი გრადიენტი არ არის +@msg the argument list is not an object არგუმენტების სია ობიექტი არ არის +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size სამუშაო დაფები ტილოზე არ ეტევა: შეამცირეთ სამუშაო დაფების რაოდენობა, დაშორება ან ზომა +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out ნახატი EPS-ისთვის ზედმეტად დიდია: PostScript-ის 256 MB-ის შემდეგ დანარჩენი გამოტოვებულია +@msg the banner has no width or height ბანერს სიგანე ან სიმაღლე არ აქვს +@msg the bitmaps hold more pixels than one file may ბიტმაპები იმაზე მეტ პიქსელს შეიცავს, ვიდრე ერთ ფაილში ეტევა +@msg the character's size can't be measured სიმბოლოს ზომის გაზომვა შეუძლებელია +@msg the code runs past its last instruction კოდი თავისი ბოლო ინსტრუქციის მიღმა გრძელდება +@msg the color book holds more than {ASE_MAX_ENTRIES} colors ფერთა წიგნი {ASE_MAX_ENTRIES}-ზე მეტ ფერს შეიცავს +@msg the color book is cut short ფერთა წიგნი შეკვეცილია +@msg the color book's names take more than {_1} MB ფერთა წიგნის სახელები {_1} MB-ზე მეტს იკავებს +@msg the compound shape is too complex to expand შედგენილი ფიგურა ზედმეტად რთულია გასაშლელად +@msg the container of the listed objects is gone ჩამოთვლილი ობიექტების კონტეინერი აღარ არსებობს +@msg the copy `{user_id}` can't be read as a library ასლის `{user_id}` ბიბლიოთეკად წაკითხვა შეუძლებელია +@msg the copy would leave the canvas ასლი ტილოს გარეთ გავიდოდა +@msg the curve tolerance must be from 0.01 to 10 points მრუდის დაშვება უნდა იყოს 0.01-დან 10 პუნქტამდე +@msg the custom dictionary couldn't be saved to {_1}: {e} მომხმარებლის ლექსიკონის შენახვა ვერ მოხერხდა აქ: {_1}: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is მომხმარებლის ლექსიკონის {_1} წაკითხვა შეუძლებელია ({why}), ამიტომ ის უცვლელი რჩება +@msg the cut cannot be made on this art: {e} ამ ნახატზე გაჭრა შეუძლებელია: {e} +@msg the data isn't an EMF or WMF picture მონაცემები EMF ან WMF სურათი არ არის +@msg the data isn't an EPS or PostScript picture მონაცემები EPS ან PostScript სურათი არ არის +@msg the deferred paste is no longer available გადადებული ჩასმა აღარ არის ხელმისაწვდომი +@msg the design is too big to draw at every data point of this graph დიზაინი ზედმეტად დიდია ამ დიაგრამის ყველა მონაცემთა წერტილზე დასახატად +@msg the design is too big to draw in every column of this graph დიზაინი ზედმეტად დიდია ამ დიაგრამის ყველა სვეტში დასახატად +@msg the dialog it was opened over is no longer open დიალოგი, რომლის ზემოთაც ის გაიხსნა, აღარ არის ღია +@msg the dictionary stack overflowed ლექსიკონების სტეკი გადაივსო +@msg the document already has an SVG filter named {name} დოკუმენტს უკვე აქვს SVG ფილტრი სახელით {name} +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it დოკუმენტს აქვს სხვა SVG ფილტრი სახელით {name}: გამოიყენეთ svgFilter.apply, ან მის შესაცვლელად svgFilter.define `replace`-ით +@msg the document has no SVG filter named {name} დოკუმენტს არ აქვს SVG ფილტრი სახელით {name} +@msg the document has no SVG filter named {r} დოკუმენტს არ აქვს SVG ფილტრი სახელით {r} +@msg the document has no color swatches დოკუმენტს ფერის ნიმუშები არ აქვს +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 დოკუმენტის ფერის ნიმუშები {_1} ფერს შეიცავს; დოკუმენტის მიხედვით შემცირება მაქსიმუმ 255-ს იღებს +@msg the editing data's hash has no place რედაქტირების მონაცემების ჰეშს ადგილი არ აქვს +@msg the entities the document declares expand to too much text დოკუმენტში გამოცხადებული ერთეულები ზედმეტად ბევრ ტექსტად იშლება +@msg the envelope's content has no bounds გარსის შიგთავსს საზღვრები არ აქვს +@msg the envelope's mesh is malformed გარსის ბადე დაზიანებულია +@msg the file holds no `variableSets` element ფაილი `variableSets` ელემენტს არ შეიცავს +@msg the file is larger than {_1} MB ფაილი {_1} MB-ზე დიდია +@msg the file is too large ფაილი ზედმეტად დიდია +@msg the file isn't valid SVG: {e} ფაილი სწორი SVG არ არის: {e} +@msg the file names no plate ფაილი არცერთ ფირფიტას არ ასახელებს +@msg the file was updated or encrypted already ფაილი უკვე განახლებული ან დაშიფრულია +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) სტრიქონის პირველ სიმბოლოს წინ არ უდგას სიმბოლო, რომელთანაც კერნინგი შეიძლება: ის მხოლოდ ზემოთ და ქვემოთ გადაადგილდება (dy) +@msg the font {_1} is not available შრიფტი {_1} ხელმისაწვდომი არ არის +@msg the format has no file extension ფორმატს ფაილის გაფართოება არ აქვს +@msg the function's prototype is not an object ფუნქციის პროტოტიპი ობიექტი არ არის +@msg the graphic can't be read: {e} გრაფიკის წაკითხვა შეუძლებელია: {e} +@msg the graphic is empty გრაფიკა ცარიელია +@msg the graphic's data is damaged გრაფიკის მონაცემები დაზიანებულია +@msg the graphic's document has no artboard გრაფიკის დოკუმენტს სამუშაო დაფა არ აქვს +@msg the grommet inset is too large for the banner ლუვერების დაშორება კიდიდან ბანერისთვის ზედმეტად დიდია +@msg the guide is hidden or locked დამხმარე ხაზი დამალული ან ჩაკეტილია +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them სათაური `{cell}` (ცვლადისთვის `{name}`) წაკითხვისას ცვლადად `{other}` აღიქმება: ერთ-ერთს სახელი გადაარქვით +@msg the hem is too deep for the banner ნაკეცი ბანერისთვის ზედმეტად ღრმაა +@msg the image `{path}` isn't followed: {r} გამოსახულების ბმული `{path}` არ გაიხსნება: {r} +@msg the image has no embedded pixels გამოსახულებას ჩაშენებული პიქსელები არ აქვს +@msg the image is empty გამოსახულება ცარიელია +@msg the image is fully transparent გამოსახულება სრულად გამჭვირვალეა +@msg the image is not on an artboard; pass rect გამოსახულება სამუშაო დაფაზე არ არის; გადაეცით rect +@msg the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {_1} megapixels: lower the scale or resolution გამოსახულება იქნებოდა {w} × {h} პიქსელი; რასტრული ექსპორტი შეზღუდულია {MAX_RASTER_SIDE} პიქსელით თითო მხარეზე და {_1} მეგაპიქსელით: შეამცირეთ მასშტაბი ან გარჩევადობა +@msg the image's pixels are missing გამოსახულების პიქსელები აკლია +@msg the image's pixels aren't in the document გამოსახულების პიქსელები დოკუმენტში არ არის +@msg the image's pixels can't be read: {e} გამოსახულების პიქსელების წაკითხვა შეუძლებელია: {e} +@msg the image's transparency doesn't fit it გამოსახულების გამჭვირვალობა მას არ შეესაბამება +@msg the job would print more than {MAX_PAGES} pages დავალება {MAX_PAGES}-ზე მეტ გვერდს დაბეჭდავდა +@msg the job would print more than {_1} pages დავალება {_1}-ზე მეტ გვერდს დაბეჭდავდა +@msg the layers hold no art to mask შრეებში დასანიღბი ნახატი არ არის +@msg the layout moved განლაგება გადაადგილდა +@msg the library has a group `{name}` already ბიბლიოთეკას უკვე აქვს ჯგუფი `{name}` +@msg the library has no group `{name}` ბიბლიოთეკას არ აქვს ჯგუფი `{name}` +@msg the library has no item `{item}` ბიბლიოთეკას არ აქვს ელემენტი `{item}` +@msg the library has no item `{other}` ბიბლიოთეკას არ აქვს ელემენტი `{other}` +@msg the library holds {MAX_GROUPS} groups already ბიბლიოთეკა უკვე შეიცავს {MAX_GROUPS} ჯგუფს +@msg the library holds {MAX_ITEMS} items of this kind already ბიბლიოთეკა უკვე შეიცავს ამ სახის {MAX_ITEMS} ელემენტს +@msg the line is too long, or the paths it passes too many, to check ხაზი ზედმეტად გრძელია, ან მის მიერ გადაკვეთილი ტრაექტორიები ზედმეტად ბევრია შესამოწმებლად +@msg the method needs an XML value მეთოდს XML მნიშვნელობა სჭირდება +@msg the name is empty სახელი ცარიელია +@msg the object has no fill, stroke or effect ობიექტს არ აქვს შევსება, კონტური ან ეფექტი +@msg the object has no graphic style (pass `name`) ობიექტს გრაფიკული სტილი არ აქვს (გადაეცით `name`) +@msg the objects don't match the plan ობიექტები გეგმას არ შეესაბამება +@msg the open and permissions passwords must differ გახსნისა და ნებართვების პაროლები ერთმანეთისგან უნდა განსხვავდებოდეს +@msg the operand stack overflowed ოპერანდების სტეკი გადაივსო +@msg the optimised image can't be read back: {e} ოპტიმიზებული გამოსახულების ხელახლა წაკითხვა შეუძლებელია: {e} +@msg the page has no area გვერდს ფართობი არ აქვს +@msg the page's type in an unread object's box isn't in its page art წაუკითხავი ობიექტის ჩარჩოში მოთავსებული გვერდის ტექსტი გვერდის ნახატში არ არის +@msg the paint behind the proxy is not a gradient აქტიური შევსების/კონტურის საღებავი გრადიენტი არ არის +@msg the parameters or body do not form one function პარამეტრები ან სხეული ერთ ფუნქციას არ ქმნის +@msg the parent must be a layer or sublayer მშობელი უნდა იყოს შრე ან ქვეშრე +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it ჩასმულ SVG ფილტრს {name} დოკუმენტის ამავე სახელის ფილტრისგან განსხვავებული სახე აქვს და მისთვის თავისუფალი სახელი ვერ მოიძებნა +@msg the path can't be extended ტრაექტორიის გაგრძელება შეუძლებელია +@msg the path can't be inside one of the objects to attach ტრაექტორია ვერ იქნება მისამაგრებელი ობიექტებიდან ერთ-ერთის შიგნით +@msg the path has no segments to measure ტრაექტორიას გასაზომი სეგმენტები არ აქვს +@msg the path is empty ტრაექტორია ცარიელია +@msg the path is too short to shorten that much ტრაექტორია ზედმეტად მოკლეა ამდენით შესამოკლებლად +@msg the paths are too many or too tangled to find where they cross ტრაექტორიები ზედმეტად ბევრი ან ზედმეტად ჩახლართულია გადაკვეთის წერტილების საპოვნელად +@msg the pen table holds more than 255 pens კალმების ცხრილი 255-ზე მეტ კალამს შეიცავს +@msg the picture has no objects სურათს ობიექტები არ აქვს +@msg the pixel buffer doesn't match the image size პიქსელების ბუფერი გამოსახულების ზომას არ შეესაბამება +@msg the plates can't be kept ფირფიტების შენარჩუნება შეუძლებელია +@msg the position table does not match the instructions პოზიციების ცხრილი ინსტრუქციებს არ შეესაბამება +@msg the preview: {e} წინასწარი ხედი: {e} +@msg the program runs too long პროგრამა ზედმეტად დიდხანს მუშაობს +@msg the program uses too much memory პროგრამა ზედმეტად ბევრ მეხსიერებას იყენებს +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes prompt-ის ტექსტი {MAX_PROMPT_BYTES} ბაიტზე გრძელია +@msg the radix must be between 2 and 36 თვლის სისტემის ფუძე უნდა იყოს 2-დან 36-მდე +@msg the rectangle must have a width and a height მართკუთხედს უნდა ჰქონდეს სიგანე და სიმაღლე +@msg the rectangle needs a size and must lie on the canvas მართკუთხედს ზომა სჭირდება და ტილოზე უნდა მდებარეობდეს +@msg the rectangle or line reaches too many paths to check მართკუთხედი ან ხაზი ზედმეტად ბევრ ტრაექტორიას სწვდება შესამოწმებლად +@msg the request may not read the New Document profiles folder მოთხოვნას არ შეუძლია ახალი დოკუმენტის პროფილების საქაღალდის წაკითხვა +@msg the right side of 'in' is not an object 'in'-ის მარჯვენა მხარე ობიექტი არ არის +@msg the right side of 'instanceof' is not a function 'instanceof'-ის მარჯვენა მხარე ფუნქცია არ არის +@msg the selected art has no width მონიშნულ ნახატს სიგანე არ აქვს +@msg the selected art is nested more than {MAX_DEPTH} levels deep მონიშნული ნახატი {MAX_DEPTH} დონეზე ღრმადაა ჩადგმული +@msg the selected art is too large to measure მონიშნული ნახატი ზედმეტად დიდია გასაზომად +@msg the selection draws nothing მონიშნული არაფერს ხატავს +@msg the selection has no area მონიშნულს ფართობი არ აქვს +@msg the selection has no art to export მონიშნულში ექსპორტისთვის ნახატი არ არის +@msg the selection has no bounds მონიშნულს საზღვრები არ აქვს +@msg the selection has no graphic style მონიშნულს გრაფიკული სტილი არ აქვს +@msg the selection has no outlines to weed around მონიშნულში არ არის კონტურები, რომელთა გარშემოც ნარჩენის მოცილება შეიძლება +@msg the shapes are too degenerate to combine ფიგურები ზედმეტად გადაგვარებულია გასაერთიანებლად +@msg the shapes cross themselves and each other too often to combine ფიგურები საკუთარ თავს და ერთმანეთს ზედმეტად ხშირად კვეთს გასაერთიანებლად +@msg the slice would leave the canvas ნაჭერი ტილოს გარეთ გავიდოდა +@msg the spacing makes more than {MAX_GROMMETS} grommets ასეთი დაშორებით {MAX_GROMMETS}-ზე მეტი ლუვერი გამოდის +@msg the spot colour couldn't be added სპოტ-ფერის დამატება ვერ მოხერხდა +@msg the subpath has no ends to extend (it is closed or a single point) ქვეტრაექტორიას გასაგრძელებელი ბოლოები არ აქვს (ის შეკრულია ან ერთი წერტილია) +@msg the swatch exchange file holds more than {ASE_MAX_ENTRIES} colors and groups ფერის ნიმუშების გაცვლის ფაილი {ASE_MAX_ENTRIES}-ზე მეტ ფერსა და ჯგუფს შეიცავს +@msg the swatch exchange file is cut short ფერის ნიმუშების გაცვლის ფაილი შეკვეცილია +@msg the symbols are nested too deeply to expand their instances სიმბოლოები ზედმეტად ღრმადაა ჩადგმული მათი ეგზემპლარების გასაშლელად +@msg the text file is too large to place as type (at most {_1} MB) ტექსტური ფაილი ზედმეტად დიდია ტექსტად განსათავსებლად (მაქსიმუმ {_1} MB) +@msg the text is empty ტექსტი ცარიელია +@msg the text is over {_1} MB ტექსტი {_1} MB-ზე მეტია +@msg the top object must be a path ზედა ობიექტი ტრაექტორია უნდა იყოს +@msg the trace was cancelled ვექტორიზაცია გაუქმდა +@msg the trailer has no size ტრეილერს ზომა არ აქვს +@msg the type object is flattened to nothing ტექსტური ობიექტი გაბრტყელებისას არაფრად იქცა +@msg the value bind was called on is not a function მნიშვნელობა, რომელზეც bind გამოიძახეს, ფუნქცია არ არის +@msg the value is not a constructor მნიშვნელობა კონსტრუქტორი არ არის +@msg the value is not a function მნიშვნელობა ფუნქცია არ არის +@msg the value is not a valid XML name მნიშვნელობა XML-ის სწორი სახელი არ არის +@msg the value is not an XML attribute name მნიშვნელობა XML ატრიბუტის სახელი არ არის +@msg the value is not an XML name მნიშვნელობა XML სახელი არ არის +@msg the value of axis `{tag}` must be a number or null ღერძის `{tag}` მნიშვნელობა უნდა იყოს რიცხვი ან null +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets ცვლადების ბიბლიოთეკა {MAX_LIBRARY_DATASETS}-ზე მეტ მონაცემთა ნაკრებს აცხადებს +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities ცვლადების ბიბლიოთეკა {MAX_LIBRARY_ENTITIES}-ზე მეტ ერთეულს აცხადებს +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables ცვლადების ბიბლიოთეკა {MAX_LIBRARY_VARIABLES}-ზე მეტ ცვლადს აცხადებს +@msg the variable library declares no variables ცვლადების ბიბლიოთეკა არცერთ ცვლადს არ აცხადებს +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes ცვლადების ბიბლიოთეკა {MAX_LIBRARY_BYTES} ბაიტზე დიდია +@msg the variable library is not UTF-8 ცვლადების ბიბლიოთეკა UTF-8 არ არის +@msg the variable library is not well-formed XML: {e} ცვლადების ბიბლიოთეკა სწორად აგებული XML არ არის: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep ცვლადების ბიბლიოთეკაში ელემენტები {MAX_LIBRARY_NESTING} დონეზე ღრმადაა ჩადგმული +@msg the variable library's document type declaration holds something other than entities of plain text ცვლადების ბიბლიოთეკის დოკუმენტის ტიპის დეკლარაცია უბრალო ტექსტის ერთეულების გარდა სხვა რამესაც შეიცავს +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes ცვლადების ბიბლიოთეკის ერთეულები {MAX_LIBRARY_BYTES} ბაიტზე მეტად იშლება +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines ნარჩენის მოსაცილებელი ხაზები ნახატის კონტურებს ზედმეტად ბევრჯერ კვეთს გასაზომად; გამოიყენეთ ნაკლები ხაზი +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap ნარჩენის მოსაცილებელი ხაზები ზედმეტად ბევრ კონტურის სეგმენტთან გადის ზედმეტად ახლოს გასაზომად; გამოიყენეთ ნაკლები ხაზი ან უფრო მცირე ღრეჩო +@msg the weeding lines would be more than {max_pieces} pieces ნარჩენის მოსაცილებელი ხაზები {max_pieces}-ზე მეტ ნაწილად დაიყოფოდა +@msg the window has no dock ფანჯარას დოკი არ აქვს +@msg the written PDF has no catalog ჩაწერილ PDF-ს კატალოგი არ აქვს +@msg the written PDF has no cross-reference table ჩაწერილ PDF-ს ჯვარედინი მიმართვების ცხრილი არ აქვს +@msg the written PDF's metadata can't be edited ჩაწერილი PDF-ის მეტამონაცემების რედაქტირება შეუძლებელია +@msg the {_1} plate can't be read: {e} ფირფიტის {_1} წაკითხვა შეუძლებელია: {e} +@msg the {_1} plate lies outside the file ფირფიტა {_1} ფაილის გარეთ მდებარეობს +@msg there are no colour swatches to draw დასახატი ფერის ნიმუშები არ არის +@msg there is already a design named `{name}` უკვე არსებობს დიზაინი სახელით `{name}` +@msg there is already a design named `{to}` უკვე არსებობს დიზაინი სახელით `{to}` +@msg there is already a toolbar named `{name}` უკვე არსებობს ხელსაწყოთა ზოლი სახელით `{name}` +@msg there is already a toolbar named `{to}` უკვე არსებობს ხელსაწყოთა ზოლი სახელით `{to}` +@msg there is no banner to mark ნიშნების დასასმელი ბანერი არ არის +@msg there is no library: create one first ბიბლიოთეკა არ არის: ჯერ შექმენით ის +@msg these paths are too degenerate for Shaper ეს ტრაექტორიები ფორმირებისთვის ზედმეტად გადაგვარებულია +@msg this binary DXF file is damaged or cut short ეს ორობითი DXF ფაილი დაზიანებული ან შეკვეცილია +@msg this is a DWG drawing, which can't be read: save it from the CAD app as a DXF and open that ეს DWG ნახაზია, რომლის წაკითხვაც ვერ ხერხდება: CAD აპიდან შეინახეთ DXF-ად და ის გახსენით +@msg this warp's points can't be edited: use Reset with Mesh first ამ გაღუნვის წერტილების რედაქტირება შეუძლებელია: ჯერ გამოიყენეთ „ბადით ჩამოყრა“ +@msg thumbnail: no page {v} (the {what} has {count}) ესკიზი: გვერდი {v} არ არის ({what}: {count} გვერდი) +@msg tileRange of {what}: {e} tileRange ({what}): {e} +@msg title can't be empty title ცარიელი ვერ იქნება +@msg to (the new name) is required to (ახალი სახელი) აუცილებელია +@msg to must be fill or stroke to უნდა იყოს fill ან stroke +@msg to must be smooth or corner to უნდა იყოს smooth ან corner +@msg toExponential() digits must be between 0 and 20 toExponential()-ის ციფრების რაოდენობა უნდა იყოს 0-დან 20-მდე +@msg toFixed() digits must be between 0 and 20 toFixed()-ის ციფრების რაოდენობა უნდა იყოს 0-დან 20-მდე +@msg toISOString is not a function toISOString ფუნქცია არ არის +@msg toPrecision() argument must be between 1 and 21 toPrecision()-ის არგუმენტი უნდა იყოს 1-დან 21-მდე +@msg toSource: a getter cannot be written as source toSource: getter-ის წყაროდ ჩაწერა შეუძლებელია +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: მასივის წყაროდ ჩაწერა შეუძლებელია, თუ მას ელემენტების გარდა თვისებებიც აქვს, აქვს არაჩამოთვლადი თვისება ან სხვა პროტოტიპი +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: ობიექტის წყაროდ ჩაწერა შეუძლებელია, თუ მას აქვს არაჩამოთვლადი თვისება ან მისი პროტოტიპი Object.prototype არ არის +@msg toSource: the source is too long toSource: წყარო ზედმეტად გრძელია +@msg toSource: the value contains itself toSource: მნიშვნელობა საკუთარ თავს შეიცავს +@msg toSource: the value has too many entries toSource: მნიშვნელობას ზედმეტად ბევრი ჩანაწერი აქვს +@msg toSource: the value is nested too deep toSource: მნიშვნელობა ზედმეტად ღრმადაა ჩადგმული +@msg toString is not a function toString ფუნქცია არ არის +@msg too large for a zip archive: package to a folder zip არქივისთვის ზედმეტად დიდია: შეფუთეთ საქაღალდეში +@msg too many Shaper edits ზედმეტად ბევრი ფორმირების ჩასწორება +@msg too many `points` (at most 100000) ზედმეტად ბევრი `points` (მაქსიმუმ 100000) +@msg too many arguments ზედმეტად ბევრი არგუმენტი +@msg too many characters: at most {MAX_CHARS} ზედმეტად ბევრი სიმბოლო: მაქსიმუმ {MAX_CHARS} +@msg too many classes ზედმეტად ბევრი კლასი +@msg too many dots for this size: lower the frequency ამ ზომისთვის ზედმეტად ბევრი წერტილია: შეამცირეთ სიხშირე +@msg too many filters over one file ერთ ფაილზე ზედმეტად ბევრი ფილტრია +@msg too many groups ზედმეტად ბევრი ჯგუფი +@msg too many layers for PSD (at most 32767) PSD-ისთვის ზედმეტად ბევრი შრეა (მაქსიმუმ 32767) +@msg too many overlapping paths for Shaper ფორმირებისთვის ზედმეტად ბევრი გადაფარვადი ტრაექტორიაა +@msg too many quantifiers ზედმეტად ბევრი კვანტიფიკატორი +@msg too many saved clips ზედმეტად ბევრი შენახული მომჭრელი ტრაექტორია +@msg too many saved graphics states ზედმეტად ბევრი შენახული გრაფიკული მდგომარეობა +@msg too many swatch exchange blocks ფერის ნიმუშების გაცვლის ზედმეტად ბევრი ბლოკი +@msg toolbar (a toolbar's name) must be a string toolbar (ხელსაწყოთა ზოლის სახელი) სტრიქონი უნდა იყოს +@msg toolbar name can't be empty ხელსაწყოთა ზოლის სახელი ცარიელი ვერ იქნება +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (ნაგულისხმევი) ან "omit" +@msg turn on at least one side ჩართეთ მინიმუმ ერთი მხარე +@msg two saved selections would be named `{_1}` ორ შენახულ მონიშნულს ერთი და იგივე სახელი ექნებოდა: `{_1}` +@msg type dimension cannot be represented with this text transform ტექსტის ზომის წარმოდგენა ამ ტექსტის გარდაქმნით შეუძლებელია +@msg type dimension must be finite ტექსტის ზომა სასრული უნდა იყოს +@msg unexpected resources მოულოდნელი რესურსები +@msg unknown CMYK profile `{name}` უცნობი CMYK პროფილი `{name}` +@msg unknown File Info field `{k}` ფაილის ინფორმაციის უცნობი ველი `{k}` +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) უცნობი OpenType შესაძლებლობა `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt, hkna, ital; გამოსართავად წინ დაურთეთ -; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom და aalt იღებს ალტერნატივის ნომერს ფორმით `tag=N`, სადაც N არის 0-დან {_1}-მდე, მაგალითად `salt=2`) +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) უცნობი OpenType შესაძლებლობა `{t}` featureChanges-ში (ის ტეგები, რომლებსაც `features` იღებს) +@msg unknown PDF preset `{name}` (presets: {_1}) უცნობი PDF პრესეტი `{name}` (პრესეტები: {_1}) +@msg unknown Warp style გაღუნვის უცნობი სტილი +@msg unknown alignToPath `{a}` უცნობი alignToPath `{a}` +@msg unknown arrowhead {v} უცნობი ისრის წვერი {v} +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) artboardLayout-ის უცნობი გასაღები `{k}` (layout, columns, spacing, rightToLeft) +@msg unknown attribute `{attribute}` უცნობი ატრიბუტი `{attribute}` +@msg unknown attribute `{k}` უცნობი ატრიბუტი `{k}` +@msg unknown axis `{other}` (vertical|horizontal|free) უცნობი ღერძი `{other}` (vertical|horizontal|free) +@msg unknown blend mode `{b}` უცნობი შერევის რეჟიმი `{b}` +@msg unknown blend mode {b} უცნობი შერევის რეჟიმი {b} +@msg unknown brush type `{o}` ფუნჯის უცნობი ტიპი `{o}` +@msg unknown category `{c}` უცნობი კატეგორია `{c}` +@msg unknown category `{c}` ({_1}) უცნობი კატეგორია `{c}` ({_1}) +@msg unknown character `{c}` უცნობი სიმბოლო `{c}` +@msg unknown character style number field სიმბოლოს სტილის უცნობი რიცხვითი ველი +@msg unknown colour profile: {0} უცნობი ფერის პროფილი: {0} +@msg unknown dither `{d}` (diffusion, pattern, noise or none) უცნობი დიზერინგი `{d}` (diffusion, pattern, noise ან none) +@msg unknown effect `{e}` უცნობი ეფექტი `{e}` +@msg unknown end {e} უცნობი ბოლო {e} +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) უცნობი flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution ან შენახული: იხ. flattener.presets.list) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) უცნობი ფორმატი `{f}` (vcswatches, gpl, ase ან css) +@msg unknown from {f} უცნობი from {f} +@msg unknown graph type `{t}` დიაგრამის უცნობი ტიპი `{t}` +@msg unknown grid colours `{name}` (one of {_1}) ბადის უცნობი ფერები `{name}` (ერთ-ერთი: {_1}) +@msg unknown gridType `{t}` უცნობი gridType `{t}` +@msg unknown highlight `{v}` უცნობი გამოკვეთა `{v}` +@msg unknown intent `{i}` უცნობი განზრახვა `{i}` +@msg unknown issue kind {id} პრობლემის უცნობი სახეობა {id} +@msg unknown issue kind {v} პრობლემის უცნობი სახეობა {v} +@msg unknown knockout state {k} ჩაკვეთის უცნობი მდგომარეობა {k} +@msg unknown language `{language}` (one of {_1}) უცნობი ენა `{language}` (ერთ-ერთი: {_1}) +@msg unknown language `{l}` (one of {_1}) უცნობი ენა `{l}` (ერთ-ერთი: {_1}) +@msg unknown method `{m}` ({_1}) უცნობი მეთოდი `{m}` ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) უცნობი რეჟიმი `{other}` (gray, rgb, hsb, lab, cmyk ან web) +@msg unknown mode `{other}` (mono|cmyk) უცნობი რეჟიმი `{other}` (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) უცნობი რეჟიმი `{other}` (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) უცნობი რეჟიმი `{other}` (set, add, subtract) +@msg unknown mode `{o}` უცნობი რეჟიმი `{o}` +@msg unknown mode {m} უცნობი რეჟიმი {m} +@msg unknown model `{other}` უცნობი მოდელი `{other}` +@msg unknown model `{other}` (keep, new or document) უცნობი მოდელი `{other}` (keep, new ან document) +@msg unknown op {o} უცნობი ოპერაცია {o} +@msg unknown order `{o}`: toggle, front, forward, backward or back უცნობი რიგი `{o}`: toggle, front, forward, backward ან back +@msg unknown overprints `{v}` (preserve, simulate or discard) უცნობი ზედდაბეჭდვა `{v}` (preserve, simulate ან discard) +@msg unknown panel უცნობი პანელი +@msg unknown panel `{panel}` უცნობი პანელი `{panel}` +@msg unknown parameter `{key}` უცნობი პარამეტრი `{key}` +@msg unknown parameter `{within}settings.{format}`; {why} უცნობი პარამეტრი `{within}settings.{format}`; {why} +@msg unknown parameter `{within}{key}`; {why} უცნობი პარამეტრი `{within}{key}`; {why} +@msg unknown pieLegend `{v}` (none, standard or wedges) უცნობი pieLegend `{v}` (none, standard ან wedges) +@msg unknown piePosition `{v}` (even, ratio or stacked) უცნობი piePosition `{v}` (even, ratio ან stacked) +@msg unknown pieSort `{v}` (none, all or first) უცნობი pieSort `{v}` (none, all ან first) +@msg unknown plate `{n}` უცნობი ფირფიტა `{n}` +@msg unknown preference `{k}` უცნობი პარამეტრი `{k}` +@msg unknown profile {id} (see stroke.widthProfile.list) უცნობი პროფილი {id} (იხ. stroke.widthProfile.list) +@msg unknown proof target `{t}` საცდელი ჩვენების უცნობი სამიზნე `{t}` +@msg unknown quotes key `{k}` (double, single) ბრჭყალების უცნობი გასაღები `{k}` (double, single) +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) უცნობი შემცირება `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform ან document) +@msg unknown resolution `{v}` უცნობი გარჩევადობა `{v}` +@msg unknown rule `{rule}` ({_1}) უცნობი წესი `{rule}` ({_1}) +@msg unknown rule `{r}` (see color.harmony) უცნობი წესი `{r}` (იხ. color.harmony) +@msg unknown setting `{k}` უცნობი პარამეტრი `{k}` +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) უცნობი ფორმა `{v}` (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) უცნობი ინტერვალი `{o}` (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) უცნობი tileType `{t}` ({_1}) +@msg unknown units უცნობი ერთეულები +@msg unknown units `{u}` უცნობი ერთეულები `{u}` +@msg unknown value axis `{v}` (left, right or both) მნიშვნელობების უცნობი ღერძი `{v}` (left, right ან both) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) უცნობი ვარიაცია `{v}` (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` გაღუნვის უცნობი სტილი `{s}` +@msg unknown {key} `{v}` (none, short or full) უცნობი {key} `{v}` (none, short ან full) +@msg unknown {key} profile `{n}` უცნობი {key} პროფილი `{n}` +@msg unknown {k} key `{key}` (size, position) {k}: უცნობი გასაღები `{key}` (size, position) +@msg unmatched ')' დაუწყვილებელი ')' +@msg unreadable object წაუკითხავი ობიექტი +@msg unsafe destination file name დანიშნულების ფაილის სახელი სახიფათოა +@msg unterminated character class დაუსრულებელი სიმბოლოთა კლასი +@msg unterminated group დაუსრულებელი ჯგუფი +@msg use paths or compound paths გამოიყენეთ ტრაექტორიები ან შედგენილი ტრაექტორიები +@msg useArtboards must be true or false, not {v} useArtboards უნდა იყოს true ან false და არა {v} +@msg value for `{var}` must match its variable kind ცვლადის `{var}` მნიშვნელობა მის ტიპს უნდა შეესაბამებოდეს +@msg variable `{_1}` is already defined ცვლადი `{_1}` უკვე განსაზღვრულია +@msg variable `{name}` has no `trait` ცვლადს `{name}` არ აქვს `trait` +@msg variable `{name}` is already defined ცვლადი `{name}` უკვე განსაზღვრულია +@msg variable `{name}` is declared twice ცვლადი `{name}` ორჯერაა გამოცხადებული +@msg variable `{to}` already exists ცვლადი `{to}` უკვე არსებობს +@msg view is required view აუცილებელია +@msg view must be one of {_1} view უნდა იყოს ერთ-ერთი: {_1} +@msg warpStyle can only edit an applied Warp effect warpStyle-ს მხოლოდ გამოყენებული გაღუნვის ეფექტის რედაქტირება შეუძლია +@msg width and height must be more than 0 and at most {limit} width და height უნდა იყოს 0-ზე მეტი და მაქსიმუმ {limit} +@msg width factors must be finite სიგანის კოეფიციენტები სასრული უნდა იყოს +@msg width must be positive width დადებითი უნდა იყოს +@msg width out of range width დიაპაზონს გარეთაა +@msg width/height must be > 0 width/height უნდა იყოს > 0 +@msg with no hem and no grommets there is nothing to mark ნაკეცისა და ლუვერების გარეშე ნიშნების დასასმელი არაფერია +@msg wordGap and lineGap need area type in the selection wordGap და lineGap პარამეტრებს მონიშნულში არის ტექსტი სჭირდება +@msg x and y must be finite numbers x და y სასრული რიცხვები უნდა იყოს +@msg x and y must be given together x და y ერთად უნდა იყოს მოცემული +@msg xpath needs its variables as an object xpath-ს ცვლადები ობიექტად სჭირდება +@msg xpath needs the expression as a string xpath-ს გამოსახულება სტრიქონად სჭირდება +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) zone-ს anchor სჭირდება: მიამაგრეთ პანელის გვერდით anchor-ით, ან ახალ დოკის სვეტში side-ით (Start ან End) +@msg zoom must be positive zoom დადებითი უნდა იყოს +@msg {C}: no effect at index {list} {C}: ინდექსზე {list} ეფექტი არ არის +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} დაცულია და რჩება +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} დაცულია: შეინახეთ პარამეტრები სხვა სახელით +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}; მისი პარამეტრებისა და ქეშის უკან გადატანა ვერ ხერხდება (საიდან: {_1}, სად: {_2}): {e} +@msg {LABEL} produced an empty result {LABEL}: შედეგი ცარიელია +@msg {_1} MB of document, at most {_2} MB დოკუმენტი: {_1} MB, მაქსიმუმ {_2} MB +@msg {_1} MB of images, at most {_2} MB გამოსახულებები: {_1} MB, მაქსიმუმ {_2} MB +@msg {_1} bytes of RGBA data for a {width} × {height} image RGBA მონაცემები: {_1} ბაიტი {width} × {height} გამოსახულებისთვის +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1} ვერ იკითხება ან წაკითხვის შემდეგ შეიცვალა: ჯერ ხელახლა დააკავშირეთ ან განაახლეთ +@msg {_1} can't go into {into} {_1} ვერ მოთავსდება აქ: {into} +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1}: ბეჭდვის დაბალი გარჩევადობით შეზღუდვა შეუძლებელია: აირჩიეთ PDF 1.4 ან უფრო ახალი, ან დაუშვით მაღალი გარჩევადობით ბეჭდვა +@msg {_1} clashes with a built-in profile name {_1} ჩაშენებული პროფილის სახელს ემთხვევა +@msg {_1} files can't be opened yet {_1} ფაილების გახსნა ჯერ შეუძლებელია +@msg {_1} files can't be password-protected: remove the passwords or choose no standard {_1} ფაილების პაროლით დაცვა შეუძლებელია: მოხსენით პაროლები ან არ აირჩიოთ სტანდარტი +@msg {_1} files can't be {_2} {_1} ფაილები ვერ იქნება {_2} +@msg {_1} files can't carry editing data: turn off preserveEditing {_1} ფაილები რედაქტირების მონაცემებს ვერ შეიცავს: გამორთეთ preserveEditing +@msg {_1} files can't have PDF layers: turn off createLayers {_1} ფაილებს PDF შრეები ვერ ექნება: გამორთეთ createLayers +@msg {_1} files need a creation date, and this platform has no clock to give one {_1} ფაილებს შექმნის თარიღი სჭირდება, ამ პლატფორმას კი საათი არ აქვს, რომ ის მისცეს +@msg {_1} is not a folder: {e} {_1} საქაღალდე არ არის: {e} +@msg {_1} isn't a path {_1} ტრაექტორია არ არის +@msg {_1} options: {e} {_1} პარამეტრები: {e} +@msg {_1} paints nothing to rasterize {_1} არაფერს ხატავს, რასტრირებისთვის არაფერია +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) {_1} პაროლები შეიძლება შეიცავდეს მაქსიმუმ {MAX_LEGACY_PASSWORD} ASCII სიმბოლოს (PDF 1.7 და უფრო ახალი ნებისმიერს იღებს) +@msg {_1} with linked images or an image map writes several files: use document.export {_1} დაკავშირებული გამოსახულებებით ან გამოსახულების რუკით რამდენიმე ფაილს წერს: გამოიყენეთ document.export +@msg {_1} writes one file per artboard here: use document.export, or name one artboard {_1} აქ თითო სამუშაო დაფაზე ერთ ფაილს წერს: გამოიყენეთ document.export, ან დაასახელეთ ერთი სამუშაო დაფა +@msg {_1} × {_2} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution {_1} × {_2} პიქსელი GIF-ისთვის ზედმეტად დიდია (მაქსიმუმ 65535 პიქსელი თითო მხარეზე): შეამცირეთ გარჩევადობა +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} პიქსელი ზედმეტად დიდია ფორმატისთვის {_3} (მაქსიმუმ {max} პიქსელი თითო მხარეზე): შეამცირეთ მასშტაბი +@msg {_1}: belongs to another account {_1}: სხვა ანგარიშს ეკუთვნის +@msg {_1}: no usable transform to/from sRGB {_1}: sRGB-ში ან sRGB-დან გამოსაყენებელი გარდაქმნა არ არის +@msg {_1}: not a folder of its own {_1}: თავად საქაღალდე არ არის +@msg {_1}: not a plain file {_1}: ჩვეულებრივი ფაილი არ არის +@msg {_1}: {_2} and {_3} are in the way {_1}: ხელს უშლის {_2} და {_3} +@msg {code}: {0} {code}: {0} +@msg {dest}: {e} {dest}: {e} +@msg {dir}: {e} {dir}: {e} +@msg {doc} has no folder {doc}: დოკუმენტს საქაღალდე არ აქვს +@msg {file} isn't linked {file} დაკავშირებული არ არის +@msg {folder} is not a package folder {folder} შეფუთვის საქაღალდე არ არის +@msg {folder}: more than {max} files and folders {folder}: {max}-ზე მეტი ფაილი და საქაღალდე +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group {g} ცოცხალი შეღებვის ჯგუფი არ არის +@msg {id} is not a slice {id} ნაჭერი არ არის +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key} უნდა იყოს 0–{MAX_ADD_AROUND} pt +@msg {key} must be [x,y] {key} უნდა იყოს [x,y] +@msg {key} must be a percentage {min}..100 {key} უნდა იყოს პროცენტი {min}..100 +@msg {key} must be a positive number, not {n} {key} უნდა იყოს დადებითი რიცხვი და არა {n} +@msg {key} must be a positive number, not {v} {key} უნდა იყოს დადებითი რიცხვი და არა {v} +@msg {key} must be one of {_1} {key} უნდა იყოს ერთ-ერთი: {_1} +@msg {key} must be {_1}, got {s} {key} უნდა იყოს {_1}, მიღებულია {s} +@msg {key} must name a floating panel {key} უნდა ასახელებდეს მცურავ პანელს +@msg {key} must name a panel in another group {key} უნდა ასახელებდეს სხვა ჯგუფში მყოფ პანელს +@msg {key} must name a panel that shows {key} უნდა ასახელებდეს ხილულ პანელს +@msg {key} must name a registered panel {key} უნდა ასახელებდეს რეგისტრირებულ პანელს +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} ფაილებს გამჭვირვალობა არ აქვს, ფაილს კი ექნებოდა: {what}: ჯერ გააბრტყელეთ (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) {kind} გამოსახულებების გახსნა შეუძლებელია (იხ. document.formats) +@msg {k} `{v}` is not one of the choices {k} `{v}` შემოთავაზებულ ვარიანტებში არ არის +@msg {k} has control characters{_1} {k} შეიცავს მმართველ სიმბოლოებს{_1} +@msg {k} is too long (at most {MAX_TEXT} characters) {k} ზედმეტად გრძელია (მაქსიმუმ {MAX_TEXT} სიმბოლო) +@msg {k} must be > 0 {k} უნდა იყოს > 0 +@msg {k} must be a number (pt) or a length such as "210 mm" {k} უნდა იყოს რიცხვი (pt) ან სიგრძე, მაგალითად "210 mm" +@msg {k} must be a number from {min} to {max} {k} უნდა იყოს რიცხვი {min}-დან {max}-მდე +@msg {k} must be a number or "auto" {k} უნდა იყოს რიცხვი ან "auto" +@msg {k} must be a string {k} სტრიქონი უნდა იყოს +@msg {k} must be text {k} ტექსტი უნდა იყოს +@msg {k} must be true or false {k} უნდა იყოს true ან false +@msg {k} must be {{size?, position?}} {k} უნდა იყოს {{size?, position?}} +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: მომხმარებლის ისრის წვერის ტრაექტორიას სჭირდება შესავსები ფართობი და მაქსიმუმ {most} საყრდენი წერტილი და {most} ქვეტრაექტორია, თითოეული საყრდენი წერტილი წვეროდან მაქსიმუმ {far} წვერის ერთეულზე +@msg {k}: bad custom arrowhead path: {e} {k}: მომხმარებლის ისრის წვერის არასწორი ტრაექტორია: {e} +@msg {k}: bad custom arrowhead: {e} {k}: მომხმარებლის ისრის წვერი არასწორია: {e} +@msg {k}: can't read `{s}` as a length {k}: `{s}` სიგრძედ ვერ იკითხება +@msg {k}: custom must be a name {k}: custom სახელი უნდა იყოს +@msg {label} must be a number at most {max} {label} უნდა იყოს რიცხვი, მაქსიმუმ {max} +@msg {label} must be a number from -1296 to 1296 {label} უნდა იყოს რიცხვი -1296-დან 1296-მდე +@msg {label} must be a number from 1 to 10000 {label} უნდა იყოს რიცხვი 1-დან 10000-მდე +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name} ჩაშენებულია და მისი წაშლა შეუძლებელია +@msg {name} is not a CMYK profile {name} CMYK პროფილი არ არის +@msg {name} is not an RGB profile {name} RGB პროფილი არ არის +@msg {name} must be a number {name} რიცხვი უნდა იყოს +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name} უნდა იყოს {lo}–{hi}{unit} (მიღებულია {v}) +@msg {name}: CRC or size mismatch {name}: CRC ან ზომა არ ემთხვევა +@msg {name}: 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 before placing {name}: Affinity-ის ამ ფაილიდან მხოლოდ ჩაშენებული წინასწარი ხედის წაკითხვა მოხერხდა; გაფრთხილებასთან ერთად მის სანახავად გამოიყენეთ ფაილი › გახსნა, ან განთავსებამდე Affinity-დან გააკეთეთ SVG-ის ან PDF-ის ექსპორტი +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}; მისი ჩაშენებული წინასწარი ხედის წაკითხვაც ვერ ხერხდება ({e}) +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy DCS ფაილს მხოლოდ მაშინ იღებს, როცა მისი ფირფიტები მის შიგნითაა; ხელახლა დააკავშირეთ იქ, სადაც მისი ფირფიტების ფაილებია +@msg {path}: could not be read {path}: წაკითხვა ვერ მოხერხდა +@msg {path}: larger than {_1} MB {path}: {_1} MB-ზე დიდია +@msg {shown} is not an XPath 1.0 function {shown} XPath 1.0 ფუნქცია არ არის +@msg {std} files are CMYK, and the file would have colours in {_1} {std} ფაილები CMYK-ია, ფაილში კი იქნებოდა ფერები სივრცეში {_1} +@msg {std} files embed their fonts, and the file would have one that isn't {std} ფაილები შრიფტებს ჩაშენებით შეიცავს, ფაილში კი იქნებოდა ჩაუშენებელი შრიფტი +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target} არის ფაილი, საიდანაც ეს დოკუმენტია წაკითხული, და წაკითხვისას ზოგი რამ გამოტოვდა ({what}): მასზე გადაწერისას ისინი სამუდამოდ დაიკარგება. ჩაწერეთ სხვა ფაილში, ან თუ მაინც გსურთ ჩანაცვლება, გადაეცით acknowledgeLoss: true +@msg {ty} brushes need selected art (or params.{k}) {ty} ფუნჯებს მონიშნული ნახატი სჭირდება (ან params.{k}) +@msg {v} is not an asset id {v} რესურსის იდენტიფიკატორი არ არის +@msg {v} names no layer or sublayer {v} არცერთ შრეს ან ქვეშრეს არ ასახელებს +@msg {what} can't be intertwined {what}: გადახლართვა შეუძლებელია +@msg {what} doesn't fit the paper with its bleed and marks {what} ჭრისპირა არითა და ნიშნებთან ერთად ქაღალდზე არ ეტევა +@msg {what} is not a function {what} ფუნქცია არ არის +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what} ფურცელზე {MAX_COPIES_PER_SHEET}-ზე მეტ ასლს დაბეჭდავდა: გაადიდეთ მასშტაბი +@msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} {MAX_TILES}-ზე მეტ ფილაზე დაიბეჭდებოდა: შეამცირეთ მასშტაბი +@msg {what} {i} out of range {what} {i} დიაპაზონს გარეთაა +@msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} პიქსელი JPEG-ისთვის ზედმეტად დიდია (მაქსიმუმ 65535 პიქსელი თითო მხარეზე): შეამცირეთ გარჩევადობა +@msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} პიქსელი Targa-სთვის ზედმეტად დიდია (მაქსიმუმ 65535 პიქსელი თითო მხარეზე): შეამცირეთ გარჩევადობა +@msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} პიქსელი PSD-ისთვის ზედმეტად დიდია (მაქსიმუმ {MAX_SIDE} პიქსელი თითო მხარეზე): შეამცირეთ გარჩევადობა +@msg <{_1}> isn't an element of an SVG filter <{_1}> SVG ფილტრის ელემენტი არ არის +@msg `scope` must be "document", {{artboards: [index, …]}} or {{layers: [id, …]}} `scope` უნდა იყოს "document", {{artboards: [index, …]}} ან {{layers: [id, …]}} +@msg `style` must be "pencil" or "brush", not {v} `style` უნდა იყოს "pencil" ან "brush" და არა {v} +@msg action step {_1} (`{id}`) failed: {reason} მოქმედების ნაბიჯი {_1} (`{id}`) ვერ შესრულდა: {reason} +@msg an SVG filter has at most {MAX_PRIMITIVES} primitives SVG ფილტრს მაქსიმუმ {MAX_PRIMITIVES} პრიმიტივი აქვს +@msg an SVG filter has no event attributes such as {_1} SVG ფილტრს მოვლენის ატრიბუტები, როგორიცაა {_1}, არ აქვს +@msg an SVG filter is at most {_1} KB SVG ფილტრის ზომა მაქსიმუმ {_1} KB-ია +@msg an SVG filter's elements nest at most {MAX_DEPTH} deep SVG ფილტრის ელემენტები მაქსიმუმ {MAX_DEPTH} დონეზეა ჩადგმული +@msg an SVG filter's id is at most {MAX_NAME} characters SVG ფილტრის id მაქსიმუმ {MAX_NAME} სიმბოლოა +@msg an SVG filter's images are PNG, JPEG, GIF or WebP data: URLs SVG ფილტრის გამოსახულებები PNG, JPEG, GIF ან WebP data: URL-ებია +@msg an SVG filter's images have at most {MAX_IMAGE_PIXELS} pixels in all SVG ფილტრის გამოსახულებებს სულ მაქსიმუმ {MAX_IMAGE_PIXELS} პიქსელი აქვს +@msg commands[{i}]: `prefs.import` runs on its own, not as a batch step commands[{i}]: `prefs.import` ცალკე სრულდება და არა პაკეტის ნაბიჯად +@msg each font must be {{name, parts}} თითოეული შრიფტი უნდა იყოს {{name, parts}} +@msg feTurbulence's numOctaves is at most {MAX_OCTAVES} feTurbulence-ის numOctaves მაქსიმუმ {MAX_OCTAVES}-ია +@msg give fonts: [{{name, parts}}…] მიუთითეთ fonts: [{{name, parts}}…] +@msg knockout groups with too many overlapping translucent elements are written as PDF knockout groups: their translucent elements knock out what they cover fully ჩაკვეთილი ჯგუფები, რომლებშიც ზედმეტად ბევრი ნახევრად გამჭვირვალე ელემენტი ერთმანეთს ფარავს, PDF-ის ჩაკვეთილ ჯგუფებად იწერება: მათი ნახევრად გამჭვირვალე ელემენტები სრულად ჩაკვეთს იმას, რასაც ფარავს +@msg no pattern `{pat}` პატერნი `{pat}` არ არის +@msg numbers out of order in {{}} quantifier {{}} კვანტიფიკატორში რიცხვები არასწორი რიგითაა +@msg plugin.remove: {_1}: {e}: nothing was removed plugin.remove: {_1}: {e}: არაფერი წაშლილა +@msg the element needs an id, the filter's name ელემენტს id სჭირდება — ფილტრის სახელი +@msg the SVG filter can't be drawn: check its primitives and their inputs SVG ფილტრის დახატვა შეუძლებელია: შეამოწმეთ მისი პრიმიტივები და მათი შესავლები +@msg the SVG filter has no primitives SVG ფილტრს პრიმიტივები არ აქვს +@msg the SVG filter is too large once its attributes are written out ატრიბუტების ჩაწერის შემდეგ SVG ფილტრი ზედმეტად დიდია +@msg the SVG filter isn't valid XML: {e} SVG ფილტრი სწორი XML არ არის: {e} +@msg the paint is not a freeform gradient (apply one with paint.editGradient {{kind: "freeform"}}) საღებავი თავისუფალი ფორმის გრადიენტი არ არის (გამოიყენეთ paint.editGradient {{kind: "freeform"}}) +@msg the plug-in store can't be locked პლაგინების საცავის ჩაკეტვა ვერ ხერხდება +@msg {C}: {_1}: {e}: nothing was installed {C}: {_1}: {e}: არაფერი დაყენებულა +@msg {EXPORT_SELECTION} must be true or false, not {v} {EXPORT_SELECTION} უნდა იყოს true ან false და არა {v} +@msg {what} is at most {max} {what} მაქსიმუმ {max}-ია + Automatic Plane Positioning სიბრტყის ავტომატური პოზიციონირება + Emboss რელიეფი + Glow ნათება + Hard Shadow მკვეთრი ჩრდილი + Heavy Blur ძლიერი გაბუნდოვნება + Rough Edges უსწორმასწორო კიდეები + Soft Blur რბილი გაბუნდოვნება + Soft Shadow რბილი ჩრდილი + Thicken გასქელება + Thin გათხელება + Anchor Point of Perspective Artwork პერსპექტიული გრაფიკის საყრდენი წერტილი + Intersection of Gridlines ბადის ხაზების გადაკვეთა +@msg Automatic Plane Positioning is off: turn on anchorPoint or gridlineIntersection (perspective.widget.options) სიბრტყის ავტომატური პოზიციონირება გამორთულია: ჩართეთ anchorPoint ან gridlineIntersection (perspective.widget.options) +@msg `artboard` must be a whole number `artboard` უნდა იყოს მთელი რიცხვი +@msg `artboard` must be an artboard's index `artboard` უნდა იყოს სამუშაო დაფის ინდექსი +@msg `artboards` must contain whole numbers `artboards` უნდა შეიცავდეს მთელ რიცხვებს +@msg `glyphForm` must be one of {_1} `glyphForm` უნდა იყოს ერთ-ერთი: {_1} +@msg `index` must be the index of an applied SVG filter `index` უნდა იყოს გამოყენებული SVG ფილტრის ინდექსი +@msg `{key}` must be a list of artboard indices `{key}` უნდა იყოს სამუშაო დაფების ინდექსების სია +@msg `{key}` must be a non-empty list of artboard indices `{key}` უნდა იყოს სამუშაო დაფების ინდექსების არაცარიელი სია +@msg `{key}` must be from 0 to {max} pt `{key}` უნდა იყოს 0-დან {max} pt-მდე +@msg `{key}` must contain whole numbers `{key}` უნდა შეიცავდეს მთელ რიცხვებს +@msg `{k}` isn't an export setting: {_1}, openLocation `{k}` ექსპორტის პარამეტრი არ არის: {_1}, openLocation +@msg a document holds at most {_1} SVG filters, and applying a default filter adds it დოკუმენტი მაქსიმუმ {_1} SVG ფილტრს იტევს, ნაგულისხმევი ფილტრის გამოყენება კი მას დოკუმენტს უმატებს +@msg a new document has no artboard ახალ დოკუმენტს სამუშაო დაფა არ აქვს +@msg anchorPoint must be true or false anchorPoint უნდა იყოს true ან false +@msg can't open `{_1}`: not a PDF `{_1}` ვერ გაიხსნა: PDF არ არის +@msg give `at` or `clear`, not both მიუთითეთ `at` ან `clear`, ორივე არა +@msg gridlineIntersection must be true or false gridlineIntersection უნდა იყოს true ან false +@msg linkPages can't be used with textAs: outlines: linked pages show the file's own type linkPages ვერ გამოიყენება textAs: outlines-თან ერთად: დაკავშირებული გვერდები ფაილის საკუთარ ტექსტს აჩვენებს +@msg linkPages can't open a file with a password: linked pages are read again without it linkPages პაროლით დაცულ ფაილს ვერ ხსნის: დაკავშირებული გვერდები ხელახლა პაროლის გარეშე იკითხება +@msg linkPages must be true or false linkPages უნდა იყოს true ან false +@msg linkPages needs the file's path: linked pages are read from it linkPages-ს ფაილის გზა სჭირდება: დაკავშირებული გვერდები ამ ფაილიდან იკითხება +@msg linkPages: `{file}` is over {_1} MB linkPages: `{file}` {_1} MB-ზე მეტია +@msg linkPages: `{file}` isn't a PDF or a PDF-compatible .ai file linkPages: `{file}` არ არის PDF ან PDF-თან თავსებადი .ai ფაილი +@msg no selected object fits on the sheet მონიშნული ობიექტებიდან არცერთი ფურცელზე არ ეტევა +@msg select at most {MAX_SHAPES} objects to nest განსალაგებლად მონიშნეთ მაქსიმუმ {MAX_SHAPES} ობიექტი +@msg sort `{x}`: name, kind, status or size დალაგების უცნობი გასაღები `{x}`: name, kind, status ან size +@msg the margin leaves no room on the sheet ზღვარი ფურცელზე ადგილს აღარ ტოვებს +@msg the perspective grid is hidden პერსპექტივის ბადე დამალულია +@msg the selection has nothing to nest მონიშნულში განსალაგებელი არაფერია +@msg the {_1} file holds no records {_1} ფაილი ჩანაწერებს არ შეიცავს +@msg the {_1} header is damaged {_1} ფაილის სათაური დაზიანებულია +@msg there is no artboard for the sheet ფურცლისთვის სამუშაო დაფა არ არის +@msg there is no artboard {i} სამუშაო დაფა {i} არ არსებობს +@msg tolerance must be 0 or more tolerance უნდა იყოს 0 ან მეტი +@msg {name} is a default SVG filter, and the document holds no copy of it to delete {name} ნაგულისხმევი SVG ფილტრია და დოკუმენტში მისი წასაშლელი ასლი არ არის +@msg `{key}` must be a number from 0 to 1 `{key}` უნდა იყოს რიცხვი 0-დან 1-მდე +@msg data set `{name}` holds a graph value for `{var}` with {_1} მონაცემთა ნაკრები `{name}` ცვლადისთვის `{var}` შეიცავს დიაგრამის მნიშვნელობას, რომელშიც {_1} +@msg data set `{name}` holds a graph value for `{var}` without rows or series მონაცემთა ნაკრები `{name}` ცვლადისთვის `{var}` შეიცავს დიაგრამის მნიშვნელობას სტრიქონებისა და სერიების გარეშე +@msg data set `{name}` holds an image value for `{var}` that isn't a file's absolute path მონაცემთა ნაკრები `{name}` ცვლადისთვის `{var}` შეიცავს გამოსახულების მნიშვნელობას, რომელიც ფაილის აბსოლუტური გზა არ არის +@msg data set `{set}` holds a graph value for `{var}` longer than {MAX_GRAPH_CSV} bytes მონაცემთა ნაკრები `{set}` ცვლადისთვის `{var}` შეიცავს დიაგრამის მნიშვნელობას, რომელიც {MAX_GRAPH_CSV} ბაიტზე გრძელია +@msg data set `{set}` holds a graph value for `{var}` with {_1} მონაცემთა ნაკრები `{set}` ცვლადისთვის `{var}` შეიცავს დიაგრამის მნიშვნელობას, რომელშიც {_1} +@msg data set `{set}` holds a value for `{_1}` that isn't {what} მონაცემთა ნაკრები `{set}` ცვლადისთვის `{_1}` შეიცავს მნიშვნელობას, რომელიც {what} არ არის +@msg data set `{set}` holds a value for `{var}` that isn't a graph's `data` element მონაცემთა ნაკრები `{set}` ცვლადისთვის `{var}` შეიცავს მნიშვნელობას, რომელიც დიაგრამის `data` ელემენტი არ არის +@msg match applies only with scope {scopes} match მხოლოდ scope-ის მნიშვნელობით {scopes} მოქმედებს +@msg match must be a paint as the document stores it match უნდა იყოს საღებავი იმ სახით, როგორც დოკუმენტი ინახავს +@msg scope applies to a click: leave out points scope დაწკაპებას ეხება: points არ მიუთითოთ +@msg scope must be "one", "connected" or "samePaint" scope უნდა იყოს "one", "connected" ან "samePaint" +@msg variable `{_1}` starts or ends with white space or holds a control character, which a variable library can't hold ცვლადი `{_1}` იწყება ან მთავრდება ჰარით ან შეიცავს მმართველ სიმბოლოს, რასაც ცვლადების ბიბლიოთეკა ვერ შეინახავს + Set Panel View პანელის ხედის დაყენება +@msg `panel` is swatches, graphicStyles or symbols `panel` უნდა იყოს swatches, graphicStyles ან symbols +@msg `showImportOptions` must be true or false `showImportOptions` უნდა იყოს true ან false +@msg `start` is a number up to 1 (below 0: before an open path's start) `start` არის რიცხვი 1-მდე (0-ზე ნაკლები: ღია ტრაექტორიის დასაწყისამდე) +@msg the character at byte {start} is not drawn: it falls before its path's start ბაიტზე {start} მდგომი სიმბოლო არ იხატება: ის ტრაექტორიის დასაწყისამდე ხვდება +@msg unknown `by` "{name}": expected one of {_1} უცნობი `by` "{name}": მოსალოდნელია ერთ-ერთი: {_1} +@msg {mode_key} is one of {ERASER_MODES}, not `{id}` {mode_key} არის ერთ-ერთი: {ERASER_MODES}, და არა `{id}` +@msg {mode_key} is one of {ERASER_MODES}, not {other} {mode_key} არის ერთ-ერთი: {ERASER_MODES}, და არა {other} +@msg {var_key} must be 0 or more {var_key} უნდა იყოს 0 ან მეტი +@msg {var_key} must be at most {most} {var_key} უნდა იყოს მაქსიმუმ {most} +@msg a linked image file is outside the read roots დაკავშირებული გამოსახულების ფაილი წაკითხვის ძირეული საქაღალდეების გარეთაა + Neon & Glow ნეონი და ნათება + Show Hidden Panels at Edge დამალული პანელების ჩვენება კიდესთან +@msg `anchor` must be a [subpath, anchor] pair `anchor` უნდა იყოს წყვილი [subpath, anchor] +@msg `anchor` needs `id`, the path the point is on `anchor`-ს სჭირდება `id`: ტრაექტორია, რომელზეც წერტილია +@msg `side` must be left, right or null `side` უნდა იყოს left, right ან null +@msg `{_1}`: U+{_2} is a control character or past U+FFFF `{_1}`: U+{_2} მმართველი სიმბოლოა ან U+FFFF-ს სცდება +@msg `{_1}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters `{_1}`: სიმბოლოთა ნაკრები მაქსიმუმ {CUSTOM_CHARS_MAX} სიმბოლოს იტევს +@msg `{code}` is not a code from U+0000 to U+FFFF `{code}` არ არის კოდი U+0000-დან U+FFFF-მდე +@msg `{font}`: `{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters `{font}`: `{name}`: სიმბოლოთა ნაკრები მაქსიმუმ {CUSTOM_CHARS_MAX} სიმბოლოს იტევს +@msg `{font}`: `{name}`: characters must be a string `{font}`: `{name}`: characters უნდა იყოს სტრიქონი +@msg `{font}`: `{name}`: {e} `{font}`: `{name}`: {e} +@msg `{font}`: each character set must be {{name, characters, font…}} `{font}`: თითოეული სიმბოლოთა ნაკრები უნდა იყოს {{name, characters, font…}} +@msg `{font}`: each character set needs a name `{font}`: თითოეულ სიმბოლოთა ნაკრებს სახელი სჭირდება +@msg `{font}`: unknown key `{k}` in a character set `{font}`: უცნობი გასაღები `{k}` სიმბოლოთა ნაკრებში +@msg `{name}` can't name a character set `{name}` ვერ იქნება სიმბოლოთა ნაკრების სახელი +@msg `{name}`: U+{code} is a control character or past U+FFFF `{name}`: U+{code} მმართველი სიმბოლოა ან U+FFFF-ს სცდება +@msg `{name}`: U+{code} is listed twice `{name}`: U+{code} ორჯერაა ჩამოთვლილი +@msg `{name}`: a character set keeps at most {CUSTOM_CHARS_MAX} characters `{name}`: სიმბოლოთა ნაკრები მაქსიმუმ {CUSTOM_CHARS_MAX} სიმბოლოს იტევს +@msg `{name}`: a composite font keeps at most {CUSTOM_SETS_MAX} character sets `{name}`: კომპოზიტური შრიფტი მაქსიმუმ {CUSTOM_SETS_MAX} სიმბოლოთა ნაკრებს იტევს +@msg `{name}`: custom must be a list of character sets `{name}`: custom უნდა იყოს სიმბოლოთა ნაკრებების სია +@msg `{raw}` can't name a character set (1 to {COMPOSITE_NAME_MAX} characters, no control characters) `{raw}` ვერ იქნება სიმბოლოთა ნაკრების სახელი (1-დან {COMPOSITE_NAME_MAX}-მდე სიმბოლო, მმართველი სიმბოლოების გარეშე) +@msg a composite font keeps at most {CUSTOM_SETS_MAX} character sets კომპოზიტური შრიფტი მაქსიმუმ {CUSTOM_SETS_MAX} სიმბოლოთა ნაკრებს იტევს +@msg anchor [{_1}, {_2}] of object {id} is not a selected anchor point ობიექტის {id} საყრდენი წერტილი [{_1}, {_2}] მონიშნული არ არის +@msg give `anchor` or `guide`, not both მიუთითეთ `anchor` ან `guide`, ორივე არა +@msg missing `newName` or `kind` აკლია `newName` ან `kind` +@msg no character set სიმბოლოთა ნაკრები არ არის +@msg no panels are docked on the {_1} side {_1} მხარეს პანელები მიმაგრებული არ არის +@msg select another anchor point to align to the key anchor საკვანძო საყრდენ წერტილზე გასასწორებლად მონიშნეთ კიდევ ერთი საყრდენი წერტილი +@msg the RGB policy can't be {_1} RGB-ის პოლიტიკა ვერ იქნება {_1} +@msg the composite fonts make a file larger than 4 MB, which Import can't read; export fewer of them კომპოზიტური შრიფტებით ფაილი 4 MB-ზე დიდი ხდება, რასაც იმპორტი ვერ წაიკითხავს; გააკეთეთ ნაკლები შრიფტის ექსპორტი +@msg the panels are not hidden პანელები დამალული არ არის +@msg the place cursor that asked for these import options is no longer loaded განთავსების კურსორი, რომელმაც იმპორტის ეს პარამეტრები მოითხოვა, აღარ არის ჩატვირთული +@msg two character sets are named `{name}` ორ სიმბოლოთა ნაკრებს ერთი სახელი აქვს: `{name}` +@msg unknown key `{k}`; a font takes name, parts and custom უცნობი გასაღები `{k}`; შრიფტი იღებს name, parts და custom +@msg {label} failed: {e} {label} ვერ შესრულდა: {e} + Glowing Edges… მანათობელი კიდეები… About VectorCraft VectorCraft-ის შესახებ Align to Path ტრაექტორიაზე სწორება @@ -170,7 +2255,7 @@ Place Image გამოსახულების განთავსება Remove Background ფონის მოშორება Background ფონი - Edge Color კიდის ფერი + Edge Color კიდეების ფერი Picked Colors არჩეული ფერები Corner Color კუთხეების ფერი Contiguous მომიჯნავე @@ -369,7 +2454,7 @@ Create Outlines კონტურებად გარდაქმნა Optical Margin Alignment ოპტიკური არშიის სწორება Dynamic Text დინამიკური ტექსტი - Dynamic Text Presets დინამიკური ტექსტის წინასწარი პარამეტრები + Dynamic Text Presets დინამიკური ტექსტის პრესეტები Arch თაღი @msg select point type or type on a path აირჩიეთ წერტილოვანი ტექსტი ან ტრაექტორიაზე ტექსტი @msg type on a path in a thread cannot use Dynamic Text: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ გამოიყენებს დინამიკურ ტექსტს: {frames} @@ -451,7 +2536,7 @@ Revolve (Classic)… ბრუნვა (კლასიკური)… Rotate (Classic)… შემობრუნება (კლასიკური)… Surface ზედაპირი - Wireframe მავთულის ჩარჩო + Wireframe კარკასი Render Settings რენდერის პარამეტრები No Shading დაჩრდილვის გარეშე Plastic პლასტიკური @@ -743,7 +2828,7 @@ Cracked Glaze დამსკდარი მინანქარი Mosaic Floor მოზაიკის იატაკი Quilted Squares ქვილთის კვადრატები - Leaded Glass ტყვიის მინა + Leaded Glass ტყვიის ვიტრაჟი Damp Paper ნესტიანი ქაღალდი Outlines and Rules კონტურები და ხაზები Hand-Drawn ხელით დახატული @@ -894,6 +2979,11 @@ NTSC Colors NTSC ფერები RGB: RGB: CMYK: CMYK: + RGB Policy: RGB წესი: + CMYK Policy: CMYK წესი: + Preserve Embedded Profiles ჩაშენებული პროფილების შენარჩუნება + Convert to Working Space სამუშაო სივრცეში გადაყვანა + Preserve Numbers რიცხვების შენარჩუნება CMYK CMYK Texture ტექსტურა Craquelure… კრაკელიური… @@ -1366,6 +3456,7 @@ lighting Light სინათლე Align to Artboard სამუშაო დაფაზე სწორება Align to Glyph Bounds გლიფის საზღვრებზე სწორება Align to Key Object საკვანძო ობიექტზე სწორება + Align to Key Anchor საკვანძო საყრდენ წერტილზე სწორება Align to Selection მონიშნულზე სწორება Align To: სწორება: Cancel Key Object საკვანძო ობიექტის გაუქმება @@ -1479,14 +3570,14 @@ digits Telugu ტელუგუ digits Kannada კანადა digits Malayalam მალაიალამური Reset Panel პანელის ჩამოყრა - Roman Baseline ლათინური საბაზისო ხაზი + Roman Baseline რომაული საბაზისო ხაზი Select a text object to edit its character attributes. სიმბოლოების ატრიბუტების რედაქტირებისთვის მონიშნეთ ტექსტური ობიექტი. Show Options პარამეტრების ჩვენება Show Recent Colors ბოლოდროინდელი ფერების ჩვენება Toggle RGB Percent RGB-ის პროცენტებში ჩვენების გადართვა Small Caps პატარა მთავრული Small Caps (on the roadmap) პატარა მთავრული (სამომავლო გეგმაშია) - Standard Vertical Roman Alignment სტანდარტული ვერტიკალური ლათინური სწორება + Standard Vertical Roman Alignment სტანდარტული ვერტიკალური რომაული სწორება Strikethrough გადახაზული Character: სიმბოლო: Paragraph: აბზაცი: @@ -1556,6 +3647,8 @@ digits Malayalam მალაიალამური Kana კანა Numerals ციფრები Scale at Center ცენტრიდან მასშტაბირება + Delete Set ნაკრების წაშლა + Direct Entry პირდაპირი შეყვანა Up/Down: ზემოთ/ქვემოთ: Left/Right: მარცხნივ/მარჯვნივ: Tate-chu-yoko Up/Down Tate-chu-yoko ზემოთ/ქვემოთ @@ -1645,7 +3738,7 @@ digits Malayalam მალაიალამური {glyph} is not an alternate of {sel} (U+{code}) as it stands in its word. {glyph} არ არის {sel} (U+{code})-ის ალტერნატივა იმ სახით, როგორც ის თავის სიტყვაშია. Double-click to insert {base} (U+{code}) instead: a presentation form does not join, the letter does. ორჯერ დაწკაპეთ ამის ნაცვლად {base} (U+{code}) ჩასასმელად: გამოსახვის ფორმა არ უკავშირდება, ასო უკავშირდება. Inserted {base} (U+{code}) instead of a presentation form: the font joins it to its neighbors by position. გამოსახვის ფორმის ნაცვლად ჩასმულია {base} (U+{code}): შრიფტი მას მეზობლებს პოზიციის მიხედვით აკავშირებს. - No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. აქ არც ერთი OpenType ფუნქცია არ ხატავს ამ გლიფს, ამიტომ ის ანაცვლებს სიმბოლოს გლიფს და შეიძლება არ დაუკავშირდეს მეზობელ სიმბოლოებს. + No OpenType feature draws this glyph here, so it replaces the character's glyph and may not join its neighbors. აქ არც ერთი OpenType შესაძლებლობა არ ხატავს ამ გლიფს, ამიტომ ის ანაცვლებს სიმბოლოს გლიფს და შეიძლება არ დაუკავშირდეს მეზობელ სიმბოლოებს. Double-click to insert ორჯერ დაწკაპეთ ჩასასმელად Entire Font მთელი შრიფტი glyph {id} გლიფი {id} @@ -1677,7 +3770,7 @@ digits Malayalam მალაიალამური Points: clicks add free points წერტილები: დაწკაპუნებები ამატებს თავისუფალ წერტილებს Radial Gradient რადიალური გრადიენტი Conic Gradient კონუსური გრადიენტი - Diamond Gradient რომბული გრადიენტი + Diamond Gradient ბრილიანტისებრი გრადიენტი Reset to White, Black თეთრზე და შავზე დაბრუნება Reverse Gradient გრადიენტის შებრუნება Gradient Presets გრადიენტის პრესეტები @@ -1830,7 +3923,10 @@ digits Malayalam მალაიალამური Tabular Lining ცხრილური ხაზოვანი Tabular Oldstyle ცხრილური ძველებური Titling Alternates სათაურის ალტერნატივები - Every-line Composer აბზაცის მიხედვით აწყობა + Middle Eastern & South Asian Single-line Composer სტრიქონის მიხედვით აწყობა (შუა აღმოსავლეთი და სამხრეთ აზია) + Middle Eastern & South Asian Every-line Composer აბზაცის მიხედვით აწყობა (შუა აღმოსავლეთი და სამხრეთ აზია) + Standard Single-line Composer სტანდარტული სტრიქონის მიხედვით აწყობა + Standard Every-line Composer სტანდარტული აბზაცის მიხედვით აწყობა First-line Left Indent პირველი სტრიქონის მარცხენა შეწევა Hyphenation… სიტყვების გადატანა… Justification… სწორება… @@ -1856,13 +3952,12 @@ digits Malayalam მალაიალამური Hard მკაცრი Right Indent მარჯვენა შეწევა Right-to-Left Paragraph Direction აბზაცის მიმართულება მარჯვნიდან მარცხნივ - Roman Hanging Punctuation ლათინური გამოწეული პუნქტუაცია + Roman Hanging Punctuation რომაული გამოწეული პუნქტუაცია Korean Line Break Anywhere კორეული სტრიქონის გადატანა ნებისმიერ ადგილას Bunri-Kinshi Bunri-Kinshi East Asian Composition Settings აღმოსავლეთ აზიური აწყობის პარამეტრები Korean Composition Settings კორეული აწყობის პარამეტრები Select a text object to edit its paragraph attributes. აბზაცის ატრიბუტების რედაქტირებისთვის მონიშნეთ ტექსტური ობიექტი. - Single-line Composer სტრიქონის მიხედვით აწყობა Toggle Line Composer აწყობის რეჟიმის გადართვა Space After Paragraph ინტერვალი აბზაცის შემდეგ Space Before Paragraph ინტერვალი აბზაცის წინ @@ -2449,7 +4544,7 @@ digits Malayalam მალაიალამური @plural {n} folder|{n} folders {n} საქაღალდე @plural {n} file|{n} files {n} ფაილი @plural {n} folder skipped, such as another app's or the system's folder or a hidden one|{n} folders skipped, such as other apps' and the system's folders and hidden ones გამოტოვებული საქაღალდეები: {n} — მაგ. სხვა აპლიკაციების, სისტემის და დამალული საქაღალდეები -@plural {n} folder or file couldn't be read|{n} folders or files couldn't be read ვერ წაიკითხა საქაღალდეები ან ფაილები: {n} +@plural {n} folder or file couldn't be read|{n} folders or files couldn't be read საქაღალდეების ან ფაილების წაკითხვა ვერ მოხერხდა: {n} The search stopped at its limit: search a smaller folder. ძიება ზღვარზე შეჩერდა: მოძებნეთ უფრო პატარა საქაღალდეში. Copy only fonts you own or are licensed to install. დააკოპირეთ მხოლოდ ის შრიფტები, რომლებიც გეკუთვნით ან რომელთა დაყენების ლიცენზია გაქვთ. VectorCraft copies the fonts to {folder}. VectorCraft შრიფტებს აკოპირებს აქ: {folder}. @@ -2609,7 +4704,7 @@ digits Malayalam მალაიალამური Printer პრინტერი Printer profile: პრინტერის პროფილი: Printer: პრინტერი: - Rendering intent: რენდერინგის მიზანი: + Rendering intent: რენდერინგის განზრახვა: Same As Source წყაროს მსგავსად Save these settings as a print preset ამ პარამეტრების შენახვა ბეჭდვის პრესეტად Scaling: მასშტაბირება: @@ -2677,7 +4772,7 @@ digits Malayalam მალაიალამური Recolor this row ამ სტრიქონის ხელახლა შეფერადება Reset the rows to the artwork's colors სტრიქონებისთვის ნახატის ფერების დაბრუნება Color Theme Picker ფერთა თემის ამრჩევი - Click or drag across the art to pick its colors; Shift adds more დააწკაპუნეთ ან გადაატარეთ ნახატზე მისი ფერების ასაღებად; Shift ამატებს კიდევ + Click or drag across the art to pick its colors; Shift adds more დაწკაპეთ ან გადაატარეთ ნახატზე მისი ფერების ასაღებად; Shift ამატებს კიდევ Prominent Colors გამოკვეთილი ფერები Save All Colors ყველა ფერის შენახვა Save Prominent Colors გამოკვეთილი ფერების შენახვა @@ -3246,9 +5341,9 @@ theme Appearance იერსახე Split Freeform Line თავისუფალი ხაზის გაყოფა Gradient Vector გრადიენტის ვექტორი Select Gradient Stop გრადიენტის გადასვლის წერტილის მონიშვნა - Create Graph გრაფიკის შექმნა - Graph Data გრაფიკის მონაცემები - Graph Type გრაფიკის ტიპი + Create Graph დიაგრამის შექმნა + Graph Data დიაგრამის მონაცემები + Graph Type დიაგრამის ტიპი Graph Options გრაფიკის პარამეტრები Left Axis მარცხენა ღერძი Right Axis მარჯვენა ღერძი @@ -3972,7 +6067,7 @@ font-technology Variable ცვლადი The defaults are applied when you click OK in Preferences. Cancel there keeps your current preferences. ნაგულისხმევი მნიშვნელობები გამოიყენება, როცა პარამეტრებში OK-ს დააჭერთ. იქ გაუქმება ინარჩუნებს თქვენს მიმდინარე პარამეტრებს. Smaller ↔ Larger უფრო პატარა ↔ უფრო დიდი Smart Guides (View → Smart Guides) ჭკვიანი დამხმარე ხაზები (ხედი → ჭკვიანი დამხმარე ხაზები) - Couldn't save recovery data: {e} აღდგენის მონაცემები ვერ შეინახა: {e} + Couldn't save recovery data: {e} აღდგენის მონაცემების შენახვა ვერ მოხერხდა: {e} Saved recovery data for {title} შენახულია აღდგენის მონაცემები: {title} Saving recovery data for {} ინახება აღდგენის მონაცემები: {} Alt Alt @@ -4194,12 +6289,12 @@ font-technology Variable ცვლადი @msg Couldn't place {name}: the document it was dropped on was reverted {name} ვერ განთავსდა: დოკუმენტი, რომელზეც ის ჩამოაგდეს, წინა ვერსიაზე დაბრუნდა @msg Couldn't place {name}: the document it was placed in was reverted {name} ვერ განთავსდა: დოკუმენტი, რომელშიც ის თავსდებოდა, წინა ვერსიაზე დაბრუნდა @msg Couldn't paste: the document it was pasted in was reverted ჩასმა ვერ მოხერხდა: დოკუმენტი, რომელშიც ის ჩაისმებოდა, წინა ვერსიაზე დაბრუნდა -@msg Couldn't read {name}: {e} {name} ვერ წაიკითხა: {e} -@msg Couldn't save recovery data: {e} აღდგენის მონაცემები ვერ შეინახა: {e} -@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) დოკუმენტის {title} აღდგენის მონაცემები ვერ შეინახა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც აღდგენის ნაგულისხმევი საქაღალდეა; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) -@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) დოკუმენტის {title} აღდგენის მონაცემები ვერ შეინახა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც პარამეტრებშია მითითებული; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) -@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) აღდგენის მონაცემები ვერ შეინახა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც აღდგენის ნაგულისხმევი საქაღალდეა; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) -@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) აღდგენის მონაცემები ვერ შეინახა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც პარამეტრებშია მითითებული; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) +@msg Couldn't read {name}: {e} ფაილის წაკითხვა ვერ მოხერხდა ({name}): {e} +@msg Couldn't save recovery data: {e} აღდგენის მონაცემების შენახვა ვერ მოხერხდა: {e} +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) დოკუმენტის {title} აღდგენის მონაცემების შენახვა ვერ მოხერხდა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც აღდგენის ნაგულისხმევი საქაღალდეა; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) +@msg Couldn't save recovery data for {title}: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) დოკუმენტის {title} აღდგენის მონაცემების შენახვა ვერ მოხერხდა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც პარამეტრებშია მითითებული; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the default recovery folder; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) აღდგენის მონაცემების შენახვა ვერ მოხერხდა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც აღდგენის ნაგულისხმევი საქაღალდეა; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) +@msg Couldn't save recovery data: {e}. Copies go to {folder}, the folder set in Preferences; to use another folder, set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) აღდგენის მონაცემების შენახვა ვერ მოხერხდა: {e}. ასლები ინახება საქაღალდეში {folder}, რომელიც პარამეტრებშია მითითებული; სხვა საქაღალდის გამოსაყენებლად მიუთითეთ პარამეტრები → ფაილების დამუშავება → მონაცემთა აღდგენა → საქაღალდე (recoveryFolder) @msg Create Outlines: select text objects კონტურების შექმნა: მონიშნეთ ტექსტური ობიექტები @msg Create from Guides: the document has no artboard დამხმარე ხაზებიდან შექმნა: დოკუმენტს სამუშაო დაფა არ აქვს @msg Create from Guides: the guides make more than {MAX_PIECES} slices დამხმარე ხაზებიდან შექმნა: დამხმარე ხაზები {MAX_PIECES}-ზე მეტ ნაჭერს ქმნის @@ -4356,7 +6451,7 @@ font-technology Variable ცვლადი @msg `{name}` is a built-in preset and can't change: save it under another name `{name}` ჩაშენებული პრესეტია და მისი შეცვლა შეუძლებელია: შეინახეთ სხვა სახელით @msg `{name}` is a built-in preset and stays `{name}` ჩაშენებული პრესეტია და მისი წაშლა შეუძლებელია @msg a swatch or color group named `{n}` exists ფერის ნიმუში ან ფერების ჯგუფი სახელით `{n}` არსებობს -@msg a slice size must be a positive number of points ნაჭრის ზომა წერტილების დადებითი რიცხვი უნდა იყოს +@msg a slice size must be a positive number of points ნაჭრის ზომა უნდა იყოს დადებითი რიცხვი პუნქტებში @msg a stream runs past the end ნაკადი ბოლოს სცილდება @msg a stream's length is wrong ნაკადის სიგრძე არასწორია @msg a stream's length isn't a number ნაკადის სიგრძე რიცხვი არ არის @@ -4568,7 +6663,7 @@ font-technology Variable ცვლადი @msg not a VectorCraft file: {0} ეს არ არის VectorCraft-ის ფაილი: {0} @msg not a VectorCraft shortcut set (missing `overrides`) ეს არ არის VectorCraft-ის მალსახმობების ნაკრები (აკლია `overrides`) @msg not a graphic style library (format `{_1}`) ეს არ არის გრაფიკული სტილების ბიბლიოთეკა (ფორმატი `{_1}`) -@msg not a stored central record ეს არ არის შენახული ცენტრალური ჩანაწერი +@msg not a stored central record ეს არ არის შეუკუმშავი (stored) ცენტრალური ჩანაწერი @msg not a valid plug-in module: {0} ეს არ არის პლაგინის სწორი მოდული: {0} @msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი @msg not an HP-GL file ეს არ არის HP-GL ფაილი @@ -4611,6 +6706,7 @@ font-technology Variable ცვლადი @msg plug-in failed: {0} პლაგინის შეცდომა: {0} @msg plug-in manifest: {0} პლაგინის მანიფესტი: {0} @msg plug-in output: {0} პლაგინის შედეგი: {0} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} @msg plug-in parameters: {0} პლაგინის პარამეტრები: {0} @msg plug-in trapped: {0} პლაგინი ავარიულად შეჩერდა: {0} @msg print settings are not an object ბეჭდვის პარამეტრები ობიექტი არ არის @@ -4629,7 +6725,7 @@ font-technology Variable ცვლადი @msg select a blend მონიშნეთ გადასვლა @msg select a clip group მონიშნეთ მომჭრელი ჯგუფი @msg select a gradient mesh მონიშნეთ გრადიენტული ბადე -@msg select a linked image: an embedded one has no file მონიშნეთ მიბმული გამოსახულება: ჩაშენებულს ფაილი არ აქვს +@msg select a linked image: an embedded one has no file მონიშნეთ დაკავშირებული გამოსახულება: ჩაშენებულს ფაილი არ აქვს @msg select a repeat მონიშნეთ გამეორება @msg select a stroke with a variable width მონიშნეთ ცვლადი სიგანის კონტური @msg select a symbol instance მონიშნეთ სიმბოლოს ეგზემპლარი @@ -4646,7 +6742,7 @@ font-technology Variable ცვლადი @msg `match` must be distance or channels `match` უნდა იყოს distance ან channels @msg `{key}` must be from 0 to 100 `{key}` უნდა იყოს 0-დან 100-მდე @msg `colors` must list 1 to 16 colors, each hex (#rrggbb or #rgb) or black, white, red, green or blue `colors` უნდა შეიცავდეს 1-დან 16-მდე ფერს, თითოეულს თექვსმეტობით ფორმატში (#rrggbb ან #rgb) ან როგორც black, white, red, green ან blue -@msg the linked image shows its preview: embed or relink it first დაკავშირებული გამოსახულება გადახედვას აჩვენებს: ჯერ ჩააშენეთ ან ხელახლა მიაბით +@msg the linked image shows its preview: embed or relink it first დაკავშირებული გამოსახულება გადახედვას აჩვენებს: ჯერ ჩააშენეთ ან ხელახლა დააკავშირეთ @msg a CMYK image has no transparency to set CMYK გამოსახულებას არ აქვს დასაყენებელი გამჭვირვალობა @msg every pixel matches the background: no image would be left ყველა პიქსელი ემთხვევა ფონს: გამოსახულება აღარ დარჩებოდა @msg an image of {w} × {h} pixels is too large to change {w} × {h} პიქსელის გამოსახულება შესაცვლელად ზედმეტად დიდია @@ -4814,7 +6910,7 @@ font-technology Variable ცვლადი @msg {name}: CRC mismatch {name}: CRC არ ემთხვევა @msg {name}: no local header {name}: ლოკალური სათაური არ არის @msg {name}: no such recovery entry {name}: ასეთი აღდგენის ჩანაწერი არ არის -@msg {path} can't be found: relink it first {path} ვერ მოიძებნა: ჯერ ხელახლა მიაბით +@msg {path} can't be found: relink it first {path} ვერ მოიძებნა: ჯერ ხელახლა დააკავშირეთ @msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: აქ ფაილური სისტემა არ არის (გასახსნელად გადაეცით dataBase64; dataBase64-ის მისაღებად გამოტოვეთ path) @msg {path}: {e} {path}: {e} @msg {status} ({e}) {status} ({e}) @@ -4837,7 +6933,7 @@ font-technology Variable ცვლადი @msgplural Opened with {n} note: {first}|Opened with {n} notes: {first} გაიხსნა, შენიშვნები: {n}: {first} @msgplural {notice}. Opened with {n} note: {first}|{notice}. Opened with {n} notes: {first} {notice}. გაიხსნა, შენიშვნები: {n}: {first} @msgplural Saved {path} with {n} note: {first}|Saved {path} with {n} notes: {first} შენახულია {path}, შენიშვნები: {n}: {first} -@msgplural Embedded {n} image; {missing} missing: relink them first|Embedded {n} images; {missing} missing: relink them first ჩაშენებულია გამოსახულებები: {n}; აკლია {missing}: ჯერ ხელახლა მიაბით +@msgplural Embedded {n} image; {missing} missing: relink them first|Embedded {n} images; {missing} missing: relink them first ჩაშენებულია გამოსახულებები: {n}; აკლია {missing}: ჯერ ხელახლა დააკავშირეთ @msgplural Embedded {n} image|Embedded {n} images ჩაშენებულია გამოსახულებები: {n} @msgplural Relinked {n} image|Relinked {n} images ხელახლა მიბმულია გამოსახულებები: {n} @msgplural Relinked {n} image; not in that folder: {names}|Relinked {n} images; not in that folder: {names} ხელახლა მიბმულია გამოსახულებები: {n}; იმ საქაღალდეში არ არის: {names} @@ -4845,10 +6941,10 @@ font-technology Variable ცვლადი @msgplural Updated {n} image|Updated {n} images განახლებულია გამოსახულებები: {n} @msgplural Replaced {n} image|Replaced {n} images ჩანაცვლებულია გამოსახულებები: {n} @msgplural Broke {n} link|Broke {n} links გაწყვეტილია ბმულები: {n} -@msgplural Broke {n} link; {missing} can't be read: relink or update them first|Broke {n} links; {missing} can't be read: relink or update them first გაწყვეტილია ბმულები: {n}; ვერ იკითხება {missing}: ჯერ ხელახლა მიაბით ან განაახლეთ -@msgplural {n} linked file can't be read here: its preview shows|{n} linked files can't be read here: their previews show მიბმული ფაილების წაკითხვა აქ ვერ ხერხდება ({n}): ჩანს მათი გადახედვა +@msgplural Broke {n} link; {missing} can't be read: relink or update them first|Broke {n} links; {missing} can't be read: relink or update them first გაწყვეტილია ბმულები: {n}; ვერ იკითხება {missing}: ჯერ ხელახლა დააკავშირეთ ან განაახლეთ +@msgplural {n} linked file can't be read here: its preview shows|{n} linked files can't be read here: their previews show დაკავშირებული ფაილების წაკითხვა აქ ვერ ხერხდება ({n}): ჩანს მათი გადახედვა @msgplural {n} image couldn't be decoded and shows as a crossed box|{n} images couldn't be decoded and show as crossed boxes სურათების დეკოდირება ვერ მოხერხდა ({n}): ისინი ჯვრით გადახაზული ჩარჩოებით ჩანს -@msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions მიბმული გამოსახულებები შეიცვალა დისკზე ({n}): ბმულების განახლება აჩვენებს ახალ ვერსიებს +@msgplural {n} linked image changed on disk: Update Links shows the new version|{n} linked images changed on disk: Update Links shows the new versions დაკავშირებული გამოსახულებები შეიცვალა დისკზე ({n}): ბმულების განახლება აჩვენებს ახალ ვერსიებს @msgplural Updated {n} linked file changed on disk|Updated {n} linked files changed on disk განახლდა დისკზე შეცვლილი დაკავშირებული ფაილები: {n} @msg the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings დოკუმენტი ადგენს ჭრისპირა არეს, რომელსაც ეს PDF/X ფაილი გამოტოვებს: ჩართეთ „დოკუმენტის ჭრისპირა არის პარამეტრების გამოყენება“ @msgplural Exported {what} with {n} note: {first}|Exported {what} with {n} notes: {first} ექსპორტირებულია {what}, შენიშვნები: {n}: {first} @@ -4947,13 +7043,13 @@ font-technology Variable ცვლადი @msg id {value} is a layer, not an object; move layers with layer.move id {value} შრეა და არა ობიექტი; შრეების გადასატანად გამოიყენეთ layer.move @msg automation filesystem access is not granted: read authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: წაკითხვის უფლება არ არის: {path} @msg automation filesystem access is not granted: write authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: ჩაწერის უფლება არ არის: {path} -@msg automation path rejected: outside the read root {root}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირის {root} გარეთაა: {path} -@msg automation path rejected: outside the read roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირების {roots} გარეთაა: {path} -@msg a linked JavaScript file is outside the read roots დაკავშირებული JavaScript ფაილი წაკითხვის ძირების გარეთაა -@msg the library's file is outside the read roots ბიბლიოთეკის ფაილი წაკითხვის ძირების გარეთაა -@msg {file} names more than one linked JavaScript file {file} ერთზე მეტ დაკავშირებულ JavaScript ფაილს შეესაბამება -@msg automation path rejected: outside the write root {root}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირის {root} გარეთაა: {path} -@msg automation path rejected: outside the write roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირების {roots} გარეთაა: {path} +@msg automation path rejected: outside the read root {root}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირეული საქაღალდის {root} გარეთაა: {path} +@msg automation path rejected: outside the read roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირეული საქაღალდეების {roots} გარეთაა: {path} +@msg a linked JavaScript file is outside the read roots დაკავშირებული JavaScript ფაილი წაკითხვის ძირეული საქაღალდეების გარეთაა +@msg the library's file is outside the read roots ბიბლიოთეკის ფაილი წაკითხვის ძირეული საქაღალდეების გარეთაა +@msg {file} names more than one linked JavaScript file {file} ერთზე მეტ დაკავშირებულ JavaScript ფაილს ასახელებს +@msg automation path rejected: outside the write root {root}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირეული საქაღალდის {root} გარეთაა: {path} +@msg automation path rejected: outside the write roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირეული საქაღალდეების {roots} გარეთაა: {path} @msg automation path rejected: path is empty ავტომატიზაციის გზა უარყოფილია: გზა ცარიელია @msg automation path rejected: a link that leads nowhere: {path} ავტომატიზაციის გზა უარყოფილია: ბმული არსად მიდის: {path} @msg automation path rejected: cannot resolve {path}: {e} ავტომატიზაციის გზა უარყოფილია: {path} ვერ გაირკვა: {e} @@ -5313,7 +7409,7 @@ font-technology Variable ცვლადი @msg every numbered name for {name} is taken in the document's folder დოკუმენტის საქაღალდეში {name}-ის ყველა დანომრილი სახელი დაკავებულია @msg No linked files to look for მოსაძებნი დაკავშირებული ფაილები არ არის @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas ბმული სიმბოლოში, დიაგრამის დიზაინში, პატერნში ან გაუმჭვირვალობის ნიღაბშია და ტილოზე ადგილი არ აქვს -@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it დიაგრამის სერიაში დიაგრამის დიზაინის გრაფიკა დიზაინიდან თავიდან იხატება: მის შესაცვლელად დაარედაქტირეთ დიზაინი +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it დიაგრამის სერიაში დიაგრამის დიზაინის ნახატი დიზაინიდან თავიდან იხატება: მის შესაცვლელად დიზაინი შეცვალეთ Ruler Origin სახაზავების სათავე Reset Ruler Origin სახაზავების სათავის ჩამოყრა Artistic მხატვრული @@ -5697,7 +7793,7 @@ imageTrace Outline კონტური @msg vc.document{place} is too large for a script vc.document{place} ზედმეტად დიდია სკრიპტისთვის @msg vc.log: the log of this run is full vc.log: ამ გაშვების ჟურნალი სავსეა @msg this function's script run has ended ამ ფუნქციის სკრიპტის გაშვება დასრულდა -@msg the script made more host calls than one run may make სკრიპტმა იმაზე მეტი მასპინძლის გამოძახება შეასრულა, ვიდრე ერთ გაშვებას შეუძლია +@msg the script made more host calls than one run may make სკრიპტმა ჰოსტის იმაზე მეტი გამოძახება შეასრულა, ვიდრე ერთ გაშვებაშია დასაშვები @msg the script passed more data to and from commands than one run may pass სკრიპტმა ბრძანებებს და ბრძანებებიდან იმაზე მეტი მონაცემი გადასცა, ვიდრე ერთ გაშვებას შეუძლია @msg the script added more objects and data to the documents than one run may add სკრიპტმა დოკუმენტებს იმაზე მეტი ობიექტი და მონაცემი დაამატა, ვიდრე ერთ გაშვებას შეუძლია @msg {what}: the object is not a File or a Folder {what}: ობიექტი არც File-ია და არც Folder @@ -5708,6 +7804,18 @@ imageTrace Outline კონტური @msg `name` must be the name of a script engine `name` უნდა იყოს სკრიპტის ძრავის სახელი @msg the main script engine is new for each run and is never kept, so it cannot be dropped მთავარი სკრიპტის ძრავა ყოველი გაშვებისთვის ახალია და არასოდეს ინახება, ამიტომ მისი მოცილება შეუძლებელია @msg there is no script engine named {name} სკრიპტის ძრავა სახელით {name} არ არსებობს +@msg `answers` must be a list of answers `answers` უნდა იყოს პასუხების სია +@msg `answers` has more than {MAX_ANSWERS} answers `answers`-ში {MAX_ANSWERS}-ზე მეტი პასუხია +@msg `answers` is larger than 1 MiB `answers` 1 MiB-ზე დიდია +@msg `answers[{i}]` {e} `answers[{i}]`: {e} +@msg must be an object with `kind` and `value` უნდა იყოს ობიექტი `kind`-ითა და `value`-ით +@msg must have exactly `kind` and `value` უნდა ჰქონდეს ზუსტად `kind` და `value` +@msg is an alert's answer, whose `value` must be "ok" alert-ის პასუხია, ამიტომ მისი `value` უნდა იყოს "ok" +@msg is a confirm's answer, whose `value` must be true or false confirm-ის პასუხია, ამიტომ მისი `value` უნდა იყოს true ან false +@msg is longer than 16 KiB 16 KiB-ზე გრძელია +@msg is a prompt's answer, whose `value` must be a string or null prompt-ის პასუხია, ამიტომ მისი `value` უნდა იყოს სტრიქონი ან null +@msg must have `kind` alert, confirm or prompt `kind` უნდა იყოს alert, confirm ან prompt +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` მხოლოდ ისეთი გაშვებისთვისაა, სადაც შესაკითხი არავინაა: აპში თითოეულ დიალოგს ადამიანი პასუხობს @msg Running a script სკრიპტი მუშაობს @msg Copied the script report კოპირებულია სკრიპტის ანგარიში @msg Copied the link info კოპირებულია ბმულის ინფორმაცია @@ -5826,8 +7934,8 @@ imageTrace Outline კონტური PDF-compatible PDF-თან თავსებადი Panels Except Tools and Control პანელები, ხელსაწყოებისა და მართვის პანელის გარდა Isolate Selected Object მონიშნული ობიექტის იზოლირება -@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer ფაილის შრეები ვერ წაიკითხა ({_1}): ის გაიხსნება როგორც მისი გვერდი, ერთ შრეში -@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part ფაილის შრეები რედაქტირების მონაცემებიდან ვერ წაიკითხა ({_1}): ისინი მისი PDF ნაწილიდან აიღება +@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer ფაილის შრეების წაკითხვა ვერ მოხერხდა ({_1}): ის გაიხსნება როგორც მისი გვერდი, ერთ შრეში +@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part ფაილის შრეების წაკითხვა რედაქტირების მონაცემებიდან ვერ მოხერხდა ({_1}): ისინი მისი PDF ნაწილიდან აიღება @msg the Color Picker is not open ფერის ამრჩევი გახსნილი არ არის @msg the Simplify slider is not open გამარტივების სლაიდერი გახსნილი არ არის @msg the Smooth slider is not open გაგლუვების სლაიდერი გახსნილი არ არის @@ -5841,7 +7949,7 @@ imageTrace Outline კონტური Preview (1 inch on the page) წინასწარი ნახვა (1 დუიმი გვერდზე) Set as Default ნაგულისხმევად დაყენება out from a highlighted edge to place its dimension | — გამოკვეთილი კიდიდან გარეთ, მისი ზომის დასასმელად | - two points, then move and click to place an aligned, horizontal or vertical dimension | — ორ წერტილზე, შემდეგ გადაადგილეთ და დააწკაპუნეთ გასწორებული, ჰორიზონტალური ან ვერტიკალური ზომის დასასმელად | + two points, then move and click to place an aligned, horizontal or vertical dimension | — ორ წერტილზე, შემდეგ გადაადგილეთ და დაწკაპეთ გასწორებული, ჰორიზონტალური ან ვერტიკალური ზომის დასასმელად | cancels | Double-click the tool for options — გაუქმება | პარამეტრებისთვის ორჯერ დაწკაპეთ ხელსაწყოზე Angular კუთხური Away from the Line ხაზისგან მოშორებით @@ -5922,7 +8030,7 @@ imageTrace Outline კონტური @msg span must be "object" or "selection", got {v} span უნდა იყოს "object" ან "selection", მიღებულია {v} @msg span: "selection" places a `gradient`, not a swatch span: "selection" ათავსებს `gradient`-ს და არა ფერის ნიმუშს @msg span: "selection" places a gradient: give `gradient` span: "selection" ათავსებს გრადიენტს: მიუთითეთ `gradient` -@msg span: "selection" places a linear, radial, conic or diamond gradient, not a freeform one span: "selection" ათავსებს წრფივ, რადიალურ, კონუსურ ან რომბულ გრადიენტს და არა თავისუფალი ფორმისას +@msg span: "selection" places a linear, radial, conic or diamond gradient, not a freeform one span: "selection" ათავსებს წრფივ, რადიალურ, კონუსურ ან ბრილიანტისებრ გრადიენტს და არა თავისუფალი ფორმისას @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" თავად ათავსებს გრადიენტს: გამოტოვეთ `start` და `end` (ისინი მას უკვე დოკუმენტის კოორდინატებში ათავსებენ) Make Image Dynamic გამოსახულების დინამიკურად ქცევა Import Data Sets… მონაცემთა ნაკრებების იმპორტი… @@ -5931,6 +8039,8 @@ imageTrace Outline კონტური Save Variable Library… ცვლადების ბიბლიოთეკის შენახვა… @msg a variable library holds text variables, and the document has none ცვლადების ბიბლიოთეკა ტექსტურ ცვლადებს შეიცავს, დოკუმენტს კი არცერთი არ აქვს @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` ცვლადი `{name}` ვერ შეინახება ცვლადების ბიბლიოთეკაში: გადაარქვით სახელი ისე, რომ იწყებოდეს ასოთი ან `_`-ით, ჰარეების და `-`, `_` და `.`-ის გარდა სხვა სიმბოლოების გარეშე +@msg {k} has control characters {k} შეიცავს მმართველ სიმბოლოებს +@msg {k} has control characters or line breaks {k} შეიცავს მმართველ სიმბოლოებს ან სტრიქონის წყვეტებს @msg data set `{name}` holds a control character, which a variable library can't hold მონაცემთა ნაკრები `{name}` შეიცავს მმართველ სიმბოლოს, რომელსაც ცვლადების ბიბლიოთეკა ვერ შეინახავს @msg data set `{name}` starts or ends with white space, which a variable library can't hold მონაცემთა ნაკრები `{name}` იწყება ან მთავრდება ჰარით, რომელსაც ცვლადების ბიბლიოთეკა ვერ შეინახავს Make Graph Dynamic დიაგრამის დინამიკურად ქცევა @@ -6124,6 +8234,14 @@ imageTrace Outline კონტური JavaScript: JavaScript: Large Gaps დიდი ღრეჩოები Last Artboard ბოლო სამუშაო დაფა + Status Bar Show ჩვენება მდგომარეობის ზოლში + Artboard Name სამუშაო დაფის სახელი + Current Tool მიმდინარე ხელსაწყო + Date and Time თარიღი და დრო + Number of Undos დაბრუნებების რაოდენობა + Document Color Profile დოკუმენტის ფერის პროფილი + Undos დაბრუნებები + Redos ხელახლა შესრულებები Levels დონეები Library Panel Options ბიბლიოთეკის პანელის პარამეტრები Locate Library… ბიბლიოთეკის მოძებნა… @@ -6443,7 +8561,7 @@ axis Y Axis Y ღერძი @msg `method` must be classic, perceptual, hue-clockwise or hue-counterclockwise `method` უნდა იყოს classic, perceptual, hue-clockwise ან hue-counterclockwise @msg `spread` must be pad, repeat or reflect `spread` უნდა იყოს pad, repeat ან reflect @msg `repeats` must be a whole number from 1 to {MAX_REPEATS} `repeats` უნდა იყოს მთელი რიცხვი 1-დან {MAX_REPEATS}-მდე -@msg `spread` applies to linear, radial, conic and diamond gradients `spread` ეხება წრფივ, რადიალურ, კონუსურ და რომბულ გრადიენტებს +@msg `spread` applies to linear, radial, conic and diamond gradients `spread` ეხება წრფივ, რადიალურ, კონუსურ და ბრილიანტისებრ გრადიენტებს # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? დავაყენოთ „{name}“, რომელიც დიდ გამოთვლით ბიუჯეტს ითხოვს? Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. თითოეულ გაშვებას შეიძლება {minutes} წუთამდე დასჭირდეს და სხვა პლაგინებისთვის დაშვებულზე {times}-ჯერ მეტი სამუშაო შეასრულოს. ის ფონურ რეჟიმში მუშაობს და შეგიძლიათ გააუქმოთ. @@ -6497,6 +8615,7 @@ axis Y Axis Y ღერძი @msg {what} points outside memory {what} მეხსიერების გარეთ მიუთითებს @msg `angle` must be a number `angle` უნდა იყოს რიცხვი @msg `aspect` must be from 0.01 to 100 `aspect` უნდა იყოს 0.01-დან 100-მდე +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` უნდა იყოს [a, b, c, d], თითოეული -1000-დან 1000-მდე, ხოლო a·d - b·c უნდა განსხვავდებოდეს 0-ისგან მინიმუმ 0.001-ით @msg `cap` must be "butt", "round" or "square" `cap` უნდა იყოს "butt", "round" ან "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` უნდა იყოს "nonzero" ან "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` უნდა იყოს https:// მისამართი, არაუმეტეს {MAX_HELP_URL} ბაიტისა @@ -6549,6 +8668,7 @@ axis Y Axis Y ღერძი @msg stops must be in order of `offset` გადასვლის წერტილები `offset`-ის მიხედვით უნდა იყოს დალაგებული @msg the document has no pattern {pattern} დოკუმენტს არ აქვს პატერნი {pattern} @msg the document has no swatch {n} დოკუმენტს არ აქვს ფერის ნიმუში {n} +@msg `plates` requires a stop linked to a spot swatch `plates` მოითხოვს გადასვლის წერტილს, რომელიც სპოტ-ფერის ნიმუშთანაა დაკავშირებული @msg the output გამოტანა @msg the path has no anchor points ტრაექტორიას საყრდენი წერტილები არ აქვს @msg too many anchor points (limit {MAX_ANCHORS}) ძალიან ბევრი საყრდენი წერტილი (ლიმიტი {MAX_ANCHORS}) @@ -7161,7 +9281,7 @@ anchor Points: წერტილები: @msg {label} must be a number of at least 0.1 {label} უნდა იყოს მინიმუმ 0,1 Advanced Character Formats სიმბოლოს დამატებითი ფორმატები Basic Paragraph Formats აბზაცის ძირითადი ფორმატები - Composition კომპოზიცია + Composition აწყობა Horizontal Scale ჰორიზონტალური მასშტაბი Vertical Scale ვერტიკალური მასშტაბი @msg {label} must be a number from {min} to {max} {label} უნდა იყოს რიცხვი {min}-დან {max}-მდე @@ -7754,3 +9874,4 @@ font-script Japanese იაპონური Nest Shapes… ფორმების განლაგება… Nest Shapes ფორმების განლაგება Roll რულონი +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer უნდა იყოს "singleLine", "everyLine", "standardSingleLine" ან "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2751e06e14..cd7130d7ef 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1067,6 +1067,7 @@ digits Malayalam 말라얄람어 Align to Artboard 아트보드에 정렬 Align to Glyph Bounds 글리프 경계에 정렬 Align to Key Object 키 개체에 정렬 + Align to Key Anchor 키 앵커 포인트에 정렬 Align to Page 페이지에 정렬 Align to Path 패스에 정렬 Align to Selection 선택 영역에 정렬 @@ -1677,7 +1678,10 @@ antiAlias Sharp 날카롭게 Every (minutes) 간격 (분) Every option is at its default. 모든 옵션이 기본값입니다. Every option matches {base}. 모든 옵션이 {base}와(과) 같습니다. - Every-line Composer 전체 줄 컴포저 + Middle Eastern & South Asian Single-line Composer 한 줄 조판 (중동·남아시아) + Middle Eastern & South Asian Every-line Composer 전체 줄 컴포저 (중동·남아시아) + Standard Single-line Composer 표준 한 줄 조판 + Standard Every-line Composer 표준 전체 줄 컴포저 Exact dash lengths 정확한 대시 길이 Exceptions (comma separated) 예외 (쉼표로 구분) Exclusion 제외 @@ -1874,6 +1878,14 @@ antiAlias Sharp 날카롭게 Previous Artboard 이전 아트보드 Next Artboard 다음 아트보드 Last Artboard 마지막 아트보드 + Status Bar Show 상태 표시줄 표시 + Artboard Name 아트보드 이름 + Current Tool 현재 도구 + Date and Time 날짜 및 시간 + Number of Undos 실행 취소 횟수 + Document Color Profile 문서 색상 프로파일 + Undos 실행 취소 + Redos 다시 실행 Gradient Fill: click to apply, drag onto art 그레이디언트 칠: 클릭하여 적용, 아트웍에 끌어 놓기 Gradient Mesh 그레이디언트 메시 Gradient Tool 그레이디언트 도구 @@ -3270,7 +3282,6 @@ mark-style Japanese 일본식 Simulate Colored Paper 색 용지 시뮬레이션 Simulate Paper 용지 시뮬레이션 Single Quotes: 작은따옴표: - Single-line Composer 한 줄 조판 Toggle Line Composer 줄 조판 방식 전환 Size Tile to Art 타일을 아트웍 크기에 맞춤 Move Tile with Art 아트웍과 함께 타일 이동 @@ -4149,6 +4160,8 @@ file-filter Templates 템플릿 Kana 가나 Numerals 숫자 Scale at Center 중심에서 크기 조정 + Delete Set 집합 삭제 + Direct Entry 직접 입력 Up/Down: 위/아래: Left/Right: 왼쪽/오른쪽: Tate-chu-yoko Up/Down 다테추요코 위/아래 @@ -5223,6 +5236,7 @@ theme Appearance 외관 @msg `name` must be the name of a script engine `name`은(는) 스크립트 엔진의 이름이어야 합니다 @msg the main script engine is new for each run and is never kept, so it cannot be dropped 기본 스크립트 엔진은 실행마다 새로 만들어지고 유지되지 않으므로 삭제할 수 없습니다 @msg there is no script engine named {name} 이름이 {name}인 스크립트 엔진이 없습니다 +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers`는 물어볼 사람이 없는 실행에만 쓰입니다. 앱에서는 사람이 각 대화 상자에 답합니다 @msg Running a script 스크립트 실행 중 @msg Copied the script report 스크립트 보고서를 복사했습니다 @msg Copied the link info 링크 정보를 복사했습니다 @@ -5414,6 +5428,11 @@ theme Appearance 외관 @msg Imported library {name} 라이브러리 {name}을(를) 가져왔습니다 RGB: RGB: CMYK: CMYK: + RGB Policy: RGB 정책: + CMYK Policy: CMYK 정책: + Preserve Embedded Profiles 포함된 프로필 유지 + Convert to Working Space 작업 공간으로 변환 + Preserve Numbers 숫자 유지 Texture 텍스처 Craquelure… 크랙… Grain… 그레인… @@ -5850,6 +5869,8 @@ lighting Light 광원 Save Variable Library… 변수 라이브러리 저장… @msg a variable library holds text variables, and the document has none 변수 라이브러리에는 텍스트 변수가 들어가는데 문서에 텍스트 변수가 없습니다 @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` 변수 `{name}`은(는) 변수 라이브러리에 저장할 수 없습니다: 문자나 `_`로 시작하고 공백이나 `-`, `_`, `.` 이외의 기호가 없도록 이름을 바꾸세요 +@msg {k} has control characters {k}에 제어 문자가 있습니다 +@msg {k} has control characters or line breaks {k}에 제어 문자 또는 줄 바꿈이 있습니다 @msg data set `{name}` holds a control character, which a variable library can't hold 데이터 세트 `{name}`에 변수 라이브러리에 담을 수 없는 제어 문자가 있습니다 @msg data set `{name}` starts or ends with white space, which a variable library can't hold 데이터 세트 `{name}`의 앞이나 뒤에 변수 라이브러리에 담을 수 없는 공백이 있습니다 Make Graph Dynamic 그래프를 동적으로 만들기 @@ -6181,6 +6202,7 @@ axis Y Axis Y축 @msg stops must be in order of `offset` 정지점은 `offset` 순서여야 합니다 @msg the document has no pattern {pattern} 문서에 패턴 {pattern}이(가) 없습니다 @msg the document has no swatch {n} 문서에 견본 {n}이(가) 없습니다 +@msg `plates` requires a stop linked to a spot swatch `plates`에는 별색 견본에 연결된 정지점이 필요합니다 @msg the output 출력 @msg the path has no anchor points 패스에 앵커 포인트가 없습니다 @msg too many anchor points (limit {MAX_ANCHORS}) 앵커 포인트가 너무 많습니다(한도 {MAX_ANCHORS}) @@ -7388,6 +7410,7 @@ breakText Glyphs 글리프 @msg Image Trace failed: {e} 이미지 추적 실패: {e} @msg Image Trace finished, but its document was closed 이미지 추적이 끝났지만 문서가 닫혔습니다 @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again 이미지 추적이 끝났지만 그 사이에 이미지나 설정이 바뀌었습니다: 다시 추적하세요 +@msg Image Trace finished, but its document was reverted: trace it again 이미지 추적이 끝났지만 문서가 되돌려졌습니다: 다시 추적하세요 @msg Import SVG Filter reads SVG files, not SVGZ SVG 필터 가져오기는 SVGZ가 아닌 SVG 파일을 읽습니다 @msg Invalid dimension offset 잘못된 치수 오프셋 @msg Invalid point 잘못된 점 @@ -7561,6 +7584,7 @@ breakText Glyphs 글리프 @msg `names` must be an array of style names `names`은(는) 스타일 이름의 배열이어야 합니다 @msg `new` is a design name, or true `new`은(는) 디자인 이름 또는 true입니다 @msg `origin` must be [x, y] in points `origin`은(는) 포인트 단위의 [x, y]여야 합니다 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes`은(는) [a, b, c, d]여야 하며, 각 값은 -1000에서 1000 사이이고 a·d - b·c는 0에서 0.001 이상 떨어져야 합니다 @msg `path` must be a folder `path`은(는) 폴더여야 합니다 @msg `path` must be a path object `path`은(는) 패스 개체여야 합니다 @msg `picks` must be an array (got {v}) `picks`은(는) 배열이어야 합니다({v} 입력됨) @@ -7967,7 +7991,7 @@ breakText Glyphs 글리프 @msg column: true floats the whole column and takes no onto, above or below column: true는 열 전체를 띄우며 onto, above, below와 함께 쓸 수 없습니다 @msg command must be a shape.* command command는 shape.* 명령이어야 합니다 @msg commands[{i}]: missing `command` commands[{i}]: `command`가 없습니다 -@msg composer must be "singleLine" or "everyLine" composer는 "singleLine" 또는 "everyLine"이어야 합니다 +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer는 "singleLine", "everyLine", "standardSingleLine" 또는 "standardEveryLine"이어야 합니다 @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes 컴파운드 모양에는 패스, 컴파운드 패스, 그룹, 컴파운드 모양, 문자, 블렌드, 엔벨로프를 넣을 수 있습니다 @msg compound {_1} must contain exactly one open path to join 컴파운드 {_1}에는 연결할 열린 패스가 정확히 하나 있어야 합니다 @msg connect needs a path other than the one extended connect에는 연장한 패스가 아닌 다른 패스가 필요합니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 51f87bb262..101d37b1e6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -145,6 +145,7 @@ @msg `name` must be the name of a script engine `name` moet de naam van een scriptengine zijn @msg the main script engine is new for each run and is never kept, so it cannot be dropped de hoofdscriptengine is bij elke uitvoering nieuw en wordt nooit bewaard, dus kan hij niet worden verwijderd @msg there is no script engine named {name} er is geen scriptengine met de naam {name} +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` is alleen voor een uitvoering zonder iemand om te vragen: in de app beantwoordt de persoon elk dialoogvenster @msg Running a script Er wordt een script uitgevoerd @msg Data Recovery can't keep copies in {path}: {e} Gegevensherstel kan geen kopieën bewaren in {path}: {e} @msg Plug-ins can't be loaded from {path}: {e} Plug-ins kunnen niet worden geladen uit {path}: {e} @@ -374,6 +375,7 @@ @msg stops must be in order of `offset` kleurstops moeten op volgorde van `offset` staan @msg the document has no pattern {pattern} het document heeft geen patroon {pattern} @msg the document has no swatch {n} het document heeft geen staal {n} +@msg `plates` requires a stop linked to a spot swatch `plates` vereist een verloopstop die aan een steunkleurstaal is gekoppeld @msg the output de uitvoer @msg the path has no anchor points het pad heeft geen ankerpunten @msg too many anchor points (limit {MAX_ANCHORS}) te veel ankerpunten (limiet {MAX_ANCHORS}) @@ -1446,6 +1448,11 @@ NTSC Colors NTSC-kleuren RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-beleid: + CMYK Policy: CMYK-beleid: + Preserve Embedded Profiles Ingesloten profielen behouden + Convert to Working Space Omzetten naar werkruimte + Preserve Numbers Waarden behouden CMYK CMYK Texture Textuur Craquelure… Craquelure… @@ -1945,6 +1952,7 @@ lighting Light Licht Align to Artboard Uitlijnen op tekengebied Align to Glyph Bounds Uitlijnen op glyphbegrenzing Align to Key Object Uitlijnen op sleutelobject + Align to Key Anchor Uitlijnen op sleutelankerpunt Align to Selection Uitlijnen op selectie Align To: Uitlijnen op: Cancel Key Object Sleutelobject annuleren @@ -2136,6 +2144,8 @@ digits Malayalam Malayalam Kana Kana Numerals Cijfers Scale at Center Schalen vanuit het midden + Delete Set Set verwijderen + Direct Entry Directe invoer Up/Down: Omhoog/omlaag: Left/Right: Links/rechts: Tate-chu-yoko Up/Down Tate-chu-yoko omhoog/omlaag @@ -2419,7 +2429,10 @@ digits Malayalam Malayalam Tabular Lining Tabulaire lining Tabular Oldstyle Tabulaire oldstyle Titling Alternates Titelalternatieven - Every-line Composer Compositie per alinea + Middle Eastern & South Asian Single-line Composer Compositie per regel (Midden-Oosten en Zuid-Azië) + Middle Eastern & South Asian Every-line Composer Compositie per alinea (Midden-Oosten en Zuid-Azië) + Standard Single-line Composer Standaardcompositie per regel + Standard Every-line Composer Standaardcompositie per alinea First-line Left Indent Eerste regel links inspringen Hyphenation… Afbreken… Justification… Uitvullen… @@ -2451,7 +2464,6 @@ digits Malayalam Malayalam East Asian Composition Settings Instellingen voor Oost-Aziatische zetting Korean Composition Settings Instellingen voor Koreaanse zetting Select a text object to edit its paragraph attributes. Selecteer een tekstobject om de alineakenmerken ervan te bewerken. - Single-line Composer Compositie per regel Toggle Line Composer Compositiemethode wisselen Space After Paragraph Ruimte na alinea Space Before Paragraph Ruimte voor alinea @@ -5717,6 +5729,7 @@ imageTrace Outline Contour @msg Image Trace failed: {e} Afbeelding traceren is mislukt: {e} @msg Image Trace finished, but its document was closed Afbeelding traceren is voltooid, maar het document ervan is gesloten @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Afbeelding traceren is voltooid, maar de afbeelding of de instellingen ervan zijn intussen gewijzigd: traceer de afbeelding opnieuw +@msg Image Trace finished, but its document was reverted: trace it again Afbeelding traceren is voltooid, maar het document ervan is teruggezet: traceer de afbeelding opnieuw @msg Import SVG Filter reads SVG files, not SVGZ SVG-filter importeren leest SVG-bestanden, geen SVGZ @msg Invalid dimension offset Ongeldige verschuiving van de maatlijn @msg Invalid point Ongeldig punt @@ -5891,6 +5904,7 @@ imageTrace Outline Contour @msg `names` must be an array of style names `names` moet een array van stijlnamen zijn @msg `new` is a design name, or true `new` is de naam van een ontwerp, of true @msg `origin` must be [x, y] in points `origin` moet [x, y] in punten zijn +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` moet [a, b, c, d] zijn, elk van -1000 tot 1000, met a·d - b·c minstens 0,001 van 0 af @msg `path` must be a folder `path` moet een map zijn @msg `path` must be a path object `path` moet een padobject zijn @msg `picks` must be an array (got {v}) `picks` moet een array zijn (kreeg {v}) @@ -6299,7 +6313,7 @@ imageTrace Outline Contour @msg column: true floats the whole column and takes no onto, above or below column: true laat de hele kolom zweven en accepteert geen onto, above of below @msg command must be a shape.* command command moet een shape.*-opdracht zijn @msg commands[{i}]: missing `command` commands[{i}]: `command` ontbreekt -@msg composer must be "singleLine" or "everyLine" composer moet "singleLine" of "everyLine" zijn +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer moet "singleLine", "everyLine", "standardSingleLine" of "standardEveryLine" zijn @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes samengestelde vormen accepteren paden, samengestelde paden, groepen, samengestelde vormen, tekst, mengsels en omvormingen @msg compound {_1} must contain exactly one open path to join samengesteld object {_1} moet precies één open pad bevatten om te verbinden @msg connect needs a path other than the one extended connect heeft een ander pad nodig dan het pad dat wordt verlengd @@ -8094,6 +8108,8 @@ mark-style Japanese Japans Save Variable Library… Variabelenbibliotheek opslaan… @msg a variable library holds text variables, and the document has none een variabelenbibliotheek bevat tekstvariabelen, en het document heeft er geen @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` variabele `{name}` kan niet in een variabelenbibliotheek worden opgeslagen: geef haar een naam die met een letter of `_` begint, zonder spaties of andere symbolen dan `-`, `_` en `.` +@msg {k} has control characters {k} bevat stuurtekens +@msg {k} has control characters or line breaks {k} bevat stuurtekens of regeleinden @msg data set `{name}` holds a control character, which a variable library can't hold gegevensset `{name}` bevat een stuurteken, dat een variabelenbibliotheek niet kan bevatten @msg data set `{name}` starts or ends with white space, which a variable library can't hold gegevensset `{name}` begint of eindigt met witruimte, die een variabelenbibliotheek niet kan bevatten Make Graph Dynamic Grafiek dynamisch maken @@ -8293,6 +8309,14 @@ swatch Bright Violet Felviolet Color wheel Kleurenwiel First Artboard Eerste tekenbord Last Artboard Laatste tekenbord + Status Bar Show Weergeven in statusbalk + Artboard Name Naam van tekenbord + Current Tool Huidig gereedschap + Date and Time Datum en tijd + Number of Undos Aantal ongedaan gemaakte stappen + Document Color Profile Kleurprofiel van document + Undos Ongedaan maken + Redos Opnieuw uitvoeren Next Artboard Volgende tekenbord Previous Artboard Vorige tekenbord Step up or down Stapsgewijs verhogen of verlagen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 93969d2bc3..f5db22b1be 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -946,6 +946,11 @@ NTSC Colors Kolory NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Zasada RGB: + CMYK Policy: Zasada CMYK: + Preserve Embedded Profiles Zachowaj osadzone profile + Convert to Working Space Konwertuj do przestrzeni roboczej + Preserve Numbers Zachowaj wartości liczbowe CMYK CMYK Texture Tekstura Craquelure… Spękania… @@ -1040,6 +1045,14 @@ lighting Light Światło Previous Artboard Poprzedni obszar kompozycji Next Artboard Następny obszar kompozycji Last Artboard Ostatni obszar kompozycji + Status Bar Show Pokaż na pasku stanu + Artboard Name Nazwa obszaru kompozycji + Current Tool Bieżące narzędzie + Date and Time Data i godzina + Number of Undos Liczba cofnięć + Document Color Profile Profil kolorów dokumentu + Undos Cofnięcia + Redos Ponowienia Graphic Style Options… Opcje stylu graficznego… Symbol Options Opcje symbolu Symbol Options… Opcje symbolu… @@ -1433,6 +1446,7 @@ lighting Light Światło Align to Artboard Wyrównaj do obszaru kompozycji Align to Glyph Bounds Wyrównaj do granic glifów Align to Key Object Wyrównaj do obiektu kluczowego + Align to Key Anchor Wyrównaj do kluczowego punktu kontrolnego Align to Selection Wyrównaj do zaznaczenia Align To: Wyrównaj do: Cancel Key Object Anuluj obiekt kluczowy @@ -1624,6 +1638,8 @@ digits Malayalam Malajalam Kana Kana Numerals Cyfry Scale at Center Skaluj od środka + Delete Set Usuń zestaw + Direct Entry Wprowadzanie bezpośrednie Up/Down: W górę/w dół: Left/Right: W lewo/w prawo: Tate-chu-yoko Up/Down Tate-chu-yoko w górę/w dół @@ -2011,7 +2027,8 @@ digits Malayalam Malajalam Tabular Lining Tabelaryczne nowoczesne Tabular Oldstyle Tabelaryczne nautyczne Titling Alternates Alternatywy tytułowe - Every-line Composer Kompozytor wielowierszowy + Middle Eastern & South Asian Every-line Composer Kompozytor wielowierszowy (Bliski Wschód i Azja Południowa) + Standard Every-line Composer Standardowy kompozytor wielowierszowy First-line Left Indent Wcięcie pierwszego wiersza Hyphenation… Dzielenie wyrazów… Words Longer Than: Wyrazy dłuższe niż: @@ -2051,7 +2068,8 @@ digits Malayalam Malajalam East Asian Composition Settings Ustawienia składu wschodnioazjatyckiego Korean Composition Settings Ustawienia składu koreańskiego Select a text object to edit its paragraph attributes. Zaznacz obiekt tekstowy, aby edytować atrybuty akapitu. - Single-line Composer Kompozytor jednowierszowy + Middle Eastern & South Asian Single-line Composer Kompozytor jednowierszowy (Bliski Wschód i Azja Południowa) + Standard Single-line Composer Standardowy kompozytor jednowierszowy Toggle Line Composer Przełącz kompozytor wierszy Space After Paragraph Odstęp po akapicie Space Before Paragraph Odstęp przed akapitem @@ -6123,6 +6141,8 @@ mark-style Japanese Japońskie Save Variable Library… Zapisz bibliotekę zmiennych… @msg a variable library holds text variables, and the document has none biblioteka zmiennych zawiera zmienne tekstowe, a dokument nie ma żadnej @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` nie można zapisać zmiennej `{name}` w bibliotece zmiennych: zmień jej nazwę tak, aby zaczynała się literą lub `_`, bez spacji i symboli innych niż `-`, `_` i `.` +@msg {k} has control characters {k} zawiera znaki sterujące +@msg {k} has control characters or line breaks {k} zawiera znaki sterujące lub podziały wierszy @msg data set `{name}` holds a control character, which a variable library can't hold zestaw danych `{name}` zawiera znak sterujący, którego biblioteka zmiennych nie może zawierać @msg data set `{name}` starts or ends with white space, which a variable library can't hold zestaw danych `{name}` zaczyna się lub kończy białym znakiem, którego biblioteka zmiennych nie może zawierać Make Graph Dynamic Uczyń wykres dynamicznym @@ -6449,6 +6469,7 @@ axis Y Axis Oś Y @msg stops must be in order of `offset` punkty koloru muszą być uporządkowane według `offset` @msg the document has no pattern {pattern} dokument nie ma wzorku {pattern} @msg the document has no swatch {n} dokument nie ma próbki {n} +@msg `plates` requires a stop linked to a spot swatch `plates` wymaga punktu koloru połączonego z próbką koloru dodatkowego @msg the output wyjście @msg the path has no anchor points ścieżka nie ma punktów kontrolnych @msg too many anchor points (limit {MAX_ANCHORS}) za dużo punktów kontrolnych (limit {MAX_ANCHORS}) @@ -7524,6 +7545,7 @@ breakText Glyphs Glify @msg `answer` for this {kind} dialog must be one of: {_1} `answer` dla tego okna dialogowego {kind} musi mieć jedną z wartości: {_1} @msg `answers` has more than {MAX_ANSWERS} answers `answers` zawiera więcej niż {MAX_ANSWERS} odpowiedzi @msg `answers` is larger than 1 MiB `answers` ma rozmiar większy niż 1 MiB +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` służy tylko do uruchomienia, w którym nie ma kogo zapytać: w aplikacji na każde okno dialogowe odpowiada użytkownik @msg `answers` must be a list of answers `answers` musi być listą odpowiedzi @msg `artboard` must be the index of an artboard of the document `artboard` musi być indeksem obszaru kompozycji dokumentu @msg `artboardsAt` must be [x, y] `artboardsAt` musi mieć postać [x, y] @@ -7556,6 +7578,7 @@ breakText Glyphs Glify @msg `name` must be a name that is not empty `name` musi być niepustą nazwą @msg `name` must not be empty `name` nie może być puste @msg `origin` must be [x, y] in points `origin` musi mieć postać [x, y] w punktach +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` musi mieć postać [a, b, c, d], każda wartość od -1000 do 1000, a a·d - b·c musi różnić się od 0 o co najmniej 0,001 @msg `picks` must be an array (got {v}) `picks` musi być tablicą (podano {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` musi zawierać od 1 do {MAX_PICKS} wskazań (podano {_1}) @msg `points` must hold from 2 to 100000 points `points` musi zawierać od 2 do 100000 punktów @@ -7931,6 +7954,7 @@ breakText Glyphs Glify @msg Image Trace failed: {e} Obrys obrazu nie powiódł się: {e} @msg Image Trace finished, but its document was closed Obrys obrazu został ukończony, ale jego dokument został zamknięty @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Obrys obrazu został ukończony, ale obraz lub jego ustawienia zmieniły się w międzyczasie: obrysuj go ponownie +@msg Image Trace finished, but its document was reverted: trace it again Obrys obrazu został ukończony, ale jego dokument został przywrócony: obrysuj go ponownie @msg Join: a subpath went missing Połącz: brakuje ścieżki podrzędnej @msg Lock Workspace is not available in VectorCraft Polecenie Zablokuj obszar roboczy nie jest dostępne w programie VectorCraft @msg Move Panel to New Window is not available: floating panels stay in the main window Polecenie Przenieś panel do nowego okna nie jest dostępne: panele pływające pozostają w oknie głównym @@ -8343,7 +8367,7 @@ breakText Glyphs Glify @msg column: true floats the whole column and takes no onto, above or below column: true odłącza całą kolumnę i nie przyjmuje onto, above ani below @msg command must be a shape.* command command musi być poleceniem shape.* @msg commands[{i}]: missing `command` commands[{i}]: brak `command` -@msg composer must be "singleLine" or "everyLine" composer musi mieć wartość "singleLine" lub "everyLine" +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer musi mieć wartość "singleLine", "everyLine", "standardSingleLine" lub "standardEveryLine" @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes kształty złożone przyjmują ścieżki, ścieżki złożone, grupy, kształty złożone, tekst, przejścia i koperty @msg compound {_1} must contain exactly one open path to join obiekt złożony {_1} musi zawierać dokładnie jedną otwartą ścieżkę do połączenia @msg connect needs a path other than the one extended connect wymaga ścieżki innej niż przedłużana @@ -9273,7 +9297,6 @@ breakText Glyphs Glify @msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) pliki {kind} nie obsługują przezroczystości, a plik zawierałby {what}: najpierw spłaszcz przezroczystość (object.flattenTransparency) @msg {kind} images can't be opened (see document.formats) obrazów {kind} nie można otwierać (patrz document.formats) @msg {k} `{v}` is not one of the choices {k} `{v}` nie jest jedną z dostępnych opcji -@msg {k} has control characters{_1} {k} zawiera znaki sterujące{_1} @msg {k} is too long (at most {MAX_TEXT} characters) {k} jest zbyt długie (najwyżej {MAX_TEXT} znaków) @msg {k} must be > 0 {k} musi być > 0 @msg {k} must be a number (pt) or a length such as "210 mm" {k} musi być liczbą (pt) lub długością, na przykład "210 mm" diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ad9df40e5a..a2b5cd06ba 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -164,6 +164,7 @@ Align to Artboard Alinhar à Mesa de Trabalho Align to Glyph Bounds Alinhar aos Limites do Glifo Align to Key Object Alinhar ao Objeto-Chave + Align to Key Anchor Alinhar ao Ponto de Ancoragem-Chave Align to Page Alinhar à Página Align to Path Alinhar ao Caminho Align to Selection Alinhar à Seleção @@ -965,7 +966,8 @@ antiAlias Sharp Nítido Every (minutes) A cada (minutos) Every option is at its default. Todas as opções estão no padrão. Every option matches {base}. Todas as opções correspondem a {base}. - Every-line Composer Compositor de Todas as Linhas + Middle Eastern & South Asian Every-line Composer Compositor de Todas as Linhas (Oriente Médio e Sul da Ásia) + Standard Every-line Composer Compositor Padrão de Todas as Linhas Exact dash lengths Comprimentos de tracejado exatos Exceptions (comma separated) Exceções (separadas por vírgula) Exclude Excluir @@ -1257,6 +1259,14 @@ antiAlias Sharp Nítido Previous Artboard Mesa de trabalho anterior Next Artboard Próxima mesa de trabalho Last Artboard Última mesa de trabalho + Status Bar Show Mostrar na barra de status + Artboard Name Nome da prancheta + Current Tool Ferramenta atual + Date and Time Data e hora + Number of Undos Número de ações desfeitas + Document Color Profile Perfil de cor do documento + Undos Desfazer + Redos Refazer Gradient Gradiente Gradient Fill: click to apply, drag onto art Preenchimento de Gradiente: clique para aplicar, arraste sobre a arte Gradient Finishes Finalizações de Gradiente @@ -2889,7 +2899,8 @@ digits Malayalam Malaiala Single Left Quotation Mark Aspa Esquerda Única Single Quotes: Aspas Simples: Single Right Quotation Mark Aspa Direita Única - Single-line Composer Compositor de Linha Única + Middle Eastern & South Asian Single-line Composer Compositor de Linha Única (Oriente Médio e Sul da Ásia) + Standard Single-line Composer Compositor Padrão de Linha Única Toggle Line Composer Alternar Compositor de Linhas Sixth Space Espaço de Sexto Size Tamanho @@ -3505,6 +3516,11 @@ imageTrace Outline Contorno NTSC Colors Cores NTSC RGB: RGB: CMYK: CMYK: + RGB Policy: Política RGB: + CMYK Policy: Política CMYK: + Preserve Embedded Profiles Preservar perfis incorporados + Convert to Working Space Converter para espaço de trabalho + Preserve Numbers Preservar números Texture Textura Craquelure… Craquelê… Grain… Granulação… @@ -5353,6 +5369,8 @@ axis Both Ambos Kana Kana Numerals Números Scale at Center Dimensionar a partir do centro + Delete Set Excluir conjunto + Direct Entry Entrada direta Up/Down: Para Cima/Para Baixo: Left/Right: Esquerda/Direita: Tate-chu-yoko Up/Down Tate-chu-yoko Para Cima/Para Baixo @@ -6509,6 +6527,7 @@ axis Y Axis Eixo Y @msg stops must be in order of `offset` as paradas devem estar em ordem de `offset` @msg the document has no pattern {pattern} o documento não tem o padrão {pattern} @msg the document has no swatch {n} o documento não tem a amostra {n} +@msg `plates` requires a stop linked to a spot swatch `plates` requer uma parada vinculada a uma amostra de cor spot @msg the output a saída @msg the path has no anchor points o caminho não tem pontos de ancoragem @msg too many anchor points (limit {MAX_ANCHORS}) pontos de ancoragem demais (limite {MAX_ANCHORS}) @@ -7857,6 +7876,7 @@ swatch Yellow Green Verde-amarelado @msg Image Trace failed: {e} O Rastreamento de Imagem falhou: {e} @msg Image Trace finished, but its document was closed O Rastreamento de Imagem terminou, mas o documento dele foi fechado @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again O Rastreamento de Imagem terminou, mas a imagem ou as configurações dela mudaram nesse meio-tempo: rastreie-a novamente +@msg Image Trace finished, but its document was reverted: trace it again O Rastreamento de Imagem terminou, mas o documento dele foi revertido: rastreie-a novamente @msg Import SVG Filter reads SVG files, not SVGZ Importar Filtro SVG lê arquivos SVG, não SVGZ @msg Invalid dimension offset Deslocamento de cota inválido @msg Invalid point Ponto inválido @@ -7904,6 +7924,7 @@ swatch Yellow Green Verde-amarelado @msg `answer` for this {kind} dialog must be one of: {_1} `answer` para esta caixa de diálogo {kind} deve ser um de: {_1} @msg `answers` has more than {MAX_ANSWERS} answers `answers` tem mais de {MAX_ANSWERS} respostas @msg `answers` is larger than 1 MiB `answers` tem mais de 1 MiB +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` é só para uma execução sem ninguém a quem perguntar: no app, a pessoa responde a cada caixa de diálogo @msg `answers` must be a list of answers `answers` deve ser uma lista de respostas @msg `artboard` must be the index of an artboard of the document `artboard` deve ser o índice de uma prancheta do documento @msg `artboardsAt` must be [x, y] `artboardsAt` deve ser [x, y] @@ -8030,6 +8051,7 @@ swatch Yellow Green Verde-amarelado @msg `names` must be an array of style names `names` deve ser uma matriz de nomes de estilos @msg `new` is a design name, or true `new` é o nome de um projeto, ou true @msg `origin` must be [x, y] in points `origin` deve ser [x, y] em pontos +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` deve ser [a, b, c, d], cada um de -1000 a 1000, com a·d - b·c a pelo menos 0.001 de 0 @msg `path` must be a folder `path` deve ser uma pasta @msg `path` must be a path object `path` deve ser um objeto de caminho @msg `picks` must be an array (got {v}) `picks` deve ser uma matriz (recebido {v}) @@ -8435,7 +8457,7 @@ swatch Yellow Green Verde-amarelado @msg column: true floats the whole column and takes no onto, above or below column: true torna flutuante a coluna inteira e não aceita onto, above nem below @msg command must be a shape.* command command deve ser um comando shape.* @msg commands[{i}]: missing `command` commands[{i}]: falta `command` -@msg composer must be "singleLine" or "everyLine" composer deve ser "singleLine" ou "everyLine" +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer deve ser "singleLine", "everyLine", "standardSingleLine" ou "standardEveryLine" @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes formas compostas aceitam caminhos, caminhos compostos, grupos, formas compostas, texto, mesclagens e envelopes @msg compound {_1} must contain exactly one open path to join o composto {_1} deve conter exatamente um caminho aberto para unir @msg connect needs a path other than the one extended connect precisa de um caminho diferente do que foi estendido @@ -9579,7 +9601,8 @@ swatch Yellow Green Verde-amarelado @msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) os arquivos {kind} não têm transparência, e o arquivo teria {what}: achate-a primeiro (object.flattenTransparency) @msg {kind} images can't be opened (see document.formats) as imagens {kind} não podem ser abertas (veja document.formats) @msg {k} `{v}` is not one of the choices {k} `{v}` não é uma das opções -@msg {k} has control characters{_1} {k} tem caracteres de controle{_1} +@msg {k} has control characters {k} tem caracteres de controle +@msg {k} has control characters or line breaks {k} tem caracteres de controle ou quebras de linha @msg {k} is too long (at most {MAX_TEXT} characters) {k} é longo demais (no máximo {MAX_TEXT} caracteres) @msg {k} must be > 0 {k} deve ser > 0 @msg {k} must be a number (pt) or a length such as "210 mm" {k} deve ser um número (pt) ou um comprimento como "210 mm" diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 073b662e3f..e5113d70ba 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -158,6 +158,7 @@ Align to Artboard Выровнять по монтажной области Align to Glyph Bounds Выровнять по границам глифа Align to Key Object Выровнять по ключевому объекту + Align to Key Anchor Выровнять по ключевой опорной точке Align to Page Выровнять по странице Align to Path Выровнять по контуру Align to Selection Выровнять по выделению @@ -1074,7 +1075,10 @@ font-technology Variable Вариативные Every (minutes) Каждые (минут) Every option is at its default. Все параметры имеют значения по умолчанию. Every option matches {base}. Все параметры совпадают с {base}. - Every-line Composer Компоновщик всех строк + Middle Eastern & South Asian Single-line Composer Однострочный компоновщик (Ближний Восток и Южная Азия) + Middle Eastern & South Asian Every-line Composer Компоновщик всех строк (Ближний Восток и Южная Азия) + Standard Single-line Composer Стандартный однострочный компоновщик + Standard Every-line Composer Стандартный компоновщик всех строк Exact dash lengths Точная длина штрихов Exceptions (comma separated) Исключения (через запятую) Exclude Исключить @@ -1411,6 +1415,14 @@ file-filter Templates Шаблоны Previous Artboard Предыдущая монтажная область Next Artboard Следующая монтажная область Last Artboard Последняя монтажная область + Status Bar Show Показ в строке состояния + Artboard Name Имя монтажной области + Current Tool Текущий инструмент + Date and Time Дата и время + Number of Undos Число отмен + Document Color Profile Цветовой профиль документа + Undos Отмены + Redos Повторы Go To Link Перейти по ссылке GPL palettes keep solid colours only, as RGB. Палитры GPL сохраняют только сплошные цвета в RGB. GPU Performance Производительность GPU @@ -3484,7 +3496,6 @@ digits Malayalam Малаялам Single Left Quotation Mark Одинарная открывающая кавычка Single Quotes: Одинарные кавычки: Single Right Quotation Mark Одинарная закрывающая кавычка - Single-line Composer Однострочный компоновщик Toggle Line Composer Переключить компоновщик строк Sixth Space Шестая шпация Size Размер @@ -3819,6 +3830,8 @@ digits Malayalam Малаялам Kana Кана Numerals Цифры Scale at Center Масштабировать от центра + Delete Set Удалить набор + Direct Entry Прямой ввод Up/Down: Вверх/вниз: Left/Right: Влево/вправо: Tate-chu-yoko Up/Down Tate-chu-yoko вверх/вниз @@ -4516,6 +4529,17 @@ axis Both Оба @msg `name` must be the name of a script engine `name` должно быть именем движка скриптов @msg the main script engine is new for each run and is never kept, so it cannot be dropped основной движок скриптов создаётся заново для каждого запуска и никогда не сохраняется, поэтому его нельзя удалить @msg there is no script engine named {name} движка скриптов с именем {name} нет +@msg `answers` must be a list of answers `answers` должен быть списком ответов +@msg `answers` has more than {MAX_ANSWERS} answers в `answers` больше {MAX_ANSWERS} ответов +@msg `answers` is larger than 1 MiB `answers` больше 1 МиБ +@msg must be an object with `kind` and `value` должен быть объектом с `kind` и `value` +@msg must have exactly `kind` and `value` должен содержать ровно `kind` и `value` +@msg is an alert's answer, whose `value` must be "ok" является ответом на alert, поэтому его `value` должно быть "ok" +@msg is a confirm's answer, whose `value` must be true or false является ответом на confirm, поэтому его `value` должно быть true или false +@msg is longer than 16 KiB длиннее 16 КиБ +@msg is a prompt's answer, whose `value` must be a string or null является ответом на prompt, поэтому его `value` должно быть строкой или null +@msg must have `kind` alert, confirm or prompt должен иметь `kind` alert, confirm или prompt +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` предназначен только для запуска, в котором некого спросить: в приложении человек сам отвечает на каждое диалоговое окно @msg Running a script Выполняется скрипт @msg Copied the script report Отчёт о сценарии скопирован @msg Copied the link info Сведения о связи скопированы @@ -5435,6 +5459,11 @@ axis Both Оба @msg Imported library {name} Импортирована библиотека {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Правило RGB: + CMYK Policy: Правило CMYK: + Preserve Embedded Profiles Сохранять встроенные профили + Convert to Working Space Преобразовывать в рабочее пространство + Preserve Numbers Сохранять числа Texture Текстура Craquelure… Кракелюры… Grain… Зерно… @@ -5876,6 +5905,8 @@ lighting Light Свет Save Variable Library… Сохранить библиотеку переменных… @msg a variable library holds text variables, and the document has none библиотека переменных содержит текстовые переменные, а в документе их нет @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` переменную `{name}` нельзя сохранить в библиотеке переменных: переименуйте её так, чтобы имя начиналось с буквы или `_` и не содержало пробелов и символов, кроме `-`, `_` и `.` +@msg {k} has control characters {k} содержит управляющие символы +@msg {k} has control characters or line breaks {k} содержит управляющие символы или разрывы строк @msg data set `{name}` holds a control character, which a variable library can't hold набор данных `{name}` содержит управляющий символ, который не может храниться в библиотеке переменных @msg data set `{name}` starts or ends with white space, which a variable library can't hold набор данных `{name}` начинается или заканчивается пробелом, который не может храниться в библиотеке переменных Make Graph Dynamic Сделать диаграмму динамической @@ -6155,6 +6186,7 @@ axis Y Axis Ось Y @msg {what} points outside memory {what} указывает за пределы памяти @msg `angle` must be a number `angle` должно быть числом @msg `aspect` must be from 0.01 to 100 `aspect` должно быть от 0.01 до 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` должно быть [a, b, c, d], каждое от -1000 до 1000, а a·d - b·c должно отличаться от 0 не менее чем на 0.001 @msg `cap` must be "butt", "round" or "square" `cap` должно быть "butt", "round" или "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` должно быть "nonzero" или "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` должно быть адресом https:// длиной не более {MAX_HELP_URL} байт @@ -6207,6 +6239,7 @@ axis Y Axis Ось Y @msg stops must be in order of `offset` точки перехода должны идти в порядке `offset` @msg the document has no pattern {pattern} в документе нет узора {pattern} @msg the document has no swatch {n} в документе нет образца {n} +@msg `plates` requires a stop linked to a spot swatch для `plates` нужна точка перехода, связанная с образцом плашечного цвета @msg the output вывод @msg the path has no anchor points у контура нет опорных точек @msg too many anchor points (limit {MAX_ANCHORS}) слишком много опорных точек (предел {MAX_ANCHORS}) @@ -7728,6 +7761,1816 @@ shape Open Открытый Nest Shapes Раскладка фигур Roll Рулон +# Status and error messages (`@msg`) of commands, scripts, and file import and export, sorted by +# their English text. Messages that begin with a feature name. +@msg 'caller', 'callee' and 'arguments' cannot be used on strict mode functions 'caller', 'callee' и 'arguments' нельзя использовать с функциями строгого режима +@msg A profile supports one Revolve effect. Edit the existing effect's options instead. Профиль поддерживает один эффект «Вращение». Измените параметры существующего эффекта. +@msg Add Points at Crossings: a path went missing Добавить точки на пересечениях: контур пропал +@msg Alignment must be one of the choices Выравнивание должно быть одним из предложенных вариантов +@msg Boolean.prototype.{name} was called on a value that is not a Boolean Boolean.prototype.{name} вызван для значения, которое не является Boolean +@msg CMYK images whose samples couldn't be read in CMYK were imported in RGB изображения CMYK, отсчёты которых не удалось прочитать в CMYK, импортированы в RGB +@msg CSS options: {e} Параметры CSS: {e} +@msg CSV has a header but no data rows в CSV есть заголовок, но нет строк данных +@msg CSV holds NUL bytes: save it as UTF-8, or as UTF-16 with a byte-order mark CSV содержит байты NUL: сохраните его в UTF-8 или в UTF-16 с меткой порядка байтов +@msg CSV is empty CSV пуст +@msg CSV is larger than {MAX_CSV_BYTES} bytes CSV больше {MAX_CSV_BYTES} байт +@msg CSV is not UTF-16 CSV не в кодировке UTF-16 +@msg CSV is not UTF-8 CSV не в кодировке UTF-8 +@msg CSV would be larger than {MAX_CSV_BYTES} bytes CSV был бы больше {MAX_CSV_BYTES} байт +@msg Cannot write to the location of “{name}”; choose where to save it Невозможно записать в расположение «{name}»; выберите, куда сохранить файл +@msg Choose original artwork, not a dimension's generated paths Выберите исходную графику, а не контуры, созданные размером +@msg Contour: select paths or shapes Эквидистанта: выделите контуры или фигуры +@msg Contour: the Offset Path effect is not available Эквидистанта: эффект «Смещение контура» недоступен +@msg Create needs fills, strokes or both Для создания нужны заливки, обводки или и то и другое +@msg DCS files can only be placed linked: use File › Place with Link on Файлы DCS можно помещать только как связанные: используйте «Файл › Поместить» с включённым параметром «Связать» +@msg DXF options: {e} Параметры DXF: {e} +@msg DXF scale must be a positive number up to {_1}, not {scale} Масштаб DXF должен быть положительным числом не больше {_1}, а не {scale} +@msg DXF {field} `{s}`: {allowed} DXF, {field} `{s}`: {allowed} +@msg Date.prototype method called on a value that is not a Date object метод Date.prototype вызван для значения, которое не является объектом Date +@msg Dimension is locked or hidden Размер заблокирован или скрыт +@msg Dimension mode does not match its source Режим размера не соответствует его источнику +@msg Draw Inside needs exactly one selected path, compound path or text Для режима «Рисовать внутри» нужен ровно один выделенный контур, составной контур или текст +@msg EPS Save writes all artwork: use document.export for selected-only EPS Сохранение в EPS записывает всю графику: чтобы записать в EPS только выделенное, используйте document.export +@msg EPS flattener: {e} Объединение EPS: {e} +@msg EPS options: {e} Параметры EPS: {e} +@msg EPS {field} `{s}`: {allowed} EPS, {field} `{s}`: {allowed} +@msg Error.prototype.toString was called on a value that is not an object Error.prototype.toString вызван для значения, которое не является объектом +@msg Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft «Экспортировать для экранов» записывает png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai или vectorcraft +@msg Flare click coordinates must be finite numbers Координаты щелчка для блика должны быть конечными числами +@msg Function.prototype.toString was called on a value that is not a function Function.prototype.toString вызван для значения, которое не является функцией +@msg HTML Text applies to object slices of type only «Текст HTML» применяется только к фрагментам текстовых объектов +@msg Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) Для параметра «Определять объекты по» выбрано XML ID: имя должно быть допустимым XML ID (начинается с буквы, подчёркивания или двоеточия; без пробелов) +@msg Image Trace failed: internal error: its thread stopped (please report this bug) Трассировка изображения не выполнена: внутренняя ошибка: её поток остановился (сообщите об этой ошибке) +@msg Image Trace failed: {e} Трассировка изображения не выполнена: {e} +@msg Image Trace finished, but its document was closed Трассировка изображения завершена, но её документ был закрыт +@msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Трассировка изображения завершена, но изображение или его настройки за это время изменились: выполните трассировку снова +@msg Import SVG Filter reads SVG files, not SVGZ Импорт фильтра SVG читает файлы SVG, а не SVGZ +@msg Invalid dimension offset Недопустимое смещение размера +@msg Invalid point Недопустимая точка +@msg JSON.parse: {why} (at offset {at}) JSON.parse: {why} (смещение {at}) +@msg JSON.stringify cannot write a structure that contains itself JSON.stringify не может записать структуру, которая содержит саму себя +@msg Join: a subpath went missing Соединить: вложенный контур пропал +@msg Line Gap must be a number Интервал между строками должен быть числом +@msg Lock Workspace is not available in VectorCraft Закрепление рабочей среды недоступно в VectorCraft +@msg Move Panel to New Window is not available: floating panels stay in the main window Перемещение панели в новое окно недоступно: плавающие панели остаются в главном окне +@msg Namespace.prototype.toString needs a Namespace для Namespace.prototype.toString нужен Namespace +@msg New Document profiles need the preferences folder, which this run doesn't have Профилям нового документа нужна папка настроек, которой нет у этого запуска +@msg Number.prototype.{name} was called on a value that is not a number Number.prototype.{name} вызван для значения, которое не является числом +@msg Object.create's prototype must be an object or null прототип для Object.create должен быть объектом или null +@msg Object.{function} was given a value that is not an object в Object.{function} передано значение, которое не является объектом +@msg Offset Path: the path is too complex to offset Смещение контура: контур слишком сложен для смещения +@msg Offset Path: {e} Смещение контура: {e} +@msg PDF content is incomplete or exceeds the reader's limits; no document was imported Содержимое PDF неполно или превышает ограничения чтения; документ не импортирован +@msg PDF flattener: {e} Объединение PDF: {e} +@msg PDF options: {e} Параметры PDF: {e} +@msg QName.prototype.toString needs a QName для QName.prototype.toString нужен QName +@msg Recolor Artwork is no longer open Окно «Перекрасить рисунок» больше не открыто +@msg RegExp.prototype.{name} was called on a value that is not a RegExp object RegExp.prototype.{name} вызван для значения, которое не является объектом RegExp +@msg SVG is not UTF-8, UTF-16 or ISO 8859-1 SVG не в кодировке UTF-8, UTF-16 или ISO 8859-1 +@msg Save writes one artboard: name one, or export several with document.export Сохранение записывает одну монтажную область: укажите одну или экспортируйте несколько через document.export +@msg Select a dimension first Сначала выделите размер +@msg Select dimensions to update Выделите размеры для обновления +@msg Select only dimensions Выделите только размеры +@msg Select two or more objects, or a group Выделите два или более объекта либо группу +@msg Shaper needs 1–64 source paths Для Shaper нужно от 1 до 64 исходных контуров +@msg Stencil Bridges: the selected art is too large or too detailed to bridge Мостики трафарета: выделенная графика слишком велика или слишком детальна для мостиков +@msg String.prototype.toString was called on a value that is not a string String.prototype.toString вызван для значения, которое не является строкой +@msg Switch x/y is for scatter graphs «Поменять x/y» применяется к точечным диаграммам +@msg Text options: {e} Параметры текста: {e} +@msg The {name} profile could not be read, so the new document has the default settings: {reason} Профиль {name} не удалось прочитать, поэтому у нового документа настройки по умолчанию: {reason} +@msg Trap: more than {_1} pairs of objects lie close enough to trap, or too many objects share a horizontal range; trap fewer objects at a time Треппинг: больше {_1} пар объектов расположены достаточно близко для треппинга, или слишком много объектов занимают один горизонтальный диапазон; применяйте треппинг к меньшему числу объектов за раз +@msg Weed Box and Lines: no weeding line fits outside the art, and the weed box is off Рамка и линии выборки: ни одна линия выборки не помещается за пределами графики, а рамка выборки отключена +@msg \\ at end of pattern \\ в конце шаблона +# Messages that begin with a parameter name. +@msg `alignOn` cannot be empty `alignOn` не может быть пустым +@msg `alignOn` must be a string `alignOn` должно быть строкой +@msg `align` is start, center or end `align` принимает start, center или end +@msg `align` must be a string `align` должно быть строкой +@msg `anchors` must be an array of [subpath, anchor] pairs `anchors` должно быть массивом пар [subpath, anchor] +@msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` должно содержать индексы опорных точек (0, 1, 2…), а не {k} +@msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` должно содержать индексы опорных точек (0, 1, 2…), а не {v} +@msg `answer` for this {kind} dialog must be one of: {_1} `answer` для этого диалогового окна {kind} должно быть одним из: {_1} +@msg `artboard` must be the index of an artboard of the document `artboard` должно быть индексом монтажной области документа +@msg `artboardsAt` must be [x, y] `artboardsAt` должно быть [x, y] +@msg `artboards` must be a list of artboard indices `artboards` должно быть списком индексов монтажных областей +@msg `artboards` names an artboard the document does not have `artboards` указывает монтажную область, которой нет в документе +@msg `artboards` names an artboard twice `artboards` указывает одну монтажную область дважды +@msg `asset` is the asset id (see assets.list) `asset` — идентификатор ресурса (см. assets.list) +@msg `at` must be [x, y] (got {at}) `at` должно быть [x, y] (получено {at}) +@msg `at` must be a byte offset `at` должно быть смещением в байтах +@msg `at` must be a point [x, y] `at` должно быть точкой [x, y] +@msg `autoLeading` must be a number `autoLeading` должно быть числом +@msg `background` must be "", "matte" or #rrggbb `background` должно быть "", "matte" или #rrggbb +@msg `beside` needs `side` для `beside` нужно `side` +@msg `burasagari` must be "none", "standard" or "forced" `burasagari` должно быть "none", "standard" или "forced" +@msg `center` must be [x, y] `center` должно быть [x, y] +@msg `charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" `charAlign` должно быть "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" или "icfBottom" +@msg `charDirection` must be "default", "leftToRight" or "rightToLeft" `charDirection` должно быть "default", "leftToRight" или "rightToLeft" +@msg `clearStroke` requires `stroke: false`, `ids: []` and no `stop` для `clearStroke` нужны `stroke: false`, `ids: []` и отсутствие `stop` +@msg `clear` must be a list of type object ids, not {v} `clear` должно быть списком идентификаторов текстовых объектов, а не {v} +@msg `clear` must list type objects; {_1} is not one `clear` должно содержать текстовые объекты; {_1} не является текстовым объектом +@msg `clear` must not list `id` `clear` не должно содержать `id` +@msg `code` must be a whole number `code` должно быть целым числом +@msg `colorMode` must be one of rgb, cmyk `colorMode` должно быть одним из: rgb, cmyk +@msg `color` needs a `rule` to make the theme from для `color` нужно `rule`, по которому создаётся тема +@msg `colors` does not apply to the {name} reduction `colors` не применяется к редукции {name} +@msg `colors` is empty `colors` пусто +@msg `colors` must be a whole number from 2 to 256 `colors` должно быть целым числом от 2 до 256 +@msg `colors` must be at least 9 for the uniform reduction `colors` должно быть не меньше 9 для редукции uniform +@msg `colors` must be between 1 and {MAX_THEME_COLORS} (got {n}) `colors` должно быть от 1 до {MAX_THEME_COLORS} (получено {n}) +@msg `columnType` is vertical, uniform, repeating or sliding `columnType` принимает vertical, uniform, repeating или sliding +@msg `columns` must be a whole number 1..50 `columns` должно быть целым числом 1..50 +@msg `commands` must not be empty `commands` не должно быть пустым +@msg `contents` goes with `to` `contents` указывается вместе с `to` +@msg `corners` must be sharp, round or square, not `{other}` `corners` должно быть sharp, round или square, а не `{other}` +@msg `corners` must list anchor indices (0, 1, 2…), not {v} `corners` должно содержать индексы опорных точек (0, 1, 2…), а не {v} +@msg `count` must be a whole number from 0 to {MAX_COUNT} `count` должно быть целым числом от 0 до {MAX_COUNT} +@msg `count` must be a whole number from 1 to 100 `count` должно быть целым числом от 1 до 100 +@msg `dataBase64` is larger than the module limit `dataBase64` больше ограничения для модуля +@msg `dataBase64` is not valid base64 `dataBase64` не является допустимым base64 +@msg `defaultsOnly` cannot be combined with `ids`, `reverse` or `append` `defaultsOnly` нельзя сочетать с `ids`, `reverse` или `append` +@msg `depth` and `childLimit` slice the summary: pass `summary: true` `depth` и `childLimit` ограничивают сводку: передайте `summary: true` +@msg `design` is a design name, or null for plain columns `design` — имя дизайна или null для обычных столбцов +@msg `design` is a design name, or null for the default marker `design` — имя дизайна или null для маркера по умолчанию +@msg `destinationFolder` is required when destination is `folder` `destinationFolder` обязательно, когда destination равно `folder` +@msg `destination` must be `none`, `saveAndClose` or `folder` `destination` должно быть `none`, `saveAndClose` или `folder` +@msg `diameter` must be from 0.1 to 2500 pt `diameter` должно быть от 0.1 до 2500 pt +@msg `digits` must be one of {_1} `digits` должно быть одним из: {_1} +@msg `dimImages` is a percentage or false `dimImages` — процент или false +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` должно быть "auto", "leftToRight" или "rightToLeft" +@msg `ditherAmount` must be a number from 0 to 100 `ditherAmount` должно быть числом от 0 до 100 +@msg `each` is the value one design stands for, more than 0 `each` — значение, которое обозначает один дизайн, больше 0 +@msg `end` must be "start" or "end" `end` должно быть "start" или "end" +@msg `ends` applies to joining exactly two paths `ends` применяется при соединении ровно двух контуров +@msg `event` must be one of {_1} `event` должно быть одним из: {_1} +@msg `every` must be "all", "odd" or "even", not {v} `every` должно быть "all", "odd" или "even", а не {v} +@msg `expand` must be true or false `expand` должно быть true или false +@msg `fidelity` must be from {lo} to {hi} pt `fidelity` должно быть от {lo} до {hi} pt +@msg `files` must be 1 to 1000 paths of font files the last text.findFontFiles search found `files` должно содержать от 1 до 1000 путей к файлам шрифтов, которые нашёл последний поиск text.findFontFiles +@msg `focal` and `radius` cannot be used together `focal` и `radius` нельзя использовать вместе +@msg `focal` must be an array of [subpath, anchor] pairs `focal` должно быть массивом пар [subpath, anchor] +@msg `focal` names [{si}, {ai}], which is not an anchor of the path `focal` указывает [{si}, {ai}], а это не опорная точка контура +@msg `folder` must be an absolute folder path `folder` должно быть абсолютным путём к папке +@msg `fontAxes` must be an object of axis tags and values, such as {"wght"} `fontAxes` должно быть объектом из тегов осей и значений, например {"wght"} +@msg `format` must be a string `format` должно быть строкой +@msg `format` must be one of {_1} `format` должно быть одним из: {_1} +@msg `fractions` is chop or scale `fractions` принимает chop или scale +@msg `from` and `to` must differ `from` и `to` должны различаться +@msg `from` has an empty frame у `from` пустая рамка +@msg `from` must be a non-negative integer `from` должно быть неотрицательным целым числом +@msg `from` must be area type or type on a path `from` должно быть текстом в области или текстом по контуру +@msg `gid` must be a glyph id, not {v} `gid` должно быть идентификатором глифа, а не {v} +@msg `glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it) `glyphAlternate` относится к одному символу, а не к стилю символов (его задаёт text.setGlyphAlternate) +@msg `glyphAlternate` takes only null, which takes a glyph choice away (text.setGlyphAlternate makes one) `glyphAlternate` принимает только null, что отменяет выбор глифа (выбор делает text.setGlyphAlternate) +@msg `group` must be one of {_1} `group` должно быть одним из: {_1} +@msg `group` must be the Objects on Path group of `member` and `target` `group` должно быть группой «Объекты на контуре», в которую входят `member` и `target` +@msg `guide` must be a guide index (guide.list) `guide` должно быть индексом направляющей (guide.list) +@msg `hAlign` must be default, left, center or right `hAlign` должно быть default, left, center или right +@msg `id` is an object id, not {v} `id` — идентификатор объекта, а не {v} +@msg `id` must be a node id `id` должно быть идентификатором узла +@msg `id` must be an object id, not {v} `id` должно быть идентификатором объекта, а не {v} +@msg `ids` must name Objects on Path groups or objects in them `ids` должно указывать группы «Объекты на контуре» или объекты в них +@msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` принимает не более {MAX_INSPECT_IDS} идентификаторов +@msg `imageMap` must be none, rectangle or polygon `imageMap` должно быть none, rectangle или polygon +@msg `includeArtboardFill` must be true or false `includeArtboardFill` должно быть true или false +@msg `includeSubdirs` must be true or false `includeSubdirs` должно быть true или false +@msg `index` must be a point index `index` должно быть индексом точки +@msg `index` must be a whole number or null `index` должно быть целым числом или null +@msg `indices` must be a non-empty array of item indices `indices` должно быть непустым массивом индексов элементов +@msg `item` needs `index` для `item` нужно `index` +@msg `items` is a list of {kind, item} `items` — список {kind, item} +@msg `items` is empty `items` пусто +@msg `items` must be a list of {id, points} `items` должно быть списком {id, points} +@msg `items` must be a list of {{id, anchors?}}, not {v} `items` должно быть списком {{id, anchors?}}, а не {v} +@msg `items` must be an array of {id, anchors} `items` должно быть массивом {id, anchors} +@msg `items` must be an array of {{id, corners?}}, not {v} `items` должно быть массивом {{id, corners?}}, а не {v} +@msg `kashida` must be "none", "short", "medium", "long" or "stylistic" `kashida` должно быть "none", "short", "medium", "long" или "stylistic" +@msg `kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it) `kerningBefore` относится к первому символу строки, а не к стилю символов (его задаёт text.setRangeStyle) +@msg `kind` is one of {_1} `kind` — одно из: {_1} +@msg `kind` must be "round", "invertedRound" or "chamfer", not {v} `kind` должно быть "round", "invertedRound" или "chamfer", а не {v} +@msg `kind` must be `text`, `visibility`, `image` or `graph` `kind` должно быть `text`, `visibility`, `image` или `graph` +@msg `kind` must be a string `kind` должно быть строкой +@msg `kind` must be image, noImage or htmlText `kind` должно быть image, noImage или htmlText +@msg `kinsokuType` must be "pushInFirst", "pushOutFirst" or "pushOutOnly" `kinsokuType` должно быть "pushInFirst", "pushOutFirst" или "pushOutOnly" +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` должно быть "none", "hard" или "soft" +@msg `label` must be a non-empty name of at most 200 characters `label` должно быть непустым именем длиной не более 200 символов +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` должно быть названием или тегом языка, например "Turkish" или "tr" +@msg `leader` must be a string `leader` должно быть строкой +@msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` должно быть "romanBaseline" или "emBoxTop" +@msg `leading` must be from 0.1 to 5000, or null for Auto `leading` должно быть от 0.1 до 5000 или null для значения «Авто» +@msg `length` must be a number of at most 1000000 pt either way `length` должно быть числом не больше 1000000 pt в любую сторону +@msg `line` must be true or false `line` должно быть true или false +@msg `max` must be 1 to {MAX_SUGGESTIONS} `max` должно быть от 1 до {MAX_SUGGESTIONS} +@msg `minAngle` ({min_angle}°) can't be more than `maxAngle` ({max_angle}°) `minAngle` ({min_angle}°) не может быть больше `maxAngle` ({max_angle}°) +@msg `mode` must be "handles", "straight" or "smooth" `mode` должно быть "handles", "straight" или "smooth" +@msg `mode` must be "straight", "bezier" or "arc" `mode` должно быть "straight", "bezier" или "arc" +@msg `mode` must be one of {_1} `mode` должно быть одним из: {_1} +@msg `mojikumi` must be "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" or "lineEndHalf" `mojikumi` должно быть "none", "yakumonoHankaku", "gyomatsuYakumonoHankaku", "gyomatsuYakumonoZenkaku", "yakumonoZenkaku" или "lineEndHalf" +@msg `name` must be a name that is not empty `name` должно быть непустым именем +@msg `name` must not be empty `name` не должно быть пустым +@msg `names` is a list of preset names `names` — список имён наборов +@msg `names` lists preset names `names` перечисляет имена наборов +@msg `names` must be a list of names `names` должно быть списком имён +@msg `names` must be a list of swatch names `names` должно быть списком имён образцов +@msg `names` must be an array of style names `names` должно быть массивом имён стилей +@msg `new` is a design name, or true `new` — имя дизайна или true +@msg `origin` must be [x, y] in points `origin` должно быть [x, y] в пунктах +@msg `path` must be a folder `path` должно быть папкой +@msg `path` must be a path object `path` должно быть объектом-контуром +@msg `picks` must be an array (got {v}) `picks` должно быть массивом (получено {v}) +@msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` должно содержать от 1 до {MAX_PICKS} выборов (получено {_1}) +@msg `pivot` must be a whole number from 0 to 8 `pivot` должно быть целым числом от 0 до 8 +@msg `points` must be a list of [x, y] points `points` должно быть списком точек [x, y] +@msg `points` must be a list of point indexes `points` должно быть списком индексов точек +@msg `points` must be an array of point indices `points` должно быть массивом индексов точек +@msg `points` must be point indexes `points` должно содержать индексы точек +@msg `points` must hold from 2 to 100000 points `points` должно содержать от 2 до 100000 точек +@msg `port` must be "in" or "out" `port` должно быть "in" или "out" +@msg `position` is above or below `position` принимает above или below +@msg `preset` must be a string `preset` должно быть строкой +@msg `radius` must be 0 or more `radius` должно быть 0 или больше +@msg `ratio` must be from 0.1 to 0.5 `ratio` должно быть от 0.1 до 0.5 +@msg `rect` must be [x, y, width, height] `rect` должно быть [x, y, width, height] +@msg `rect` must be [x, y, width, height] with a positive size `rect` должно быть [x, y, width, height] с положительным размером +@msg `rect` must be [x0, y0, x1, y1] (got {r}) `rect` должно быть [x0, y0, x1, y1] (получено {r}) +@msg `relation` must be overlap, inside or outside `relation` должно быть overlap, inside или outside +@msg `replace` must be a string `replace` должно быть строкой +@msg `reversed` must be true or false `reversed` должно быть true или false +@msg `rotate` must be a finite number `rotate` должно быть конечным числом +@msg `rows` must be lists of numbers or nulls, not {r} `rows` должно состоять из списков чисел или значений null, а не {r} +@msg `rule` must be nonZero or evenOdd `rule` должно быть nonZero или evenOdd +@msg `scale` must be a number from 0.001 to 1000 `scale` должно быть числом от 0.001 до 1000 +@msg `scale` must be a number in (0, 100] `scale` должно быть числом в диапазоне (0, 100] +@msg `scope.{key}` must be a non-empty array `scope.{key}` должно быть непустым массивом +@msg `scope` must be "document", {artboards} or {layers} `scope` должно быть "document", {artboards} или {layers} +@msg `scope` must be "selection" or "document", not {other} `scope` должно быть "selection" или "document", а не {other} +@msg `script` must be a string `script` должно быть строкой +@msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` — непустой список индексов рядов +@msg `seriesIndexes` needs a `type` or a `valueAxis` для `seriesIndexes` нужно `type` или `valueAxis` +@msg `shift` must be a number of points `shift` должно быть числом в пунктах +@msg `showCenter` must be true or false `showCenter` должно быть true или false +@msg `side` must be "left", "right", "top" or "bottom" `side` должно быть "left", "right", "top" или "bottom" +@msg `side` places the artboard: leave out x, y, width and height `side` определяет положение монтажной области: не указывайте x, y, width и height +@msg `singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" `singleWordJustification` должно быть "fullJustify", "alignLeft", "alignCenter" или "alignRight" +@msg `size` is a percentage of the base text's size, 1 to 100 `size` — процент от размера основного текста, от 1 до 100 +@msg `source` and `target` are the same object `source` и `target` — один и тот же объект +@msg `source` is the only path in series group `target` `source` — единственный контур в группе серии `target` +@msg `source` must be `folder` or `datasets` `source` должно быть `folder` или `datasets` +@msg `spacing` makes more than {MAX_COUNT} lines across the box `spacing` даёт больше {MAX_COUNT} линий поперёк рамки +@msg `spacing` must be a number `spacing` должно быть числом +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` должно быть числом в пунктах, 0 или больше, либо "auto" +@msg `spacing` must be more than 0 and at most {MAX_DISTANCE} pt `spacing` должно быть больше 0 и не больше {MAX_DISTANCE} pt +@msg `start` must be a finite number `start` должно быть конечным числом +@msg `starts` must be an array of anchor indices (or nulls) `starts` должно быть массивом индексов опорных точек (или значений null) +@msg `status` must be linked, embedded, missing or modified, or a list of them `status` должно быть linked, embedded, missing или modified либо их списком +@msg `stop` must be a stop index `stop` должно быть индексом точки перехода +@msg `story` styles the whole story: give no `start` or `end` `story` задаёт стиль всего материала: не указывайте `start` и `end` +@msg `story` takes the whole story: give no `start` or `end` `story` охватывает весь материал: не указывайте `start` и `end` +@msg `strokeMode` applies to strokes (stroke: true) `strokeMode` применяется к обводкам (stroke: true) +@msg `style` must be roman or japanese `style` должно быть roman или japanese +@msg `subpaths` must be two integer indices `subpaths` должно быть двумя целочисленными индексами +@msg `symbol` must be a symbol name `symbol` должно быть именем символа +@msg `target` must be "object" or "contents", not {v} `target` должно быть "object" или "contents", а не {v} +@msg `target` must be an object of the same Objects on Path group as `member` `target` должно быть объектом той же группы «Объекты на контуре», что и `member` +@msg `tint` applies to global and spot colours `tint` применяется к глобальным и плашечным цветам +@msg `tint` goes with a global or spot `swatch` `tint` указывается вместе с глобальным или плашечным `swatch` +@msg `tint` must be a number (%) `tint` должно быть числом (%) +@msg `tint` needs a global or spot colour для `tint` нужен глобальный или плашечный цвет +@msg `to` is already threaded `to` уже входит в связанный текст +@msg `to` is the design's new name `to` — новое имя дизайна +@msg `to` must be [x, y] `to` должно быть [x, y] +@msg `to` must be a closed path, area type or type on a path other than `from` `to` должно быть замкнутым контуром, текстом в области или текстом по контуру, отличным от `from` +@msg `to` must be a whole number `to` должно быть целым числом +@msg `to` takes the rows of one object's stack, not aligned rows `to` принимает строки стопки одного объекта, а не выровненные строки +@msg `tolerance` must be from 0 to 100 pt `tolerance` должно быть от 0 до 100 pt +@msg `useLab` must be true or false `useLab` должно быть true или false +@msg `vAlign` must be default, top, middle, baseline or bottom `vAlign` должно быть default, top, middle, baseline или bottom +@msg `values` must be a list `values` должно быть списком +@msg `values` must be an object `values` должно быть объектом +@msg `values` must name hex, rgb, cmyk, lab or gray `values` должно указывать hex, rgb, cmyk, lab или gray +@msg `values` names a kind twice `values` указывает один вид дважды +@msg `version` must be 0 or {_1} `version` должно быть 0 или {_1} +@msg `version` must be 0 or {newest} `version` должно быть 0 или {newest} +@msg `warichuAlign` must be one of {_1} `warichuAlign` должно быть одним из: {_1} +@msg `warichuLines` must be a whole number from {_1} to {_2} `warichuLines` должно быть целым числом от {_1} до {_2} +@msg `weight` must be from 0.01 to 1000 pt `weight` должно быть от 0.01 до 1000 pt +@msg `weights` must be a list of {_1} numbers, one per row (got {v}) `weights` должно быть списком чисел по одному на строку, всего чисел: {_1} (получено {v}) +@msg `xml` must be a string `xml` должно быть строкой +@msg `{KEY}` entry {n} gives the paragraph at byte {start} of {_1} other indents than an entry before it `{KEY}`, запись {n}: задаёт абзацу с байта {start} объекта {_1} другие отступы, чем предыдущая запись +@msg `{KEY}` entry {n} must be an object `{KEY}`, запись {n}: должна быть объектом +@msg `{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent` `{KEY}`, запись {n}: не задаёт ни `leftIndent`, ни `firstLineIndent` +@msg `{KEY}` entry {n}: `id` must be an object id `{KEY}`, запись {n}: `id` должно быть идентификатором объекта +@msg `{KEY}` entry {n}: `start` must be a byte offset `{KEY}`, запись {n}: `start` должно быть смещением в байтах +@msg `{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt `{KEY}`, запись {n}: `{k}` должно быть числом от -{MAX_PARA} до {MAX_PARA} pt +@msg `{KEY}` entry {n}: no paragraph of {_1} starts at byte {start} `{KEY}`, запись {n}: ни один абзац объекта {_1} не начинается с байта {start} +@msg `{KEY}` entry {n}: unknown key `{k}` `{KEY}`, запись {n}: неизвестный ключ `{k}` +@msg `{KEY}` entry {n}: {_1} is not a type object `{KEY}`, запись {n}: {_1} не является текстовым объектом +@msg `{KEY}` is empty `{KEY}` пусто +@msg `{KEY}` must be a list `{KEY}` должно быть списком +@msg `{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {_1}) `{KEY}` принимает не более {MAX_PARAGRAPH_INDENTS} записей (получено {_1}) +@msg `{KEY}` takes no `{other}` beside it `{KEY}` не допускает `{other}` рядом с собой +@msg `{_1}` can't be edited or removed `{_1}` нельзя изменить или удалить +@msg `{_1}` is built in and always listed `{_1}` встроен и всегда есть в списке +@msg `{_1}` is not a font file the last text.findFontFiles search found `{_1}` не является файлом шрифта, который нашёл последний поиск text.findFontFiles +@msg `{_1}` would be overwritten by the package: choose a different folder or name пакет перезаписал бы `{_1}`: выберите другую папку или имя +@msg `{base}` is larger than {_1} MB `{base}` больше {_1} МБ +@msg `{base}` is not in the DCS file's folder `{base}` нет в папке файла DCS +@msg `{file}` has no art to place в `{file}` нет графики для помещения +@msg `{file}` is over {_1} MB `{file}` больше {_1} МБ +@msg `{f}` belongs to a VectorCraft that is running `{f}` принадлежит запущенному экземпляру VectorCraft +@msg `{f}` is the copy of a document open now `{f}` — копия документа, открытого сейчас +@msg `{id}` is no writable format (see document.formats) `{id}` не является форматом для записи (см. document.formats) +@msg `{id}` isn't a loaded library `{id}` не является загруженной библиотекой +@msg `{key}` has no colours в `{key}` нет цветов +@msg `{key}` holds more than {MAX_EDIT_WORDS} words `{key}` содержит больше {MAX_EDIT_WORDS} слов +@msg `{key}` is a design name `{key}` — имя дизайна +@msg `{key}` is a design name or a list of them `{key}` — имя дизайна или список таких имён +@msg `{key}` is longer than {MAX_NAME} characters `{key}` длиннее {MAX_NAME} символов +@msg `{key}` is out of range `{key}` вне диапазона +@msg `{key}` must be 0 or more `{key}` должно быть 0 или больше +@msg `{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent `{key}` должно быть [min, desired, max] или {{min?, desired?, max?}}, в процентах +@msg `{key}` must be [x, y] numbers `{key}` должно быть числами [x, y] +@msg `{key}` must be a list of words `{key}` должно быть списком слов +@msg `{key}` must be a list, not {v} `{key}` должно быть списком, а не {v} +@msg `{key}` must be a non-negative integer `{key}` должно быть неотрицательным целым числом +@msg `{key}` must be a number or null, not {v} `{key}` должно быть числом или null, а не {v} +@msg `{key}` must be a number {_1}..{_2} `{key}` должно быть числом {_1}..{_2} +@msg `{key}` must be a whole number of 0 or more `{key}` должно быть целым числом, 0 или больше +@msg `{key}` must be a whole number of at least 0, not {n} `{key}` должно быть целым числом не меньше 0, а не {n} +@msg `{key}` must be an appearance item index (paint order) or null `{key}` должно быть индексом элемента оформления (в порядке отрисовки) или null +@msg `{key}` must be an array of asset ids (see assets.list) `{key}` должно быть массивом идентификаторов ресурсов (см. assets.list) +@msg `{key}` must be from 0 to 180 degrees, not {a} `{key}` должно быть от 0 до 180 градусов, а не {a} +@msg `{key}` must be from 0 to {MAX_DISTANCE} pt `{key}` должно быть от 0 до {MAX_DISTANCE} pt +@msg `{key}` must be from 0.01 to 1000 pt `{key}` должно быть от 0.01 до 1000 pt +@msg `{key}` must be from {_1} to {shift_max} `{key}` должно быть от {_1} до {shift_max} +@msg `{key}` must be from {lo} to {hi}, not {v} `{key}` должно быть от {lo} до {hi}, а не {v} +@msg `{key}` must be one of auto, always, never `{key}` должно быть одним из: auto, always, never +@msg `{key}` must be one of miter, round, bevel `{key}` должно быть одним из: miter, round, bevel +@msg `{key}` must be one word of at most {_1} characters `{key}` должно быть одним словом длиной не более {_1} символов +@msg `{key}` must hold single words of at most {_1} characters `{key}` должно содержать отдельные слова длиной не более {_1} символов +@msg `{key}` must name an object attached to the path of an Objects on Path group `{key}` должно указывать объект, прикреплённый к контуру группы «Объекты на контуре» +@msg `{key}` names an object that is hidden or locked `{key}` указывает скрытый или заблокированный объект +@msg `{key}`: not a colour: {v} `{key}`: не цвет: {v} +@msg `{k}.family` is required `{k}.family` обязательно +@msg `{k}` is a number 0..1 `{k}` — число 0..1 +@msg `{k}` is true or false `{k}` принимает true или false +@msg `{k}` isn't an export setting: {_1} `{k}` не является параметром экспорта: {_1} +@msg `{k}` must be a byte offset `{k}` должно быть смещением в байтах +@msg `{k}` must be a number `{k}` должно быть числом +@msg `{k}` must be a number of points `{k}` должно быть числом в пунктах +@msg `{k}` must be a string `{k}` должно быть строкой +@msg `{k}` must be a whole number `{k}` должно быть целым числом +@msg `{k}` must be from {lo} to {hi} for {key} `{k}` должно быть от {lo} до {hi} для {key} +@msg `{k}` must be {what}, not {v} `{k}` должно быть {what}, а не {v} +@msg `{l}` isn't a global or spot colour `{l}` не является глобальным или плашечным цветом +@msg `{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText `{m}` не является растровым изображением: используйте clipboard.importSvg, importPdf или importText +@msg `{name}` can't be deleted `{name}` нельзя удалить +@msg `{name}` can't be duplicated `{name}` нельзя дублировать +@msg `{name}` can't be edited `{name}` нельзя изменить +@msg `{name}` can't be edited this way `{name}` нельзя изменить таким способом +@msg `{name}` can't be renamed `{name}` нельзя переименовать +@msg `{name}` can't name a folder `{name}` не может быть именем папки +@msg `{name}` has no art to place в `{name}` нет графики для помещения +@msg `{name}` has no text to place в `{name}` нет текста для помещения +@msg `{name}` in the Word document is not UTF-8 or UTF-16 text `{name}` в документе Word не является текстом UTF-8 или UTF-16 +@msg `{name}` in the Word document is not well-formed XML: {e} `{name}` в документе Word не является корректным XML: {e} +@msg `{name}` in the Word document nests elements more than {MAX_NESTING} deep в `{name}` в документе Word элементы вложены глубже {MAX_NESTING} уровней +@msg `{name}` is a built-in preset and is read-only: save it under another name `{name}` — встроенный набор, доступный только для чтения: сохраните его под другим именем +@msg `{name}` is a built-in preset: save under another name `{name}` — встроенный набор: сохраните под другим именем +@msg `{name}` is a built-in profile and stays `{name}` — встроенный профиль, он не удаляется +@msg `{name}` is a file of its own, which can only be found next to the DCS file's path `{name}` — отдельный файл, который можно найти только рядом с файлом DCS +@msg `{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) `{name}` — оттенок `{base}`: изменяйте `{base}` (образцу оттенка можно задать только новое имя) +@msg `{name}` is a working space: choose another with edit.colorSettings first `{name}` — рабочее пространство: сначала выберите другое через edit.colorSettings +@msg `{name}` is built in and stays `{name}` встроен и не удаляется +@msg `{name}` takes a paint of its own kind (colour, gradient or pattern) `{name}` принимает краску только своего вида (цвет, градиент или узор) +@msg `{name}`: {_1}: {e} `{name}`: {_1}: {e} +@msg `{n}` can't be deleted `{n}` нельзя удалить +@msg `{n}` can't be merged `{n}` нельзя объединить +@msg `{n}` can't be moved `{n}` нельзя переместить +@msg `{n}` is not a colour swatch `{n}` не является цветовым образцом +@msg `{n}` isn't a solid colour `{n}` не является сплошным цветом +@msg `{n}`: colour groups hold solid colours only `{n}`: цветовые группы содержат только сплошные цвета +@msg `{other}` doesn't trace an image `{other}` не трассирует изображение +@msg `{s}`: the step's references copy in more than {_1} MB of values `{s}`: ссылки шага копируют больше {_1} МБ значений +@msg `{tag}` is not an axis tag: a tag is four characters, such as "wght" `{tag}` не является тегом оси: тег состоит из четырёх символов, например "wght" +@msg `{to}` is empty or already used `{to}` пусто или уже используется +@msg `{u}` is no Unicode character `{u}` не является символом Unicode +@msg `{what}` is longer than {MAX_TEXT} bytes `{what}` длиннее {MAX_TEXT} байт +@msg `{what}` must be text `{what}` должно быть текстом +@msg `{word}` isn't in the custom dictionary `{word}` нет в пользовательском словаре +# Messages that begin with a lowercase word, a to c. +@msg a CMap has too many mappings в CMap слишком много сопоставлений +@msg a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard у чертежа DXF одно начало координат: передайте useArtboards: true, чтобы получить по одному чертежу на монтажную область +@msg a PDF presets file with over {_1} presets файл наборов PDF, в котором больше {_1} наборов +@msg a \\u escape needs four hexadecimal digits после \\u нужны четыре шестнадцатеричные цифры +@msg a batch preview cannot switch documents предварительный просмотр пакета не может переключать документы +@msg a brush named `{nn}` exists кисть с именем `{nn}` уже существует +@msg a comma in an XPath expression must separate function arguments запятая в выражении XPath должна разделять аргументы функции +@msg a container of the listed objects is gone контейнер перечисленных объектов больше не существует +@msg a content stream can't be read поток содержимого невозможно прочитать +@msg a content stream isn't a stream поток содержимого не является потоком +@msg a design holds at most {_1} objects дизайн содержит не более {_1} объектов +@msg a design needs a name дизайну нужно имя +@msg a dictionary grew too large словарь стал слишком большим +@msg a document holds at most {_1} SVG filters документ содержит не более {_1} фильтров SVG +@msg a document keeps at most {_1} graph designs документ хранит не более {_1} дизайнов диаграмм +@msg a document keeps at most {_1} saved selections документ хранит не более {_1} сохранённых выделений +@msg a document keeps at most {_1} views документ хранит не более {_1} видов +@msg a document links at most {MAX_SVG_SCRIPTS} JavaScript files документ связывает не более {MAX_SVG_SCRIPTS} файлов JavaScript +@msg a document with unsaved changes has no recovery copy у документа с несохранёнными изменениями нет копии для восстановления +@msg a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes порядок фильтра feConvolveMatrix равен {x}×{y}, что переполняет произведение размеров ядра, которое вычисляет usvg +@msg a font's data is too large данные шрифта слишком велики +@msg a font's subroutines are too large подпрограммы шрифта слишком велики +@msg a function declaration without a name объявление функции без имени +@msg a gradient preset is already called {n} набор градиента с именем {n} уже существует +@msg a graph has at most {_1} series, so it transposes with at most that many categories в диаграмме не более {_1} рядов, поэтому при транспонировании допускается не больше стольких же категорий +@msg a host value has too many entries в значении хоста слишком много записей +@msg a host value is nested too deep значение хоста вложено слишком глубоко +@msg a layer can't be merged into its own sublayer слой нельзя объединить с его собственным подслоем +@msg a layer's form box isn't an array рамка формы слоя не является массивом +@msg a layer's form can't be read форму слоя невозможно прочитать +@msg a layer's form has no box у формы слоя нет рамки +@msg a layer's form has no dictionary у формы слоя нет словаря +@msg a legend draws at most {MAX_CHIPS} swatches легенда рисует не более {MAX_CHIPS} образцов +@msg a name names one asset: give multiple: false, or one object имя обозначает один ресурс: укажите multiple: false или один объект +@msg a namespace declaration uses a reserved prefix or URI объявление пространства имён использует зарезервированный префикс или URI +@msg a namespace qualifier is undefined квалификатор пространства имён не определён +@msg a namespace with an empty URI cannot have a prefix у пространства имён с пустым URI не может быть префикса +@msg a number has no digits в числе нет цифр +@msg a number's exponent has no digits в показателе степени числа нет цифр +@msg a number's fraction has no digits в дробной части числа нет цифр +@msg a page can't be read страницу невозможно прочитать +@msg a page object is used by something else объект страницы используется чем-то ещё +@msg a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) миниатюра страницы должна состоять из пикселей RGB, от 1 до {THUMBNAIL_SIZE} по стороне (получено {w} × {h}) +@msg a pattern layer doesn't end what it starts слой узора не завершает то, что начинает +@msg a pattern layer's colors hold something else цвета слоя узора содержат что-то другое +@msg a pattern named `{n}` exists узор с именем `{n}` уже существует +@msg a plug-in with id {_1} is already installed модуль с идентификатором {_1} уже установлен +@msg a preset named `{n}` exists набор с именем `{n}` уже существует +@msg a print presets file with over {_1} presets файл наборов печати, в котором больше {_1} наборов +@msg a profile is already called {n} профиль с именем {n} уже существует +@msg a property descriptor cannot have both a value or writable and a get or set дескриптор свойства не может одновременно содержать value или writable и get или set +@msg a property descriptor must be an object дескриптор свойства должен быть объектом +@msg a property's {name} must be a function or undefined {name} свойства должно быть функцией или undefined +@msg a row can't go into itself строку нельзя поместить в саму себя +@msg a series goes on the left or the right value axis ряд размещается на левой или правой оси значений +@msg a start must be an anchor index ≥ 0 or null начало должно быть индексом опорной точки ≥ 0 или null +@msg a stream in it would decode to more than 256 MB, or its decode parameters can't be applied поток в нём при декодировании занял бы больше 256 МБ, или его параметры декодирования невозможно применить +@msg a string holds a control character строка содержит управляющий символ +@msg a string holds an escape sequence JSON does not have строка содержит escape-последовательность, которой нет в JSON +@msg a string is not closed строка не закрыта +@msg a style named `{name}` already exists стиль с именем `{name}` уже существует +@msg a style named `{to}` already exists стиль с именем `{to}` уже существует +@msg a swatch needs a colour, gradient or pattern образцу нужен цвет, градиент или узор +@msg a symbol named `{n}` already exists символ с именем `{n}` уже существует +@msg a theme has 1 to {THEME_SIZE} colours в теме от 1 до {THEME_SIZE} цветов +@msg a theme is already called {name} (replace: "{name}" overwrites it) тема с именем {name} уже существует (replace: "{name}" перезапишет её) +@msg a toolbar name has at most {MAX_NAME} characters имя панели инструментов содержит не более {MAX_NAME} символов +@msg a view named `{_1}` exists вид с именем `{_1}` уже существует +@msg a view named `{name}` exists вид с именем `{name}` уже существует +@msg a workspace keeps at most {MAX} toolbars рабочая среда хранит не более {MAX} панелей инструментов +@msg a {_1} series can't go in a {_2} graph ряд типа {_1} нельзя поместить в диаграмму типа {_2} +@msg aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) aiVersion {v}: "pdf" (совместимый с PDF, по умолчанию) или "7" (устаревший .ai, версия 7) +@msg already editing an opacity mask непрозрачная маска уже редактируется +@msg already editing another object's opacity mask уже редактируется непрозрачная маска другого объекта +@msg already in pattern editing mode режим редактирования узора уже включён +@msg an SVG filter is named by its id: give `xml` with `name` фильтр SVG называется по своему id: укажите `xml` вместе с `name` +@msg an XML element has two attributes with the same name у элемента XML два атрибута с одинаковым именем +@msg an XML value cannot be inserted into itself or its descendants значение XML нельзя вставить в само себя или в его потомков +@msg an XML value's children cannot be assigned by position дочерние элементы значения XML нельзя присваивать по позиции +@msg an XML value's children cannot be deleted by position дочерние элементы значения XML нельзя удалять по позиции +@msg an XMLList converts to XML only when it has one item XMLList преобразуется в XML, только когда содержит один элемент +@msg an XPath node test is missing отсутствует проверка узла XPath +@msg an XPath operand is missing отсутствует операнд XPath +@msg an XPath operator is missing отсутствует оператор XPath +@msg an XPath predicate or step cannot follow here предикат или шаг XPath здесь недопустим +@msg an XPath predicate or step follows a value that is not a node-set предикат или шаг XPath следует за значением, которое не является набором узлов +@msg an XPath step is missing отсутствует шаг XPath +@msg an artboard is listed twice монтажная область указана дважды +@msg an empty artboard has no width or height to scale to у пустой монтажной области нет ширины или высоты для масштабирования +@msg an image couldn't be decoded and is missing from the picture изображение не удалось декодировать, и его нет на рисунке +@msg an image is too large for DXF and was left out изображение слишком велико для DXF и пропущено +@msg an image of {_1} MB, at most {_2} MB изображение размером {_1} МБ при допустимых {_2} МБ +@msg an image of {declared_w} × {declared_h} pixels is too large to draw изображение размером {declared_w} × {declared_h} пикселей слишком велико для отрисовки +@msg an image of {width} × {height} pixels is too large to draw изображение размером {width} × {height} пикселей слишком велико для отрисовки +@msg an image of {w} × {h} pixels is too large to convert (at most {_1} pixels) изображение размером {w} × {h} пикселей слишком велико для преобразования (не более {_1} пикселей) +@msg an index must be a whole number индекс должен быть целым числом +@msg an inline graphic has no glyphs у встроенной в строку графики нет глифов +@msg an invisible text form can't be read форму невидимого текста невозможно прочитать +@msg an object is missing объект отсутствует +@msg an object's header is missing отсутствует заголовок объекта +@msg an overprinting form can't be read форму с наложением невозможно прочитать +@msg an unread object has no readable bounds у непрочитанного объекта нет читаемых границ +@msg an unread object holds a frame of threaded type непрочитанный объект содержит рамку связанного текста +@msg an unread object is missing from its layer непрочитанный объект отсутствует в своём слое +@msg anchor `smooth` must be true or false `smooth` опорной точки должно быть true или false +@msg anchor `{key}` must be [x, y] `{key}` опорной точки должно быть [x, y] +@msg anchor `{key}` must be a number `{key}` опорной точки должно быть числом +@msg angle and roundness must be finite angle и roundness должны быть конечными +@msg angles must be a list as long as pins (degrees or null) angles должно быть списком той же длины, что и pins (градусы или null) +@msg appearance must be flat|center|edge appearance должно быть flat|center|edge +@msg apply is "fill" or "stroke", not `{other}` apply принимает "fill" или "stroke", а не `{other}` +@msg apply was called on a value that is not a function apply вызван для значения, которое не является функцией +@msg area must be a closed polygon of 3 to 100000 [x, y] points area должно быть замкнутым многоугольником из 3–100000 точек [x, y] +@msg area type in a thread cannot use Dynamic Text: {frames} текст в области в связанном тексте не может использовать динамический текст: {frames} +@msg area type needs a path that encloses an area тексту в области нужен контур, охватывающий область +@msg area type {_1} is named twice текст в области {_1} указан дважды +@msg array literal too long литерал массива слишком длинный +@msg arrowAlign must be extend|tip, got {o} arrowAlign должно быть extend|tip, получено {o} +@msg arrowScale must be [start, end] arrowScale должно быть [start, end] +@msg artboard is a 0-based index artboard — индекс, отсчитываемый от 0 +@msg artboard must be a 0-based index artboard должно быть индексом, отсчитываемым от 0 +@msg artboard {i} doesn't print монтажная область {i} не печатается +@msg artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} artboardLayout должно быть {layout?, columns?, spacing?, rightToLeft?} +@msg artboardLayout.columns must be 1 or more artboardLayout.columns должно быть 1 или больше +@msg artboardLayout.layout must be gridByRow, gridByColumn, row or column artboardLayout.layout должно быть gridByRow, gridByColumn, row или column +@msg artboardLayout.rightToLeft must be true or false artboardLayout.rightToLeft должно быть true или false +@msg artboardLayout.spacing must be 0–{MAX_SPACING} pt artboardLayout.spacing должно быть 0–{MAX_SPACING} pt +@msg artboards must be a non-empty list artboards должно быть непустым списком +@msg artboards must be a number artboards должно быть числом +@msg asset `{_1}` has no art to export в ресурсе `{_1}` нет графики для экспорта +@msg assignment to a constant binding присваивание константной привязке +@msg at least one row that isn't excluded needs a weight above 0 хотя бы одной неисключённой строке нужен вес больше 0 +@msg at most {MAX_KEYWORDS} keywords не более {MAX_KEYWORDS} ключевых слов +@msg at most {MAX_MATCHES} matches are kept сохраняется не более {MAX_MATCHES} совпадений +@msg at most {MAX_POINTS} points не более {MAX_POINTS} точек +@msg at most {MAX_QUEUE} files не более {MAX_QUEUE} файлов +@msg at must be [x, y] at должно быть [x, y] +@msg at/rect lie outside the canvas at/rect находятся за пределами холста +@msg attribute must be size|leading|tracking|kerning|baselineShift attribute должно быть size|leading|tracking|kerning|baselineShift +@msg attrs must be an object attrs должно быть объектом +@msg automation path rejected: the folder's name isn't valid text путь автоматизации отклонён: имя папки не является допустимым текстом +@msg automation path rejected: {_1} isn't valid text путь автоматизации отклонён: {_1} не является допустимым текстом +@msg automation path rejected: {why}: {path} путь автоматизации отклонён: {why}: {path} +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` axis должно быть "vertical", "horizontal" или углом в градусах, а не `{given}` +@msg backgroundContents must be transparent or white backgroundContents должно быть transparent или white +@msg bad AES key недопустимый ключ AES +@msg bad `runs`: {e} недопустимое `runs`: {e} +@msg bad arrowScale недопустимое arrowScale +@msg bad base64 недопустимый base64 +@msg bad color {c} недопустимый цвет {c} +@msg bad colour недопустимый цвет +@msg bad colour in `colors` недопустимый цвет в `colors` +@msg bad colour {v} недопустимый цвет {v} +@msg bad colour {v} in groupColors недопустимый цвет {v} в groupColors +@msg bad cross-reference entry недопустимая запись перекрёстных ссылок +@msg bad cross-reference table недопустимая таблица перекрёстных ссылок +@msg bad current colour {v} недопустимый текущий цвет {v} +@msg bad dataBase64 недопустимое dataBase64 +@msg bad edit недопустимая правка +@msg bad fill {v} недопустимая заливка {v} +@msg bad fit: {e} недопустимое fit: {e} +@msg bad model {other} (keep, new or document) недопустимая модель {other} (keep, new или document) +@msg bad new colour {v} недопустимый новый цвет {v} +@msg bad object недопустимый объект +@msg bad object number недопустимый номер объекта +@msg bad offset недопустимое смещение +@msg bad path data: {e} недопустимые данные контура: {e} +@msg bad point {v}: expected [x, y] недопустимая точка {v}: ожидается [x, y] +@msg bad point {v}: expected [x, y] or [x, y, pressure] недопустимая точка {v}: ожидается [x, y] или [x, y, pressure] +@msg bad reference недопустимая ссылка +@msg bad trailer size недопустимый размер в трейлере +@msg bad {k} colour недопустимый цвет {k} +@msg batches cannot nest пакеты нельзя вкладывать друг в друга +@msg bind something to a variable first сначала привяжите что-нибудь к переменной +@msg bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} bleed должно быть 0–{MAX_BLEED} pt: число, [top, bottom, left, right] или {{top, bottom, left, right}} +@msg blend must be a mode name blend должно быть названием режима +@msg both sides of an XPath | must select nodes обе стороны оператора | в XPath должны выбирать узлы +@msg brush must be a name or null brush должно быть именем или null +@msg by must be a number by должно быть числом +@msg can't add page thumbnails: {why} невозможно добавить миниатюры страниц: {why} +@msg can't add the editing data for other apps: {why} невозможно добавить данные редактирования для других приложений: {why} +@msg can't edit the written PDF: {why} невозможно изменить записанный PDF: {why} +@msg can't encrypt the PDF: {why} невозможно зашифровать PDF: {why} +@msg can't linearise the PDF: {why} невозможно линеаризовать PDF: {why} +@msg can't open `{_1}`: {_2} невозможно открыть `{_1}`: {_2} +@msg can't open `{_1}`: {e} невозможно открыть `{_1}`: {e} +@msg can't open `{name}`: not a format VectorCraft reads (see document.formats) невозможно открыть `{name}`: VectorCraft не читает этот формат (см. document.formats) +@msg can't place `{name}`: not a format VectorCraft reads (see document.formats) невозможно поместить `{name}`: VectorCraft не читает этот формат (см. document.formats) +@msg can't read back the written PDF: {e} невозможно прочитать записанный PDF: {e} +@msg can't read this Word document: {why} невозможно прочитать этот документ Word: {why} +@msg can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp невозможно записать изображения .{e}: используйте .png, .jpg, .gif, .webp, .tif или .bmp +@msg can't write PDF layers, overprint or invisible text: {why} невозможно записать слои PDF, наложение или невидимый текст: {why} +@msg can't write the file's copyright: {why} невозможно записать сведения об авторских правах файла: {why} +@msg can't write the file's modification date: {why} невозможно записать дату изменения файла: {why} +@msg can't write the pages' user units: {why} невозможно записать пользовательские единицы страниц: {why} +@msg canceled отменено +@msg cannot convert the object to a primitive value объект невозможно преобразовать в примитивное значение +@msg cannot convert undefined or null to an object undefined или null невозможно преобразовать в объект +@msg cannot distort a zero-size or non-finite bounding box невозможно исказить ограничивающую рамку нулевого или неконечного размера +@msg cannot move an object into itself объект нельзя переместить в самого себя +@msg case must be upper|lower|title|sentence case должно быть upper|lower|title|sentence +@msg category `{c}`: one of {_1} категория `{c}`: одно из: {_1} +@msg center must be [x, y] center должно быть [x, y] +@msg character dimensions cannot be represented with the source text transform размеры символов невозможно представить с трансформацией исходного текста +@msg character dimensions cannot be represented with the target text transform размеры символов невозможно представить с трансформацией целевого текста +@msg choose at least one artboard выберите хотя бы одну монтажную область +@msg choose at least one status выберите хотя бы одно состояние +@msg clip must be true or false clip должно быть true или false +@msg clippingMask must be true or false clippingMask должно быть true или false +@msg closed must be a boolean closed должно быть логическим значением +@msg collapsed must be true or false collapsed должно быть true или false +@msg color.profile: no RGB or CMYK profile is called “{profile}” color.profile: нет профиля RGB или CMYK с именем «{profile}» +@msg colorMode must be rgb or cmyk, not `{m}` colorMode должно быть rgb или cmyk, а не `{m}` +@msg colorModel must be {own} (the document's mode), grayscale or bitmap colorModel должно быть {own} (режим документа), grayscale или bitmap +@msg colour groups can't go into colour groups цветовые группы нельзя помещать в цветовые группы +@msg colour, mode and spot apply to solid-colour swatches only colour, mode и spot применяются только к образцам сплошного цвета +@msg column {column}: `{cell}` is a `%` column of graph data files, which are not read столбец {column}: `{cell}` — столбец `%` из файлов данных диаграмм, которые не читаются +@msg column {column}: `{cell}` names `{name}`, which is a {_1} variable столбец {column}: `{cell}` указывает `{name}`, а это переменная типа {_1} +@msg column {column}: `{cell}` names no variable столбец {column}: `{cell}` не указывает ни одной переменной +@msg column {column}: the variable name is longer than {MAX_NAME} characters столбец {column}: имя переменной длиннее {MAX_NAME} символов +@msg column: true floats the whole column and takes no onto, above or below column: true делает плавающим весь столбец и не принимает onto, above или below +@msg command must be a shape.* command command должно быть командой shape.* +@msg commands[{i}]: missing `command` commands[{i}]: отсутствует `command` +@msg composer must be "singleLine" or "everyLine" composer должно быть "singleLine" или "everyLine" +@msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes составные фигуры принимают контуры, составные контуры, группы, составные фигуры, текст, переходы и оболочки +@msg compound {_1} must contain exactly one open path to join составной объект {_1} должен содержать ровно один открытый контур для соединения +@msg connect end must be "start" or "end" end для connect должно быть "start" или "end" +@msg connect needs a path other than the one extended для connect нужен контур, отличный от продлеваемого +@msg connect needs id для connect нужен id +@msg connect path has no open end у контура для connect нет открытого конца +@msg continuation must be an array of path chunks continuation должно быть массивом фрагментов контура +@msg continuation requires points для continuation нужны points +@msg copy needs path: the file to copy into the document's folder для copy нужен path: файл, который копируется в папку документа +@msg copy takes images only: relink placed documents where their files are copy принимает только изображения: восстанавливайте связь помещённых документов там, где находятся их файлы +@msg copying a file isn't available on the web копирование файла недоступно в веб-версии +@msg copyrightStatus must be unknown, copyrighted or publicDomain copyrightStatus должно быть unknown, copyrighted или publicDomain +@msg copyrightUrl must be a URL (no spaces, at most 2048 characters) copyrightUrl должно быть URL-адресом (без пробелов, не более 2048 символов) +@msg corners must be 4 [x, y] points (TL, TR, BR, BL) corners должно быть 4 точками [x, y] (TL, TR, BR, BL) +@msg counterPer `{c}`: item or file counterPer `{c}`: item или file +@msg crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" crop `{_1}`: рамки art, trim, bleed и media есть только у страниц PDF; используйте "crop" или "bounding" +@msg crop rectangle doesn't overlap the image прямоугольник обрезки не пересекается с изображением +@msg cross-reference and object streams aren't supported потоки перекрёстных ссылок и потоки объектов не поддерживаются +# Messages that begin with a lowercase word, d to m. +@msg damaged compressed data: {e} повреждённые сжатые данные: {e} +@msg dash lengths must be numbers, got {v} длины штрихов должны быть числами, получено {v} +@msg dash must be a list of lengths or null, got {v} dash должно быть списком длин или null, получено {v} +@msg data key `{k}`: letters, digits, -, _ and . (not name or vc-…) ключ данных `{k}`: буквы, цифры, -, _ и . (но не name и не vc-…) +@msg data must be an object of key: value, not {v} data должно быть объектом из пар key: value, а не {v} +@msg data set `{name}` holds a value for `{var}`, which the library doesn't declare набор данных `{name}` содержит значение для `{var}`, а библиотека не объявляет эту переменную +@msg data set `{name}` holds two values for `{var}` набор данных `{name}` содержит два значения для `{var}` +@msg data set `{name}` is declared twice набор данных `{name}` объявлен дважды +@msg data takes at most {MAX_DATA} keys at once data принимает не более {MAX_DATA} ключей за раз +@msg dataset `{name}` already exists набор данных `{name}` уже существует +@msg dataset `{to}` already exists набор данных `{to}` уже существует +@msg date `{d}`: YYYY-MM-DD дата `{d}`: YYYY-MM-DD +@msg decoded data is too large декодированные данные слишком велики +@msg define a variable first сначала определите переменную +@msg detect must be true or false detect должно быть true или false +@msg direct-select anchor points of a path выделите прямым выделением опорные точки контура +@msg distance must be > 0 distance должно быть > 0 +@msg dx and dy give the arrow's direction dx и dy задают направление стрелки +@msg dx and dy must be numbers dx и dy должны быть числами +@msg dxf fitTo must be [width, height], positive dxf fitTo должно быть [width, height] с положительными значениями +@msg dxf must be an object of DXF import options dxf должно быть объектом с параметрами импорта DXF +@msg dxf scale must be a positive number up to {_1}, not {s} dxf scale должно быть положительным числом не больше {_1}, а не {s} +@msg dxf unit `{u}`: a unit such as mm, cm, in or pt dxf unit `{u}`: единица, например mm, cm, in или pt +@msg dxf: {e} dxf: {e} +@msg each format is an object {format, scale?, suffix?} каждый формат — объект {format, scale?, suffix?} +@msg each item needs an id каждому элементу нужен id +@msg each match needs `kind` каждому совпадению нужно `kind` +@msg each match needs a `name` каждому совпадению нужно `name` +@msg each of `fonts` is {family, style?}; 1 to 1000 of them каждый элемент `fonts` — {family, style?}; от 1 до 1000 элементов +@msg each of `items` is {kind, item} каждый элемент `items` — {kind, item} +@msg each of `items` is {{id, corners?}} with an object id, not {it} каждый элемент `items` — {{id, corners?}} с идентификатором объекта, а не {it} +@msg each path chunk must contain 1 to 4096 points каждый фрагмент контура должен содержать от 1 до 4096 точек +@msg each pick is {{at: [x, y]}} or {{rect: [x0, y0, x1, y1]}} (got {v}) каждый выбор — {{at: [x, y]}} или {{rect: [x0, y0, x1, y1]}} (получено {v}) +@msg each row needs `from` каждой строке нужно `from` +@msg each stop needs a position каждой точке перехода нужно положение +@msg each subpath needs an anchors array каждому вложенному контуру нужен массив anchors +@msg each weight must be a number of 0 or more (got {v}) каждый вес должен быть числом, 0 или больше (получено {v}) +@msg easingType must be easeIn, easeOut, easeInOut or easeOutIn easingType должно быть easeIn, easeOut, easeInOut или easeOutIn +@msg editingData must be true or false editingData должно быть true или false +@msg editingData must be true, false or "always" editingData должно быть true, false или "always" +@msg edits overlap правки перекрываются +@msg effects of objects whose appearances differ can't be moved together эффекты объектов с разным оформлением нельзя перемещать вместе +@msg empty header in column {_1} пустой заголовок в столбце {_1} +@msg encoding `{_1}`: utf8 or utf16 кодировка `{_1}`: utf8 или utf16 +@msg ends must be two of "first" and "last" ends должно быть двумя значениями из "first" и "last" +@msg every numbered name for the result is taken все нумерованные имена для результата заняты +@msg expanded SVG styles exceed the import size limit развёрнутые стили SVG превышают ограничение размера импорта +@msg export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes файл наборов настроек экспорта больше {MAX_PRESET_FILE_BYTES} байт +@msg exportText must be editable or appearance exportText должно быть editable или appearance +@msg exportType must be movieClip or graphic, not `{s}` exportType должно быть movieClip или graphic, а не `{s}` +@msg extend needs id для extend нужен id +@msg featureChanges lists at most {MAX_FEATURE_CHANGES} tags featureChanges содержит не более {MAX_FEATURE_CHANGES} тегов +@msg featureChanges must be a list of tags featureChanges должно быть списком тегов +@msg features must be a list of tags features должно быть списком тегов +@msg fidelity must be 0..100 fidelity должно быть 0..100 +@msg files must be {name, dataBase64} files должно быть {name, dataBase64} +@msg finally table {t} out of range таблица finally {t} вне диапазона +@msg fit must be none|autoHeight|shrinkText|block, got {id} fit должно быть none|autoHeight|shrinkText|block, получено {id} +@msg flags cannot be given with a RegExp object флаги нельзя указывать вместе с объектом RegExp +@msg flattener: {e} объединение: {e} +@msg flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) flattenerPreset `{name}`: high, medium, low или сохранённый набор (см. flattener.presets.list) +@msg flipProfile must be along|across, got {o} flipProfile должно быть along|across, получено {o} +@msg folders `{f}`: none, rows, format, item or itemFormat folders `{f}`: none, rows, format, item или itemFormat +@msg folders deeper than {MAX_BATCH_DEPTH} levels папки глубже {MAX_BATCH_DEPTH} уровней +@msg folders deeper than {MAX_DEPTH} levels папки глубже {MAX_DEPTH} уровней +@msg font family `{family}` is not available семейство шрифтов `{family}` недоступно +@msg font family `{tf}` is not available семейство шрифтов `{tf}` недоступно +@msg format `{f}`: pdf or postscript формат `{f}`: pdf или postscript +@msg format `{f}`: text or json формат `{f}`: text или json +@msg format is `{format}` формат: `{format}` +@msg formats must be an array of rows, not {v} formats должно быть массивом строк, а не {v} +@msg frequency must be between 1 and 300 lines per inch frequency должно быть от 1 до 300 линий на дюйм +@msg from and to must be finite from и to должны быть конечными +@msg from must be [x0, y0, x1, y1] from должно быть [x0, y0, x1, y1] +@msg function declaration index out of range индекс объявления функции вне диапазона +@msg give `before` or `after`, not both укажите `before` или `after`, но не оба +@msg give `char`, `text`, `code` or `unicode` укажите `char`, `text`, `code` или `unicode` +@msg give `colors`, or `color` and `rule` укажите `colors` либо `color` и `rule` +@msg give `count` or `spacing`, not both укажите `count` или `spacing`, но не оба +@msg give `event`, such as onclick укажите `event`, например onclick +@msg give `file`, the JavaScript file's path or URL укажите `file` (путь или URL файла JavaScript) +@msg give `fill` and/or `stroke` укажите `fill` и/или `stroke` +@msg give `fill`, `stroke` or both укажите `fill`, `stroke` или оба +@msg give `folder` укажите `folder` +@msg give `fonts`, or open a document укажите `fonts` или откройте документ +@msg give `from` or `indices`, not both укажите `from` или `indices`, но не оба +@msg give `id` (no text is being edited) укажите `id` (текст сейчас не редактируется) +@msg give `id` and `anchors`, or `items`, not both укажите `id` и `anchors` либо `items`, но не то и другое +@msg give `id` or `guide`, not both укажите `id` или `guide`, но не оба +@msg give `length` or `to` укажите `length` или `to` +@msg give `matches`: [{kind, name, target}] укажите `matches`: [{kind, name, target}] +@msg give `name` (or edit a pattern) укажите `name` (или редактируйте узор) +@msg give `name` or `names` укажите `name` или `names` +@msg give `name`, an SVG filter's name укажите `name` (имя фильтра SVG) +@msg give `path` (a .wasm file) or `dataBase64` (the module) укажите `path` (файл .wasm) или `dataBase64` (модуль) +@msg give `path`, `data` or `dataBase64` укажите `path`, `data` или `dataBase64` +@msg give `path`, `data`, `dataBase64` or `csv` укажите `path`, `data`, `dataBase64` или `csv` +@msg give `path`, `xml` or `dataBase64` укажите `path`, `xml` или `dataBase64` +@msg give `path`, an SVG file, or `svg`, SVG text укажите `path` (файл SVG) или `svg` (текст SVG) +@msg give `script`, the JavaScript the event runs ("" removes the event) укажите `script` (код JavaScript, который выполняет событие; "" удаляет событие) +@msg give `text` (the reading) or `remove` укажите `text` (чтение) или `remove` +@msg give `x` and `y` (and a `tolerance` of 0 or more) укажите `x` и `y` (и `tolerance`, 0 или больше) +@msg give `xml`, one SVG element укажите `xml` (один элемент SVG ) +@msg give a mode or r/g/b, c/m/y/k, gray or tint adjustments укажите режим или коррекции r/g/b, c/m/y/k, gray или tint +@msg give a name, or paint the active proxy with a saved preset укажите имя или закрасьте активный индикатор сохранённым набором +@msg give a name, or select a stroke with a saved profile укажите имя или выделите обводку с сохранённым профилем +@msg give a pattern name or select an object painted with a pattern укажите имя узора или выделите объект, закрашенный узором +@msg give at least one of clip, invert, disabled, linked укажите хотя бы одно из: clip, invert, disabled, linked +@msg give color, none, swatch or gradient укажите color, none, swatch или gradient +@msg give dataBase64 укажите dataBase64 +@msg give either at (a click) or area (a path drawn around the overlaps) укажите либо at (щелчок), либо area (контур, нарисованный вокруг перекрытий) +@msg give exactly one of `at`, `rect` and `points` укажите ровно одно из: `at`, `rect` и `points` +@msg give exactly one of `path`, `data` and `dataBase64` укажите ровно одно из: `path`, `data` и `dataBase64` +@msg give formats or a preset, not both укажите formats или набор, но не оба +@msg give index or indexes, not both укажите index или indexes, но не оба +@msg give newName or delete укажите newName или delete +@msg give offset or by укажите offset или by +@msg give one element укажите один элемент +@msg give one of `to`, `rect` or `at` укажите одно из: `to`, `rect` или `at` +@msg give one of assets, layers, selection or fullDocument укажите одно из: assets, layers, selection или fullDocument +@msg give only one of onto, above, below, icon and a column place укажите только одно из: onto, above, below, icon и место в столбце +@msg give opacity or blend укажите opacity или blend +@msg give origin: [x, y], or reset: true укажите origin: [x, y] или reset: true +@msg give overprintFill, overprintStroke, showCenter, imageMap, url or note укажите overprintFill, overprintStroke, showCenter, imageMap, url или note +@msg give path (a file) or folder укажите path (файл) или folder +@msg give path, or name and dataBase64 укажите path либо name и dataBase64 +@msg give paths or files укажите paths или files +@msg give rows, rowHeight, columns or columnWidth укажите rows, rowHeight, columns или columnWidth +@msg give svg, or dataBase64 of an SVG or SVGZ file укажите svg или dataBase64 файла SVG или SVGZ +@msg give text укажите text +@msg give the asset a name дайте ресурсу имя +@msg give the preset a `name` укажите `name` набора +@msg give the preset a name дайте набору имя +@msg give the preset a name (1–64 characters) дайте набору имя (от 1 до 64 символов) +@msg give the settings to change, e.g. {"formats"}]} укажите изменяемые настройки, например {"formats"}]} +@msg give the style a name дайте стилю имя +@msg give two or more style `names` укажите в `names` два или более стиля +@msg give two or more swatches укажите два или более образца +@msg give x, y, or a handle's length or angle укажите x, y либо длину или угол маркера +@msg give {key, value} or {values} укажите {key, value} или {values} +@msg global must be true or false global должно быть true или false +@msg glyph {_1} is not drawn for {ch} with the character's style (such as All Caps, Small Caps or a numeral system) глиф {_1} не рисуется для {ch} при стиле этого символа (например, «Все прописные», «Капитель» или система счисления) +@msg glyph {g} is not an alternate of {ch} in {_1} {_2} глиф {g} не является альтернативой для {ch} в {_1} {_2} +@msg gradient must be "objects" or "mesh" gradient должно быть "objects" или "mesh" +@msg grays must be "profile" or "black", not `{g}` grays должно быть "profile" или "black", а не `{g}` +@msg gridColors must be a preset name or two colours gridColors должно быть именем набора или двумя цветами +@msg gridSize must be small, medium or large gridSize должно быть small, medium или large +@msg guide {g} is not selected направляющая {g} не выделена +@msg handle must be 0..3 handle должно быть 0..3 +@msg handler at {_1} expects {_2} values but its range starts with {d} обработчик по адресу {_1} ожидает значений: {_2}, но его диапазон начинается с {d} +@msg handler depth too large глубина обработчика слишком велика +@msg handler range {_1}..{_2} out of range диапазон обработчика {_1}..{_2} вне допустимых пределов +@msg hiddenLayers must be true or false hiddenLayers должно быть true или false +@msg hiddenObjects {v}: "layer" (the default), "omit" or "visible" hiddenObjects {v}: "layer" (по умолчанию), "omit" или "visible" +@msg highlight a layer to merge into выделите строку слоя, в который нужно объединить +@msg highlight two or more rows in one layer or group выделите две или более строки в одном слое или группе +@msg highlight two or more rows to merge выделите две или более строки для объединения +@msg hvcC hvcC +@msg icon docks a panel in the icon column: use window.panel.dock icon прикрепляет панель в столбце значков: используйте window.panel.dock +@msg id must be a dimension id id должно быть идентификатором размера +@msg image has no bounds у изображения нет границ +@msg image {_1} is linked already изображение {_1} уже связано +@msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client или server +@msg images in a spot ink whose samples couldn't be read were imported in RGB изображения в плашечной краске, отсчёты которых не удалось прочитать, импортированы в RGB +@msg images past the most pixels one file may hold were left out изображения сверх наибольшего числа пикселей, допустимого для одного файла, пропущены +@msg images too large to read were left out изображения, слишком большие для чтения, пропущены +@msg index out of range индекс вне диапазона +@msg indexes may hold at most {MAX_GUIDE_INDEXES} entries indexes может содержать не более {MAX_GUIDE_INDEXES} записей +@msg indexes must be an array of guide indexes indexes должно быть массивом индексов направляющих +@msg instances must be 1..1000 instances должно быть 1..1000 +@msg instruction out of range инструкция вне диапазона +@msg instruction {pc} ({op}) pops below the stack инструкция {pc} ({op}) извлекает значения из пустого стека +@msg instruction {pc} reached with stack depths {seen} and {d} инструкция {pc} достигается с глубинами стека {seen} и {d} +@msg invalid `color` недопустимое `color` +@msg invalid anchor index {v}: expected a non-negative integer недопустимый индекс опорной точки {v}: ожидается неотрицательное целое число +@msg invalid anchor reference {v}: expected [subpath, anchor] недопустимая ссылка на опорную точку {v}: ожидается [subpath, anchor] +@msg invalid angle недопустимый угол +@msg invalid array length недопустимая длина массива +@msg invalid brush: {e} недопустимая кисть: {e} +@msg invalid color недопустимый цвет +@msg invalid colour {c} недопустимый цвет {c} +@msg invalid delta недопустимое приращение +@msg invalid group недопустимая группа +@msg invalid object id {v}: expected a non-negative integer недопустимый идентификатор объекта {v}: ожидается неотрицательное целое число +@msg invalid point недопустимая точка +@msg invalid regular expression flag '{_1}' недопустимый флаг регулярного выражения '{_1}' +@msg invalid regular expression: {m} недопустимое регулярное выражение: {m} +@msg invalid size недопустимый размер +@msg invalid t недопустимое t +@msg invalid time value недопустимое значение времени +@msg invalid tolerance недопустимый допуск +@msg it draws its image through a clipping path он рисует своё изображение через контур обрезки +@msg it draws no image он не рисует изображения +@msg it is larger than {MAX_DICTIONARY_FILE_BYTES} bytes он больше {MAX_DICTIONARY_FILE_BYTES} байт +@msg it isn't a file это не файл +@msg it names {_1} plates, and at most {MAX_PLATES} are read в нём указано пластин: {_1}, а читается не более {MAX_PLATES} +@msg its art is too long or too detailed for a head его графика слишком длинна или слишком детальна для наконечника +@msg its art paints nothing его графика ничего не закрашивает +@msg its artboards all lie beyond the canvas (more than {_1} pt from the origin) все его монтажные области находятся за пределами холста (дальше {_1} pt от начала координат) +@msg its clipping masks are nested too deeply его маски обрезки вложены слишком глубоко +@msg its image is too large to read его изображение слишком велико для чтения +@msg its metadata has no description в его метаданных нет описания +@msg its metadata has no direct length у его метаданных нет прямо заданной длины +@msg its plates are too large: more than {MAX_SAMPLES} pixels together его пластины слишком велики: вместе больше {MAX_SAMPLES} пикселей +@msg its plates are too large: {_1} plates of {w} × {h} pixels его пластины слишком велики: пластин {_1}, размер каждой {w} × {h} пикселей +@msg its plates can't be kept together его пластины невозможно сохранить вместе +@msg its shapes could not be combined into one его фигуры не удалось объединить в одну +@msg join two different paths соедините два разных контура +@msg jump to {pc}, past the end переход к {pc}, за конец кода +@msg justify must be auto|left|center|right|justifyAll justify должно быть auto|left|center|right|justifyAll +@msg kbps must be a positive number, not {kbps} kbps должно быть положительным числом, а не {kbps} +@msg keywords must be a list of words or a comma-separated string keywords должно быть списком слов или строкой со словами через запятую +@msg keywords must be at most {MAX_KEYWORD} characters, without control characters каждое слово в keywords должно быть не длиннее {MAX_KEYWORD} символов и без управляющих символов +@msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind должно быть 1, 2 или 3, либо укажите `name` набора (см. perspective.presets.list) +@msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind должно быть graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle или paraStyle +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout должно быть "on", "off", "neutral" или логическим значением, а не {v} +@msg layer `{name}` has no art to export в слое `{name}` нет графики для экспорта +@msg layer must name a layer or sublayer layer должно указывать слой или подслой +@msg layers can't be intertwined: select the objects in them слои нельзя переплести: выделите объекты в них +@msg layers go only at the top level or in layers, objects in layers and groups слои могут находиться только на верхнем уровне или в слоях, а объекты — в слоях и группах +@msg layers must be true or false layers должно быть true или false +@msg layout must be gridByRow, gridByColumn, row or column layout должно быть gridByRow, gridByColumn, row или column +@msg leading must be a number or "auto" leading должно быть числом или "auto" +@msg left/right must be non-negative widths left/right должны быть неотрицательными значениями ширины +@msg legacyNameEncoding must be one of {_1} legacyNameEncoding должно быть одним из: {_1} +@msg level `{t}`: 2 or 3 уровень `{t}`: 2 или 3 +@msg line {line}: more than {MAX_CSV_COLS} columns строка {line}: больше {MAX_CSV_COLS} столбцов +@msg line {line}: unclosed quote строка {line}: незакрытая кавычка +@msg line {line}: value for `{var}` must match its variable kind строка {line}: значение `{var}` должно соответствовать типу переменной +@msg lineEndings `{other}`: lf or crlf lineEndings `{other}`: lf или crlf +@msg loading profiles from disk isn't available in the browser загрузка профилей с диска недоступна в браузере +@msg localize: the first argument must be a localization object or a string localize: первый аргумент должен быть объектом локализации или строкой +@msg margin: a {m} pt margin leaves no imageable area on the paper margin: поле {m} pt не оставляет на бумаге области печати +@msg matrix must be finite and invertible, depth finite matrix должно быть конечной и обратимой матрицей, а depth — конечным +@msg matte: {e} matte: {e} +@msg member is for order front, forward, backward or back member используется для order front, forward, backward или back +@msg missing `artboards` отсутствует `artboards` +@msg missing `at` [x, y] отсутствует `at` [x, y] +@msg missing `attrs` отсутствует `attrs` +@msg missing `color` or `opacity` отсутствует `color` или `opacity` +@msg missing `colors` отсутствует `colors` +@msg missing `commands` отсутствует `commands` +@msg missing `destinationFolder` отсутствует `destinationFolder` +@msg missing `effect` id отсутствует идентификатор `effect` +@msg missing `family` отсутствует `family` +@msg missing `find` отсутствует `find` +@msg missing `gid` (a glyph id, or null for the default glyph) отсутствует `gid` (идентификатор глифа или null для глифа по умолчанию) +@msg missing `group` отсутствует `group` +@msg missing `id` отсутствует `id` +@msg missing `id`: give one, or select one type object отсутствует `id`: укажите его или выделите один текстовый объект +@msg missing `index` (a point index, or null to clear) отсутствует `index` (индекс точки или null для сброса) +@msg missing `index` (a stop index, or null to clear) отсутствует `index` (индекс точки перехода или null для сброса) +@msg missing `index` (an item index or null) отсутствует `index` (индекс элемента или null) +@msg missing `item` отсутствует `item` +@msg missing `library` отсутствует `library` +@msg missing `library` (see graphicStyle.libraries) отсутствует `library` (см. graphicStyle.libraries) +@msg missing `library` (see swatch.library.list) отсутствует `library` (см. swatch.library.list) +@msg missing `map` (rows [{from, to}] or {current}) отсутствует `map` (строки [{from, to}] или {current}) +@msg missing `matrix` [a,b,c,d,e,f] отсутствует `matrix` [a,b,c,d,e,f] +@msg missing `name` (see gradient.presets.list) отсутствует `name` (см. gradient.presets.list) +@msg missing `offset` (a distance in points) отсутствует `offset` (расстояние в пунктах) +@msg missing `path` отсутствует `path` +@msg missing `path` (a path object id) отсутствует `path` (идентификатор объекта-контура) +@msg missing `picks` отсутствует `picks` +@msg missing `rule` отсутствует `rule` +@msg missing `source` id отсутствует идентификатор `source` +@msg missing `start` (the byte offset of a character) отсутствует `start` (смещение символа в байтах) +@msg missing `status`: linked, embedded, missing or modified отсутствует `status`: linked, embedded, missing или modified +@msg missing `target` id отсутствует идентификатор `target` +@msg missing `text` отсутствует `text` +@msg missing `to` отсутствует `to` +@msg missing `to` (an index) отсутствует `to` (индекс) +@msg missing `to`: a non-negative integer отсутствует `to`: неотрицательное целое число +@msg missing `type` отсутствует `type` +@msg missing `value` отсутствует `value` +@msg missing `word` отсутствует `word` +@msg missing `{key}` отсутствует `{key}` +@msg missing `{k}` отсутствует `{k}` +@msg missing anchor отсутствует anchor +@msg missing anchors отсутствует anchors +@msg missing anchors [[subpath, anchor]…] отсутствует anchors [[subpath, anchor]…] +@msg missing array `{key}` отсутствует массив `{key}` +@msg missing case отсутствует case +@msg missing color отсутствует color +@msg missing commands отсутствует commands +@msg missing destination folder отсутствует папка назначения +@msg missing from отсутствует from +@msg missing group отсутствует group +@msg missing id or ids отсутствует id или ids +@msg missing ids отсутствует ids +@msg missing indexes отсутствует indexes +@msg missing matrix [a,b,c,d,e,f] отсутствует matrix [a,b,c,d,e,f] +@msg missing name отсутствует name +@msg missing op отсутствует op +@msg missing or invalid `color` `color` отсутствует или недопустимо +@msg missing or unknown `tool` `tool` отсутствует или неизвестно +@msg missing point отсутствует point +@msg missing point [x,y] отсутствует point [x,y] +@msg missing point `{key}` ([x, y]) отсутствует точка `{key}` ([x, y]) +@msg missing points отсутствует points +@msg missing points [[x,y]…] отсутствует points [[x,y]…] +@msg missing segment отсутствует segment +@msg missing stops отсутствует stops +@msg missing subpaths отсутствует subpaths +@msg missing target отсутствует target +@msg missing to отсутствует to +@msg missing x, y отсутствуют x, y +@msg mode is set, add or toggle, not `{other}` mode принимает set, add или toggle, а не `{other}` +@msg mode must be "cmyk" or "rgb" mode должно быть "cmyk" или "rgb" +@msg mode must be area|onPath, got {o} mode должно быть area|onPath, получено {o} +@msg more than 10000 linked files: package dependencies in smaller parts больше 10000 связанных файлов: упаковывайте зависимости меньшими частями +@msg more than 256 commands больше 256 команд +@msg more than {MAX_BATCH_FILES} data sets больше {MAX_BATCH_FILES} наборов данных +@msg more than {MAX_BATCH_FILES} files больше {MAX_BATCH_FILES} файлов +@msg more than {MAX_CSV_ROWS} data rows больше {MAX_CSV_ROWS} строк данных +@msg more than {MAX_FILES} images больше {MAX_FILES} изображений +@msg more than {MAX_IMPORT} presets больше {MAX_IMPORT} наборов +@msg moved must be [[x,y]…] moved должно быть [[x,y]…] +# Messages that begin with a lowercase word, n to s. +@msg name (a toolbar's name) is required требуется name (имя панели инструментов) +@msg name (a toolbar's name) must be a string name (имя панели инструментов) должно быть строкой +@msg name must be a toolbar's name name должно быть именем панели инструментов +@msg names is empty names пусто +@msg names must be an array of symbol names names должно быть массивом имён символов +@msg need `anchor` or `segment` + `t` нужно `anchor` или `segment` + `t` +@msg need at least one anchor нужна хотя бы одна опорная точка +@msg need at least one point нужна хотя бы одна точка +@msg need at least two points нужны хотя бы две точки +@msg needs a closing quote after the name нужна закрывающая кавычка после имени +@msg needs a name нужно имя +@msg needs a name of at most 64 bytes нужно имя длиной не более 64 байт +@msg needs a name of letters, digits and _, or a quoted name нужно имя из букв, цифр и _ либо имя в кавычках +@msg needs a name without control characters нужно имя без управляющих символов +@msg needs points нужны points +@msg nesting too deep слишком глубокая вложенность +@msg nineSlice must be true or false nineSlice должно быть true или false +@msg nineSliceGuides must be [left, top, right, bottom], each -16..16, with left + right < 1 and top + bottom < 1 nineSliceGuides должно быть [left, top, right, bottom], каждое значение -16..16, при этом left + right < 1 и top + bottom < 1 +@msg no HP-GL format нет формата HP-GL +@msg no New Document profile called `{shown}` ({_1}) нет профиля нового документа с именем `{shown}` ({_1}) +@msg no PDF preset named `{n}` нет набора PDF с именем `{n}` +@msg no SVG filter at index {k} нет фильтра SVG с индексом {k} +@msg no action set is current нет текущего набора действий +@msg no active data set нет активного набора данных +@msg no appearance item {index} нет элемента оформления {index} +@msg no arrowheads file here здесь нет файла стрелок +@msg no artboard {_1} нет монтажной области {_1} +@msg no artboard {artboard} (0-based; the document has {_1}) нет монтажной области {artboard} (отсчёт от 0; всего в документе: {_1}) +@msg no asset {id} нет ресурса {id} +@msg no asset {id} (see assets.list) нет ресурса {id} (см. assets.list) +@msg no assets to export нет ресурсов для экспорта +@msg no assets yet: collect art for export first (assets.add) ресурсов пока нет: сначала соберите графику для экспорта (assets.add) +@msg no brush named `{n}` нет кисти с именем `{n}` +@msg no character at byte {start} нет символа по байту {start} +@msg no character has code {code} нет символа с кодом {code} +@msg no colour нет цвета +@msg no colour group `{group}` нет цветовой группы `{group}` +@msg no colour group `{name}` нет цветовой группы `{name}` +@msg no custom arrowhead {name} (see stroke.arrowheads.list) нет пользовательской стрелки {name} (см. stroke.arrowheads.list) +@msg no data sets нет наборов данных +@msg no dataset named `{from}` нет набора данных с именем `{from}` +@msg no dataset named `{name}` нет набора данных с именем `{name}` +@msg no datasets нет наборов данных +@msg no design named `{name}` нет дизайна с именем `{name}` +@msg no design named `{n}` нет дизайна с именем `{n}` +@msg no design named `{old}` нет дизайна с именем `{old}` +@msg no encoder for {_1} yet кодировщика для {_1} пока нет +@msg no export preset `{name}` (see document.exportPresets.list) нет набора настроек экспорта `{name}` (см. document.exportPresets.list) +@msg no file to open нет файла для открытия +@msg no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder нет папки: передайте `path` или задайте «Настройки › Модули & Рабочие диски › Папка дополнительных модулей» +@msg no fonts to look for: they are all available нет шрифтов для поиска: все они доступны +@msg no formats to export нет форматов для экспорта +@msg no gradient preset `{name}` (see gradient.presets.list) нет набора градиента `{name}` (см. gradient.presets.list) +@msg no graphic style `{n}` in `{_1}` нет графического стиля `{n}` в `{_1}` +@msg no graphic style library `{key}` (see graphicStyle.libraries) нет библиотеки графических стилей `{key}` (см. graphicStyle.libraries) +@msg no guide selected направляющая не выделена +@msg no guide selected (or give an index) направляющая не выделена (или укажите индекс) +@msg no guide {i} нет направляющей {i} +@msg no images in the folder в папке нет изображений +@msg no instructions нет инструкций +@msg no item at index {_1} нет элемента с индексом {_1} +@msg no item at index {from} нет элемента с индексом {from} +@msg no item at one of the indices {idx} нет элемента по одному из индексов {idx} +@msg no layer нет слоя +@msg no layer to rename нет слоя для переименования +@msg no layer {id} (see layer.list) нет слоя {id} (см. layer.list) +@msg no layers to export нет слоёв для экспорта +@msg no library `{key}` нет библиотеки `{key}` +@msg no metafile format нет формата метафайла +@msg no object {_1} нет объекта {_1} +@msg no objects нет объектов +@msg no parent layer нет родительского слоя +@msg no perspective grid preset named `{b}` (see perspective.presets.list) нет набора сетки перспективы с именем `{b}` (см. perspective.presets.list) +@msg no perspective grid preset named `{n}` нет набора сетки перспективы с именем `{n}` +@msg no point selected (give `index`) точка не выделена (укажите `index`) +@msg no preset called `{from}` (see file.newPresets) нет набора с именем `{from}` (см. file.newPresets) +@msg no preset called `{name}` (see file.newPresets) нет набора с именем `{name}` (см. file.newPresets) +@msg no preset named `{n}` нет набора с именем `{n}` +@msg no print preset named `{b}` (see print.presets.list) нет набора печати с именем `{b}` (см. print.presets.list) +@msg no print preset named `{name}` (presets: {_1}) нет набора печати с именем `{name}` (наборы: {_1}) +@msg no print preset named `{n}` нет набора печати с именем `{n}` +@msg no profile named `{name}` нет профиля с именем `{name}` +@msg no random numbers ({e}) нет случайных чисел ({e}) +@msg no readable files in the folder в папке нет читаемых файлов +@msg no recovery copy `{f}` (see file.recovery.list) нет копии восстановления `{f}` (см. file.recovery.list) +@msg no rows нет строк +@msg no saved PDF preset named `{name}` нет сохранённого набора PDF с именем `{name}` +@msg no saved export preset `{name}` нет сохранённого набора настроек экспорта `{name}` +@msg no saved gradient preset called {name} нет сохранённого набора градиента с именем {name} +@msg no saved gradient presets нет сохранённых наборов градиента +@msg no saved preset called `{name}` нет сохранённого набора с именем `{name}` +@msg no saved presets to export (name [Default] in `names` to export it) нет сохранённых наборов для экспорта (укажите [Default] в `names`, чтобы экспортировать его) +@msg no saved presets to export (name built-in ones in `names`) нет сохранённых наборов для экспорта (встроенные укажите в `names`) +@msg no saved print preset named `{name}` нет сохранённого набора печати с именем `{name}` +@msg no saved profile called {name} нет сохранённого профиля с именем {name} +@msg no saved selection `{name}` нет сохранённого выделения `{name}` +@msg no saved theme called {name} (see colorTheme.list) нет сохранённой темы с именем {name} (см. colorTheme.list) +@msg no saved views нет сохранённых видов +@msg no selected or given object has a visible 3D effect ни у одного выделенного или указанного объекта нет видимого 3D-эффекта +@msg no series {i} нет ряда {i} +@msg no slice {n} in the image (see slice.list) в изображении нет фрагмента {n} (см. slice.list) +@msg no source art нет исходной графики +@msg no stop {i} (the gradient has {n}) нет точки перехода {i} (всего в градиенте: {n}) +@msg no style `{name}` нет стиля `{name}` +@msg no such artboard {v} монтажная область {v} не существует +@msg no such assets (see assets.list) таких ресурсов нет (см. assets.list) +@msg no such frame anchor, or the frame would reach past the canvas такой опорной точки рамки нет, или рамка вышла бы за пределы холста +@msg no such objects таких объектов нет +@msg no such path anchor, or the path would reach past the canvas такой опорной точки контура нет, или контур вышел бы за пределы холста +@msg no such rows таких строк нет +@msg no swatch `{keep}` нет образца `{keep}` +@msg no swatch `{n}` нет образца `{n}` +@msg no swatch `{n}` (swatches and colour groups move separately) нет образца `{n}` (образцы и цветовые группы перемещаются отдельно) +@msg no swatch library `{key}` (see swatch.library.list) нет библиотеки образцов `{key}` (см. swatch.library.list) +@msg no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) нет библиотеки образцов `{key}` (см. swatch.library.list; "document": образцы документа) +@msg no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) нет библиотеки образцов `{key}` (см. swatch.library.list; "{DOCUMENT_SWATCHES}": образцы документа) +@msg no swatch named `{n}` нет образца с именем `{n}` +@msg no swatch or colour group `{n}` нет образца или цветовой группы `{n}` +@msg no swatch or colour group `{n}` in `{_1}` нет образца или цветовой группы `{n}` в `{_1}` +@msg no symbol named `{_1}` нет символа с именем `{_1}` +@msg no symbol named `{name}` нет символа с именем `{name}` +@msg no text objects selected текстовые объекты не выделены +@msg no toolbar named `{_1}` нет панели инструментов с именем `{_1}` +@msg no toolbar named `{name}` нет панели инструментов с именем `{name}` +@msg no tracing preset `{preset}` (see imageTrace.presets) нет набора трассировки `{preset}` (см. imageTrace.presets) +@msg no user library folder here (save with a path, or without one to get the data) здесь нет папки пользовательских библиотек (сохраните с путём или без пути, чтобы получить данные) +@msg no variable named `{from}` нет переменной с именем `{from}` +@msg no variable named `{name}` нет переменной с именем `{name}` +@msg no variable named `{var}` нет переменной с именем `{var}` +@msg no variable named `{v}` нет переменной с именем `{v}` +@msg no view named `{name}` нет вида с именем `{name}` +@msg node {_1} already has the text variable `{_2}` у узла {_1} уже есть текстовая переменная `{_2}` +@msg node {_1} is not a graph узел {_1} не является диаграммой +@msg node {_1} is not an image узел {_1} не является изображением +@msg node {_1} is not type узел {_1} не является текстом +@msg not a DCS file не файл DCS +@msg not a PDF presets file (format `{_1}`) не файл наборов PDF (формат `{_1}`) +@msg not a PDF presets file: {e} не файл наборов PDF: {e} +@msg not a RegExp object не объект RegExp +@msg not a Word document: its WordDocument stream doesn't start with a file information block не документ Word: его поток WordDocument не начинается с блока сведений о файле +@msg not a Word document: its compound file has no WordDocument stream не документ Word: в его составном файле нет потока WordDocument +@msg not a Word document: its main part is not a WordprocessingML document не документ Word: его основная часть не является документом WordprocessingML +@msg not a Word document: its package has no main document part не документ Word: в его пакете нет основной части документа +@msg not a closed path не замкнутый контур +@msg not a flattener presets file (format `{_1}`) не файл наборов объединения (формат `{_1}`) +@msg not a flattener presets file: {e} не файл наборов объединения: {e} +@msg not a layer or group не слой и не группа +@msg not a library file: {e} не файл библиотеки: {e} +@msg not a mesh envelope point не точка сетки оболочки +@msg not a perspective grid presets file (format `{_1}`) не файл наборов сетки перспективы (формат `{_1}`) +@msg not a perspective grid presets file: {e} не файл наборов сетки перспективы: {e} +@msg not a print presets file (format `{_1}`) не файл наборов печати (формат `{_1}`) +@msg not a print presets file: {e} не файл наборов печати: {e} +@msg not a regular expression не регулярное выражение +@msg not a stored or deflated central record центральная запись не сохранена без сжатия и не сжата методом deflate +@msg not a valid SVGZ file: {e} недопустимый файл SVGZ: {e} +@msg not an action set: the text doesn't start with /version and a number не набор действий: текст не начинается с /version и числа +@msg not an image VectorCraft reads (see document.formats) не изображение, которое читает VectorCraft (см. document.formats) +@msg not area type (text in a frame) outside perspective не текст в области (текст в рамке) вне перспективы +@msg not editing an opacity mask непрозрачная маска не редактируется +@msg not type on a path outside perspective не текст по контуру вне перспективы +@msg nothing prints ничего не печатается +@msg nothing to bind нечего привязывать +@msg nothing to change нечего изменять +@msg nothing to change: give dx, dy, hScale, vScale or rotation нечего изменять: укажите dx, dy, hScale, vScale или rotation +@msg nothing to crop нечего обрезать +@msg nothing to export нечего экспортировать +@msg nothing to export: the document has no visible art нечего экспортировать: в документе нет видимой графики +@msg nothing to mark нечего отмечать +@msg nothing to print: every artboard picked is blank нечего печатать: все выбранные монтажные области пусты +@msg nothing to print: the document has no art that prints нечего печатать: в документе нет печатаемой графики +@msg nothing to repeat нечего повторять +@msg numbers out of order in {_1} quantifier числа в квантификаторе {_1} идут в неверном порядке +@msg object is not a path объект не является контуром +@msg object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object прозрачность объекта нельзя перенести на выделенные символы; отключите «Прозрачность» в окне «Параметры пипетки» или выделите объект целиком +@msg object {_1} can't be set as point type объект {_1} нельзя преобразовать в текст из точки +@msg object {_1} has lost its file's contents: relink it объект {_1} потерял содержимое своего файла: восстановите связь +@msg object {_1} has no path у объекта {_1} нет контура +@msg object {_1} has no size у объекта {_1} нет размера +@msg object {_1} is hidden or locked, or on a locked artboard объект {_1} скрыт или заблокирован либо находится на заблокированной монтажной области +@msg object {_1} is not a placed document объект {_1} не является помещённым документом +@msg object {_1} is not an Intertwine group объект {_1} не является группой переплетения +@msg object {_1} is not an image объект {_1} не является изображением +@msg object {_1} is not an image or a linked file объект {_1} не является изображением или связанным файлом +@msg object {_1} is not an image or a placed document объект {_1} не является изображением или помещённым документом +@msg object {_1} is not in this Intertwine group объект {_1} не входит в эту группу переплетения +@msg object {_1} needs a 3D effect объекту {_1} нужен 3D-эффект +@msg object {_1} needs a 3D effect at index {index} объекту {_1} нужен 3D-эффект с индексом {index} +@msg object {_1}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic объект {_1}: у полого эффекта «Выдавливание и фаска» нет торцов: включите «Окончание», чтобы наложить графику +@msg object {_1}: with a depth of 0 the object has one face: map its graphics onto the front объект {_1}: при глубине 0 у объекта одна грань: наложите его графику на переднюю сторону +@msg object {_1}: {_2} объект {_1}: {_2} +@msg object {_1}: {e} объект {_1}: {e} +@msg object {_1}: {name} at index {index} has no lighting presets; set its lights with effect.setParams объект {_1}: у {name} с индексом {index} нет наборов освещения; задайте источники света через effect.setParams +@msg object {id} has anchors 0–{_1} for `corners`, not {past} у объекта {id} для `corners` есть опорные точки 0–{_1}, а не {past} +@msg object {id} has {n} anchors, so no anchor {past} у объекта {id} опорных точек: {n}, поэтому опорной точки {past} нет +@msg objects must be none, move or copy objects должно быть none, move или copy +@msg objects were left out объекты пропущены +@msg offset out of range смещение вне диапазона +@msg on must be true or false on должно быть true или false +@msg only groups, layers, type and images have a Contents (Characters) row строка «Содержимое» («Символы») есть только у групп, слоёв, текста и изображений +@msg only interior mesh lines can be deleted удалять можно только внутренние линии сетки +@msg only solid colours can be added (not gradients or patterns) добавлять можно только сплошные цвета (не градиенты и не узоры) +@msg only solid-colour swatches can be spot colours плашечными цветами могут быть только образцы сплошного цвета +@msg 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 удалось прочитать только встроенный предпросмотр этого файла Affinity: используйте «Файл › Открыть», чтобы увидеть его с предупреждением, или сначала экспортируйте SVG или PDF из Affinity +@msg only this Affinity file's embedded preview could be read; use File › Open to see it with its warning удалось прочитать только встроенный предпросмотр этого файла Affinity; используйте «Файл › Открыть», чтобы увидеть его с предупреждением +@msg onto, above, below, icon and a column place belong to window.panel.float and window.panel.dock onto, above, below, icon и место в столбце относятся к window.panel.float и window.panel.dock +@msg onto, above, below, icon and a column place take no anchor, side, zone or before onto, above, below, icon и место в столбце не сочетаются с anchor, side, zone и before +@msg onto, above, below, icon and a column place take no x, y, width or height onto, above, below, icon и место в столбце не сочетаются с x, y, width и height +@msg opacity must be a number opacity должно быть числом +@msg open a document to look for its linked files откройте документ, чтобы искать его связанные файлы +@msg operand too large операнд слишком велик +@msg options must be an object (see file.formatOptions) options должно быть объектом (см. file.formatOptions) +@msg order is a whole number from 0 to {others}, not `{v}` order — целое число от 0 до {others}, а не `{v}` +@msg order must be leftToRight or rightToLeft order должно быть leftToRight или rightToLeft +@msg orientation must be portrait or landscape, not `{o}` orientation должно быть portrait или landscape, а не `{o}` +@msg outlineText {v}: true or false outlineText {v}: true или false +@msg output intent profile: {e} профиль назначения вывода: {e} +@msg output.destination: no RGB or CMYK profile is called “{name}” output.destination: нет профиля RGB или CMYK с именем «{name}» +@msg output.destination: {_1} files are CMYK, and “{name}” is an RGB profile output.destination: файлы {_1} используют CMYK, а «{name}» — профиль RGB +@msg output.destination: {e} output.destination: {e} +@msg output.inks: no ink is set to print output.inks: ни одна краска не назначена для печати +@msg output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile output.outputIntent: нет профиля с именем «{name}», а назначение вывода {std} внедряет свой профиль +@msg output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) output.outputIntent: нет профиля с именем «{name}»: назначение вывода {std} внедряет профиль или указывает зарегистрированное условие печати (outputConditionId и registry) +@msg output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile output.outputIntent: файлы {std} печатаются в CMYK, а «{name}» — профиль RGB +@msg output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent output.outputIntent: «{name}» — профиль оттенков серого, он не может быть назначением вывода {std} +@msg overlap: {o} pt is half a tile or more overlap: {o} pt составляет половину плитки или больше +@msg overlapping Shaper paths must be in the same layer or group перекрывающиеся контуры Shaper должны находиться в одном слое или группе +@msg overlapping edits перекрывающиеся правки +@msg page art conflicts with image data for {key} графика страницы конфликтует с данными изображения для {key} +@msg page art conflicts with pattern {name} графика страницы конфликтует с узором {name} +@msg page art conflicts with swatch {name} графика страницы конфликтует с образцом {name} +@msg page art is missing image data for {key} в графике страницы отсутствуют данные изображения для {key} +@msg page art is missing pattern {name} в графике страницы отсутствует узор {name} +@msg page art is missing swatch {name} в графике страницы отсутствует образец {name} +@msg page must be a whole number from 1 page должно быть целым числом от 1 +@msg page thumbnail: {e} миниатюра страницы: {e} +@msg page {page} of the PDF has no objects на странице {page} файла PDF нет объектов +@msg page {page}: `{file}` has {n} {artboards} страница {page}: в `{file}` {n} {artboards} +@msg page {page}: `{name}` has {n} {pages} страница {page}: в `{name}` {n} {pages} +@msg pages must be a range such as "2-3" or a page number, not {v} pages должно быть диапазоном, например "2-3", или номером страницы, а не {v} +@msg pages: {e} pages: {e} +@msg panel position must be finite положение панели должно быть конечным +@msg parameters must be an object параметры должны быть объектом +@msg params must be an object params должно быть объектом +@msg passwords can have up to {MAX_PASSWORD} bytes пароли могут содержать до {MAX_PASSWORD} байт +@msg path chunks must share their boundary point фрагменты контура должны иметь общую граничную точку +@msg path has no open end у контура нет открытого конца +@msg paths must be strings paths должно состоять из строк +@msg pdfCompatible: false needs every artboard to keep the editable artwork pdfCompatible: false требует все монтажные области, чтобы сохранить редактируемую графику +@msg pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false когда useArtboards равно false, pdfCompatible: false требует монтажные области, чтобы сохранить редактируемую графику +@msg pdfs `{m}`: single or multiple pdfs `{m}`: single или multiple +@msg pickColorFrom is "artwork" or "swatches", not {v} pickColorFrom принимает "artwork" или "swatches", а не {v} +@msg pins and moved must be non-empty lists of the same length pins и moved должны быть непустыми списками одинаковой длины +@msg pins must be [[x,y]…] pins должно быть [[x,y]…] +@msg pins must be finite pins должны быть конечными +@msg place is above, below or inside, not `{other}` place принимает above, below или inside, а не `{other}` +@msg plane must be left|right|ground plane должно быть left|right|ground +@msg plane must be left|right|ground|none plane должно быть left|right|ground|none +@msg point is outside the mesh точка находится за пределами сетки +@msg point x must be a number x точки должно быть числом +@msg point y must be a number y точки должно быть числом +@msg points must be [t, left, right] with non-negative widths points должно быть [t, left, right] с неотрицательной шириной +@msg points must be [x,y] pairs points должно состоять из пар [x,y] +@msg points must be a non-empty [[x,y,pressure?]…] list points должно быть непустым списком [[x,y,pressure?]…] +@msg points must be a non-empty list of finite [x,y] points должно быть непустым списком конечных [x,y] +@msg points must be an array or null points должно быть массивом или null +@msg points must be finite points должны быть конечными +@msg points must be finite [x,y] pairs points должно состоять из конечных пар [x,y] +@msg points must be finite canvas points points должно состоять из конечных точек холста +@msg points must contain 1–4096 coordinates points должно содержать от 1 до 4096 координат +@msg position must be normal, superscript or subscript position должно быть normal, superscript или subscript +@msg position must be topLeft, topRight, bottomLeft or bottomRight position должно быть topLeft, topRight, bottomLeft или bottomRight +@msg positions must be between 0 and 100000 pt positions должны быть от 0 до 100000 pt +@msg ppi must be a positive number, not {ppi} ppi должно быть положительным числом, а не {ppi} +@msg ppi must be between 1 and 2400 ppi должно быть от 1 до 2400 +@msg preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill preserve `{v}` не является одним из transforms|bounds|fileDimensions|fit|fill +@msg preserveShape must be clippingMask|transparency preserveShape должно быть clippingMask|transparency +@msg preset `{id}`: mobile or density набор `{id}`: mobile или density +@msg preset must be standard, diffuse, topLeft or right preset должно быть standard, diffuse, topLeft или right +@msg preset must be the name of a print preset preset должно быть именем набора печати +@msg previewMode must be default, pixel or overprint previewMode должно быть default, pixel или overprint +@msg print settings: {e} параметры печати: {e} +@msg procedures nest too deeply процедуры вложены слишком глубоко +@msg procedures nested too deeply вложенность процедур слишком глубока +@msg quotes must be {double?, single?} quotes должно быть {double?, single?} +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" quotes.{k} должно быть открывающей и закрывающей кавычками, например "“”" +@msg range out of order in character class диапазон в классе символов задан в неверном порядке +@msg rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) rasterEffectsPpi должно быть 1–2400 (72 для экрана, 150 для среднего, 300 для высокого) +@msg rating must be a whole number from 0 to {MAX_RATING} rating должно быть целым числом от 0 до {MAX_RATING} +@msg rect and {k} contradict each other: give the area or the artboards rect и {k} противоречат друг другу: укажите область или монтажные области +@msg rect applies to raster formats, not {_1} rect применяется к растровым форматам, а не к {_1} +@msg rect is [x, y, width, height] in points rect — это [x, y, width, height] в пунктах +@msg rect must be [x, y, width, height] rect должно быть [x, y, width, height] +@msg rect must be [x, y, width, height] with a positive width and height rect должно быть [x, y, width, height] с положительными шириной и высотой +@msg rect needs finite numbers and a positive width and height для rect нужны конечные числа и положительные ширина и высота +@msg redraw needs id для redraw нужен id +@msg redraw within must be a finite distance of 0 or more within для redraw должно быть конечным расстоянием, 0 или больше +@msg reduce of an empty array with no initial value reduce для пустого массива без начального значения +@msg registration must be 0..8 registration должно быть 0..8 +@msg regular expression flag '{_1}' given twice флаг регулярного выражения '{_1}' указан дважды +@msg release a layer or a group распределять по слоям можно слой или группу +@msg replace keeps the replaced object's place and transform: drop template, at and rect replace сохраняет положение и трансформацию заменяемого объекта: уберите template, at и rect +@msg replace: select the one object to replace replace: выделите один объект для замены +@msg resolution must be a number or screen|medium|high resolution должно быть числом или screen|medium|high +@msg resolution must be between 1 and 2400 ppi resolution должно быть от 1 до 2400 ppi +@msg rotation must be a number of degrees rotation должно быть числом в градусах +@msg rotation must be finite rotation должно быть конечным +@msg rows and cols must be 1..50 rows и cols должны быть 1..50 +@msg rows and columns must be 1..500 rows и columns должны быть 1..500 +@msg rows of objects whose appearances differ can't be reordered together строки объектов с разным оформлением нельзя переупорядочивать вместе +@msg sampled text would exceed the canvas (coordinates out of range) текст с перенесёнными атрибутами превысил бы холст (координаты вне диапазона) +@msg save the document first: Package collects a saved document сначала сохраните документ: «Упаковать» собирает сохранённый документ +@msg save the document first: copy puts the file in its folder сначала сохраните документ: copy помещает файл в его папку +@msg savedPreset: {e} savedPreset: {e} +@msg scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) scale `{s}`: коэффициент, например 2x, ширина (100w), высота (100h) или разрешение (72ppi) +@msg scale must be a number above 0, at most 64 scale должно быть числом больше 0 и не больше 64 +@msg scale must be non-zero scale не должно быть нулём +@msg scope must be selection or all (got {other}) scope должно быть selection или all (получено {other}) +@msg scribble is too long каракули слишком длинные +@msg segment does not exist сегмент не существует +@msg select a Shaper group выделите группу Shaper +@msg select a blend to set easing or color shift выделите переход, чтобы задать сглаживание или сдвиг цвета +@msg select a graph выделите диаграмму +@msg select a group or layer with visible, unlocked members выделите группу или слой с видимыми незаблокированными элементами +@msg select a layer выделите слой +@msg select a text object выделите текстовый объект +@msg select an SVG filter выделите фильтр SVG +@msg select an image (or an Image Trace group) выделите изображение (или группу трассировки изображения) +@msg select an image, or pass id выделите изображение или передайте id +@msg select an object (or pass `id`) выделите объект (или передайте `id`) +@msg select an object with a 3D effect выделите объект с 3D-эффектом +@msg select an object with a 3D effect, or give its id in ids выделите объект с 3D-эффектом или укажите его идентификатор в ids +@msg select an object with a freeform gradient выделите объект с произвольным градиентом +@msg select an object with an opacity mask выделите объект с непрозрачной маской +@msg select area type выделите текст в области +@msg select area type (text in a frame) выделите текст в области (текст в рамке) +@msg select area type and closed paths (at least one area type object) выделите текст в области и замкнутые контуры (хотя бы один объект текста в области) +@msg select area type whose first line can fit its frame выделите текст в области, первая строка которого может поместиться в рамку +@msg select artwork to add its colours выделите рисунок, чтобы добавить его цвета +@msg select artwork to make a group of its colours выделите рисунок, чтобы создать группу из его цветов +@msg select artwork to recolour, or give a colour `group` выделите рисунок для перекрашивания или укажите цветовую группу в `group` +@msg select exactly two path ends to join выделите ровно два конца контуров для соединения +@msg select objects or give ids выделите объекты или укажите ids +@msg select objects to align to the guide выделите объекты для выравнивания по направляющей +@msg select one Intertwine group выделите одну группу переплетения +@msg select one image or pass id выделите одно изображение или передайте id +@msg select one layer, group or object to rename выделите один слой, группу или объект для переименования +@msg select paths выделите контуры +@msg select paths or compound paths выделите контуры или составные контуры +@msg select rows or objects to collect выделите строки или объекты для сбора +@msg select something to export выделите что-нибудь для экспорта +@msg select something to export (or turn off selectedOnly) выделите что-нибудь для экспорта (или отключите selectedOnly) +@msg select text to redefine the style from выделите текст, по которому нужно переопределить стиль +@msg select the art and, on top of it, the mask object выделите графику и расположенный поверх неё объект маски +@msg select the art for the design выделите графику для дизайна +@msg select the art for the pattern tile выделите графику для плитки узора +@msg select the art to add first сначала выделите добавляемую графику +@msg select the art to collect (or give its ids) выделите графику для сбора (или укажите её ids) +@msg select the art to weed выделите графику для выборки +@msg select the banner, or mark every artboard выделите баннер или разметьте все монтажные области +@msg select the content and a path on top выделите содержимое и контур поверх него +@msg select the images to relink, or pass ids выделите изображения для восстановления связи или передайте ids +@msg select the images, or pass ids выделите изображения или передайте ids +@msg select the linked images or placed documents, or pass ids выделите связанные изображения или помещённые документы либо передайте ids +@msg select the object to copy from выделите объект, с которого нужно копировать +@msg select the objects (or give their ids) выделите объекты (или укажите их ids) +@msg select the objects to attach to the path выделите объекты для прикрепления к контуру +@msg select the objects to bind выделите объекты для привязки +@msg select two or more objects to intertwine выделите два или более объекта для переплетения +@msg select two or more slices to combine выделите два или более фрагмента для объединения +@msg select type on a path выделите текст по контуру +@msg select type on a path or area type выделите текст по контуру или текст в области +@msg selection `{m}`: one or each selection `{m}`: one или each +@msg settings is an object of document.exportForScreens params settings — объект с параметрами document.exportForScreens +@msg settings must be an object settings должно быть объектом +@msg show `{show}`: all, missing, modified or embedded show `{show}`: all, missing, modified или embedded +@msg show must be true or false show должно быть true или false +@msg side is left or right, not `{v}` side принимает left или right, а не `{v}` +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size должно быть "small", "medium", "large" или числом в пунктах больше 0 и не больше {_1} +@msg size must be positive size должно быть положительным +@msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode принимает "fixed" или "pressure", а не `{other}` +@msg sizeMode is "fixed" or "pressure", not {other} sizeMode принимает "fixed" или "pressure", а не {other} +@msg sizeVariation must be 0 or more sizeVariation должно быть 0 или больше +@msg smallCaps must be true or false smallCaps должно быть true или false +@msg sort `{x}`: name, kind or status sort `{x}`: name, kind или status +@msg source paths are too large for Shaper исходные контуры слишком велики для Shaper +@msg source text transform is collapsed or unrepresentable трансформация исходного текста вырождена или непредставима +@msg spot, mode and group apply to solid colours only spot, mode и group применяются только к сплошным цветам +@msg status `{s}`: linked, embedded, missing or modified status `{s}`: linked, embedded, missing или modified +@msg steps must be 1..1000 steps должно быть 1..1000 +@msg steps must be 1..{MAX_STEPS} steps должно быть 1..{MAX_STEPS} +@msg stop {i}: `plates` must be four numbers from 0 to 1 (C, M, Y, K) точка перехода {i}: `plates` должно быть четырьмя числами от 0 до 1 (C, M, Y, K) +@msg stopped before the archive was finished остановлено до завершения архива +@msg strokeMode must be within|along|across, got {m} strokeMode должно быть within|along|across, получено {m} +@msg strokeOptions must be an object of stroke.set options strokeOptions должно быть объектом с параметрами stroke.set +@msg strokeWidth must be a positive number of pixels strokeWidth должно быть положительным числом пикселей +@msg subpath {si} is not an open subpath вложенный контур {si} не является открытым +@msg subpaths required when joining the same path при соединении одного и того же контура требуется subpaths +@msg swatchConflict must be "merge" or "add", not {v} swatchConflict должно быть "merge" или "add", а не {v} +# Messages that begin with a lowercase word, t to z. +@msg table must be true or false table должно быть true или false +@msg takes one name принимает одно имя +@msg target text transform is collapsed or unrepresentable трансформация целевого текста вырождена или непредставима +@msg text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} text должно быть {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} +@msg text transform is collapsed or unrepresentable трансформация текста вырождена или непредставима +@msg text.replaceSpaces must be a whole number from 2 to 100 text.replaceSpaces должно быть целым числом от 2 до 100 +@msg text.{key} `{v}`: use "{_1}" or "{_2}" text.{key} `{v}`: используйте "{_1}" или "{_2}" +@msg textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it textureImage: укажите {path} или {name, dataBase64} файла изображения либо текстуру в том виде, в каком её выдаёт effect.list +@msg textureImage: {e} textureImage: {e} +@msg that makes more than {MAX_PIECES} slices так получится больше {MAX_PIECES} фрагментов +@msg that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer так получится слишком много частей (не более 100000) или шагов штриха: упростите шаблон штрихов или удлините штрихи +@msg that object is not in the overlap этого объекта нет в перекрытии +@msg the Affinity document has no page with a size в документе Affinity нет страницы с заданным размером +@msg the CMYK image couldn't be written изображение CMYK не удалось записать +@msg the Layers panel is not shown панель «Слои» не показана +@msg the Layers panel's search, filter or Show Layers Only hides its row поиск, фильтр или параметр «Показывать только слои» в панели «Слои» скрывает его строку +@msg the Objects on Path group is hidden or locked группа «Объекты на контуре» скрыта или заблокирована +@msg the PDF security settings were saved without their passwords: give openPassword or permissionsPassword again, or security without passwordRequired to export unprotected параметры безопасности PDF сохранены без паролей: снова укажите openPassword или permissionsPassword либо security без passwordRequired, чтобы экспортировать без защиты +@msg the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) в файле RTF слишком много абзацев или изменений форматирования, чтобы поместить его как текст (не более {MAX_PARAGRAPHS} абзацев и {MAX_RUNS} фрагментов форматирования) +@msg the RTF file is too large to place as type (at most {_1} MB) файл RTF слишком велик, чтобы поместить его как текст (не более {_1} МБ) +@msg the SVG expands to more than {0} nested SVG images SVG разворачивается более чем в {0} вложенных изображений SVG +@msg the SVG expands to more than {0} objects SVG разворачивается более чем в {0} объектов +@msg the SVG file is larger than {MAX_IMPORT_BYTES} bytes файл SVG больше {MAX_IMPORT_BYTES} байт +@msg the SVG has no drawable objects в SVG нет отображаемых объектов +@msg the SVGZ file unpacks to more than {_1} MB файл SVGZ распаковывается более чем в {_1} МБ +@msg the Word document has more than {MAX_PAGES} pages of properties, too many to place в документе Word больше {MAX_PAGES} страниц свойств, это слишком много для помещения +@msg the Word document has more than {MAX_RUNS} runs of character or paragraph properties, too many to place в документе Word больше {MAX_RUNS} фрагментов свойств символов или абзацев, это слишком много для помещения +@msg the Word document has more than {_1} MB of formatting properties, too many to place в документе Word больше {_1} МБ свойств форматирования, это слишком много для помещения +@msg the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) в документе Word слишком много текста или форматирования, чтобы поместить его как текст (не более {MAX_PARAGRAPHS} абзацев, {MAX_RUNS} фрагментов форматирования и {_1} МБ текста) +@msg the Word document has too much text to place as type (at most {_1} MB) в документе Word слишком много текста, чтобы поместить его как текст (не более {_1} МБ) +@msg the Word document is encrypted or protected with a password; save it without one to place it документ Word зашифрован или защищён паролем; сохраните его без пароля, чтобы поместить +@msg the Word document is too large to place (its {name} stream holds more than {_1} MB) документ Word слишком велик для помещения (его поток {name} содержит больше {_1} МБ) +@msg the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off документ Word последний раз сохранён с быстрым сохранением, которое VectorCraft не читает; сохраните его снова, отключив быстрое сохранение +@msg the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document документ Word сохранён в Word 6.0 или Word 95, а этот формат VectorCraft не читает; сохраните его как документ Word 97-2003 +@msg the Word document's text is made of more than {MAX_PIECES} pieces, too many to place текст документа Word состоит более чем из {MAX_PIECES} частей, это слишком много для помещения +@msg the XML operation needs an XML or XMLList value для операции XML нужно значение XML или XMLList +@msg the XPath expression does not evaluate to a node-set выражение XPath не даёт набор узлов +@msg the XPath expression ends early выражение XPath обрывается +@msg the XPath expression has an unmatched ) в выражении XPath есть непарная ) +@msg the XPath expression has an unmatched ] в выражении XPath есть непарная ] +@msg the XPath expression has an unmatched closing bracket в выражении XPath есть непарная закрывающая скобка +@msg the XPath expression selects a namespace node, which is not an XML value выражение XPath выбирает узел пространства имён, который не является значением XML +@msg the XPath expression selects the root node, which is not an XML value выражение XPath выбирает корневой узел, который не является значением XML +@msg the XPath function {name} needs a node-set функции XPath {name} нужен набор узлов +@msg the XPath function {name} takes the wrong number of arguments функции XPath {name} передано неверное число аргументов +@msg the XPath name functions need a node-set функциям имён XPath нужен набор узлов +@msg the XPath prefix {shown} is not bound to a namespace префикс XPath {shown} не привязан к пространству имён +@msg the XPath variable ${shown} is not bound переменная XPath ${shown} не привязана +@msg the action set ids are not a list идентификаторы наборов действий не являются списком +@msg the active paint is not a gradient активная краска не является градиентом +@msg the argument list is not an object список аргументов не является объектом +@msg the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size монтажные области не помещаются на холсте: уменьшите их число, интервал или размер +@msg the artwork is too large for EPS: what follows 256 MB of PostScript was left out графика слишком велика для EPS: всё, что следует после 256 МБ PostScript, пропущено +@msg the banner has no width or height у баннера нет ширины или высоты +@msg the bitmaps hold more pixels than one file may растровые изображения содержат больше пикселей, чем допустимо для одного файла +@msg the character's size can't be measured размер символа невозможно измерить +@msg the code runs past its last instruction код выполняется дальше своей последней инструкции +@msg the compound shape is too complex to expand составная фигура слишком сложна, чтобы её разобрать +@msg the container of the listed objects is gone контейнер перечисленных объектов больше не существует +@msg the copy `{user_id}` can't be read as a library копию `{user_id}` невозможно прочитать как библиотеку +@msg the copy would leave the canvas копия вышла бы за пределы холста +@msg the curve tolerance must be from 0.01 to 10 points допуск кривой должен быть от 0.01 до 10 пунктов +@msg the custom dictionary couldn't be saved to {_1}: {e} пользовательский словарь не удалось сохранить в {_1}: {e} +@msg the custom dictionary {_1} can't be read ({why}), so it is left as it is пользовательский словарь {_1} невозможно прочитать ({why}), поэтому он оставлен без изменений +@msg the cut cannot be made on this art: {e} разрез невозможно выполнить на этой графике: {e} +@msg the data isn't an EMF or WMF picture данные не являются рисунком EMF или WMF +@msg the data isn't an EPS or PostScript picture данные не являются рисунком EPS или PostScript +@msg the deferred paste is no longer available отложенная вставка больше недоступна +@msg the design is too big to draw at every data point of this graph дизайн слишком велик, чтобы рисовать его в каждой точке данных этой диаграммы +@msg the design is too big to draw in every column of this graph дизайн слишком велик, чтобы рисовать его в каждом столбце этой диаграммы +@msg the dialog it was opened over is no longer open диалоговое окно, поверх которого оно было открыто, больше не открыто +@msg the dictionary stack overflowed стек словарей переполнен +@msg the document already has an SVG filter named {name} в документе уже есть фильтр SVG с именем {name} +@msg the document has another SVG filter named {name}: use svgFilter.apply, or svgFilter.define with `replace` to edit it в документе есть другой фильтр SVG с именем {name}: используйте svgFilter.apply или, чтобы изменить его, svgFilter.define с `replace` +@msg the document has no SVG filter named {name} в документе нет фильтра SVG с именем {name} +@msg the document has no SVG filter named {r} в документе нет фильтра SVG с именем {r} +@msg the document has no color swatches в документе нет цветовых образцов +@msg the document's swatches hold {_1} colors; the document reduction takes at most 255 образцы документа содержат цветов: {_1}; редукция document принимает не более 255 +@msg the editing data's hash has no place для хеша данных редактирования нет места +@msg the entities the document declares expand to too much text сущности, объявленные в документе, разворачиваются в слишком большой текст +@msg the envelope's content has no bounds у содержимого оболочки нет границ +@msg the envelope's mesh is malformed сетка оболочки повреждена +@msg the file holds no `variableSets` element в файле нет элемента `variableSets` +@msg the file is larger than {_1} MB файл больше {_1} МБ +@msg the file is too large файл слишком велик +@msg the file isn't valid SVG: {e} файл не является допустимым SVG: {e} +@msg the file names no plate файл не указывает ни одной пластины +@msg the file was updated or encrypted already файл уже обновлён или зашифрован +@msg the first character of a line has no character before it to kern: it moves only up and down (dy) перед первым символом строки нет символа для кернинга: он перемещается только вверх и вниз (dy) +@msg the font {_1} is not available шрифт {_1} недоступен +@msg the format has no file extension у формата нет расширения файла +@msg the function's prototype is not an object прототип функции не является объектом +@msg the graphic can't be read: {e} графику невозможно прочитать: {e} +@msg the graphic is empty графика пуста +@msg the graphic's data is damaged данные графики повреждены +@msg the graphic's document has no artboard в документе графики нет монтажной области +@msg the grommet inset is too large for the banner отступ люверсов слишком велик для баннера +@msg the guide is hidden or locked направляющая скрыта или заблокирована +@msg the header `{cell}` for `{name}` would read back as the variable `{other}`: rename one of them заголовок `{cell}` для `{name}` при чтении дал бы переменную `{other}`: переименуйте одну из них +@msg the hem is too deep for the banner подгиб слишком широк для баннера +@msg the image `{path}` isn't followed: {r} переход к изображению `{path}` не выполняется: {r} +@msg the image has no embedded pixels у изображения нет встроенных пикселей +@msg the image is empty изображение пусто +@msg the image is fully transparent изображение полностью прозрачно +@msg the image is not on an artboard; pass rect изображение не находится на монтажной области; передайте rect +@msg the image's pixels are missing пиксели изображения отсутствуют +@msg the image's pixels aren't in the document пикселей изображения нет в документе +@msg the image's pixels can't be read: {e} пиксели изображения невозможно прочитать: {e} +@msg the image's transparency doesn't fit it прозрачность изображения не соответствует ему +@msg the job would print more than {MAX_PAGES} pages задание напечатало бы больше {MAX_PAGES} страниц +@msg the job would print more than {_1} pages задание напечатало бы больше {_1} страниц +@msg the layers hold no art to mask в слоях нет графики для маски +@msg the layout moved расположение изменилось +@msg the library has a group `{name}` already в библиотеке уже есть группа `{name}` +@msg the library has no group `{name}` в библиотеке нет группы `{name}` +@msg the library has no item `{item}` в библиотеке нет элемента `{item}` +@msg the library has no item `{other}` в библиотеке нет элемента `{other}` +@msg the library holds {MAX_GROUPS} groups already в библиотеке уже наибольшее число групп: {MAX_GROUPS} +@msg the library holds {MAX_ITEMS} items of this kind already в библиотеке уже наибольшее число элементов этого вида: {MAX_ITEMS} +@msg the library's file is outside the read roots файл библиотеки находится вне папок, разрешённых для чтения +@msg the line is too long, or the paths it passes too many, to check линия слишком длинна или пересекает слишком много контуров для проверки +@msg the method needs an XML value методу нужно значение XML +@msg the name is empty имя пусто +@msg the object has no fill, stroke or effect у объекта нет заливки, обводки или эффекта +@msg the object has no graphic style (pass `name`) у объекта нет графического стиля (передайте `name`) +@msg the objects don't match the plan объекты не соответствуют плану +@msg the open and permissions passwords must differ пароль для открытия и пароль прав доступа должны различаться +@msg the operand stack overflowed стек операндов переполнен +@msg the page has no area у страницы нет площади +@msg the page's type in an unread object's box isn't in its page art текста страницы в рамке непрочитанного объекта нет в графике этой страницы +@msg the paint behind the proxy is not a gradient краска за индикатором не является градиентом +@msg the paint is not a freeform gradient (apply one with paint.editGradient {kind}) краска не является произвольным градиентом (примените его через paint.editGradient {kind}) +@msg the parameters or body do not form one function параметры или тело не образуют одну функцию +@msg the parent must be a layer or sublayer родитель должен быть слоем или подслоем +@msg the pasted SVG filter {name} draws differently from the document's filter of that name, and no free name fits it вставленный фильтр SVG {name} рисует иначе, чем фильтр документа с тем же именем, и свободного имени для него не нашлось +@msg the path can't be extended контур невозможно продлить +@msg the path can't be inside one of the objects to attach контур не может находиться внутри одного из прикрепляемых объектов +@msg the path has no segments to measure у контура нет сегментов для измерения +@msg the path is empty контур пуст +@msg the path is too short to shorten that much контур слишком короток, чтобы укоротить его настолько +@msg the paths are too many or too tangled to find where they cross контуров слишком много, или они слишком запутаны, чтобы найти их пересечения +@msg the pen table holds more than 255 pens таблица перьев содержит больше 255 перьев +@msg the picture has no objects в рисунке нет объектов +@msg the plates can't be kept пластины невозможно сохранить +@msg the position table does not match the instructions таблица позиций не соответствует инструкциям +@msg the program runs too long программа выполняется слишком долго +@msg the program uses too much memory программа использует слишком много памяти +@msg the prompt's text is longer than {MAX_PROMPT_BYTES} bytes текст запроса длиннее {MAX_PROMPT_BYTES} байт +@msg the radix must be between 2 and 36 основание системы счисления должно быть от 2 до 36 +@msg the rectangle must have a width and a height у прямоугольника должны быть ширина и высота +@msg the rectangle needs a size and must lie on the canvas прямоугольнику нужен размер, и он должен находиться на холсте +@msg the rectangle or line reaches too many paths to check прямоугольник или линия затрагивает слишком много контуров для проверки +@msg the right side of 'in' is not an object правая часть 'in' не является объектом +@msg the right side of 'instanceof' is not a function правая часть 'instanceof' не является функцией +@msg the selected art has no width у выделенной графики нет ширины +@msg the selected art is nested more than {MAX_DEPTH} levels deep выделенная графика вложена глубже {MAX_DEPTH} уровней +@msg the selected art is too large to measure выделенная графика слишком велика для измерения +@msg the selection draws nothing выделение ничего не рисует +@msg the selection has no area у выделения нет площади +@msg the selection has no art to export в выделении нет графики для экспорта +@msg the selection has no bounds у выделения нет границ +@msg the selection has no graphic style у выделения нет графического стиля +@msg the selection has no outlines to weed around в выделении нет контуров, вокруг которых делается выборка +@msg the slice would leave the canvas фрагмент вышел бы за пределы холста +@msg the spacing makes more than {MAX_GROMMETS} grommets при таком интервале получается больше {MAX_GROMMETS} люверсов +@msg the spot colour couldn't be added плашечный цвет не удалось добавить +@msg the subpath has no ends to extend (it is closed or a single point) у вложенного контура нет концов для продления (он замкнут или состоит из одной точки) +@msg the symbols are nested too deeply to expand their instances символы вложены слишком глубоко, чтобы разобрать их экземпляры +@msg the text file is too large to place as type (at most {_1} MB) текстовый файл слишком велик, чтобы поместить его как текст (не более {_1} МБ) +@msg the text is empty текст пуст +@msg the text is over {_1} MB текст больше {_1} МБ +@msg the top object must be a path верхний объект должен быть контуром +@msg the trace was cancelled трассировка отменена +@msg the trailer has no size в трейлере нет размера +@msg the type object is flattened to nothing после сведения от текстового объекта ничего не остаётся +@msg the value bind was called on is not a function значение, для которого вызван bind, не является функцией +@msg the value is not a constructor значение не является конструктором +@msg the value is not a function значение не является функцией +@msg the value is not a valid XML name значение не является допустимым именем XML +@msg the value is not an XML attribute name значение не является именем атрибута XML +@msg the value is not an XML name значение не является именем XML +@msg the value of axis `{tag}` must be a number or null значение оси `{tag}` должно быть числом или null +@msg the variable library declares more than {MAX_LIBRARY_DATASETS} data sets библиотека переменных объявляет больше {MAX_LIBRARY_DATASETS} наборов данных +@msg the variable library declares more than {MAX_LIBRARY_ENTITIES} entities библиотека переменных объявляет больше {MAX_LIBRARY_ENTITIES} сущностей +@msg the variable library declares more than {MAX_LIBRARY_VARIABLES} variables библиотека переменных объявляет больше {MAX_LIBRARY_VARIABLES} переменных +@msg the variable library declares no variables библиотека переменных не объявляет ни одной переменной +@msg the variable library is larger than {MAX_LIBRARY_BYTES} bytes библиотека переменных больше {MAX_LIBRARY_BYTES} байт +@msg the variable library is not UTF-8 библиотека переменных не в кодировке UTF-8 +@msg the variable library is not well-formed XML: {e} библиотека переменных не является корректным XML: {e} +@msg the variable library nests elements more than {MAX_LIBRARY_NESTING} deep в библиотеке переменных элементы вложены глубже {MAX_LIBRARY_NESTING} уровней +@msg the variable library's document type declaration holds something other than entities of plain text объявление типа документа библиотеки переменных содержит что-то кроме сущностей с простым текстом +@msg the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes сущности библиотеки переменных разворачиваются более чем в {MAX_LIBRARY_BYTES} байт +@msg the weeding lines cross the art's outlines too many times to measure; use fewer lines линии выборки пересекают контуры графики слишком много раз для измерения; используйте меньше линий +@msg the weeding lines pass too close to too many outline segments to measure; use fewer lines or a smaller gap линии выборки проходят слишком близко к слишком большому числу сегментов контуров для измерения; используйте меньше линий или меньший зазор +@msg the weeding lines would be more than {max_pieces} pieces линии выборки состояли бы более чем из {max_pieces} частей +@msg the window has no dock у окна нет области прикрепления +@msg the written PDF has no catalog в записанном PDF нет каталога +@msg the written PDF has no cross-reference table в записанном PDF нет таблицы перекрёстных ссылок +@msg the written PDF's metadata can't be edited метаданные записанного PDF невозможно изменить +@msg the {_1} plate lies outside the file пластина {_1} находится за пределами файла +@msg there are no colour swatches to draw нет цветовых образцов для отрисовки +@msg there is already a design named `{name}` дизайн с именем `{name}` уже существует +@msg there is already a design named `{to}` дизайн с именем `{to}` уже существует +@msg there is already a toolbar named `{name}` панель инструментов с именем `{name}` уже существует +@msg there is already a toolbar named `{to}` панель инструментов с именем `{to}` уже существует +@msg there is no banner to mark нет баннера для разметки +@msg there is no library: create one first библиотеки нет: сначала создайте её +@msg these paths are too degenerate for Shaper эти контуры слишком вырождены для Shaper +@msg this is a binary DXF file, which can't be read: save it from the CAD app as an ASCII DXF and open that это двоичный файл DXF, который невозможно прочитать: сохраните его из САПР как ASCII DXF и откройте полученный файл +@msg this warp's points can't be edited: use Reset with Mesh first точки этой деформации нельзя редактировать: сначала используйте «Сбросить с сеткой» +@msg thumbnail: no page {v} (the {what} has {count}) thumbnail: страница {v} не существует ({what}, всего страниц: {count}) +@msg tileRange of {what}: {e} tileRange для {what}: {e} +@msg title can't be empty title не может быть пустым +@msg to (the new name) is required требуется to (новое имя) +@msg to must be fill or stroke to должно быть fill или stroke +@msg to must be smooth or corner to должно быть smooth или corner +@msg toExponential() digits must be between 0 and 20 число цифр для toExponential() должно быть от 0 до 20 +@msg toFixed() digits must be between 0 and 20 число цифр для toFixed() должно быть от 0 до 20 +@msg toISOString is not a function toISOString не является функцией +@msg toPrecision() argument must be between 1 and 21 аргумент toPrecision() должен быть от 1 до 21 +@msg toSource: a getter cannot be written as source toSource: геттер невозможно записать как исходный код +@msg toSource: an array with properties besides its elements, a property that is not enumerable or another prototype cannot be written as source toSource: массив со свойствами помимо элементов, с неперечислимым свойством или с другим прототипом невозможно записать как исходный код +@msg toSource: an object with a property that is not enumerable, or whose prototype is not Object.prototype, cannot be written as source toSource: объект с неперечислимым свойством или с прототипом, отличным от Object.prototype, невозможно записать как исходный код +@msg toSource: the source is too long toSource: исходный код слишком длинный +@msg toSource: the value contains itself toSource: значение содержит само себя +@msg toSource: the value has too many entries toSource: в значении слишком много записей +@msg toSource: the value is nested too deep toSource: значение вложено слишком глубоко +@msg toString is not a function toString не является функцией +@msg too large for a zip archive: package to a folder слишком велико для архива zip: упакуйте в папку +@msg too many Shaper edits слишком много правок Shaper +@msg too many `points` (at most 100000) слишком много `points` (не более 100000) +@msg too many arguments слишком много аргументов +@msg too many characters: at most {MAX_CHARS} слишком много символов: не более {MAX_CHARS} +@msg too many dots for this size: lower the frequency слишком много точек для этого размера: уменьшите частоту +@msg too many filters over one file слишком много фильтров для одного файла +@msg too many overlapping paths for Shaper слишком много перекрывающихся контуров для Shaper +@msg too many quantifiers слишком много квантификаторов +@msg too many saved clips слишком много сохранённых областей обрезки +@msg too many saved graphics states слишком много сохранённых графических состояний +@msg toolbar (a toolbar's name) must be a string toolbar (имя панели инструментов) должно быть строкой +@msg toolbar name can't be empty имя панели инструментов не может быть пустым +@msg transparentImages {v}: "flatten" (the default) or "omit" transparentImages {v}: "flatten" (по умолчанию) или "omit" +@msg turn on at least one side включите хотя бы одну сторону +@msg two saved selections would be named `{_1}` два сохранённых выделения получили бы имя `{_1}` +@msg type dimension cannot be represented with this text transform размер текста невозможно представить с этой трансформацией текста +@msg type dimension must be finite размер текста должен быть конечным +@msg unexpected resources непредвиденные ресурсы +@msg unknown CMYK profile `{name}` неизвестный профиль CMYK `{name}` +@msg unknown File Info field `{k}` неизвестное поле сведений о файле `{k}` +@msg unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; prefix - to turn off; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take an alternate's number as `tag=N` with N from 0 to {_1}, as `salt=2`) неизвестная функция OpenType `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom, aalt; префикс - отключает функцию; swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom и aalt принимают номер альтернативы в виде `tag=N`, где N от 0 до {_1}, например `salt=2`) +@msg unknown OpenType feature `{t}` in featureChanges (the tags `features` takes) неизвестная функция OpenType `{t}` в featureChanges (допустимы теги, которые принимает `features`) +@msg unknown PDF preset `{name}` (presets: {_1}) неизвестный набор PDF `{name}` (наборы: {_1}) +@msg unknown Warp style неизвестный стиль деформации +@msg unknown alignToPath `{a}` неизвестное значение alignToPath `{a}` +@msg unknown arrowhead {v} неизвестная стрелка {v} +@msg unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) неизвестный ключ artboardLayout `{k}` (layout, columns, spacing, rightToLeft) +@msg unknown attribute `{attribute}` неизвестный атрибут `{attribute}` +@msg unknown attribute `{k}` неизвестный атрибут `{k}` +@msg unknown axis `{other}` (vertical|horizontal|free) неизвестная ось `{other}` (vertical|horizontal|free) +@msg unknown blend mode `{b}` неизвестный режим наложения `{b}` +@msg unknown blend mode {b} неизвестный режим наложения {b} +@msg unknown brush type `{o}` неизвестный тип кисти `{o}` +@msg unknown category `{c}` неизвестная категория `{c}` +@msg unknown category `{c}` ({_1}) неизвестная категория `{c}` ({_1}) +@msg unknown character `{c}` неизвестный символ `{c}` +@msg unknown character style number field неизвестное числовое поле стиля символов +@msg unknown dither `{d}` (diffusion, pattern, noise or none) неизвестный дизеринг `{d}` (diffusion, pattern, noise или none) +@msg unknown effect `{e}` неизвестный эффект `{e}` +@msg unknown end {e} неизвестное значение end {e} +@msg unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) неизвестный flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution или сохранённый набор: см. flattener.presets.list) +@msg unknown format `{f}` (vcswatches, gpl, ase or css) неизвестный формат `{f}` (vcswatches, gpl, ase или css) +@msg unknown from {f} неизвестное значение from {f} +@msg unknown graph type `{t}` неизвестный тип диаграммы `{t}` +@msg unknown grid colours `{name}` (one of {_1}) неизвестные цвета сетки `{name}` (допустимо одно из: {_1}) +@msg unknown gridType `{t}` неизвестное значение gridType `{t}` +@msg unknown highlight `{v}` неизвестное значение highlight `{v}` +@msg unknown intent `{i}` неизвестное значение intent `{i}` +@msg unknown issue kind {id} неизвестный вид проблемы {id} +@msg unknown issue kind {v} неизвестный вид проблемы {v} +@msg unknown knockout state {k} неизвестное состояние вырезания {k} +@msg unknown language `{language}` (one of {_1}) неизвестный язык `{language}` (допустимо одно из: {_1}) +@msg unknown language `{l}` (one of {_1}) неизвестный язык `{l}` (допустимо одно из: {_1}) +@msg unknown method `{m}` ({_1}) неизвестный метод `{m}` ({_1}) +@msg unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) неизвестный режим `{other}` (gray, rgb, hsb, lab, cmyk или web) +@msg unknown mode `{other}` (mono|cmyk) неизвестный режим `{other}` (mono|cmyk) +@msg unknown mode `{other}` (rgb|cmyk|gray|global) неизвестный режим `{other}` (rgb|cmyk|gray|global) +@msg unknown mode `{other}` (set, add, subtract) неизвестный режим `{other}` (set, add, subtract) +@msg unknown mode `{o}` неизвестный режим `{o}` +@msg unknown mode {m} неизвестный режим {m} +@msg unknown model `{other}` неизвестная модель `{other}` +@msg unknown model `{other}` (keep, new or document) неизвестная модель `{other}` (keep, new или document) +@msg unknown op {o} неизвестная операция {o} +@msg unknown order `{o}`: toggle, front, forward, backward or back неизвестный порядок `{o}`: toggle, front, forward, backward или back +@msg unknown overprints `{v}` (preserve, simulate or discard) неизвестное значение overprints `{v}` (preserve, simulate или discard) +@msg unknown panel неизвестная панель +@msg unknown panel `{panel}` неизвестная панель `{panel}` +@msg unknown parameter `{key}` неизвестный параметр `{key}` +@msg unknown parameter `{within}settings.{format}`; {why} неизвестный параметр `{within}settings.{format}`; {why} +@msg unknown parameter `{within}{key}`; {why} неизвестный параметр `{within}{key}`; {why} +@msg unknown pieLegend `{v}` (none, standard or wedges) неизвестное значение pieLegend `{v}` (none, standard или wedges) +@msg unknown piePosition `{v}` (even, ratio or stacked) неизвестное значение piePosition `{v}` (even, ratio или stacked) +@msg unknown pieSort `{v}` (none, all or first) неизвестное значение pieSort `{v}` (none, all или first) +@msg unknown plate `{n}` неизвестная пластина `{n}` +@msg unknown preference `{k}` неизвестная настройка `{k}` +@msg unknown profile {id} (see stroke.widthProfile.list) неизвестный профиль {id} (см. stroke.widthProfile.list) +@msg unknown proof target `{t}` неизвестная цель цветопробы `{t}` +@msg unknown quotes key `{k}` (double, single) неизвестный ключ quotes `{k}` (double, single) +@msg unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document) неизвестная редукция `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform или document) +@msg unknown resolution `{v}` неизвестное разрешение `{v}` +@msg unknown rule `{rule}` ({_1}) неизвестное правило `{rule}` ({_1}) +@msg unknown rule `{r}` (see color.harmony) неизвестное правило `{r}` (см. color.harmony) +@msg unknown setting `{k}` неизвестный параметр настройки `{k}` +@msg unknown shape `{v}` (circle|ellipse|square|diamond|line) неизвестная фигура `{v}` (circle|ellipse|square|diamond|line) +@msg unknown spacing `{o}` (smooth|steps|distance) неизвестный интервал `{o}` (smooth|steps|distance) +@msg unknown tileType `{t}` ({_1}) неизвестное значение tileType `{t}` ({_1}) +@msg unknown units неизвестные единицы +@msg unknown units `{u}` неизвестные единицы `{u}` +@msg unknown value axis `{v}` (left, right or both) неизвестная ось значений `{v}` (left, right или both) +@msg unknown variation `{v}` (tintsShades|warmCool|vividMuted) неизвестный вариант `{v}` (tintsShades|warmCool|vividMuted) +@msg unknown warp style `{s}` неизвестный стиль деформации `{s}` +@msg unknown {key} `{v}` (none, short or full) неизвестное значение {key} `{v}` (none, short или full) +@msg unknown {key} profile `{n}` неизвестный профиль {key} `{n}` +@msg unknown {k} key `{key}` (size, position) неизвестный ключ {k} `{key}` (size, position) +@msg unmatched ')' непарная ')' +@msg unreadable object нечитаемый объект +@msg unsafe destination file name небезопасное имя файла назначения +@msg unterminated character class незавершённый класс символов +@msg unterminated group незавершённая группа +@msg use paths or compound paths используйте контуры или составные контуры +@msg useArtboards must be true or false, not {v} useArtboards должно быть true или false, а не {v} +@msg value for `{var}` must match its variable kind значение `{var}` должно соответствовать типу переменной +@msg variable `{_1}` is already defined переменная `{_1}` уже определена +@msg variable `{name}` has no `trait` у переменной `{name}` нет `trait` +@msg variable `{name}` is already defined переменная `{name}` уже определена +@msg variable `{name}` is declared twice переменная `{name}` объявлена дважды +@msg variable `{to}` already exists переменная `{to}` уже существует +@msg view is required требуется view +@msg view must be one of {_1} view должно быть одним из: {_1} +@msg warpStyle can only edit an applied Warp effect warpStyle может изменять только применённый эффект «Деформация» +@msg width and height must be more than 0 and at most {limit} width и height должны быть больше 0 и не больше {limit} +@msg width factors must be finite коэффициенты ширины должны быть конечными +@msg width must be positive width должно быть положительным +@msg width out of range ширина вне диапазона +@msg width/height must be > 0 width/height должны быть > 0 +@msg with no hem and no grommets there is nothing to mark без подгиба и люверсов размечать нечего +@msg wordGap and lineGap need area type in the selection для wordGap и lineGap в выделении нужен текст в области +@msg x and y must be finite numbers x и y должны быть конечными числами +@msg x and y must be given together x и y нужно указывать вместе +@msg xpath needs its variables as an object для xpath переменные нужны в виде объекта +@msg xpath needs the expression as a string для xpath выражение нужно в виде строки +@msg zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End) для zone нужен anchor: прикрепите рядом с панелью через anchor или в новом столбце прикрепления через side (Start или End) +@msg zoom must be positive zoom должно быть положительным +# Messages that begin with a placeholder. +@msg {C}: no effect at index {list} {C}: нет эффекта с индексом {list} +@msg {DEFAULT_PRESET} is protected and stays {DEFAULT_PRESET} защищён и не удаляется +@msg {DEFAULT_PRESET} is protected: save the settings under another name {DEFAULT_PRESET} защищён: сохраните настройки под другим именем +@msg {GRANT_NOT_REMOVED}, and its settings and cache can't be moved back from {_1} to {_2}: {e} {GRANT_NOT_REMOVED}, а его настройки и кэш невозможно переместить обратно из {_1} в {_2}: {e} +@msg {LABEL} produced an empty result {LABEL}: получен пустой результат +@msg {_1} MB of document, at most {_2} MB документ размером {_1} МБ при допустимых {_2} МБ +@msg {_1} MB of images, at most {_2} MB изображения размером {_1} МБ при допустимых {_2} МБ +@msg {_1} bytes of RGBA data for a {width} × {height} image данных RGBA: {_1} байт для изображения {width} × {height} +@msg {_1} can't be read, or changed since it was read: relink or update it first {_1} невозможно прочитать, или файл изменился после чтения: сначала восстановите связь или обновите его +@msg {_1} can't go into {into} {_1} нельзя поместить в {into} +@msg {_1} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing {_1} не может ограничить печать низким разрешением: выберите PDF 1.4 или новее либо разрешите печать с высоким разрешением +@msg {_1} files can't be opened yet файлы {_1} пока нельзя открыть +@msg {_1} files can't be password-protected: remove the passwords or choose no standard файлы {_1} нельзя защитить паролем: уберите пароли или не выбирайте стандарт +@msg {_1} files can't be {_2} файлы {_1} не могут быть {_2} +@msg {_1} files can't carry editing data: turn off preserveEditing файлы {_1} не могут содержать данные редактирования: отключите preserveEditing +@msg {_1} files can't have PDF layers: turn off createLayers файлы {_1} не могут содержать слои PDF: отключите createLayers +@msg {_1} files need a creation date, and this platform has no clock to give one файлам {_1} нужна дата создания, а на этой платформе нет часов, чтобы её получить +@msg {_1} is not a folder: {e} {_1} не является папкой: {e} +@msg {_1} isn't a path {_1} не является контуром +@msg {_1} options: {e} параметры {_1}: {e} +@msg {_1} paints nothing to rasterize {_1} не рисует ничего, что можно растеризовать +@msg {_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) пароли {_1} могут содержать до {MAX_LEGACY_PASSWORD} символов ASCII (PDF 1.7 и новее принимают любые) +@msg {_1} with linked images or an image map writes several files: use document.export {_1} со связанными изображениями или картой изображения записывает несколько файлов: используйте document.export +@msg {_1} writes one file per artboard here: use document.export, or name one artboard здесь {_1} записывает по одному файлу на монтажную область: используйте document.export или укажите одну монтажную область +@msg {_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale {_1} × {_2} пикселей слишком много для {_3} (не более {max} пикселей по стороне): уменьшите масштаб +@msg {_1}: belongs to another account {_1}: принадлежит другой учётной записи +@msg {_1}: not a folder of its own {_1}: не отдельная папка +@msg {_1}: not a plain file {_1}: не обычный файл +@msg {_1}: {_2} and {_3} are in the way {_1}: мешают {_2} и {_3} +@msg {code}: {0} {code}: {0} +@msg {dest}: {e} {dest}: {e} +@msg {dir}: {e} {dir}: {e} +@msg {doc} has no folder у {doc} нет папки +@msg {file} isn't linked {file} не связан +@msg {file} names more than one linked JavaScript file {file} обозначает больше одного связанного файла JavaScript +@msg {folder} is not a package folder {folder} не является папкой пакета +@msg {folder}: more than {max} files and folders {folder}: больше {max} файлов и папок +@msg {folder}: {e} {folder}: {e} +@msg {g} is not a Live Paint group {g} не является группой быстрой заливки +@msg {id} is not a slice {id} не является фрагментом +@msg {key} must be 0–{MAX_ADD_AROUND} pt {key} должно быть 0–{MAX_ADD_AROUND} pt +@msg {key} must be [x,y] {key} должно быть [x,y] +@msg {key} must be a percentage {min}..100 {key} должно быть процентом {min}..100 +@msg {key} must be a positive number, not {n} {key} должно быть положительным числом, а не {n} +@msg {key} must be a positive number, not {v} {key} должно быть положительным числом, а не {v} +@msg {key} must be one of {_1} {key} должно быть одним из: {_1} +@msg {key} must be {_1}, got {s} {key} должно быть {_1}, получено {s} +@msg {key} must name a floating panel {key} должно указывать плавающую панель +@msg {key} must name a panel in another group {key} должно указывать панель в другой группе +@msg {key} must name a panel that shows {key} должно указывать показанную панель +@msg {key} must name a registered panel {key} должно указывать зарегистрированную панель +@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) в файлах {kind} нет прозрачности, а в файле оказалось бы {what}: сначала объедините прозрачность (object.flattenTransparency) +@msg {kind} images can't be opened (see document.formats) изображения {kind} невозможно открыть (см. document.formats) +@msg {k} `{v}` is not one of the choices {k} `{v}` не входит в число допустимых вариантов +@msg {k} has control characters{_1} {k} содержит управляющие символы{_1} +@msg {k} is too long (at most {MAX_TEXT} characters) {k} слишком длинно (не более {MAX_TEXT} символов) +@msg {k} must be > 0 {k} должно быть > 0 +@msg {k} must be a number (pt) or a length such as "210 mm" {k} должно быть числом (pt) или длиной, например "210 mm" +@msg {k} must be a number from {min} to {max} {k} должно быть числом от {min} до {max} +@msg {k} must be a number or "auto" {k} должно быть числом или "auto" +@msg {k} must be a string {k} должно быть строкой +@msg {k} must be text {k} должно быть текстом +@msg {k} must be true or false {k} должно быть true или false +@msg {k} must be {{size?, position?}} {k} должно быть {{size?, position?}} +@msg {k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip {k}: контуру пользовательской стрелки нужна область для заливки, не более {most} опорных точек и {most} вложенных контуров, причём каждая опорная точка должна быть не дальше {far} единиц наконечника от его острия +@msg {k}: bad custom arrowhead path: {e} {k}: недопустимый контур пользовательской стрелки: {e} +@msg {k}: bad custom arrowhead: {e} {k}: недопустимая пользовательская стрелка: {e} +@msg {k}: can't read `{s}` as a length {k}: `{s}` невозможно прочитать как длину +@msg {k}: custom must be a name {k}: custom должно быть именем +@msg {label} must be a number at most {max} {label} должно быть числом не больше {max} +@msg {label} must be a number from -1296 to 1296 {label} должно быть числом от -1296 до 1296 +@msg {label} must be a number from 1 to 10000 {label} должно быть числом от 1 до 10000 +@msg {name} `{s}`: {ids} {name} `{s}`: {ids} +@msg {name} is built in and can't be deleted {name} встроен, его нельзя удалить +@msg {name} must be a number {name} должно быть числом +@msg {name} must be {lo}–{hi}{unit} (got {v}) {name} должно быть {lo}–{hi}{unit} (получено {v}) +@msg {name}: CRC or size mismatch {name}: несовпадение CRC или размера +@msg {name}: 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 before placing {name}: удалось прочитать только встроенный предпросмотр этого файла Affinity; используйте «Файл › Открыть», чтобы увидеть его с предупреждением, или перед помещением экспортируйте SVG или PDF из Affinity +@msg {name}: {e} {name}: {e} +@msg {native}; its embedded preview can't be read either ({e}) {native}; его встроенный предпросмотр тоже невозможно прочитать ({e}) +@msg {path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are {path}: copy принимает файл DCS, только когда его пластины находятся внутри него; восстановите связь с ним там, где находятся файлы его пластин +@msg {path}: larger than {_1} MB {path}: больше {_1} МБ +@msg {saving} failed: {e} {saving}: ошибка: {e} +@msg {shown} is not an XPath 1.0 function {shown} не является функцией XPath 1.0 +@msg {std} files are CMYK, and the file would have colours in {_1} файлы {std} используют CMYK, а в файле оказались бы цвета в {_1} +@msg {std} files embed their fonts, and the file would have one that isn't файлы {std} внедряют свои шрифты, а в файле оказался бы невнедрённый шрифт +@msg {target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway {target} — файл, из которого прочитан этот документ, и при чтении часть содержимого была пропущена ({what}): при записи поверх него она была бы потеряна навсегда. Запишите другой файл или передайте acknowledgeLoss: true, чтобы всё равно заменить его +@msg {ty} brushes need selected art (or params.{k}) кистям {ty} нужна выделенная графика (или params.{k}) +@msg {v} is not an asset id {v} не является идентификатором ресурса +@msg {v} names no layer or sublayer {v} не указывает ни слой, ни подслой +@msg {what} can't be intertwined {what} нельзя переплести +@msg {what} doesn't fit the paper with its bleed and marks {what} не помещается на бумаге вместе с вылетом и метками +@msg {what} is not a function {what} не является функцией +@msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up для {what} на листе печаталось бы больше {MAX_COPIES_PER_SHEET} копий: увеличьте масштаб +@msg {what} would print on more than {MAX_TILES} tiles: scale it down для {what} потребовалось бы больше {MAX_TILES} плиток: уменьшите масштаб +@msg {what} {i} out of range {what} {i} вне диапазона + # Messages of the effects, 3D, image export, color profiles, swatch exchange files, color books and DXF import. @msg 3D effects require finite coordinates within 100 million points 3D-эффектам нужны конечные координаты в пределах 100 миллионов пунктов @msg 3D effects require one to ten lights 3D-эффектам нужно от одного до десяти источников света @@ -7843,3 +9686,4 @@ shape Open Открытый @msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} пикселей — слишком большой размер для JPEG (не более 65535 пикселей по стороне): уменьшите разрешение @msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} пикселей — слишком большой размер для Targa (не более 65535 пикселей по стороне): уменьшите разрешение @msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} пикселей — слишком большой размер для PSD (не более {MAX_SIDE} пикселей по стороне): уменьшите разрешение +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer должен быть "singleLine", "everyLine", "standardSingleLine" или "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index c2a7e589f2..e344e63b8f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -148,6 +148,7 @@ Align to Artboard Justera mot rityta Align to Glyph Bounds Justera mot glyfgränser Align to Key Object Justera mot nyckelobjekt + Align to Key Anchor Justera mot nyckelankarpunkt Align to Page Justera mot sida Align to Path Justera mot bana Align to Selection Justera mot markering @@ -1055,7 +1056,8 @@ font-technology Variable Variabla Every (minutes) Intervall (minuter) Every option is at its default. Alla alternativ har standardvärden. Every option matches {base}. Alla alternativ matchar {base}. - Every-line Composer Flerradssättning + Middle Eastern & South Asian Every-line Composer Flerradssättning (Mellanöstern och Sydasien) + Standard Every-line Composer Standard-flerradssättning Exact dash lengths Exakta strecklängder Exceptions (comma separated) Undantag (kommaavgränsade) Exclude Uteslut @@ -1360,6 +1362,14 @@ font-technology Variable Variabla Previous Artboard Föregående rityta Next Artboard Nästa rityta Last Artboard Sista ritytan + Status Bar Show Visa i statusfältet + Artboard Name Ritytans namn + Current Tool Aktuellt verktyg + Date and Time Datum och tid + Number of Undos Antal ångringar + Document Color Profile Dokumentets färgprofil + Undos Ångringar + Redos Gör om Go To Link Gå till länk GPL palettes keep solid colours only, as RGB. GPL-paletter sparar bara enfärgade färger, som RGB. GPU Performance GPU-prestanda @@ -3374,7 +3384,8 @@ digits Malayalam Malayalam Single Left Quotation Mark Enkelt vänster citattecken Single Quotes: Enkla citattecken: Single Right Quotation Mark Enkelt höger citattecken - Single-line Composer Enradssättare + Middle Eastern & South Asian Single-line Composer Enradssättare (Mellanöstern och Sydasien) + Standard Single-line Composer Standard-enradssättare Toggle Line Composer Växla radsättare Sixth Space Sjättedelsblanksteg Size Storlek @@ -3709,6 +3720,8 @@ digits Malayalam Malayalam Kana Kana Numerals Siffror Scale at Center Skala från mitten + Delete Set Ta bort uppsättning + Direct Entry Direktinmatning Up/Down: Upp/ned: Left/Right: Vänster/höger: Tate-chu-yoko Up/Down Tate-chu-yoko upp/ned @@ -4389,6 +4402,18 @@ axis Both Båda @msg `name` must be the name of a script engine `name` måste vara namnet på en skriptmotor @msg the main script engine is new for each run and is never kept, so it cannot be dropped huvudskriptmotorn är ny för varje körning och sparas aldrig, så den kan inte tas bort @msg there is no script engine named {name} det finns ingen skriptmotor med namnet {name} +@msg `answers` must be a list of answers `answers` måste vara en lista med svar +@msg `answers` has more than {MAX_ANSWERS} answers `answers` har fler än {MAX_ANSWERS} svar +@msg `answers` is larger than 1 MiB `answers` är större än 1 MiB +@msg `answers[{i}]` {e} `answers[{i}]` {e} +@msg must be an object with `kind` and `value` måste vara ett objekt med `kind` och `value` +@msg must have exactly `kind` and `value` måste ha exakt `kind` och `value` +@msg is an alert's answer, whose `value` must be "ok" är svaret på en alert, vars `value` måste vara "ok" +@msg is a confirm's answer, whose `value` must be true or false är svaret på en confirm, vars `value` måste vara true eller false +@msg is longer than 16 KiB är längre än 16 KiB +@msg is a prompt's answer, whose `value` must be a string or null är svaret på en prompt, vars `value` måste vara en sträng eller null +@msg must have `kind` alert, confirm or prompt måste ha `kind` alert, confirm eller prompt +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` är bara för en körning utan någon att fråga: i appen svarar personen på varje dialogruta @msg Running a script Kör ett skript @msg Copied the script report Kopierade skriptrapporten @msg Copied the link info Kopierade länkinfo @@ -5303,6 +5328,11 @@ axis Both Båda @msg Imported library {name} Importerade biblioteket {name} RGB: RGB: CMYK: CMYK: + RGB Policy: RGB-princip: + CMYK Policy: CMYK-princip: + Preserve Embedded Profiles Behåll inbäddade profiler + Convert to Working Space Konvertera till arbetsfärgrymd + Preserve Numbers Behåll värden Texture Textur Craquelure… Krakelering… Grain… Korn… @@ -6525,6 +6555,7 @@ axis Y Axis Y-axel @msg needs approval: it asks for {0}, which the user grants when installing it on its own kräver godkännande: det begär {0}, som användaren beviljar när pluginet installeras för sig @msg `angle` must be a number `angle` måste vara ett tal @msg `aspect` must be from 0.01 to 100 `aspect` måste vara från 0.01 till 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` måste vara [a, b, c, d], vart och ett från -1000 till 1000, med a·d - b·c minst 0.001 från 0 @msg `cap` must be "butt", "round" or "square" `cap` måste vara "butt", "round" eller "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` måste vara "nonzero" eller "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` måste vara en https://-adress på högst {MAX_HELP_URL} byte @@ -6577,6 +6608,7 @@ axis Y Axis Y-axel @msg stops must be in order of `offset` stoppen måste vara ordnade efter `offset` @msg the document has no pattern {pattern} dokumentet har inget mönster {pattern} @msg the document has no swatch {n} dokumentet har ingen färgruta {n} +@msg `plates` requires a stop linked to a spot swatch `plates` kräver ett gradientstopp som är länkat till en dekorfärgruta @msg the output utdata @msg the path has no anchor points banan har inga ankarpunkter @msg too many anchor points (limit {MAX_ANCHORS}) för många ankarpunkter (gräns {MAX_ANCHORS}) @@ -7566,7 +7598,8 @@ digits Hindi Hindi @msg {g} is not a Live Paint group {g} är inte en direktmålningsgrupp @msg {id} is not a slice {id} är inte en skiva @msg {k} `{v}` is not one of the choices {k} `{v}` är inte ett av valen -@msg {k} has control characters{_1} {k} innehåller styrtecken{_1} +@msg {k} has control characters {k} innehåller styrtecken +@msg {k} has control characters or line breaks {k} innehåller styrtecken eller radbrytningar @msg {k} is too long (at most {MAX_TEXT} characters) {k} är för lång (högst {MAX_TEXT} tecken) @msg {k} must be {{size?, position?}} {k} måste vara {{size?, position?}} @msg {k} must be > 0 {k} måste vara > 0 @@ -8001,7 +8034,7 @@ digits Hindi Hindi @msg column: true floats the whole column and takes no onto, above or below column: true gör hela kolumnen flytande och tar inte onto, above eller below @msg command must be a shape.* command command måste vara ett shape.*-kommando @msg commands[{i}]: missing `command` commands[{i}]: `command` saknas -@msg composer must be "singleLine" or "everyLine" composer måste vara "singleLine" eller "everyLine" +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer måste vara "singleLine", "everyLine", "standardSingleLine" eller "standardEveryLine" @msg compound {_1} must contain exactly one open path to join sammansatt {_1} måste innehålla exakt en öppen bana att foga samman @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes sammansatta former tar banor, sammansatta banor, grupper, sammansatta former, text, övergångar och kuvert @msg connect end must be "start" or "end" connect end måste vara "start" eller "end" @@ -8194,6 +8227,7 @@ digits Hindi Hindi @msg Image Trace failed: internal error: its thread stopped (please report this bug) Bildkalkering misslyckades: internt fel: dess tråd stoppades (rapportera gärna felet) @msg Image Trace finished, but its document was closed Bildkalkeringen är klar, men dess dokument har stängts @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Bildkalkeringen är klar, men bilden eller dess inställningar ändrades under tiden: vektorisera den igen +@msg Image Trace finished, but its document was reverted: trace it again Bildkalkeringen är klar, men dess dokument har återgått till sparad version: vektorisera den igen @msg imageMap `{m}`: none, client or server imageMap `{m}`: none, client eller server @msg index out of range index utanför intervallet @msg instances must be 1..1000 instances måste vara 1..1000 diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 44db5d3dfd..b632e5cac3 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2194,6 +2194,42 @@ fn recolor_notes_on_objects_left_unchanged_translate_with_their_counts() { } } +/// `file.info`'s refusal of a value with control characters is two whole messages, one for the +/// fields that take line breaks and one for the fields that don't, and each is translated in every +/// complete-message language with the field's key kept as written. +#[test] +fn file_info_control_character_refusals_translate_in_every_complete_catalog() { + use serde_json::json; + let literals = message_literals_in(&["engine"]); + for key in ["{k} has control characters", "{k} has control characters or line breaks"] { + assert!(literals.contains(key), "the message scan doesn't find {key:?}"); + } + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let refusals = [ + (json!({"description": "a\u{7}b"}), "description", "has control characters"), + (json!({"copyrightNotice": "a\u{7}b"}), "copyrightNotice", "has control characters"), + (json!({"title": "a\nb"}), "title", "has control characters or line breaks"), + (json!({"author": "a\nb"}), "author", "has control characters or line breaks"), + (json!({"authorTitle": "a\rb"}), "authorTitle", "has control characters or line breaks"), + (json!({"copyrightUrl": "a\u{7}b"}), "copyrightUrl", "has control characters or line breaks"), + (json!({"keywords": ["ok", "a\tb"]}), "keywords", "has control characters or line breaks"), + ]; + for (params, field, rule) in refusals { + let error = s.execute("file.info", ¶ms).unwrap_err().to_string(); + assert_eq!(error, format!("invalid parameters for `file.info`: {field} {rule}")); + for code in COMPLETE_MESSAGES { + let shown = message(Lang::from_code(code).expect("registered"), &error); + // Whole runs of Latin letters, so that Spanish's "control" is not read as English and + // Korean's `description에` still shows the key. + let words: Vec<&str> = shown.split(|c: char| !c.is_ascii_alphabetic()).collect(); + let english = ["has", "characters", "or", "line", "breaks"]; + assert!(words.iter().all(|w| !english.contains(w)), "{code}: {shown}"); + assert!(words.contains(&field), "{code}: {shown}"); + } + } +} + /// Every complete-message catalog has a row for each error that `type.compositeFonts.set`, /// `.export` and `.import`, the composite font checks behind them and the Composite Fonts dialog /// give, and translates them with the font name, the key and the class key kept, and the reason @@ -2345,6 +2381,26 @@ fn a_repeated_placeholder_matches_the_same_text() { assert!(c.message("alert: the next answer is for prompt, not confirm").is_none()); } +/// Of two templates that start with text, the one with the longer text before its first +/// placeholder matches first, so a row that wraps a message takes it before a row with more text +/// in all whose placeholder would take part of it; a quoted name may still hold a backtick. +#[test] +fn the_longer_leading_text_matches_first() { + let c = Catalog::parse(concat!( + "@msg\t`{key}` must be true or false\tkey {key}\n", + "@msg\t`answers[{i}]` {e}\tanswer {i} {e}\n", + "@msg\tno brush named `{name}`\tbrush {name}\n", + )); + let (form, caps) = c.message("`select` must be true or false").expect("matches"); + assert_eq!((form, caps), ("key {key}", vec![("key", "select")])); + let (form, caps) = c.message("`answers[3]` is a confirm's answer, whose `value` must be true or false").expect("matches"); + assert_eq!((form, caps), ("answer {i} {e}", vec![("i", "3"), ("e", "is a confirm's answer, whose `value` must be true or false")])); + let (form, caps) = c.message("`a`b` must be true or false").expect("matches"); + assert_eq!((form, caps), ("key {key}", vec![("key", "a`b")])); + let (form, caps) = c.message("no brush named `a`b`").expect("matches"); + assert_eq!((form, caps), ("brush {name}", vec![("name", "a`b")])); +} + /// A template that starts with text matches a message it wraps before a template that starts /// with a placeholder, even one with more literal text, so the wrapped reason is translated by /// its own row. @@ -3157,6 +3213,72 @@ fn f(text: &str, n: usize) -> Result { assert!(message_literals_in(&["effects"]).contains("the SVG filter isn't valid XML: {e}")); } +/// The scan reads the message a `.map_err` closure gives with `return`, also from inside an `if` +/// branch or a `let` statement but not from a closure inside it, the last expression after a block +/// statement with no `;`, and the values a `let` statement gives a local that the closure returns, +/// and not those of a local the closure only passes elsewhere or writes into a format string, by +/// name or as a `format!` argument. +#[test] +fn message_scan_reads_returns_and_tails_and_skips_locals() { + let code = r#" +fn f(text: &str) -> Result { + let a = read(text).map_err(|_| { + return "the file cannot be read".to_string(); + })?; + let b = read(text).map_err(|e| { + if e.is_io() { + return format!("the disk failed: {e}"); + } + e.to_string() + })?; + let c = read(text).map_err(|e| { + if e.is_io() { cleanup(); } + format!("the file is cut short: {e}") + })?; + let d = read(text).map_err(|e| { + if e.is_io() { cleanup(); } + let level = if e.is_io() { "warning" } else { "error" }; + log_failure(level, &e); + format!("the file is not a library: {e}") + })?; + let g = read(text).map_err(|e| { + let kind = match e.kind() { + Kind::Gone => return GONE.into(), + _ => { "some other kind" } + }; + format!("the {kind} file is wrong") + })?; + let h = read(text).map_err(|e| { + let name = e.name().unwrap_or_else(|| return "unnamed source"); + let note = e.note().map(move |n| { return "noted source" }); + format!("the source {name} is gone") + })?; + let k = read(text).map_err(|e| { + let why = if e.is_deep() { format!("its objects are nested too deep ({e})") } else { e.to_string() }; + FormatError::NotVectorcraft(why) + })?; + let m = read(text).map_err(|e| { + let level = if e.is_io() { "warning" } else { "error" }; + format!("the file failed with a {}", level) + })?; + Ok(a) +} +"#; + let consts = [("GONE", "the file is gone")].map(|(k, v)| (k.to_string(), vec![v.to_string()])).into(); + let want = [ + "the file cannot be read", + "the disk failed: {e}", + "the file is cut short: {e}", + "the file is not a library: {e}", + "the file is gone", + "the {kind} file is wrong", + "the source {name} is gone", + "its objects are nested too deep ({e})", + "the file failed with a {_1}", + ]; + assert_eq!(source_messages(code, &[], &consts), want); +} + /// The scan reads the messages of an error handler that a `.map_err(name)` names: a closure bound /// to that name, up to its `;`, and a function of that name, each of its statements. #[test] @@ -3691,6 +3813,96 @@ fn dataset_import_repeated_column_error_is_translated() { } } +/// The scan reads the refusals `script.run`'s `param(msg)` gives, and not the arguments of a type's +/// own function of that name. +#[test] +fn message_scan_reads_script_run_param_refusals() { + let code = r#" +fn f(p: &Value) -> Result<()> { + let grays = Grays::param(C, p)?; + let pick = Pick::param(p, C)?; + let _ = cmd::bad(C, "the name is too long"); + Err(param("`path` is empty")) +} +"#; + let engine: Vec<_> = ERROR_HELPERS.iter().filter(|(c, _, _)| *c == "engine").map(|&(_, call, before)| (call, before)).collect(); + let consts = [("C".to_string(), vec!["view.proofSetup".to_string()])].into(); + assert_eq!(source_messages(code, &engine, &consts), ["the name is too long", "`path` is empty"]); +} + +/// `script.run`'s refusals of `answers`, the list's own, an answer's at its index and the refusal +/// of `answers` in the app, are translated in every language that translates every message, with +/// the index, the counts and the code names kept. The command's error stays in English. +#[test] +fn script_answers_errors_are_translated() { + use serde_json::{Value, json}; + use vectorcraft_engine::{EngineError, Session}; + let reasons = [ + "must be an object with `kind` and `value`", + "must have exactly `kind` and `value`", + "is an alert's answer, whose `value` must be \"ok\"", + "is a confirm's answer, whose `value` must be true or false", + "is longer than 16 KiB", + "is a prompt's answer, whose `value` must be a string or null", + "must have `kind` alert, confirm or prompt", + ]; + let in_app = "`answers` is only for a run with nobody to ask: in the app, the person answers each dialog"; + let lists = ["`answers` must be a list of answers", "`answers` has more than {MAX_ANSWERS} answers", "`answers` is larger than 1 MiB"]; + let scanned = message_literals(); + for template in reasons.into_iter().chain(lists).chain(["`answers[{i}]` {e}", in_app]) { + assert!(scanned.contains(template), "{template}"); + } + let ok = json!({"kind": "alert", "value": "ok"}); + let wrong = [ + json!(1), + json!({"kind": "alert"}), + json!({"kind": "alert", "value": "yes"}), + json!({"kind": "confirm", "value": "yes"}), + json!({"kind": "prompt", "value": "x".repeat(16 * 1024 + 1)}), + json!({"kind": "prompt", "value": 1}), + json!({"kind": "beep", "value": 1}), + ]; + let run = |s: &mut Session, answers: Value| s.execute("script.run", &json!({"source": "1", "answers": answers})).unwrap_err(); + let mut s = Session::new(); + // Each error, with the English message the command gives and the reason an answer's holds. + let mut errors = vec![]; + for (reason, answer) in reasons.into_iter().zip(wrong) { + for at in [3, 9_999] { + let mut list = vec![ok.clone(); at]; + list.push(answer.clone()); + errors.push((run(&mut s, json!(list)), format!("`answers[{at}]` {reason}"), Some(reason))); + } + } + errors.push((run(&mut s, json!("ok")), lists[0].to_string(), None)); + errors.push((run(&mut s, json!(vec![ok.clone(); 10_001])), "`answers` has more than 10000 answers".to_string(), None)); + let long = json!({"kind": "prompt", "value": "x".repeat(16 * 1024)}); + errors.push((run(&mut s, json!(vec![long; 70])), lists[2].to_string(), None)); + s.script_frames = true; + errors.push((run(&mut s, json!([ok])), in_app.to_string(), None)); + for (e, want, reason) in &errors { + assert!(matches!(e, EngineError::BadParams { cmd, msg } if cmd == "script.run" && msg == want), "{e}"); + let line = e.to_string(); + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let shown = message(lang, &line); + let inner = message(lang, want); + assert_ne!(inner, *want, "{code}"); + assert!(shown.contains(&inner) && !shown.contains("invalid parameters"), "{code}: {shown}"); + if let Some(reason) = reason { + let translated = message(lang, reason); + assert_ne!(translated, *reason, "{code}"); + assert!(inner.contains(&translated), "{code}: {inner}"); + } + let numbers: Vec<&str> = want.split(|c: char| !c.is_ascii_digit()).filter(|n| !n.is_empty()).collect(); + let names = ["`answers", "`kind`", "`value`", "true", "false", "null"]; + assert!(numbers.iter().chain(&names).all(|n| !want.contains(n) || shown.contains(n)), "{code}: {shown}"); + if want.ends_with("alert, confirm or prompt") { + assert!(["alert", "confirm", "prompt"].iter().all(|k| shown.contains(k)), "{code}: {shown}"); + } + } + } +} + /// The errors of a command's parameter schema are translated in every language that translates /// every message, with the parameter's path and the refused value kept. #[test] @@ -3877,7 +4089,8 @@ fn bracketed_message_parts_translate_only_as_exact_messages() { let point = message(lang, "[x, y] (two numbers)"); assert_ne!(point, "[x, y] (two numbers)", "{code}"); assert!(point.contains("[x, y]"), "{code}: {point}"); - for got in ["[1]", "[1, 2, 3]", r#"{"objects": [1]}"#, r#"[{"a": 1}, "b: c", …"#] { + // ``["] `x`: b"]`` fits the text of the effect refusal ``[{j}] `{_1}`: {why}`` but not its index. + for got in ["[1]", "[1, 2, 3]", r#"{"objects": [1]}"#, r#"[{"a": 1}, "b: c", …"#, r#"["] `x`: b"]"#] { let shown = message(lang, &format!("`center` must be [x, y] (two numbers), not {got}")); assert!(shown.contains(&point) && shown.contains(got), "{code}: {shown}"); let shown = message(lang, &format!("`p` must be an object, not {got}")); @@ -3886,6 +4099,42 @@ fn bracketed_message_parts_translate_only_as_exact_messages() { } } +/// An effect refusal inside another message (`plug-in output: …`, and an importer's `can't open …`) +/// is translated through its template with its reason, keeping the index and the effect id. +#[test] +fn nested_effect_refusals_translate_with_their_reason() { + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let refusal = "[0] `fx`: nothing selected"; + let output = format!("plug-in output: objects[0].effects: {refusal}"); + for shown in [message(lang, refusal), message(lang, &output), message(lang, &format!("can't open `a.svg`: {output}"))] { + assert!(!shown.contains("nothing selected") && shown.contains("[0] `fx`"), "{code}: {shown}"); + } + } + let de = de(); + let refusal = "[0] `fx`: `radius` must be a number"; + assert_eq!(message(de, refusal), "[0] `fx`: `radius` muss eine Zahl sein"); + let output = format!("plug-in output: objects[0].effects: {refusal}"); + assert_eq!(message(de, &output), "Plug-in-Ausgabe: objects[0].effects: [0] `fx`: `radius` muss eine Zahl sein"); + assert_eq!( + message(de, &format!("can't open `a.svg`: {output}")), + "`a.svg` kann nicht geöffnet werden: Plug-in-Ausgabe: objects[0].effects: [0] `fx`: `radius` muss eine Zahl sein" + ); + assert_eq!( + message(de, "plug-in output: objects[12].effects: [3] `fx`: no row for this reason"), + "Plug-in-Ausgabe: objects[12].effects: [3] `fx`: no row for this reason" + ); + let selected = "[1] `fx`: nothing selected"; + assert_eq!( + message(de, &format!("plug-in output: objects[0].effects: {selected}")), + "Plug-in-Ausgabe: objects[0].effects: [1] `fx`: nichts ausgewählt" + ); + // Past the depth limit the refusal stays as it is. + let deep = format!("plug-in output: plug-in output: plug-in output: plug-in output: objects[0].effects: {selected}"); + let shown = message(de, &deep); + assert!(shown.ends_with(selected), "{shown}"); +} + /// The warning for a PDF/X file that leaves out the document's bleed is translated in every catalog, /// alone and in the status bar's note. #[test] @@ -4041,6 +4290,27 @@ fn dataset_import_cell_errors_are_translated() { } } +/// Russian shows command, script and file messages in Russian: a whole message, a message that +/// begins with a feature name, messages with a name, a number or a command id filled in, and the +/// two messages whose catalog rows escape a backslash. +#[test] +fn russian_translates_command_script_and_file_messages() { + let ru = Lang::from_code("ru").expect("registered"); + for (english, russian) in [ + ("CSV is empty", "CSV пуст"), + ("Offset Path: the path is too complex to offset", "Смещение контура: контур слишком сложен для смещения"), + ("no swatch named `Gold`", "нет образца с именем `Gold`"), + ("object 12 is not an Intertwine group", "объект 12 не является группой переплетения"), + ("no slice 4 in the image (see slice.list)", "в изображении нет фрагмента 4 (см. slice.list)"), + ("give origin: [x, y], or reset: true", "укажите origin: [x, y] или reset: true"), + ("`kinsoku` must be \"none\", \"hard\" or \"soft\"", "`kinsoku` должно быть \"none\", \"hard\" или \"soft\""), + ("\\ at end of pattern", "\\ в конце шаблона"), + ("a \\u escape needs four hexadecimal digits", "после \\u нужны четыре шестнадцатеричные цифры"), + ] { + assert_eq!(message(ru, english), russian, "{english}"); + } +} + /// The errors of the parameter checks that many commands share (a number given as text, a missing /// number or index, a fraction for a whole number, `ids` that aren't a list of object ids, an /// `ids` entry or an `id` that is negative, an object id that names no object, a date that isn't @@ -4536,14 +4806,15 @@ const MESSAGE_MARKERS: &[&str] = &[".status(", "ui.status = ", "status = ", "Oth /// message: the engine's `bad(cmd, msg)` (`EngineError::BadParams`), `err(msg)` (`cmd/fileio`) /// and `error(msg)` (`cmd/dimension.rs`), the script host's `fail(msg)` (`script/files.rs`, /// `script/dollar.rs`) and `value(msg)`, which gives the messages the script `File` object's own -/// code throws (`script/files.rs`), and `.over(limit)` for a limit a run passes (`script/mod.rs`), -/// the PDF writer's `bad(why)` (`forms.rs`) and `failed(why)` (`encrypt.rs`, `linearize.rs`), the +/// code throws (`script/files.rs`), `.over(limit)` for a limit a run passes (`script/mod.rs`) and +/// `param(msg)` for `script.run`'s parameters (`cmd/scripts.rs`), the PDF writer's `bad(why)` (`forms.rs`) and `failed(why)` (`encrypt.rs`, `linearize.rs`), the /// plug-in host's `bad(what)` (a parameter's spec and dialog keys), `wrong(what)` (a parameter's /// value), `out_err(at, msg)` (plug-in output), `in_range(x, lo, hi, at, what)` and /// `unit(x, at, what)` (a painted effect's numbers) and `.read_packed(packed, what, max)` (the block /// a plug-in returned), and the script interpreter's methods that throw an error at a script: /// `.type_error(msg)`, `.throw(kind, msg)` and the XPath evaluator's `.error(msg)`. A helper that -/// starts with `.` is a method of any receiver. +/// starts with `.` is a method of any receiver; any other is a function, not a type's own function +/// of the same name (`Grays::param(C, p)`). const ERROR_HELPERS: &[(&str, &str, usize)] = &[ ("engine", "bad(", 1), ("engine", "err(", 0), @@ -4551,6 +4822,7 @@ const ERROR_HELPERS: &[(&str, &str, usize)] = &[ ("engine", "fail(", 0), ("engine", "value(", 0), ("engine", ".over(", 0), + ("engine", "param(", 0), ("pdf", "bad(", 0), ("pdf", "failed(", 0), ("plugins", "bad(", 0), @@ -4733,6 +5005,10 @@ fn source_messages(code: &str, helpers: &[(&str, usize)], consts: &std::collecti let in_helpers = helpers.iter().flat_map(|&(call, before)| { code.match_indices(call) .filter(|&(at, _)| call.starts_with('.') || !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.')) + .filter(|&(at, _)| { + let owner = code[..at].strip_suffix("::").map(|p| p.rsplit(|c: char| !c.is_alphanumeric() && c != '_').next().unwrap_or("")); + !owner.is_some_and(|o| o.starts_with(|c: char| c.is_ascii_uppercase())) + }) // `format!(` given as a helper, and an `error(` helper that is an `#[error(…)]` attribute, take a format string. .filter_map(move |(at, _)| { skip_args(&code[at + call.len()..], before).map(|rest| (rest, call == "format!(" || code[..at].ends_with("#["))) @@ -4846,27 +5122,72 @@ fn statement(code: &str) -> &str { /// Where each value a closure can return starts, `body` being the text after its parameters: the /// body itself, each block or `if`/`else` branch in it (`{ ACB_CUT_SHORT.into() }`, not the -/// `{` of a `json!({…})` value), each `match` arm (`=>`) and each statement after a `;` (the -/// last expression of a block or a function body), up to the bracket that closes the call the -/// closure is given to or the function. +/// `{` of a `json!({…})` value), each `match` arm (`=>`), each statement after a `;` or after a +/// block statement's `}` (the last expression of a block or a function body) and the value of +/// each `return`, up to the bracket that closes the call the closure is given to or the function. +/// The blocks, arms and statements inside a `let` statement give a local its value, so they count +/// only when the local's name is used, outside a string literal and a `format!`, in a value the closure returns +/// (`let why = if … { format!("…") } else { … }; Error(why)`), and not when the local only goes +/// elsewhere (`let level = if … { "warning" } else { "error" }; log(level);`). A `return` counts +/// wherever it is, except inside a closure the body holds (`unwrap_or_else(|| return …)`). fn closure_returns(body: &str) -> Vec<&str> { - let mut out = vec![body]; - let (mut depth, mut chars) = (0usize, body.char_indices()); + let ident = |c: char| c.is_alphanumeric() || c == '_'; + // The name a `let` statement starting at `at` binds (empty for a pattern), if it is one. + let let_at = |at: usize| { + let rest = body[at..].trim_start().strip_prefix("let").filter(|r| r.starts_with(char::is_whitespace))?.trim_start(); + let rest = rest.strip_prefix("mut ").map_or(rest, str::trim_start); + Some(&rest[..rest.find(|c: char| !ident(c)).unwrap_or(rest.len())]) + }; + // Each place a value starts, with the `let` whose local it gives a value to, if any, and + // whether a `return` gives it. + let mut found: Vec<(&str, Option<&str>, bool)> = vec![(body, None, false)]; + // For each open bracket, the `quiet` and `in_let` of the text around it: `quiet` names the + // `let` that a value inside the bracket belongs to, `in_let` the `let` the statement is. + let mut outer = vec![]; + let (mut quiet, mut in_let, mut chars) = (None, let_at(0), body.char_indices()); + // Where the innermost closure in the body that a `return` would leave ends. + let mut inner_closure_end = 0; while let Some((at, c)) = chars.next() { match c { - '(' | '[' => depth += 1, - '{' => { - depth += 1; - if !body[..at].trim_end().ends_with('(') { - out.push(&body[at + 1..]); + '|' if at >= inner_closure_end => { + let before = body[..at].trim_end(); + let after_operator = before.ends_with(['(', ',', '=']) && !["==", "!=", "<=", ">="].iter().any(|op| before.ends_with(op)); + let opens = after_operator || (before.ends_with("move") && !before[..before.len() - 4].ends_with(ident)); + let params_end = if body[at + 1..].starts_with('|') { Some(at + 1) } else { body[at + 1..].find('|').map(|end| at + 1 + end) }; + if let Some(params_end) = params_end.filter(|_| opens) { + inner_closure_end = params_end + 1 + statement(&body[params_end + 1..]).len(); } } - ')' | ']' | '}' => match depth.checked_sub(1) { - Some(d) => depth = d, - None => break, - }, - ';' => out.push(&body[at + 1..]), - '=' if body[at + 1..].starts_with('>') => out.push(&body[at + 2..]), + '(' | '[' | '{' => { + outer.push((quiet, in_let)); + if c == '{' && !body[..at].trim_end().ends_with('(') { + found.push((&body[at + 1..], quiet.or(in_let), false)); + } + quiet = quiet.or(in_let); + in_let = if c == '{' { let_at(at + 1) } else { None }; + } + ')' | ']' | '}' => { + let Some(around) = outer.pop() else { break }; + (quiet, in_let) = around; + if c == '}' { + found.push((&body[at + 1..], quiet.or(in_let), false)); + if in_let.is_none() { + in_let = let_at(at + 1); + } + } + } + ';' => { + found.push((&body[at + 1..], quiet, false)); + in_let = let_at(at + 1); + } + '=' if body[at + 1..].starts_with('>') => found.push((&body[at + 2..], quiet.or(in_let), false)), + 'r' if at >= inner_closure_end + && body[at..].starts_with("return") + && !body[..at].ends_with(ident) + && !body[at + "return".len()..].starts_with(ident) => + { + found.push((&body[at + "return".len()..], None, true)); + } '"' => loop { match chars.next() { Some((_, '\\')) => { @@ -4879,6 +5200,69 @@ fn closure_returns(body: &str) -> Vec<&str> { _ => {} } } + // The values the closure returns: each `return`, and each expression that ends its block + // rather than a `;` (a block's start, which spans the whole block, aside). + let returned: Vec = found + .iter() + .filter(|&&(start, held, by_return)| { + let tail = !start.trim_start().starts_with('{') && !start[statement(start).len()..].starts_with(';'); + held.is_none() && (by_return || tail) + }) + .map(|(start, ..)| without_format_args(&without_strings(statement(start)))) + .collect(); + let used = |name: &str| !name.is_empty() && returned.iter().any(|value| holds_ascii_word(value, name)); + found.into_iter().filter(|&(_, held, _)| held.is_none_or(used)).map(|(start, ..)| start).collect() +} + +/// `code`, its string literals already emptied, with the arguments of each `format!(…)` removed: +/// a value written into a format string is part of a message, not one of its own. +fn without_format_args(code: &str) -> String { + let (mut out, mut rest) = (String::new(), code); + while let Some(at) = rest.find("format!(") { + out.push_str(&rest[..at + "format!(".len()]); + rest = &rest[at + "format!(".len()..]; + let mut depth = 0usize; + let end = rest.char_indices().find_map(|(i, c)| match c { + '(' | '[' | '{' => { + depth += 1; + None + } + ')' | ']' | '}' => match depth.checked_sub(1) { + Some(d) => { + depth = d; + None + } + None => Some(i), + }, + _ => None, + }); + rest = &rest[end.unwrap_or(rest.len())..]; + } + out.push_str(rest); + out +} + +/// `code` with the text of its string literals removed, the quotes kept. +fn without_strings(code: &str) -> String { + let (mut out, mut chars) = (String::new(), code.chars()); + while let Some(c) = chars.next() { + out.push(c); + if c == '"' { + loop { + match chars.next() { + Some('\\') => { + chars.next(); + } + Some('"') => { + out.push('"'); + break; + } + Some(_) => {} + None => break, + } + } + } + } out } diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 4ee1ba27c9..c999e476e1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -145,6 +145,7 @@ Align to Artboard Çalışma Yüzeyine Hizala Align to Glyph Bounds Glif Sınırlarına Hizala Align to Key Object Anahtar Nesneye Hizala + Align to Key Anchor Anahtar Bağlantı Noktasına Hizala Align to Page Sayfaya Hizala Align to Path Yola Hizala Align to Selection Seçime Hizala @@ -1047,7 +1048,10 @@ font-technology Variable Değişken Every (minutes) Her (dakika) Every option is at its default. Her seçenek varsayılan değerdedir. Every option matches {base}. Her seçenek {base} ile eşleşir. - Every-line Composer Her Satır Bestecisi + Middle Eastern & South Asian Single-line Composer Tek Satır Oluşturucu (Orta Doğu ve Güney Asya) + Middle Eastern & South Asian Every-line Composer Her Satır Bestecisi (Orta Doğu ve Güney Asya) + Standard Single-line Composer Standart Tek Satır Oluşturucu + Standard Every-line Composer Standart Her Satır Bestecisi Exact dash lengths Tam çizgi uzunlukları Exceptions (comma separated) İstisnalar (virgülle ayrılmış) Exclude Hariç tut @@ -3250,7 +3254,6 @@ digits Malayalam Malayalam Single Left Quotation Mark Tek Sol Tırnak İşareti Single Quotes: Tek Alıntılar: Single Right Quotation Mark Tek Sağ Tırnak İşareti - Single-line Composer Tek Satır Oluşturucu Toggle Line Composer Satır Oluşturucuyu Değiştir Sixth Space Altıncı Uzay Size Boyut @@ -3573,6 +3576,8 @@ digits Malayalam Malayalam Kana Kana Numerals Rakamlar Scale at Center Merkezden Ölçekle + Delete Set Seti Sil + Direct Entry Doğrudan Giriş Up/Down: Yukarı/Aşağı: Left/Right: Sol/Sağ: Tate-chu-yoko Up/Down Tate-chu-yoko Yukarı/Aşağı @@ -4252,6 +4257,7 @@ axis Both Her İkisi @msg `name` must be the name of a script engine `name` bir betik motorunun adı olmalıdır @msg the main script engine is new for each run and is never kept, so it cannot be dropped ana betik motoru her çalıştırmada yenidir ve hiç saklanmaz, bu yüzden bırakılamaz @msg there is no script engine named {name} {name} adlı bir betik motoru yok +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` yalnızca sorulacak kimsenin olmadığı bir çalıştırma içindir: uygulamada her iletişim kutusunu kişi yanıtlar @msg Running a script Betik çalıştırılıyor @msg Copied the script report Komut dosyası raporu kopyalandı @msg Copied the link info Bağlantı bilgisi kopyalandı @@ -5201,6 +5207,11 @@ axis Both Her İkisi @msg Imported library {name} İçe aktarılan kitaplık {name} RGB: RGB: CMYK: CMYK: + RGB Policy: RGB İlkesi: + CMYK Policy: CMYK İlkesi: + Preserve Embedded Profiles Gömülü Profilleri Koru + Convert to Working Space Çalışma Alanına Dönüştür + Preserve Numbers Sayıları Koru Texture Doku Craquelure… Küçük çatlak… Grain… Tahıl… @@ -6126,6 +6137,14 @@ theme Appearance Görünüm JavaScript: JavaScript: Large Gaps Büyük Boşluklar Last Artboard Son Çalışma Yüzeyi + Status Bar Show Durum Çubuğunda Göster + Artboard Name Çalışma Yüzeyi Adı + Current Tool Geçerli Araç + Date and Time Tarih ve Saat + Number of Undos Geri Alma Sayısı + Document Color Profile Belge Renk Profili + Undos Geri Al + Redos Yinele Levels Düzeyler Library Panel Options Kütüphane Paneli Seçenekleri Link JavaScript Files… JavaScript Dosyalarını Bağla… @@ -6374,6 +6393,8 @@ theme Appearance Görünüm Save Variable Library… Değişken Kitaplığını Kaydet… @msg a variable library holds text variables, and the document has none değişken kitaplığı metin değişkenleri içerir ve belgede hiç metin değişkeni yok @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` `{name}` değişkeni değişken kitaplığına kaydedilemiyor: adını bir harf veya `_` ile başlayacak, boşluk ya da `-`, `_` ve `.` dışında simge içermeyecek şekilde değiştirin +@msg {k} has control characters {k} denetim karakterleri içeriyor +@msg {k} has control characters or line breaks {k} denetim karakterleri veya satır sonları içeriyor @msg data set `{name}` holds a control character, which a variable library can't hold `{name}` veri kümesi, değişken kitaplığının tutamayacağı bir denetim karakteri içeriyor @msg data set `{name}` starts or ends with white space, which a variable library can't hold `{name}` veri kümesi, değişken kitaplığının tutamayacağı bir boşlukla başlıyor veya bitiyor Long Uzun @@ -6668,6 +6689,7 @@ axis Y Axis Y ekseni @msg stops must be in order of `offset` duraklar `offset` sırasında olmalıdır @msg the document has no pattern {pattern} belgede {pattern} deseni yok @msg the document has no swatch {n} belgede {n} renk örneği yok +@msg `plates` requires a stop linked to a spot swatch `plates`, nokta rengi örneğine bağlı bir degrade durağı gerektirir @msg the output çıktı @msg the path has no anchor points yolun bağlantı noktası yok @msg too many anchor points (limit {MAX_ANCHORS}) çok fazla bağlantı noktası (sınır {MAX_ANCHORS}) @@ -6904,6 +6926,7 @@ axis Y Axis Y ekseni @msg `name` must be a name that is not empty `name` boş olmayan bir ad olmalıdır @msg `name` must not be empty `name` boş olmamalıdır @msg `origin` must be [x, y] in points `origin` punto cinsinden [x, y] olmalıdır +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` [a, b, c, d] olmalıdır; her biri -1000 ile 1000 arasında, a·d - b·c ise 0'dan en az 0.001 uzakta olmalıdır @msg `picks` must be an array (got {v}) `picks` bir dizi olmalıdır (verilen: {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` 1 ile {MAX_PICKS} arasında seçim içermelidir (verilen: {_1}) @msg `points` must hold from 2 to 100000 points `points` 2 ile 100000 arasında nokta içermelidir @@ -7272,6 +7295,7 @@ axis Y Axis Y ekseni @msg Image Trace failed: {e} Görüntü İzleme başarısız oldu: {e} @msg Image Trace finished, but its document was closed Görüntü İzleme tamamlandı, ancak belgesi kapatılmıştı @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Görüntü İzleme tamamlandı, ancak bu sırada görüntü veya ayarları değişti: yeniden izleyin +@msg Image Trace finished, but its document was reverted: trace it again Görüntü İzleme tamamlandı, ancak belgesi geri döndürüldü: yeniden izleyin @msg Invalid dimension offset Geçersiz boyut ofseti @msg Invalid point Geçersiz nokta @msg Join: a subpath went missing Birleştir: bir alt yol kayboldu @@ -7686,7 +7710,7 @@ axis Y Axis Y ekseni @msg column: true floats the whole column and takes no onto, above or below column: true tüm sütunu yüzdürür ve onto, above veya below almaz @msg command must be a shape.* command command bir shape.* komutu olmalıdır @msg commands[{i}]: missing `command` commands[{i}]: `command` eksik -@msg composer must be "singleLine" or "everyLine" composer "singleLine" veya "everyLine" olmalıdır +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer "singleLine", "everyLine", "standardSingleLine" veya "standardEveryLine" olmalıdır @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes bileşik şekiller yolları, bileşik yolları, grupları, bileşik şekilleri, metni, karışımları ve zarfları alır @msg compound {_1} must contain exactly one open path to join bileşik {_1}, birleştirilecek tam olarak bir açık yol içermelidir @msg connect needs a path other than the one extended connect için uzatılan yoldan başka bir yol gerekir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 95a5066abb..f4aed127a4 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -154,6 +154,7 @@ Align to Artboard Вирівняти по монтажній області Align to Glyph Bounds Вирівняти за межами гліфа Align to Key Object Вирівняти за ключовим об’єктом + Align to Key Anchor Вирівняти за ключовою опорною точкою Align to Page Вирівняти за сторінкою Align to Path Вирівняти за контуром Align to Selection Вирівняти за виділенням @@ -1079,7 +1080,8 @@ font-technology Variable Варіативні Every (minutes) Кожен (хвилин) Every option is at its default. Усі параметри мають значення за промовчанням. Every option matches {base}. Усі параметри збігаються з {base}. - Every-line Composer Компонувальник всіх рядків + Middle Eastern & South Asian Every-line Composer Компонувальник всіх рядків (Близький Схід і Південна Азія) + Standard Every-line Composer Стандартний компонувальник всіх рядків Exact dash lengths Точна довжина штрихів Exceptions (comma separated) Винятки (через кому) Exclude Виключити @@ -1416,6 +1418,14 @@ file-filter Templates Шаблони Previous Artboard Попередня монтажна область Next Artboard Наступна монтажна область Last Artboard Остання монтажна область + Status Bar Show Показ у рядку стану + Artboard Name Назва монтажної області + Current Tool Поточний інструмент + Date and Time Дата й час + Number of Undos Кількість скасувань + Document Color Profile Колірний профіль документа + Undos Скасування + Redos Повтори Go To Link Перейти за посиланням GPL palettes keep solid colours only, as RGB. Палітри GPL зберігають лише суцільні кольори RGB. GPU Performance Продуктивність GPU @@ -3489,7 +3499,8 @@ digits Malayalam Малаялам Single Left Quotation Mark Одинарна лапка, що відкриває Single Quotes: Одинарні лапки: Single Right Quotation Mark Одинарна лапка, що закриває - Single-line Composer Однорядковий компонувальник + Middle Eastern & South Asian Single-line Composer Однорядковий компонувальник (Близький Схід і Південна Азія) + Standard Single-line Composer Стандартний однорядковий компонувальник Toggle Line Composer Переключити компонувальник рядків Sixth Space Шоста шпація Size Розмір @@ -3826,6 +3837,8 @@ effect Points Точки Kana Кана Numerals Цифри Scale at Center Масштабувати від центру + Delete Set Видалити набір + Direct Entry Пряме введення Up/Down: Вгору/вниз: Left/Right: Ліворуч/праворуч: Tate-chu-yoko Up/Down Tate-chu-yoko вгору/вниз @@ -5430,6 +5443,11 @@ axis Both Обидві @msg Imported library {name} Імпортовано бібліотеку {name} RGB: RGB: CMYK: CMYK: + RGB Policy: Правило RGB: + CMYK Policy: Правило CMYK: + Preserve Embedded Profiles Зберігати вбудовані профілі + Convert to Working Space Перетворювати в робочий простір + Preserve Numbers Зберігати числа Texture Текстура Craquelure… Кракелюри… Grain… Зерно… @@ -5869,6 +5887,8 @@ lighting Light Світло Save Variable Library… Зберегти бібліотеку змінних… @msg a variable library holds text variables, and the document has none бібліотека змінних містить текстові змінні, а в документі їх немає @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` змінну `{name}` не можна зберегти в бібліотеці змінних: перейменуйте її так, щоб ім’я починалося з літери або `_` і не містило пробілів і символів, крім `-`, `_` і `.` +@msg {k} has control characters {k} містить керівні символи +@msg {k} has control characters or line breaks {k} містить керівні символи або розриви рядків @msg data set `{name}` holds a control character, which a variable library can't hold набір даних `{name}` містить керівний символ, який бібліотека змінних не може містити @msg data set `{name}` starts or ends with white space, which a variable library can't hold набір даних `{name}` починається або закінчується пробілом, який бібліотека змінних не може містити Make Graph Dynamic Зробити діаграму динамічною @@ -6200,6 +6220,7 @@ axis Y Axis Вісь Y @msg stops must be in order of `offset` точки переходу мають іти в порядку `offset` @msg the document has no pattern {pattern} у документі немає візерунка {pattern} @msg the document has no swatch {n} у документі немає зразка {n} +@msg `plates` requires a stop linked to a spot swatch для `plates` потрібна точка переходу, пов’язана зі зразком плашкового кольору @msg the output виведення @msg the path has no anchor points контур не має опорних точок @msg too many anchor points (limit {MAX_ANCHORS}) забагато опорних точок (межа {MAX_ANCHORS}) @@ -7399,6 +7420,7 @@ breakText Glyphs Гліфи @msg Image Trace failed: {e} Трасування зображення не вдалося: {e} @msg Image Trace finished, but its document was closed Трасування зображення завершено, але його документ закрито @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again Трасування зображення завершено, але зображення або його параметри тим часом змінилися: виконайте трасування ще раз +@msg Image Trace finished, but its document was reverted: trace it again Трасування зображення завершено, але його документ було повернуто до збереженої версії: виконайте трасування ще раз @msg Import SVG Filter reads SVG files, not SVGZ Імпорт фільтра SVG читає файли SVG, а не SVGZ @msg Invalid dimension offset Неприпустиме зміщення розміру @msg Invalid point Неприпустима точка @@ -7445,6 +7467,7 @@ breakText Glyphs Гліфи @msg `answer` for this {kind} dialog must be one of: {_1} `answer` для цього діалогового вікна ({kind}) має бути одним з: {_1} @msg `answers` has more than {MAX_ANSWERS} answers `answers` містить понад {MAX_ANSWERS} відповідей @msg `answers` is larger than 1 MiB `answers` більший за 1 МіБ +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` призначений лише для запуску, у якому немає кого запитати: у застосунку людина сама відповідає на кожне діалогове вікно @msg `answers` must be a list of answers `answers` має бути списком відповідей @msg `artboard` must be the index of an artboard of the document `artboard` має бути індексом монтажної області документа @msg `artboardsAt` must be [x, y] `artboardsAt` має бути [x, y] @@ -7570,6 +7593,7 @@ breakText Glyphs Гліфи @msg `names` must be an array of style names `names` має бути масивом імен стилів @msg `new` is a design name, or true `new` — це назва дизайну або true @msg `origin` must be [x, y] in points `origin` має бути [x, y] у пунктах +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` має бути [a, b, c, d], кожне від -1000 до 1000, а a·d - b·c має відрізнятися від 0 щонайменше на 0.001 @msg `path` must be a folder `path` має бути папкою @msg `path` must be a path object `path` має бути об’єктом-контуром @msg `picks` must be an array (got {v}) `picks` має бути масивом (отримано {v}) @@ -7969,7 +7993,7 @@ breakText Glyphs Гліфи @msg column: true floats the whole column and takes no onto, above or below column: true робить плаваючим увесь стовпець і не приймає onto, above або below @msg command must be a shape.* command command має бути командою shape.* @msg commands[{i}]: missing `command` commands[{i}]: відсутній `command` -@msg composer must be "singleLine" or "everyLine" composer має бути "singleLine" або "everyLine" +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer має бути "singleLine", "everyLine", "standardSingleLine" або "standardEveryLine" @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes складені фігури приймають контури, складені контури, групи, складені фігури, текст, переходи й оболонки @msg compound {_1} must contain exactly one open path to join складений об’єкт {_1} має містити рівно один відкритий контур для з’єднання @msg connect needs a path other than the one extended для connect потрібен контур, відмінний від того, що продовжується @@ -9108,7 +9132,6 @@ breakText Glyphs Гліфи @msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) файли {kind} не мають прозорості, а файл містив би таке: {what}; спочатку зведіть прозорість (object.flattenTransparency) @msg {kind} images can't be opened (see document.formats) зображення {kind} не можна відкрити (див. document.formats) @msg {k} `{v}` is not one of the choices {k} `{v}` не є одним із варіантів -@msg {k} has control characters{_1} {k} містить керівні символи{_1} @msg {k} is too long (at most {MAX_TEXT} characters) {k} задовгий (не більше {MAX_TEXT} символів) @msg {k} must be > 0 {k} має бути > 0 @msg {k} must be a number (pt) or a length such as "210 mm" {k} має бути числом (pt) або довжиною на зразок "210 mm" diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 21d20f0157..5a901079b3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -175,6 +175,7 @@ Align to Artboard Căn theo bảng vẽ Align to Glyph Bounds Căn theo giới hạn glyph Align to Key Object Căn theo đối tượng chính + Align to Key Anchor Căn theo điểm neo chính Align to Page Căn theo trang Align to Path Căn theo đường dẫn Align to Selection Căn theo vùng chọn @@ -607,6 +608,11 @@ font-technology Variable Biến thiên CMYK Equivalents Tương đương CMYK CMYK profile in effect Hồ sơ CMYK đang có hiệu lực CMYK: CMYK: + RGB Policy: Chính sách RGB: + CMYK Policy: Chính sách CMYK: + Preserve Embedded Profiles Giữ cấu hình nhúng + Convert to Working Space Chuyển sang không gian làm việc + Preserve Numbers Giữ giá trị số Coarse Dots Chấm thô Collapse Panels to Icons Thu gọn bảng thành biểu tượng Collapse to Icons Thu gọn thành biểu tượng @@ -1251,7 +1257,10 @@ font-technology Variable Biến thiên Every (minutes) Mỗi (phút) Every option is at its default. Mọi tùy chọn đang ở mặc định. Every option matches {base}. Mọi tùy chọn khớp với {base}. - Every-line Composer Soạn theo từng dòng + Middle Eastern & South Asian Single-line Composer Soạn thảo một dòng (Trung Đông và Nam Á) + Middle Eastern & South Asian Every-line Composer Soạn theo từng dòng (Trung Đông và Nam Á) + Standard Single-line Composer Soạn thảo một dòng tiêu chuẩn + Standard Every-line Composer Soạn theo từng dòng tiêu chuẩn Exact dash lengths Độ dài nét đứt chính xác Exceptions (comma separated) Ngoại lệ (phân cách bằng dấu phẩy) Exclude Loại trừ @@ -1647,6 +1656,14 @@ file-filter Templates Mẫu Previous Artboard Bảng vẽ trước Next Artboard Bảng vẽ tiếp theo Last Artboard Bảng vẽ cuối cùng + Status Bar Show Hiển thị trên thanh trạng thái + Artboard Name Tên bảng vẽ + Current Tool Công cụ hiện tại + Date and Time Ngày và giờ + Number of Undos Số lần hoàn tác + Document Color Profile Hồ sơ màu của tài liệu + Undos Hoàn tác + Redos Làm lại Go To Link Đến liên kết Good Tốt GPL palettes keep solid colours only, as RGB. Bảng màu GPL chỉ lưu màu đặc theo RGB. @@ -3844,7 +3861,6 @@ mark-style Japanese Kiểu Nhật Single Left Quotation Mark Dấu nháy đơn mở Single Quotes: Dấu nháy đơn: Single Right Quotation Mark Dấu nháy đơn đóng - Single-line Composer Soạn thảo một dòng Toggle Line Composer Chuyển đổi cách soạn dòng Sixth Space Dấu cách 1/6 Size Kích thước @@ -4172,6 +4188,8 @@ mark-style Japanese Kiểu Nhật Kana Kana Numerals Chữ số Scale at Center Co giãn từ tâm + Delete Set Xóa bộ + Direct Entry Nhập trực tiếp Up/Down: Lên/Xuống: Left/Right: Trái/Phải: Tate-chu-yoko Up/Down Tate-chu-yoko lên/xuống @@ -4948,6 +4966,17 @@ theme Appearance Giao diện @msg `name` must be the name of a script engine `name` phải là tên của một bộ máy tập lệnh @msg the main script engine is new for each run and is never kept, so it cannot be dropped bộ máy tập lệnh chính được tạo mới cho mỗi lần chạy và không bao giờ được giữ lại, nên không thể loại bỏ nó @msg there is no script engine named {name} không có bộ máy tập lệnh nào tên {name} +@msg `answers` must be a list of answers `answers` phải là một danh sách câu trả lời +@msg `answers` has more than {MAX_ANSWERS} answers `answers` có hơn {MAX_ANSWERS} câu trả lời +@msg `answers` is larger than 1 MiB `answers` lớn hơn 1 MiB +@msg must be an object with `kind` and `value` phải là một đối tượng có `kind` và `value` +@msg must have exactly `kind` and `value` phải có đúng `kind` và `value` +@msg is an alert's answer, whose `value` must be "ok" là câu trả lời cho alert, nên `value` phải là "ok" +@msg is a confirm's answer, whose `value` must be true or false là câu trả lời cho confirm, nên `value` phải là true hoặc false +@msg is longer than 16 KiB dài hơn 16 KiB +@msg is a prompt's answer, whose `value` must be a string or null là câu trả lời cho prompt, nên `value` phải là chuỗi hoặc null +@msg must have `kind` alert, confirm or prompt phải có `kind` là alert, confirm hoặc prompt +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` chỉ dành cho lần chạy không có ai để hỏi: trong ứng dụng, người dùng trả lời từng hộp thoại @msg Running a script Đang chạy tập lệnh @msg Copied the script report Đã sao chép báo cáo script @msg Copied the link info Đã sao chép thông tin liên kết @@ -5874,6 +5903,8 @@ theme Appearance Giao diện Save Variable Library… Lưu thư viện biến số… @msg a variable library holds text variables, and the document has none thư viện biến số chứa các biến số văn bản, và tài liệu không có biến số văn bản nào @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` không thể lưu biến số `{name}` vào thư viện biến số: hãy đổi tên để bắt đầu bằng một chữ cái hoặc `_`, không có khoảng trắng hay ký hiệu nào ngoài `-`, `_` và `.` +@msg {k} has control characters {k} chứa ký tự điều khiển +@msg {k} has control characters or line breaks {k} chứa ký tự điều khiển hoặc ngắt dòng @msg data set `{name}` holds a control character, which a variable library can't hold tập dữ liệu `{name}` chứa ký tự điều khiển mà thư viện biến số không thể chứa @msg data set `{name}` starts or ends with white space, which a variable library can't hold tập dữ liệu `{name}` bắt đầu hoặc kết thúc bằng khoảng trắng mà thư viện biến số không thể chứa Make Graph Dynamic Tạo biểu đồ động @@ -6153,6 +6184,7 @@ axis Y Axis Trục Y @msg {what} points outside memory {what} trỏ ra ngoài bộ nhớ @msg `angle` must be a number `angle` phải là một số @msg `aspect` must be from 0.01 to 100 `aspect` phải từ 0.01 đến 100 +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` phải là [a, b, c, d], mỗi số từ -1000 đến 1000, và a·d - b·c phải cách 0 ít nhất 0.001 @msg `cap` must be "butt", "round" or "square" `cap` phải là "butt", "round" hoặc "square" @msg `fillRule` must be "nonzero" or "evenodd" `fillRule` phải là "nonzero" hoặc "evenodd" @msg `helpUrl` must be an https:// address of at most {MAX_HELP_URL} bytes `helpUrl` phải là một địa chỉ https:// dài tối đa {MAX_HELP_URL} byte @@ -6205,6 +6237,7 @@ axis Y Axis Trục Y @msg stops must be in order of `offset` các điểm dừng phải theo thứ tự của `offset` @msg the document has no pattern {pattern} tài liệu không có họa tiết {pattern} @msg the document has no swatch {n} tài liệu không có mẫu màu {n} +@msg `plates` requires a stop linked to a spot swatch `plates` cần một điểm dừng chuyển sắc liên kết với mẫu màu đặc biệt @msg the output đầu ra @msg the path has no anchor points đường dẫn không có điểm neo @msg too many anchor points (limit {MAX_ANCHORS}) quá nhiều điểm neo (giới hạn {MAX_ANCHORS}) @@ -7368,3 +7401,4 @@ breakText Glyphs Glyph @msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} điểm ảnh quá lớn cho JPEG (tối đa 65535 điểm ảnh mỗi cạnh): hãy giảm độ phân giải @msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} điểm ảnh quá lớn cho Targa (tối đa 65535 điểm ảnh mỗi cạnh): hãy giảm độ phân giải @msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} điểm ảnh quá lớn cho PSD (tối đa {MAX_SIDE} điểm ảnh mỗi cạnh): hãy giảm độ phân giải +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer phải là "singleLine", "everyLine", "standardSingleLine" hoặc "standardEveryLine" diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5c0570781d..8d4c110afd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -109,6 +109,7 @@ Align to Artboard 对齐画板 Align to Glyph Bounds 对齐到字形边界 Align to Key Object 对齐关键对象 + Align to Key Anchor 对齐关键锚点 Align to Page 对齐页面 Align to Path 对齐路径 Align to Selection 对齐选区 @@ -1018,7 +1019,8 @@ antiAlias Sharp 锐利 Every (minutes) 每隔 (分钟) Every option is at its default. 所有选项皆为默认值。 Every option matches {base}. 所有选项皆与{base}相同。 - Every-line Composer 逐行排版器 + Middle Eastern & South Asian Every-line Composer 逐行排版器(中东和南亚) + Standard Every-line Composer 标准逐行排版器 Exact dash lengths 精确虚线长度 Exceptions (comma separated) 例外词 (逗号分隔) Exclude 差集 @@ -1351,6 +1353,14 @@ file-filter Templates 模板 Previous Artboard 上一个画板 Next Artboard 下一个画板 Last Artboard 最后一个画板 + Status Bar Show 状态栏显示 + Artboard Name 画板名称 + Current Tool 当前工具 + Date and Time 日期和时间 + Number of Undos 撤销次数 + Document Color Profile 文档颜色配置文件 + Undos 撤销 + Redos 重做 Go To Link 转到链接 GPL palettes keep solid colours only, as RGB. GPL 调板仅保留为 RGB 的纯色。 GPU Performance GPU 性能 @@ -3311,7 +3321,8 @@ digits Malayalam 马拉雅拉姆数字 Single Left Quotation Mark 左单引号 Single Quotes: 单引号: Single Right Quotation Mark 右单引号 - Single-line Composer 单行排版器 + Middle Eastern & South Asian Single-line Composer 单行排版器(中东和南亚) + Standard Single-line Composer 标准单行排版器 Toggle Line Composer 切换行排版器 Sixth Space 六分之一空格 Size 大小 @@ -3646,6 +3657,8 @@ digits Malayalam 马拉雅拉姆数字 Kana 假名 Numerals 数字 Scale at Center 从中心缩放 + Delete Set 删除集 + Direct Entry 直接输入 Up/Down: 上/下: Left/Right: 左/右: Tate-chu-yoko Up/Down 纵中横上/下 @@ -5332,6 +5345,11 @@ imageTrace Outline 轮廓 None (Color Preview) 无 (颜色预览) Preserve 保留 CMYK: CMYK: + RGB Policy: RGB 策略: + CMYK Policy: CMYK 策略: + Preserve Embedded Profiles 保留嵌入的配置文件 + Convert to Working Space 转换为工作空间 + Preserve Numbers 保留数值 Art & Illustration 艺术和插画 Branding 品牌 Film & Video 影片和视频 @@ -6111,6 +6129,8 @@ lighting Light 光源 Save Variable Library… 保存变量库… @msg a variable library holds text variables, and the document has none 变量库包含文本变量,而文档中没有文本变量 @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` 无法将变量 `{name}` 保存到变量库中:请重命名,使其以字母或 `_` 开头,且不含空格或 `-`、`_`、`.` 以外的符号 +@msg {k} has control characters {k} 包含控制字符 +@msg {k} has control characters or line breaks {k} 包含控制字符或换行 @msg data set `{name}` holds a control character, which a variable library can't hold 数据集 `{name}` 包含变量库无法保存的控制字符 @msg data set `{name}` starts or ends with white space, which a variable library can't hold 数据集 `{name}` 的开头或结尾有变量库无法保存的空白字符 Make Graph Dynamic 使图表动态化 @@ -6433,6 +6453,7 @@ axis Y Axis Y 轴 @msg stops must be in order of `offset` 色标必须按 `offset` 排序 @msg the document has no pattern {pattern} 文档中没有图案 {pattern} @msg the document has no swatch {n} 文档中没有色板 {n} +@msg `plates` requires a stop linked to a spot swatch `plates` 需要链接到专色色板的渐变色标 @msg the output 输出 @msg the path has no anchor points 路径没有锚点 @msg too many anchor points (limit {MAX_ANCHORS}) 锚点过多(上限 {MAX_ANCHORS}) @@ -7505,6 +7526,7 @@ breakText Glyphs 字形 @msg `answer` for this {kind} dialog must be one of: {_1} 此 {kind} 对话框的 `answer` 必须是以下之一:{_1} @msg `answers` has more than {MAX_ANSWERS} answers `answers` 中的回答超过 {MAX_ANSWERS} 个 @msg `answers` is larger than 1 MiB `answers` 大于 1 MiB +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` 仅用于无人可问的运行:在应用中,由用户回答每个对话框 @msg `answers` must be a list of answers `answers` 必须是回答的列表 @msg `artboard` must be the index of an artboard of the document `artboard` 必须是文档中某个画板的索引 @msg `artboardsAt` must be [x, y] `artboardsAt` 必须是 [x, y] @@ -7537,6 +7559,7 @@ breakText Glyphs 字形 @msg `name` must be a name that is not empty `name` 必须是非空的名称 @msg `name` must not be empty `name` 不能为空 @msg `origin` must be [x, y] in points `origin` 必须是以点为单位的 [x, y] +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` 必须是 [a, b, c, d],每个值在 -1000 到 1000 之间,且 a·d - b·c 与 0 的差至少为 0.001 @msg `picks` must be an array (got {v}) `picks` 必须是数组 (实际为 {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` 必须包含 1 到 {MAX_PICKS} 个拾取项 (实际为 {_1}) @msg `points` must hold from 2 to 100000 points `points` 必须包含 2 到 100000 个点 @@ -7915,6 +7938,7 @@ breakText Glyphs 字形 @msg Image Trace failed: {e} 图像描摹失败:{e} @msg Image Trace finished, but its document was closed 图像描摹已完成,但其文档已关闭 @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again 图像描摹已完成,但图像或其设置在此期间发生了更改:请重新描摹 +@msg Image Trace finished, but its document was reverted: trace it again 图像描摹已完成,但其文档已恢复:请重新描摹 @msg Invalid dimension offset 尺寸标注的偏移无效 @msg Invalid point 点无效 @msg Join: a subpath went missing 连接:有一条子路径丢失 @@ -8329,7 +8353,7 @@ breakText Glyphs 字形 @msg column: true floats the whole column and takes no onto, above or below column: true 会使整列浮动,不能再给出 onto、above 或 below @msg command must be a shape.* command command 必须是 shape.* 命令 @msg commands[{i}]: missing `command` commands[{i}]:缺少 `command` -@msg composer must be "singleLine" or "everyLine" composer 必须是 "singleLine" 或 "everyLine" +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer 必须是 "singleLine"、"everyLine"、"standardSingleLine" 或 "standardEveryLine" @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes 复合形状接受路径、复合路径、编组、复合形状、文字、混合和封套 @msg compound {_1} must contain exactly one open path to join 复合对象 {_1} 必须恰好包含一条要连接的开放路径 @msg connect needs a path other than the one extended connect 需要一条不同于被延伸路径的路径 @@ -9259,7 +9283,6 @@ breakText Glyphs 字形 @msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} 文件不支持透明度,而该文件会包含 {what}:请先拼合 (object.flattenTransparency) @msg {kind} images can't be opened (see document.formats) 无法打开 {kind} 图像 (参见 document.formats) @msg {k} `{v}` is not one of the choices {k} `{v}` 不在可选项之中 -@msg {k} has control characters{_1} {k} 含有控制字符{_1} @msg {k} is too long (at most {MAX_TEXT} characters) {k} 过长 (最多 {MAX_TEXT} 个字符) @msg {k} must be > 0 {k} 必须 > 0 @msg {k} must be a number (pt) or a length such as "210 mm" {k} 必须是数字 (pt) 或 "210 mm" 这样的长度 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5de2e008bd..30da93ab71 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -118,6 +118,7 @@ Align to Artboard 對齊工作區域 Align to Glyph Bounds 對齊字符邊界 Align to Key Object 對齊關鍵物件 + Align to Key Anchor 對齊關鍵錨點 Align to Page 對齊頁面 Align to Path 對齊路徑 Align to Selection 對齊選取範圍 @@ -1049,7 +1050,8 @@ font-technology Variable 可變 Every (minutes) 每隔 (分鐘) Every option is at its default. 所有選項皆為預設值。 Every option matches {base}. 所有選項皆與{base}相同。 - Every-line Composer 逐行排版程式 + Middle Eastern & South Asian Every-line Composer 逐行排版程式(中東與南亞) + Standard Every-line Composer 標準逐行排版程式 Exact dash lengths 精確虛線長度 Exceptions (comma separated) 例外 (以逗號分隔) Exclude 差集 @@ -1386,6 +1388,14 @@ file-filter Templates 範本 Previous Artboard 上一個工作區域 Next Artboard 下一個工作區域 Last Artboard 最後一個工作區域 + Status Bar Show 狀態列顯示 + Artboard Name 工作區域名稱 + Current Tool 目前工具 + Date and Time 日期和時間 + Number of Undos 復原次數 + Document Color Profile 文件色彩描述檔 + Undos 復原 + Redos 重做 Go To Link 前往連結 GPL palettes keep solid colours only, as RGB. GPL 調色盤僅保留純色,並以 RGB 儲存。 GPU Performance GPU 效能 @@ -2625,6 +2635,7 @@ file-filter Templates 範本 @msg `name` must be the name of a script engine `name` 必須是指令碼引擎的名稱 @msg the main script engine is new for each run and is never kept, so it cannot be dropped 主指令碼引擎在每次執行時都是新的,從不保留,因此無法捨棄 @msg there is no script engine named {name} 沒有名為 {name} 的指令碼引擎 +@msg `answers` is only for a run with nobody to ask: in the app, the person answers each dialog `answers` 僅適用於沒有人可詢問的執行:在應用程式中,由使用者回答每個對話框 @msg Running a script 正在執行指令碼 @msg Copied the script report 已複製指令碼報告 @msg Copied the link info 已複製連結資訊 @@ -4572,7 +4583,8 @@ digits Malayalam 馬來亞拉姆數字 Single Left Quotation Mark 左單引號 Single Quotes: 單引號: Single Right Quotation Mark 右單引號 - Single-line Composer 單行排版程式 + Middle Eastern & South Asian Single-line Composer 單行排版程式(中東與南亞) + Standard Single-line Composer 標準單行排版程式 Toggle Line Composer 切換行排版程式 Sixth Space 六分之一空格 Size 大小 @@ -4908,6 +4920,8 @@ digits Malayalam 馬來亞拉姆數字 Kana 假名 Numerals 數字 Scale at Center 從中心縮放 + Delete Set 刪除集 + Direct Entry 直接輸入 Up/Down: 上/下: Left/Right: 左/右: Tate-chu-yoko Up/Down 縱中橫上/下 @@ -5564,6 +5578,11 @@ axis Both 兩者 Interpolation 內插 RGB: RGB: CMYK: CMYK: + RGB Policy: RGB 原則: + CMYK Policy: CMYK 原則: + Preserve Embedded Profiles 保留內嵌描述檔 + Convert to Working Space 轉換為工作空間 + Preserve Numbers 保留數值 Texture 紋理 Craquelure… 龜裂紋… Grain… 顆粒… @@ -5945,6 +5964,8 @@ lighting Light 光源 Save Variable Library… 儲存變數資料庫… @msg a variable library holds text variables, and the document has none 變數資料庫包含文字變數,但文件中沒有文字變數 @msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` 無法將變數 `{name}` 儲存到變數資料庫:請重新命名,使其以字母或 `_` 開頭,且不含空格或 `-`、`_`、`.` 以外的符號 +@msg {k} has control characters {k} 包含控制字元 +@msg {k} has control characters or line breaks {k} 包含控制字元或換行 @msg data set `{name}` holds a control character, which a variable library can't hold 資料集 `{name}` 包含變數資料庫無法儲存的控制字元 @msg data set `{name}` starts or ends with white space, which a variable library can't hold 資料集 `{name}` 的開頭或結尾有變數資料庫無法儲存的空白字元 Make Graph Dynamic 使圖表動態化 @@ -6240,6 +6261,7 @@ axis Y Axis Y 軸 @msg stops must be in order of `offset` 色標必須依 `offset` 排序 @msg the document has no pattern {pattern} 文件中沒有圖樣 {pattern} @msg the document has no swatch {n} 文件中沒有色票 {n} +@msg `plates` requires a stop linked to a spot swatch `plates` 需要連結到特別色色票的漸層色標 @msg the output 輸出 @msg the path has no anchor points 路徑沒有錨點 @msg too many anchor points (limit {MAX_ANCHORS}) 錨點過多(上限 {MAX_ANCHORS}) @@ -7465,6 +7487,7 @@ breakText Glyphs 字符 @msg `name` must be a name that is not empty `name` 必須是不為空的名稱 @msg `name` must not be empty `name` 不能為空 @msg `origin` must be [x, y] in points `origin` 必須是以點為單位的 [x, y] +@msg `axes` must be [a, b, c, d], each from -1000 to 1000, with a·d - b·c at least 0.001 from 0 `axes` 必須是 [a, b, c, d],每個值介於 -1000 到 1000 之間,且 a·d - b·c 與 0 的差至少為 0.001 @msg `picks` must be an array (got {v}) `picks` 必須是陣列 (收到 {v}) @msg `picks` must hold 1 to {MAX_PICKS} picks (got {_1}) `picks` 必須包含 1 到 {MAX_PICKS} 個選擇 (收到 {_1}) @msg `points` must hold from 2 to 100000 points `points` 必須包含 2 到 100000 個點 @@ -7843,6 +7866,7 @@ breakText Glyphs 字符 @msg Image Trace failed: {e} 影像描圖失敗:{e} @msg Image Trace finished, but its document was closed 影像描圖已完成,但其文件已關閉 @msg Image Trace finished, but the image or its settings changed meanwhile: trace it again 影像描圖已完成,但影像或其設定在此期間已變更:請重新描圖 +@msg Image Trace finished, but its document was reverted: trace it again 影像描圖已完成,但其文件已回復:請重新描圖 @msg Invalid dimension offset 尺寸標註位移無效 @msg Invalid point 點無效 @msg Join: a subpath went missing 連接:有一條子路徑不見了 @@ -8257,7 +8281,7 @@ breakText Glyphs 字符 @msg column: true floats the whole column and takes no onto, above or below column: true 會讓整欄浮動,不接受 onto、above 或 below @msg command must be a shape.* command command 必須是 shape.* 指令 @msg commands[{i}]: missing `command` commands[{i}]:未提供 `command` -@msg composer must be "singleLine" or "everyLine" composer 必須是 "singleLine" 或 "everyLine" +@msg composer must be "singleLine", "everyLine", "standardSingleLine" or "standardEveryLine" composer 必須是 "singleLine"、"everyLine"、"standardSingleLine" 或 "standardEveryLine" @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes 複合形狀接受路徑、複合路徑、群組、複合形狀、文字、漸變物件和封套 @msg compound {_1} must contain exactly one open path to join 複合路徑 {_1} 必須剛好包含一條可連接的開放路徑 @msg connect needs a path other than the one extended connect 需要延伸路徑以外的另一條路徑 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index e0e4e4c404..11c02ca139 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -52,10 +52,12 @@ fn open_bytes_with(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opt let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library }; return load(app, p).map(|_| Value::Null); } - // A PDF with several pages or a password asks first (the Import PDF dialog), and so does a - // DXF drawing (DXF Import Options). + // A PDF with several pages or a password asks first (the Import PDF dialog), and so do a DXF + // drawing (DXF Import Options) and a PSD file with several layers or with slices (PSD Import + // Options), unless `p` says how its layers come in. if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) || crate::dialogs::dxf_import::offer(app, name, bytes, path.clone(), None) + || (p.get("layers").is_none() && crate::dialogs::psd_import::offer(app, name, bytes, path.clone(), None)) { return Ok(Value::Null); } @@ -552,9 +554,11 @@ fn open_file_with(app: &mut VectorcraftApp, path: &str, background: bool, p: &Va check_import_size(path, false)?; let mut bytes = read(app, path)?; if background && opens_as(path).is_none() { - // A PDF with several pages or a password, or a DXF drawing, asks first, as at once. + // A PDF with several pages or a password, a DXF drawing, or a PSD file with several layers + // or with slices asks first, as at once. if crate::dialogs::import_pdf::offer(app, path, &bytes, Some(path.to_string()), None) || crate::dialogs::dxf_import::offer(app, path, &bytes, Some(path.to_string()), None) + || (p.get("layers").is_none() && crate::dialogs::psd_import::offer(app, path, &bytes, Some(path.to_string()), None)) { note_recent(app, path); return Ok(Value::Null); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index df3f08b55e..994f3e571a 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -455,6 +455,7 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option go_to_artboard(app, p), + "view.statusBarShow" => crate::chrome::set_status_bar_show(app, p), // The navigator's four buttons as commands of their own, so Edit › Keyboard Shortcuts lists // them (view.goToArtboard needs an index, so a key alone can't run it). "view.firstArtboard" => go_to_artboard(app, &json!({ "index": "first" })), diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 98c13ec06e..f08bd87998 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -63,6 +63,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), "window.control" => app.ui.control_bar, "window.helpBar" => app.ui.help_bar, + "view.statusBarShow" => p.get("show").and_then(Value::as_str) == Some(app.ui.status_bar_show.param()), "window.control.dock" => p.get("edge").and_then(Value::as_str) == Some(crate::chrome::control_bar_placement(&app.ui)), "window.control.options" => p .get("group") diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index e8d53fc50f..c099422957 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -393,7 +393,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "type.lineComposer", "Toggle Line Composer", "Cmd+Alt+Shift+C", - "{} the selected type's paragraphs (or those the Type tool's selection touches) use the Single-line Composer when they all use the Every-line Composer, else the Every-line Composer (text.setFormat {composer}) → \"singleLine\" | \"everyLine\"", + "{} the selected type's paragraphs (or those the Type tool's selection touches) use the Single-line Composer when they all use the Every-line Composer, else the Every-line Composer; paragraphs that all use a Standard composer keep it, any other mix takes the Middle Eastern & South Asian one (text.setFormat {composer}) → \"singleLine\" | \"everyLine\" | \"standardSingleLine\" | \"standardEveryLine\"", ), ( "type.hyphenate", @@ -433,6 +433,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("view.nextArtboard", "Next Artboard", "", "{} the navigator's Next Artboard button: view.goToArtboard {index: \"next\"} → {index}"), ("view.lastArtboard", "Last Artboard", "", "{} the navigator's Last Artboard button: view.goToArtboard {index: \"last\"} → {index}"), + ( + "view.statusBarShow", + "Status Bar Show", + "", + "{show: \"artboardName\" | \"currentTool\" | \"dateTime\" | \"undos\" | \"colorProfile\"} the status bar's Show menu (a click on its info field): the field shows the name of the artboard the navigator shows, the tool in use (the default), the date and time (local on the desktop, UTC on the web), the active document's undo and redo step counts, or the profile its colors use (the one assigned for its color mode, else, or when that one is not available, that mode's working space); kept with the UI state; any other value is an error → show", + ), ("view.edges", "Hide Edges", "Cmd+H", "{}"), ("view.artboards", "Hide Artboards", "Cmd+Shift+H", "{}"), ( @@ -613,6 +619,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Shift+Tab", "{} show/hide the dock and the floating panels (hiding them closes a panel popped out of the icon column), leaving the toolbar and the Control bar as they are; returns whether the panels show", ), + ( + "window.panels.peek", + "Show Hidden Panels at Edge", + "", + "{side: left|right|null} while Tab or Shift+Tab hides the panels, show the docked panels on that side of the canvas over its edge, as resting the pointer on that edge of the window for 0.3 s does; they hide again 0.3 s after the pointer moves away from them, and null hides them now. Floating panels, the toolbar and the Control bar stay hidden, and Tab or Shift+Tab shows the panels for good. Fails and changes nothing while the panels show, in Presentation Mode, for any other side, or when no panel is docked on that side; returns the side shown or null", + ), ( "window.panel", "Show Panel", @@ -1610,7 +1622,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::LOAD_SET, "Load Actions…", "", - "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", + "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file. A Set Selection step (found by its internal name, whatever its label) comes in as select.byNote {note, caseSensitive, wholeWord} when it has exactly three recorded values: text (ustring, UTF-8; 1 to 1,024 bytes once trimmed), word and case (boolean, 0 or 1); with any other values it is left out. The other steps, every other step with no label and every other step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", ), ( crate::panels::actions::DELETE_SETS, diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index e22dcd9cac..5b4913d552 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -51,6 +51,8 @@ pub(crate) struct Runtime { raise: Option, /// The panel the user last clicked or pressed in (a tab, a panel body, a drop). focused: Option, + /// Hidden panels shown from the window's edges ([`peek`]). + peek: peek::State, } /// Every docking control the main window drew last frame with its rect. Controls in floating @@ -72,6 +74,7 @@ pub(crate) fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "hidden": app.ui.docking_hidden.keys().collect::>(), "autoCollapse": app.session.prefs.auto_collapse_icon_panels, "panelsShown": app.ui.dock && app.ui.screen_mode < 3, + "peek": peek::shown(app), "hitRects": hits, }) } @@ -322,6 +325,7 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Opt match id { "window.panel" => return Some(show_panel(app, params)), "window.collapseDock" => return Some(crate::menus::opt_bool(params, "collapsed").and_then(|c| collapse_dock(app, c))), + "window.panels.peek" => return Some(peek::command(app, params)), "window.panel.collapse" => { let minimized = match crate::menus::opt_bool(params, "collapsed") { Ok(c) => c, @@ -1167,6 +1171,9 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if modal || egui::Popup::is_any_open(&ctx) { blocked.push(ctx.content_rect()); } + // Panels hidden by Tab or Shift+Tab show on the side of the window edge the pointer rests on. + let peeking = peek::update(app, &ctx, ui.available_rect_before_wrap(), &layout, !blocked.is_empty()).map(|edge| peek::edge_panels(&layout, edge)); + let shown_from_edge = |panel: &String| peeking.as_ref().is_some_and(|panels| panels.contains(panel)); let style = dock_style(ui); let mut dock = craft_ui::docking::DockArea::new(area) .blocked_rects(&blocked) @@ -1187,7 +1194,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { // Tab and Shift+Tab (`window.dock`, `window.panels`) and the full screen modes hide the panels; // the canvas stays. if !app.ui.dock || app.ui.screen_mode >= 3 { - dock = dock.hide_panels(HidePanels::All); + dock = dock.hide_panels(if peeking.is_some() { HidePanels::Except(&shown_from_edge) } else { HidePanels::All }); } if let Some(panel) = app.dock_rt.raise.take() { dock = dock.raise_panel(panel); @@ -1220,7 +1227,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { app.open_dropped_files(window.dropped, None, shift); } forward_keys(app, &ctx, output.forwarded_events, output.forwarded_modifiers); - if !thumbnail_panel_showing(&app.ui, &ctx) { + if peeking.is_none() && !thumbnail_panel_showing(&app.ui, &ctx) { // No panel can ask for these rows again this pass. Keep the cache of open documents, but // discard worker jobs queued behind the one already rendering. crate::panels::layers::show_thumbs_of(None); @@ -1383,8 +1390,10 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { /// Draw a registered panel body, fitting untouched flyouts and pinning sized panels' footers. fn body(&mut self, ui: &mut egui::Ui, id: &String) { if id == CANVAS { - crate::toolbar::show_beside_canvas(self.app, ui); - self.app.canvas_area(ui); + peek::canvas(self.app, ui, |app, ui| { + crate::toolbar::show_beside_canvas(app, ui); + app.canvas_area(ui); + }); return; } let floating = ui.ctx().viewport_id() != egui::ViewportId::ROOT; @@ -1513,6 +1522,8 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } } +mod peek; + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panel_docking/peek.rs b/crates/ui-egui/src/panel_docking/peek.rs new file mode 100644 index 0000000000..baf147f4fd --- /dev/null +++ b/crates/ui-egui/src/panel_docking/peek.rs @@ -0,0 +1,248 @@ +//! Panels hidden by Tab or Shift+Tab, shown for a while: resting the pointer on the left or right +//! edge of the dock area shows the docked panels on that side of the canvas +//! (`window.panels.peek`). They hide again once the pointer has been away from them for +//! [`GRACE`] seconds; Tab or Shift+Tab shows them for good. The canvas keeps the size it has with +//! every panel hidden, so the panels cover its edge and the artwork stays where it is. Floating +//! panels, the Tools panel and the Control bar stay hidden. + +use craft_ui::docking::{Layout, Node}; +use craft_ui::layout::SplitAxis; +use serde_json::{Value, json}; + +use super::CANVAS; +use crate::VectorcraftApp; + +/// Width of the band along each edge of the dock area that shows the panels on that side. +pub(crate) const EDGE: f32 = 6.0; +/// How long the pointer rests on an edge band before the panels show, in seconds. +pub(crate) const DELAY: f64 = 0.3; +/// How long the pointer stays away from the panels shown before they hide, in seconds. +pub(crate) const GRACE: f64 = 0.3; + +/// A side of the canvas. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Edge { + Left, + Right, +} + +impl Edge { + fn id(self) -> &'static str { + match self { + Edge::Left => "left", + Edge::Right => "right", + } + } +} + +/// The panels shown on one side. +#[derive(Clone, Copy, Debug)] +struct Shown { + edge: Edge, + /// When the pointer left them; `None` while it is over them or has not moved since they showed. + left_at: Option, +} + +/// The session's state of the panels shown from the edges. +pub(crate) struct State { + /// Whether Tab or Shift+Tab hides the panels this frame. + hidden: bool, + /// The dock area this frame. + area: egui::Rect, + /// The edge band the pointer rests on, and since when. + resting: Option<(Edge, f64)>, + shown: Option, + /// How far the canvas's corners lay from the dock area's with every panel hidden. + canvas: Option<(egui::Vec2, egui::Vec2)>, + /// The part of the canvas left uncovered while panels show. + uncovered: Option, +} + +impl Default for State { + fn default() -> Self { + Self { hidden: false, area: egui::Rect::NOTHING, resting: None, shown: None, canvas: None, uncovered: None } + } +} + +/// The docked panels on `edge`'s side of the canvas, at any depth of horizontal splits. +pub(crate) fn edge_panels(layout: &Layout, edge: Edge) -> Vec { + let canvas = CANVAS.to_string(); + let tree = |node: &Node| Layout { root: Some(node.clone()), floating: Vec::new() }; + let mut out = Vec::new(); + let mut node = layout.root.as_ref(); + let mut depth = 0usize; + while let Some(Node::Split { axis, first, second, .. }) = node { + depth = depth.saturating_add(1); + if depth > 2 * craft_ui::docking::MAX_PANELS { + break; + } + let (inner, outer, side) = if tree(first).contains(&canvas) { (first, second, Edge::Right) } else { (second, first, Edge::Left) }; + if matches!(axis, SplitAxis::Horizontal) && side == edge { + out.extend(tree(outer).panels().into_iter().filter(|p| **p != canvas).cloned()); + } + node = Some(inner); + } + out +} + +/// The side whose panels show this frame. `area` is the dock area and `blocked` whether a modal +/// dialog or a popup takes the input. +pub(super) fn update(app: &mut VectorcraftApp, ctx: &egui::Context, area: egui::Rect, layout: &Layout, blocked: bool) -> Option { + let hidden = !app.ui.dock && app.ui.screen_mode < 3; + let flyout = app.ui.open_panel.is_some(); + // The docked Tools panel's rect, read only while Tools shows (Tab turns `toolbar` off). + let tools = app.ui.toolbar.then(|| ctx.data(|d| d.get_temp::(crate::toolbar::docked_rect_id()))).flatten(); + let st = &mut app.dock_rt.peek; + st.hidden = hidden; + st.area = area; + if !hidden { + st.resting = None; + st.shown = None; + st.uncovered = None; + return None; + } + let (now, pointer, down, moved) = ctx.input(|i| { + // A release counts as moving away: a press may have held the panels while it left them. + let moved = + i.pointer.delta() != egui::Vec2::ZERO || i.pointer.any_released() || i.events.iter().any(|e| matches!(e, egui::Event::PointerGone)); + (i.time, i.pointer.hover_pos(), i.pointer.any_down(), moved) + }); + // A workspace switched to while panels show may dock none on that side. + if st.shown.is_some_and(|shown| edge_panels(layout, shown.edge).is_empty()) { + st.shown = None; + st.uncovered = None; + } + if let Some(shown) = st.shown.as_mut() { + // The panels shown sit beside the canvas, level with it. + let over = pointer.is_some_and(|p| { + let uncovered = st.uncovered.unwrap_or_else(|| area.shrink2(egui::vec2(EDGE, 0.0))); + area.contains(p) + && (uncovered.top()..=uncovered.bottom()).contains(&p.y) + && match shown.edge { + Edge::Left => p.x < uncovered.left(), + Edge::Right => p.x > uncovered.right(), + } + }); + if over || down || blocked || flyout { + shown.left_at = None; + } else if moved || pointer.is_none() { + shown.left_at.get_or_insert(now); + } + match shown.left_at { + Some(left) if now - left >= GRACE => { + st.shown = None; + st.uncovered = None; + // A flyout opened from the panels shown closes with them. + app.ui.open_panel = None; + return None; + } + Some(left) => ctx.request_repaint_after_secs((GRACE - (now - left)) as f32), + None => {} + } + return Some(shown.edge); + } + let edge = pointer.filter(|p| area.contains(*p) && !tools.is_some_and(|r| r.contains(*p))).and_then(|p| { + if p.x <= area.left() + EDGE { + Some(Edge::Left) + } else if p.x >= area.right() - EDGE { + Some(Edge::Right) + } else { + None + } + }); + let Some(edge) = edge.filter(|e| !down && !blocked && !edge_panels(layout, *e).is_empty()) else { + st.resting = None; + return None; + }; + let since = match st.resting { + Some((rested, since)) if rested == edge => since, + _ => now, + }; + if now - since >= DELAY { + st.resting = None; + st.shown = Some(Shown { edge, left_at: None }); + return Some(edge); + } + st.resting = Some((edge, since)); + ctx.request_repaint_after_secs((DELAY - (now - since)) as f32); + // The strip the pointer rests on. + let band = match edge { + Edge::Left => egui::Rect::from_min_max(area.left_top(), egui::pos2(area.left() + EDGE, area.bottom())), + Edge::Right => egui::Rect::from_min_max(egui::pos2(area.right() - EDGE, area.top()), area.right_bottom()), + }; + let t = crate::theme::Tokens::get(ctx); + let layer = egui::LayerId::new(egui::Order::Foreground, super::area_id().with("peek-strip")); + ctx.layer_painter(layer).rect_filled(band, 0.0, t.accent.gamma_multiply(0.6)); + None +} + +/// The side shown, for `ui.docking`. +pub(super) fn shown(app: &VectorcraftApp) -> Option<&'static str> { + app.dock_rt.peek.shown.map(|s| s.edge.id()) +} + +/// Draw the canvas panel's body with `draw`. While panels show from an edge, the canvas keeps the +/// rect it has with every panel hidden, drawn under the panels and taking input only where they +/// leave it uncovered. +pub(super) fn canvas(app: &mut VectorcraftApp, ui: &mut egui::Ui, draw: impl FnOnce(&mut VectorcraftApp, &mut egui::Ui)) { + let uncovered = ui.max_rect(); + let st = &mut app.dock_rt.peek; + let wide = if !st.hidden { + st.canvas = None; + None + } else if st.shown.is_none() { + st.canvas = Some((uncovered.min - st.area.min, uncovered.max - st.area.max)); + None + } else { + st.uncovered = Some(uncovered); + // The canvas's insets in the dock area with every panel hidden, kept as the window resizes; + // none before such a frame was drawn, as it fills the area then. + let (min, max) = st.canvas.unwrap_or((egui::Vec2::ZERO, egui::Vec2::ZERO)); + Some(egui::Rect::from_min_max(st.area.min + min, st.area.max + max)) + }; + match wide { + Some(full) if full != uncovered && full.is_positive() => { + let clip = uncovered.intersect(ui.clip_rect()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(full)); + child.set_clip_rect(clip); + draw(app, &mut child); + } + _ => draw(app, ui), + } +} + +/// `window.panels.peek {side: left|right|null}`: show the docked panels on that side while Tab or +/// Shift+Tab hides the panels, as resting the pointer on that edge does, or (null) hide them. +pub(super) fn command(app: &mut VectorcraftApp, params: &Value) -> Result { + let edge = match params.get("side") { + None | Some(Value::Null) => None, + Some(Value::String(s)) if s == "left" => Some(Edge::Left), + Some(Value::String(s)) if s == "right" => Some(Edge::Right), + Some(_) => return Err("`side` must be left, right or null".into()), + }; + if app.ui.screen_mode >= 3 { + return Err("panels can't be shown in Presentation Mode".into()); + } + if app.ui.dock { + return Err("the panels are not hidden".into()); + } + match edge { + Some(edge) => { + if edge_panels(&super::layout_of(&app.ui), edge).is_empty() { + return Err(format!("no panels are docked on the {} side", edge.id())); + } + let st = &mut app.dock_rt.peek; + st.resting = None; + st.shown = Some(Shown { edge, left_at: None }); + Ok(json!(edge.id())) + } + None => { + let st = &mut app.dock_rt.peek; + if st.shown.take().is_some() { + st.uncovered = None; + app.ui.open_panel = None; + } + Ok(Value::Null) + } + } +} diff --git a/crates/ui-egui/src/panel_docking/tests.rs b/crates/ui-egui/src/panel_docking/tests.rs index befffb1a9e..2c95ff0e16 100644 --- a/crates/ui-egui/src/panel_docking/tests.rs +++ b/crates/ui-egui/src/panel_docking/tests.rs @@ -468,6 +468,200 @@ fn tab_hides_the_panels_and_the_canvas_keeps_drawing() { assert!(h.tab("properties").is_some()); } +/// Hide the panels with Tab and settle; returns the canvas rect with every panel hidden. +fn hide_with_tab(h: &mut Harness) -> Rect { + h.app.run("window.dock", json!({})).unwrap(); + h.frame(vec![]); + h.frame(vec![]); + assert!(h.tab("properties").is_none()); + h.app.canvas_rect.expect("the canvas draws") +} + +/// Frames with the pointer still, for `secs` of the harness clock. +fn wait(h: &mut Harness, secs: f64) { + let until = h.time + secs; + while h.time < until { + h.frame(vec![]); + } +} + +#[test] +fn resting_on_the_right_edge_shows_the_hidden_panels_over_the_canvas_until_the_pointer_leaves() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + let edge = pos2(SCREEN.right() - 2.0, 500.0); + h.frame(vec![Event::PointerMoved(edge)]); + wait(&mut h, peek::DELAY / 2.0); + assert!(h.tab("properties").is_none(), "the panels wait for the pointer to rest"); + wait(&mut h, peek::DELAY); + let tab = h.tab("properties").expect("resting on the edge shows the right-hand panels"); + assert!(h.hit(&Hit::Icon("swatches".into())).is_some(), "the icon column shows with them"); + assert!(!h.app.ui.dock, "the panels stay hidden for good"); + assert_eq!(h.app.canvas_rect, Some(wide), "the canvas keeps its size, so the art stays where it is"); + assert_eq!(inspect(&h.app, &h.ctx)["peek"], json!("right")); + h.frame(vec![Event::PointerMoved(tab.center())]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.tab("properties").is_some(), "they stay while the pointer is over them"); + h.frame(vec![Event::PointerMoved(wide.center())]); + wait(&mut h, peek::GRACE / 2.0); + assert!(h.tab("properties").is_some(), "a short trip away keeps them"); + wait(&mut h, peek::GRACE); + assert!(h.tab("properties").is_none(), "away from them, they hide again"); + assert_eq!(inspect(&h.app, &h.ctx)["peek"], Value::Null); +} + +#[test] +fn a_press_on_the_canvas_while_the_panels_show_from_the_edge_does_not_reach_the_art_under_them() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + h.app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + h.frame(vec![]); + fn selected(app: &VectorcraftApp) -> bool { + !app.session.active().unwrap().selection.is_empty() + } + assert!(selected(&h.app)); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(), json!("right")); + h.frame(vec![]); + h.frame(vec![]); + let tab = h.tab("properties").expect("the right-hand panels show"); + assert!(tab.left() < wide.right(), "they cover the canvas's right edge"); + assert_eq!(h.app.canvas_rect, Some(wide)); + // A click in the panels, where the canvas lies under them, leaves the selection. + h.click(pos2(tab.center().x, wide.bottom() - 40.0)); + assert!(selected(&h.app), "the canvas under the panels takes no click"); + // A click on the uncovered canvas away from the art deselects it, as it always does. + h.click(pos2(wide.left() + 40.0, wide.bottom() - 40.0)); + assert!(!selected(&h.app)); +} + +#[test] +fn the_canvas_keeps_the_hidden_panels_size_when_the_window_resizes_or_the_panels_show_at_once() { + let mut h = Harness::new(); + // Shown before any frame has drawn the panels hidden. + h.app.run("window.dock", json!({})).unwrap(); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(), json!("right")); + h.frame(vec![]); + h.frame(vec![]); + assert!(h.tab("properties").is_some()); + let wide = h.app.canvas_rect.unwrap(); + h.app.run("window.panels.peek", json!({"side": null})).unwrap(); + h.frame(vec![]); + h.frame(vec![]); + assert_eq!(h.app.canvas_rect, Some(wide), "the canvas had the size it has with every panel hidden"); + // A narrower window while they show. + h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(); + let narrow = Rect::from_min_max(SCREEN.min, pos2(SCREEN.right() - 200.0, SCREEN.bottom())); + for _ in 0..3 { + h.time += 0.05; + h.run(egui::RawInput { time: Some(h.time), screen_rect: Some(narrow), ..Default::default() }); + } + assert!(h.tab("properties").is_some()); + let canvas = h.app.canvas_rect.unwrap(); + assert!((canvas.width() - (wide.width() - 200.0)).abs() < 1.0, "{canvas:?} follows the window, under the panels"); +} + +#[test] +fn panels_held_by_a_press_hide_after_its_release_away_from_them() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + let tab = h.tab("properties").expect("the panels show"); + let body = pos2(tab.center().x, wide.bottom() - 40.0); + h.frame(vec![Event::PointerMoved(body), button(body, true)]); + h.frame(vec![Event::PointerMoved(wide.center())]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.tab("properties").is_some(), "a held button keeps them"); + h.frame(vec![button(wide.center(), false)]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.tab("properties").is_none(), "released away from them, they hide"); +} + +#[test] +fn a_drag_to_the_edge_or_a_pointer_on_the_left_edge_shows_nothing() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + // The default layout docks no panel on the left. + h.frame(vec![Event::PointerMoved(pos2(SCREEN.left() + 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_none()); + // A drag that reaches the right edge holds a button down. + h.frame(vec![Event::PointerMoved(wide.center()), button(wide.center(), true)]); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_none(), "a drag never shows them"); + h.frame(vec![button(pos2(SCREEN.right() - 2.0, 500.0), false)]); +} + +#[test] +fn tab_while_the_panels_show_from_the_edge_shows_them_for_good() { + let mut h = Harness::new(); + let wide = hide_with_tab(&mut h); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_some()); + h.app.run("window.dock", json!({})).unwrap(); + h.frame(vec![Event::PointerMoved(wide.center())]); + wait(&mut h, peek::GRACE * 2.0); + assert!(h.app.ui.dock && h.tab("properties").is_some()); + assert_eq!(inspect(&h.app, &h.ctx)["peek"], Value::Null); +} + +#[test] +fn shift_tab_hidden_panels_show_from_the_edge_too() { + let mut h = Harness::new(); + h.app.run("window.panels", json!({})).unwrap(); + h.frame(vec![]); + h.frame(vec![]); + assert!(h.tab("properties").is_none()); + h.frame(vec![Event::PointerMoved(pos2(SCREEN.right() - 2.0, 500.0))]); + wait(&mut h, peek::DELAY * 2.0); + assert!(h.tab("properties").is_some()); +} + +#[test] +fn window_panels_peek_rejects_what_it_cannot_show_and_changes_nothing() { + let mut h = Harness::new(); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap_err(), "the panels are not hidden"); + hide_with_tab(&mut h); + for side in [json!("up"), json!(1), json!(true), json!("Right")] { + assert_eq!(h.app.run("window.panels.peek", json!({"side": side})).unwrap_err(), "`side` must be left, right or null", "{side}"); + } + assert_eq!(h.app.run("window.panels.peek", json!({"side": "left"})).unwrap_err(), "no panels are docked on the left side"); + assert_eq!(h.app.run("window.panels.peek", json!({})).unwrap(), Value::Null, "nothing to hide"); + h.frame(vec![]); + assert!(h.tab("properties").is_none() && !h.app.ui.dock); + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap(), json!("right")); + h.frame(vec![]); + assert!(h.tab("properties").is_some(), "the panels stay while the pointer has not moved"); + assert_eq!(h.app.run("window.panels.peek", json!({"side": null})).unwrap(), Value::Null); + h.frame(vec![]); + assert!(h.tab("properties").is_none()); + h.app.ui.screen_mode = 3; + assert_eq!(h.app.run("window.panels.peek", json!({"side": "right"})).unwrap_err(), "panels can't be shown in Presentation Mode"); + h.app.ui.screen_mode = 0; +} + +#[test] +fn edge_panels_are_the_docked_panels_on_each_side_of_the_canvas() { + let layout = default_from(DockTab::ALL[0], &[], false, None, None); + let right = peek::edge_panels(&layout, peek::Edge::Right); + assert!(right.contains(&"properties".to_string()) && right.contains(&"swatches".to_string())); + assert!(!right.contains(&CANVAS.to_string())); + assert!(peek::edge_panels(&layout, peek::Edge::Left).is_empty()); + let mirrored = Layout { + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::FixedFirst(310.0), + first: Box::new(Node::Tabs { panels: vec!["layers".to_string()], active: 0 }), + second: Box::new(Node::Tabs { panels: vec![CANVAS.to_string()], active: 0 }), + }), + floating: Vec::new(), + }; + assert_eq!(peek::edge_panels(&mirrored, peek::Edge::Left), ["layers"]); + assert!(peek::edge_panels(&mirrored, peek::Edge::Right).is_empty()); +} + #[test] fn switching_workspace_during_a_tab_drag_keeps_the_new_workspace_layout() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index a7f32611cc..1c5509e9ae 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -4237,6 +4237,125 @@ mod tests { assert_eq!(r["skipped"], json!([{"action": "A", "step": "group"}, {"action": "A", "step": "Ungroup"}])); assert_eq!(r["leftOut"], 2); } + + /// A Set Selection step written from scratch, as `/event-N` text: its label (none when empty), + /// whether it is on, and its recorded values, each the text inside a `/parameter-N` block. + fn set_selection_event(n: usize, label: &str, on: bool, values: &[String]) -> String { + let hex = |t: &[u8]| format!("[ {} {} ]", t.len(), t.iter().map(|b| format!("{b:02x}")).collect::()); + let label = if label.is_empty() { String::new() } else { format!("/localizedName {} ", hex(label.as_bytes())) }; + let params: String = values.iter().enumerate().map(|(i, v)| format!("/parameter-{} {{ {v} }} ", i + 1)).collect(); + format!( + "/event-{n} {{ /useRulersIn1stQuadrant 0 /internalName (adobe_setSelection) {label}/isOn {} /isOff 0 /parameterCount {} {params}}} ", + u8::from(on), + values.len() + ) + } + + /// The recorded values of a Set Selection step: the note's bytes, then Whole Word and Case + /// Sensitive. + fn set_selection_values(note: &[u8], whole_word: u8, case_sensitive: u8) -> Vec { + let hex = |t: &[u8]| format!("[ {} {} ]", t.len(), t.iter().map(|b| format!("{b:02x}")).collect::()); + vec![ + format!("/key 1952807028 /showInPalette 4294967295 /type (ustring) /value {}", hex(note)), + format!("/key 2003792484 /showInPalette 4294967295 /type (boolean) /value {whole_word}"), + format!("/key 1667330917 /showInPalette 4294967295 /type (boolean) /value {case_sensitive}"), + ] + } + + /// Load Actions… reads an `.aia` Set Selection step, found by its internal name whatever its + /// label, as a Select Object step with its note (trimmed), Case Sensitive and Whole Word, in any + /// order; a step that is off stays excluded. The set saves to VectorCraft's own file and loads + /// back the same, and playing it selects the objects with that note. + #[test] + fn load_actions_reads_set_selection_steps_as_select_object() { + let mut reordered = set_selection_values("Größe ✓".as_bytes(), 1, 0); + reordered.reverse(); + let events = [ + set_selection_event(1, "Set Selection", true, &set_selection_values(b" Badge front ", 0, 1)), + set_selection_event(2, "Auswahl festlegen", true, &reordered), + set_selection_event(3, "", false, &set_selection_values(b"Off", 1, 1)), + ] + .concat(); + let text = format!("/version 3 /name [ 5 4e6f746573 ] /actionCount 1 /action-1 {{ /name [ 1 41 ] /eventCount 3 {events}}}"); + let mut app = app_with_sets(); + let r = app.run(LOAD_SET, json!({ "data": text })).unwrap(); + assert_eq!((r["leftOut"].clone(), r["skipped"].clone()), (json!(0), json!([]))); + let step = |note: &str, case_sensitive: bool, whole_word: bool| { + (SELECT_OBJECT_STEP.to_string(), json!({"note": note, "caseSensitive": case_sensitive, "wholeWord": whole_word})) + }; + let expected = [step("Badge front", true, false), step("Größe ✓", false, true), step("Off", true, true)]; + assert_eq!(app.ui.action_sets[3].actions[0].steps, expected); + assert_eq!(app.ui.action_sets[3].actions[0].excluded_steps, [2].into()); + + let saved = files::write_set(&app.ui.action_sets[3]); + assert!(saved.contains("\"version\": 4"), "{saved}"); + app.run(LOAD_SET, json!({ "data": saved })).unwrap(); + assert_eq!(app.ui.action_sets[4].actions[0].steps, expected); + assert_eq!(app.ui.action_sets[4].actions[0].excluded_steps, [2].into()); + + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let mut ids = vec![]; + for x in [0, 50] { + ids.push(app.run("shape.rectangle", json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()); + } + app.run("attributes.set", json!({"ids": [ids[1]], "note": "Badge front"})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.ui.action_sets[3].actions[0].excluded_steps = [1, 2].into(); + play(&mut app, 3, 0).unwrap(); + let selected: Vec = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect(); + assert_eq!(selected, [ids[1]], "the step selected the object with the note"); + } + + /// A Set Selection step whose recorded values are not exactly the note (UTF-8 text, not empty + /// once trimmed, at most 1,024 bytes) and the two options (each 0 or 1), with the right types and + /// no other value, is left out, listed by its label (else its internal name) and counted; no step + /// comes in with a missing option filled in. A value whose byte count is wrong refuses the file. + #[test] + fn set_selection_steps_with_bad_values_are_left_out() { + let good = || set_selection_values(b"Badge", 0, 0); + let with = |i: usize, value: &str| { + let mut v = good(); + v[i] = value.to_string(); + v + }; + let mut extra = good(); + extra.push("/key 1 /type (boolean) /value 0".into()); + let mut duplicate = good(); + duplicate[0] = good()[2].clone(); + let bad: Vec> = vec![ + good()[1..].to_vec(), + vec![good()[0].clone(), good()[1].clone()], + extra, + duplicate, + with(0, "/key 1952807028 /type (ustring) /value [ 2 c328 ]"), + with(0, "/key 1952807028 /type (ustring) /value [ 3 202020 ]"), + with(0, &format!("/key 1952807028 /type (ustring) /value [ 1025 {} ]", "61".repeat(1025))), + with(0, "/key 1952807028 /type (text) /value [ 5 4261646765 ]"), + with(0, "/key 1952807028 /type (ustring) /value 5"), + with(1, "/key 2003792484 /type (boolean) /value 2"), + with(1, "/key 2003792484 /type (integer) /value 1"), + with(2, "/key 1667330917 /type (boolean) /value 1.0"), + with(2, "/key 1667330917 /type (boolean)"), + with(2, "/key 1 /type (boolean) /value 1"), + ]; + let events: String = bad.iter().enumerate().map(|(i, values)| set_selection_event(i + 1, "Set Selection", true, values)).collect(); + let events = events + &set_selection_event(bad.len() + 1, "", true, &[]); + let text = format!("/version 3 /name [ 3 426164 ] /action-1 {{ /name [ 1 41 ] {events}}}"); + let mut app = app_with_sets(); + let r = app.run(LOAD_SET, json!({ "data": text })).unwrap(); + assert!(app.ui.action_sets[3].actions[0].steps.is_empty(), "{:?}", app.ui.action_sets[3].actions[0].steps); + assert_eq!(r["leftOut"], bad.len() + 1); + let names: Vec<&str> = r["skipped"].as_array().unwrap().iter().map(|s| s["step"].as_str().unwrap()).collect(); + let mut expected = vec!["Set Selection"; bad.len()]; + expected.push("adobe_setSelection"); + assert_eq!(names, expected); + assert_eq!(app.ui.status, format!("Loaded “Bad”: {} steps were left out", bad.len() + 1)); + + let wrong_count = set_selection_event(1, "Set Selection", true, &with(0, "/key 1952807028 /type (ustring) /value [ 6 4261646765 ]")); + let text = format!("/version 3 /action-1 {{ {wrong_count}}}"); + assert_eq!(app.run(LOAD_SET, json!({ "data": text })).unwrap_err(), "the action set file can't be read in full"); + assert_eq!(app.ui.action_sets.len(), 4, "no set added"); + } /// Load Actions… picks action set files of both kinds, and a set another app saved without a /// name takes the file's name, or `Actions` when there is no file; one named like a set in the /// panel gets the next free number. A file that isn't UTF-8 still reads. A set past the reader's diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index 0d19a0bf2b..5bdd0b9a98 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -15,7 +15,7 @@ //! version 6 so older builds refuse them rather than fail at that step. Other sets use version 1, and //! earlier version 1 to 5 files still work. //! Load Actions… also reads the action sets another app saves as text (`.aia`, through -//! `actionSet.read`). +//! `actionSet.read`), a Set Selection step as a Select Object step. use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -398,6 +398,45 @@ fn step_commands() -> std::collections::HashMap> { by_label } +/// The `/internalName` of a step of another app's action set that selects objects by their note: the +/// Set Selection step. +const SET_SELECTION: &str = "adobe_setSelection"; // brand-ok: the step's internal name in the file format + +/// The `/key` of a Set Selection step's note (`text`), of its Whole Word option (`word`) and of its +/// Case Sensitive option (`case`): four characters read as a big-endian number. +const NOTE_KEY: i64 = 0x7465_7874; +const WHOLE_WORD_KEY: i64 = 0x776f_7264; +const CASE_SENSITIVE_KEY: i64 = 0x6361_7365; + +/// The Select Object step ([`super::SELECT_OBJECT_STEP`]) a Set Selection step of `actionSet.read`'s +/// answer stands for. The step must have exactly three recorded values, all kept: the note, of type +/// `ustring` and UTF-8, and Whole Word and Case Sensitive, each of type `boolean` and 0 or 1. The note +/// is as [`super::select_object_params`] takes it (trimmed, not empty, at most 1,024 bytes). Anything +/// else stands for nothing. +fn select_object_step(step: &Value) -> Option<(String, Value)> { + let values = step.get("values").and_then(Value::as_array)?; + if values.len() != 3 || step.get("params").and_then(Value::as_u64) != Some(3) { + return None; + } + let value = |key: i64, kind: &str| { + let mut found = values.iter().filter(|v| v.get("key").and_then(Value::as_i64) == Some(key)); + let v = found.next().filter(|v| v.get("type").and_then(Value::as_str) == Some(kind))?; + if found.next().is_some() { + return None; + } + v.get("value") + }; + let flag = |key: i64| match value(key, "boolean")?.as_i64()? { + 0 => Some(false), + 1 => Some(true), + _ => None, + }; + let note = value(NOTE_KEY, "ustring")?.as_str()?; + let params = json!({"note": note, "caseSensitive": flag(CASE_SENSITIVE_KEY)?, "wholeWord": flag(WHOLE_WORD_KEY)?}); + let (note, case_sensitive, whole_word) = super::select_object_params(¶ms).ok()?; + Some((super::SELECT_OBJECT_STEP.to_string(), json!({"note": note, "caseSensitive": case_sensitive, "wholeWord": whole_word}))) +} + /// The command a step of another app's action set stands for ([`step_commands`]). #[cfg(test)] pub(super) fn step_command(label: &str) -> Option<&'static str> { @@ -414,8 +453,9 @@ struct AiaLoad { } /// The set of `read`, `actionSet.read`'s answer, named `name` when the file gives none. A step comes -/// in as the command it stands for ([`step_name`], [`step_commands`]), with no params, and excluded -/// from playback when it is off in the file. An action with no name is called `Action N`. +/// in as the command it stands for ([`step_name`], [`step_commands`]), with no params, or a Set +/// Selection step as a Select Object step ([`select_object_step`]), and excluded from playback when +/// it is off in the file. An action with no name is called `Action N`. fn aia_set(read: &Value, name: &str) -> AiaLoad { let (mut skipped, mut left_out) = (vec![], 0usize); let commands = step_commands(); @@ -432,7 +472,12 @@ fn aia_set(read: &Value, name: &str) -> AiaLoad { let (mut steps, mut excluded_steps) = (vec![], std::collections::BTreeSet::new()); for s in a.get("steps").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() { let (name, can_stand) = step_name(s); - let Some(id) = commands.get(&bare_label(&name)).copied().flatten().filter(|_| can_stand) else { + let step = if s.get("name").and_then(Value::as_str) == Some(SET_SELECTION) { + select_object_step(s) + } else { + commands.get(&bare_label(&name)).copied().flatten().filter(|_| can_stand).map(|id| (id.to_string(), json!({}))) + }; + let Some(step) = step else { left_out += 1; if skipped.len() < MAX_SKIPPED { skipped.push(json!({ "action": action, "step": name })); @@ -442,7 +487,7 @@ fn aia_set(read: &Value, name: &str) -> AiaLoad { if s.get("on").and_then(Value::as_bool) == Some(false) { excluded_steps.insert(steps.len()); } - steps.push((id.to_string(), json!({}))); + steps.push(step); } Action { excluded_steps, ..Action::new(action, steps) } }) @@ -496,7 +541,8 @@ fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result { /// /// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its /// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]), -/// with no params; a step that is off in the file is excluded from playback. A set with no name is +/// with no params, and its Set Selection steps as Select Object steps ([`select_object_step`]); a +/// step that is off in the file is excluded from playback. A set with no name is /// called after the file, else `Actions`. The other steps are left out and listed, and the status /// bar says how many → `{set, name, actions, skipped: [{action, step}] (at most 1,000), leftOut: n}`. /// Such a file that `actionSet.read` marks `truncated` is refused, and so is one whose set Save diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 02c7969be2..e5cb5d07dc 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -297,9 +297,10 @@ fn topmost(app: &VectorcraftApp) -> Option { app.session.active().and_then(|st| st.selection.in_paint_order(&st.doc).last().copied()) } -/// What Align aligns to: the key object or the key guide (#2143) while there is one (a click on -/// an object of the selection makes it the key, and Align to Key Object makes the topmost one the -/// key, or a guide selected with a single object), else the stored Align To choice, Selection or +/// What Align aligns to: the key object, the key guide (#2143) or the key anchor while there is one +/// (a click on an object of the selection makes it the key, and Align to Key Object makes the +/// topmost one the key, or a guide selected with a single object; a Shift-click with Direct +/// Selection makes the point it adds the key anchor), else the stored Align To choice, Selection or /// Artboard. The Distribute Objects and Distribute Spacing buttons send it as their `to`. fn align_to(app: &VectorcraftApp) -> AlignTo { if has_key(app) { AlignTo::Key } else { app.ui.align_to } @@ -308,15 +309,23 @@ fn align_to(app: &VectorcraftApp) -> AlignTo { /// The Align To choices of the Align panel and of the Control bar's Align To menu, in their order. const ALIGN_TO: [AlignTo; 3] = [AlignTo::Selection, AlignTo::Key, AlignTo::Artboard]; -/// The icon and the translated name of Align To choice `v`. -fn align_to_icon_name(v: AlignTo) -> (&'static str, &'static str) { +/// The icon and the translated name of Align To choice `v`; `anchor`: the key is an anchor point +/// (Direct Selection), and the key choice is named Align to Key Anchor. +fn align_to_icon_name(v: AlignTo, anchor: bool) -> (&'static str, &'static str) { match v { AlignTo::Selection => ("dc-alignto-selection", tl!("Align to Selection")), + AlignTo::Key if anchor => ("dc-alignto-key", tl!("Align to Key Anchor")), AlignTo::Key => ("dc-alignto-key", tl!("Align to Key Object")), AlignTo::Artboard => ("dc-alignto-artboard", tl!("Align to Artboard")), } } +/// Whether the key is an anchor point: the last of two or more anchor points that a Shift-click +/// with Direct Selection added ([`vectorcraft_doc::Selection::key_anchor`]). +fn key_is_anchor(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| st.selection.key_anchor.is_some()) +} + /// The Align To choice that the Align panel and the Control bar show chosen: Artboard while one /// object is selected (the buttons then align it to the artboard), else [`align_to`]. With the /// Artboard tool chosen, it is the stored choice, Selection or Artboard. @@ -386,7 +395,8 @@ const ALIGN_TO_ARROW: f32 = 14.0; pub(crate) fn align_to_menu(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { let t = crate::theme::Tokens::get(ui.ctx()); let shown = shown_align_to(app); - let (icon, name) = align_to_icon_name(shown); + let anchor = key_is_anchor(app); + let (icon, name) = align_to_icon_name(shown, anchor); let (rect, resp) = ui.allocate_exact_size(egui::vec2(align_to_menu_width(size), size), egui::Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, egui::CornerRadius::same(5), t.hover); @@ -403,7 +413,7 @@ pub(crate) fn align_to_menu(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { let picked = egui::Popup::menu(&resp).show(|ui| { let mut picked = None; for v in ALIGN_TO { - if menu_item(ui, align_to_icon_name(v).1, can_choose(app, v), shown == v) { + if menu_item(ui, align_to_icon_name(v, anchor).1, can_choose(app, v), shown == v) { picked = Some(v); } } @@ -485,9 +495,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::subheader(ui, tl!("Align To:")); // With one object selected the buttons align it to the artboard, and Align To shows Artboard. let shown = shown_align_to(app); + let anchor = key_is_anchor(app); ui.horizontal(|ui| { for v in ALIGN_TO { - let (icon, tip) = align_to_icon_name(v); + let (icon, tip) = align_to_icon_name(v, anchor); if widgets::icon_button_enabled(ui, icon, tip, shown == v, can_choose(app, v), 28.0).clicked() { choose_align_to(app, v); } diff --git a/crates/ui-egui/src/panels/align/tests.rs b/crates/ui-egui/src/panels/align/tests.rs index 385fd25fae..3e3d51f8d6 100644 --- a/crates/ui-egui/src/panels/align/tests.rs +++ b/crates/ui-egui/src/panels/align/tests.rs @@ -672,6 +672,32 @@ fn anchor_points_of_one_path_align_to_each_other() { assert_eq!(xs(&app), [0.0, 0.0, 150.0], "to the artboard's left edge: {}", app.ui.status); } +/// #1636: with a key anchor, the Align panel shows the key choice chosen and named Align to Key +/// Anchor, its align button moves the other points to the key anchor, which stays, and Align to +/// Selection lets go of it. +#[test] +fn a_key_anchor_is_chosen_named_and_aligned_to() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("path.create", json!({"d": "M100 40 L200 60 L150 160 Z"})).unwrap()["id"].as_u64().unwrap(); + let xs = |app: &VectorcraftApp| -> Vec { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + node.path_data().unwrap().anchors().map(|(_, _, a)| a.p.x).collect() + }; + app.run("select.anchors", json!({"id": id, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + app.run("select.anchors", json!({"id": id, "anchors": [[0, 2]], "mode": "toggle"})).unwrap(); + let ctx = egui::Context::default(); + let ([selection, key, _], left) = panel_buttons(&mut app, &ctx); + assert_eq!((align_to(&app), chosen_align_to(&mut app, &ctx)), (AlignTo::Key, vec![key]), "chosen automatically"); + let named = tree(|ui| show(&mut app, ui)); + assert_eq!(role_and_state(&named, "Align to Key Anchor"), (Role::Button, Some(Toggled::True))); + click(&ctx, &mut app, left.center(), show); + assert_eq!(xs(&app), [150.0, 200.0, 150.0], "to the key anchor's x: {}", app.ui.status); + click(&ctx, &mut app, selection.center(), show); + assert!(!has_key(&app), "Align to Selection lets go of the key anchor"); + assert_eq!(app.ui.align_to, AlignTo::Selection); +} + /// #1636: the Distribute buttons work on the anchor points of one path (one object selected). #[test] fn distribute_buttons_space_the_anchor_points_of_one_path() { diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index 0f7223f7e8..b7f9e25df5 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -41,6 +41,13 @@ fn art_option_boxes(app: &mut VectorcraftApp, ui: &mut Ui, compact: bool) { } } +/// The widths [`art_option_boxes`] gives Move and Scale Artwork with Artboard, or with `compact` the +/// boxes alone. +fn art_option_widths(ui: &Ui, compact: bool) -> [f32; 2] { + let label = |text: &str| widgets::choice_width(ui, if compact { "" } else { text }); + [label(tl!("Move Artwork with Artboard")), label(tl!("Scale Artwork with Artboard"))] +} + /// The room Move and Scale Artwork with Artboard need with their labels in the Control bar; with /// less, the boxes show alone. const ART_OPTIONS_WIDTH: f32 = 370.0; @@ -56,15 +63,17 @@ const NAME_FIELD_ROOM: f32 = 330.0; /// The Control bar while the Artboard tool is in use, in place of the object controls: the active /// artboard's preset and orientation, New Artboard and Delete Artboard, its name (in a narrower /// field when the bar is short of room), Artboard Options, Rearrange All Artboards and the Transform -/// link (the Transform panel in a popover), which all fit in the narrowest window, left of the bar's -/// panel menu button; then Move and Scale Artwork with Artboard (the boxes alone, with their -/// names in tooltips, when the bar is short of room) and, while the bar has room, the reference -/// point the Transform panel also uses with the artboard's X and Y, and its W and H with the -/// Constrain Width and Height Proportions link. Each control runs the command its Properties panel -/// or Transform panel counterpart runs. With `options` false (the bar's panel menu has its Artboard -/// Options group off), the controls other than the Transform link and the fields are left out; with -/// `transform` false (its Transform group off), the Transform link and the fields are. Returns -/// false, and draws nothing, while another tool is in use. +/// link (the Transform panel in a popover), left of the bar's panel menu button; then Move and Scale +/// Artwork with Artboard (the boxes alone, with their names in tooltips, when the bar is short of +/// room) and, while the bar has room, the reference point the Transform panel also uses with the +/// artboard's X and Y, and its W and H with the Constrain Width and Height Proportions link. Each +/// control, and each label with its field or menu, shows only while the row has room for it, with +/// the Transform link's room left after the controls ahead of the link, and the controls after one +/// left out are left out too, except the Transform link, which shows whenever it fits. Each control runs the command its Properties panel or Transform panel +/// counterpart runs. With `options` false (the bar's panel menu has its Artboard Options group off), +/// the controls other than the Transform link and the fields are left out; with `transform` false +/// (its Transform group off), the Transform link and the fields are. Returns false, and draws +/// nothing, while another tool is in use. pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, options: bool, transform: bool) -> bool { if app.session.tool_id() != "artboard" { return false; @@ -74,38 +83,58 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, options: bool, let i = selected(app, n); let Some(ab) = app.session.active().and_then(|d| d.doc.artboards.get(i)).cloned() else { return true }; let r = ab.rect; + let mut fit = crate::chrome::Fit::new(if transform { crate::chrome::link_reserve(ui) } else { 0.0 }); if options { - super::properties::artboard_size_row(app, ui, i, r.width(), r.height()); - if widgets::icon_button(ui, "dc-new-item", tl!("New Artboard"), false, 24.0).clicked() { + if fit.room(ui, &super::properties::artboard_size_row_widths(ui)) { + super::properties::artboard_size_row(app, ui, i, r.width(), r.height()); + } + if fit.room(ui, &[24.0]) && widgets::icon_button(ui, "dc-new-item", tl!("New Artboard"), false, 24.0).clicked() { add_new(app); } - if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1, 24.0).clicked() { + if fit.room(ui, &[24.0]) && widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1, 24.0).clicked() { delete_selected(app); } - ui.separator(); + fit.separator(ui); // The field shows the name as it is (names are never translated). A new name is applied on Enter // or a click elsewhere, and an empty field or Escape keeps the old one, as in the Properties panel. - ui.label(egui::RichText::new(tl!("Name:")).size(12.0).color(t.text)); - let width = if ui.available_width() < NAME_FIELD_ROOM { 100.0 } else { 120.0 }; - if let Some(name) = widgets::text_field_named(ui, ("cb-ab", "name"), Some(tl!("Name:")), Some(&ab.name), width, 1).filter(|n| !n.is_empty()) { - app.run("artboard.setProps", json!({"index": i, "name": name})).ok(); + let label = egui::RichText::new(tl!("Name:")).size(12.0).color(t.text); + let label_width = widgets::text_width(ui, label.clone()); + // The room after the label decides the field's width. + let width = if ui.available_width() - label_width - ui.spacing().item_spacing.x < NAME_FIELD_ROOM { 100.0 } else { 120.0 }; + if fit.room(ui, &[label_width, width]) { + ui.label(label); + if let Some(name) = + widgets::text_field_named(ui, ("cb-ab", "name"), Some(tl!("Name:")), Some(&ab.name), width, 1).filter(|n| !n.is_empty()) + { + app.run("artboard.setProps", json!({"index": i, "name": name})).ok(); + } } - if widgets::icon_button(ui, "dc-artboard-options", tl!("Artboard Options"), false, 24.0).clicked() { + if fit.room(ui, &[24.0]) && widgets::icon_button(ui, "dc-artboard-options", tl!("Artboard Options"), false, 24.0).clicked() { app.run("tool.options", json!({"tool": "artboard"})).ok(); } - if widgets::icon_button_enabled(ui, "dc-rearrange", tl!("Rearrange All Artboards"), false, n > 1, 24.0).clicked() { + if fit.room(ui, &[24.0]) && widgets::icon_button_enabled(ui, "dc-rearrange", tl!("Rearrange All Artboards"), false, n > 1, 24.0).clicked() { open_rearrange(app); } - ui.separator(); + fit.separator(ui); } - if transform { + fit.set_reserve(0.0); + // The link is its own group: it shows whenever it fits, even after an option was left out. + if transform && crate::chrome::Fit::new(0.0).room(ui, &[super::transform::link_width(ui)]) { super::transform::link(app, ui); crate::chrome::group_separator(ui); } if options { - art_option_boxes(app, ui, ui.available_width() < ART_OPTIONS_WIDTH); + let full = art_option_widths(ui, false); + let spacing = ui.spacing().item_spacing.x; + let compact = ui.available_width() < ART_OPTIONS_WIDTH || ui.available_width() < full.iter().map(|w| w + spacing).sum::(); + // The fields after the boxes need far more room than the spacing after them, so the boxes + // can end the row. + if fit.room_at_end(ui, &if compact { art_option_widths(ui, true) } else { full }) { + art_option_boxes(app, ui, compact); + } } - if !transform || ui.available_width() < INLINE_FIELDS_WIDTH { + // With no room left for a control above, the fields after it are left out too. + if !transform || !fit.room(ui, &[]) || ui.available_width() < INLINE_FIELDS_WIDTH { return true; } if options { diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 7777380180..bca4192b61 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -349,19 +349,25 @@ pub(crate) fn preset_dropdown(app: &mut VectorcraftApp, ui: &mut Ui, traced_with } /// The Control bar for one selected Image Trace object: its preset, view, the Image Trace panel -/// and Expand. Whether it is one. -pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { +/// and Expand. Whether it is one. Each label with its menu, and each button, shows only while the row +/// has room for it and `reserve` after it, and the controls after one left out are left out too. +pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) -> bool { let Some((preset, view)) = selected_trace(app) else { return false }; - widgets::dim_label(ui, tl!("Preset:")); - preset_dropdown(app, ui, &preset, 150.0); - view_row(app, ui, view, 190.0); - if widgets::icon_button(ui, "image", tl!("Image Trace"), false, 24.0).clicked() { + let mut fit = crate::chrome::Fit::new(reserve); + if fit.room(ui, &[widgets::dim_label_width(ui, tl!("Preset:")), 150.0]) { + widgets::dim_label(ui, tl!("Preset:")); + preset_dropdown(app, ui, &preset, 150.0); + } + if fit.room(ui, &[widgets::dim_label_width(ui, tl!("View:")), 190.0]) { + view_row(app, ui, view, 190.0); + } + if fit.room(ui, &[24.0]) && widgets::icon_button(ui, "image", tl!("Image Trace"), false, 24.0).clicked() { open_settings(app); } - if widgets::flat_button(ui, tl!("Expand"), 64.0).clicked() { + if fit.room(ui, &[widgets::flat_button_fit_width(ui, tl!("Expand"), 64.0)]) && widgets::flat_button_fit(ui, tl!("Expand"), 64.0).clicked() { app.run("imageTrace.expand", json!({})).ok(); } - crate::chrome::group_separator(ui); + fit.separator(ui); true } diff --git a/crates/ui-egui/src/panels/image_trace/tests.rs b/crates/ui-egui/src/panels/image_trace/tests.rs index 1586943c07..72e30921ac 100644 --- a/crates/ui-egui/src/panels/image_trace/tests.rs +++ b/crates/ui-egui/src/panels/image_trace/tests.rs @@ -1051,7 +1051,7 @@ fn the_control_bar_and_properties_show_a_renamed_or_changed_preset_as_the_panel_ if properties { crate::panels::properties::show(app, ui); } else { - ui.horizontal(|ui| control_bar(app, ui)); + ui.horizontal(|ui| control_bar(app, ui, 0.0)); } }); out.textures_delta.clear(); diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 100540cbd0..3f1a56cee5 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -54,15 +54,29 @@ pub(crate) fn align(app: &mut VectorcraftApp, justify: &str) -> Result Result { text_style(app).ok_or("select some type first")?; - let id = if shared_composer(app) == Some(Composer::EveryLine) { "singleLine" } else { "everyLine" }; + let to_single = shared_para(app, |p| p.composer.is_single_line()) == Some(false); + let standard = shared_para(app, |p| p.composer.shapes_complex_scripts()) == Some(false); + let base = if standard { Composer::StandardEveryLine } else { Composer::EveryLine }; + let id = composer_id(base.with_breaking(to_single)); format(app, json!({ "composer": id })); Ok(json!(id)) } +/// The `text.setFormat` value of `composer`. +fn composer_id(composer: Composer) -> &'static str { + match composer { + Composer::SingleLine => "singleLine", + Composer::EveryLine => "everyLine", + Composer::StandardSingleLine => "standardSingleLine", + Composer::StandardEveryLine => "standardEveryLine", + } +} + /// `type.hyphenate` (Cmd+Alt+Shift+H): what the Hyphenate checkbox does, on the paragraphs the /// panel acts on. Returns whether they now hyphenate. pub(crate) fn toggle_hyphenate(app: &mut VectorcraftApp) -> Result { @@ -874,11 +888,14 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.separator(); let composer = shared_composer(app); - for (c, label, id) in - [(Composer::SingleLine, tl!("Single-line Composer"), "singleLine"), (Composer::EveryLine, tl!("Every-line Composer"), "everyLine")] - { + for (c, label) in [ + (Composer::SingleLine, tl!("Middle Eastern & South Asian Single-line Composer")), + (Composer::EveryLine, tl!("Middle Eastern & South Asian Every-line Composer")), + (Composer::StandardSingleLine, tl!("Standard Single-line Composer")), + (Composer::StandardEveryLine, tl!("Standard Every-line Composer")), + ] { if menu_item(ui, label, has, composer == Some(c)) { - format(app, json!({ "composer": id })); + format(app, json!({ "composer": composer_id(c) })); } } ui.separator(); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 25c05ce3fc..11fd7fbafc 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -82,6 +82,83 @@ fn paragraph_mixed_composer_checks_neither_menu_item() { assert_eq!(shared_composer(&app), Some(Composer::EveryLine)); } +/// Cmd+Alt+Shift+C switches between Single-line and Every-line and keeps what each paragraph's +/// composer does with complex scripts: a Standard composer toggles to the other Standard one. +#[test] +fn toggling_the_line_composer_keeps_the_shaping() { + let (mut app, id) = editing_text("A\nB\n"); + for (from, first, second) in [ + ("standardEveryLine", "standardSingleLine", "standardEveryLine"), + ("everyLine", "singleLine", "everyLine"), + ("standardSingleLine", "standardEveryLine", "standardSingleLine"), + ] { + app.run("text.setFormat", json!({"id": id, "composer": from})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(toggle_composer(&mut app).unwrap(), json!(first), "{from}"); + assert_eq!(toggle_composer(&mut app).unwrap(), json!(second), "{from} again"); + } + // Paragraphs that break differently take Every-line, as before the Standard composers existed. + app.run("text.setFormat", json!({"id": id, "composer": "standardSingleLine"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "composer": "singleLine"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(toggle_composer(&mut app).unwrap(), json!("everyLine")); + // Paragraphs that both break every-line but differ in shaping: Single-line, Middle Eastern & South Asian. + app.run("text.setFormat", json!({"id": id, "composer": "everyLine"})).unwrap(); + app.run("text.setFormat", json!({"id": id, "start": 2, "end": 3, "composer": "standardEveryLine"})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + assert_eq!(toggle_composer(&mut app).unwrap(), json!("singleLine")); +} + +/// The Paragraph panel menu lists the four composers, and ticks the one every selected paragraph has. +#[test] +fn the_paragraph_menu_sets_each_of_the_four_composers() { + let (mut app, id) = editing_text("A\nB\n"); + app.session.set_tool_option("selectAll", &json!(true)); + let ctx = egui::Context::default(); + let items = |app: &mut VectorcraftApp| { + let mut seen = vec![]; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| menu(app, ui)); + out.textures_delta.clear(); + for shape in out.shapes { + collect_texts(&shape.shape, &mut seen); + } + seen + }; + fn collect_texts(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(v) => v.iter().for_each(|s| collect_texts(s, out)), + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + _ => {} + } + } + let labels = [ + ("singleLine", "Middle Eastern & South Asian Single-line Composer"), + ("everyLine", "Middle Eastern & South Asian Every-line Composer"), + ("standardSingleLine", "Standard Single-line Composer"), + ("standardEveryLine", "Standard Every-line Composer"), + ]; + // The menu lists all four, each row ticked (and only that one) when every selected paragraph has it. + for (value, _) in labels { + app.run("text.setFormat", json!({"id": id, "composer": value})).unwrap(); + app.session.set_tool_option("selectAll", &json!(true)); + let texts = items(&mut app); + for (v, label) in labels { + let row = if v == value { format!("✓ {label}") } else { format!(" {label}") }; + assert!(texts.contains(&row), "{value}: {row:?} in {texts:?}"); + } + } + // Choosing a row sets its composer: the row is a `text.setFormat {composer}` with the row's id. + for (value, want) in [ + ("standardEveryLine", Composer::StandardEveryLine), + ("singleLine", Composer::SingleLine), + ("standardSingleLine", Composer::StandardSingleLine), + ] { + app.run("text.setFormat", json!({"id": id, "composer": value})).unwrap(); + assert_eq!(shared_composer(&app), Some(want), "{value}"); + assert_eq!(composer_id(want), value); + } +} + #[test] fn paragraph_mixed_hyphenate_checks_off() { let (mut app, id) = editing_text("A\nB\n"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 6195de4b24..99c444def1 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -356,7 +356,7 @@ fn image_section(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(4.0); let w = (ui.available_width() - 6.0) / 2.0; let links = crate::panels::links::ID; - ui.horizontal(|ui| crate::place::link_buttons(app, ui, id, linked, &name, Some(w))); + ui.horizontal(|ui| crate::place::link_buttons(app, ui, id, linked, &name, crate::place::LinkButtons::Width(w))); ui.horizontal(|ui| { if app.services.pick_open.is_some() && widgets::flat_button(ui, tl!("Replace…"), w).clicked() { crate::panels::links::replace(app, vec![id]); @@ -631,7 +631,7 @@ fn artboard_size_controls_inner( let mut fit = None; ui.horizontal(|ui| { dim_label(ui, tl!("Preset:")); - preset = widgets::combo(ui, "ab-preset", ¤t, 140.0, |ui| { + preset = widgets::combo(ui, "ab-preset", ¤t, PRESET_MENU_WIDTH, |ui| { let mut chosen = None; if let Some(enabled) = fits { for (selection, label) in [(false, tl!("Fit to Artwork Bounds")), (true, tl!("Fit to Selected Art"))] { @@ -664,6 +664,19 @@ fn artboard_size_controls_inner( (preset.map(|(w, h)| ArtboardSizeChoice::Preset(w, h)).or_else(|| orientation.map(ArtboardSizeChoice::Orientation)), fit) } +/// The width of the artboard size row's Preset menu. +const PRESET_MENU_WIDTH: f32 = 140.0; + +/// The width of the artboard size row's Portrait and Landscape buttons, 26 points each, with the +/// 2 points between them. +const ORIENTATION_BUTTONS_WIDTH: f32 = 2.0 * 26.0 + 2.0; + +/// The widths of the parts of [`artboard_size_row`] in a row, as [`crate::chrome::Fit::room`] takes +/// them: the Preset label, its menu, and the Portrait and Landscape buttons. +pub(crate) fn artboard_size_row_widths(ui: &Ui) -> [f32; 3] { + [widgets::dim_label_width(ui, tl!("Preset:")), PRESET_MENU_WIDTH, ORIENTATION_BUTTONS_WIDTH] +} + /// Artboard `i`'s Fill, its background `color`: the chip shows it (the None paint when the artboard /// has none) and a click on it opens the Color Picker for the artboard. The list sets None, White or /// Black (in the document's color mode) or opens the Color Picker. diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index d3cf595666..bdb4210920 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -1,11 +1,12 @@ //! Separations Preview panel: process plates C/M/Y/K plus spot plates with eye toggles (one visible //! plate renders as greyscale ink coverage; Alt-click an eye to show only that plate), and the //! colour-management controls behind Edit → Color Settings… / Assign Profile… and View → Proof -//! Setup (working spaces, intent, black-point compensation, proof target, gamut check). +//! Setup (working spaces, color management policies, intent, black-point compensation, proof +//! target, gamut check). use egui::{Sense, Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_color::cms::{self, Intent, ProfileKind}; +use vectorcraft_color::cms::{self, Intent, Policy, ProfileKind}; use vectorcraft_render::proof; use crate::VectorcraftApp; @@ -100,8 +101,9 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { } } -/// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, Load -/// Profile… and Gamut Check, and the profile the active document is assigned. +/// The section's rows: the RGB and CMYK working spaces, intent, black point compensation, the RGB +/// and CMYK color management policies, Load Profile… and Gamut Check, and the profile the active +/// document is assigned. fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { let st = cms::working_settings(); widgets::section_header(ui, tl!("Color Settings")); @@ -127,6 +129,21 @@ fn settings_rows(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::check(ui, tl!("Use Black Point Compensation"), st.bpc, true) { run(app, "edit.colorSettings", json!({"bpc": !st.bpc})); } + // Color management policies: what opening a document does with a profile it is tagged with + // that is not the working space. + let policies: [(&str, &str, Policy, &[Policy]); 2] = + [("rgbPolicy", tl!("RGB Policy:"), st.rgb_policy, &Policy::RGB), ("cmykPolicy", tl!("CMYK Policy:"), st.cmyk_policy, &Policy::CMYK)]; + for (key, label, current, choices) in policies { + ui.horizontal(|ui| { + ui.label(label); + let labels: Vec<&str> = choices.iter().map(|p| policy_label(*p)).collect(); + if let Some(i) = widgets::dropdown_names(ui, key, policy_label(current), &labels, 210.0) + && let Some(p) = choices.get(i) + { + run(app, "edit.colorSettings", json!({ key: p.id() })); + } + }); + } ui.horizontal(|ui| { if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked() { // Its file dialog, shown off the UI thread, loads the profile when it answers. @@ -243,6 +260,16 @@ fn assign_rows(app: &mut VectorcraftApp, ui: &mut Ui, row: &AssignRow) { } } +/// The name of color management policy `p` in the UI language. +fn policy_label(p: Policy) -> &'static str { + match p { + Policy::Off => tl!("Off"), + Policy::PreserveEmbedded => tl!("Preserve Embedded Profiles"), + Policy::Convert => tl!("Convert to Working Space"), + Policy::PreserveNumbers => tl!("Preserve Numbers"), + } +} + /// The status bar's line after Gamut Check finds `n` colors outside the CMYK gamut. pub(crate) fn gamut_check_status(n: usize) -> String { if n == 0 { diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 195d6172f4..741b080635 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -251,35 +251,45 @@ fn selected_instance_symbols(app: &VectorcraftApp) -> Option> { /// (), /// and the Reset button, which gives them their symbol's scale, rotation and shear again /// (). -pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { +/// Edit Symbol, Replace with its menu and Reset each show only while the row has room for them and +/// `reserve` after them, and the controls after one left out are left out too. +pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) { let Some(symbols) = selected_instance_symbols(app) else { return }; let Some(st) = app.session.active() else { return }; let names: Vec = st.doc.symbols.iter().map(|s| s.name.clone()).collect(); let shared = symbols.first().filter(|first| symbols.iter().all(|s| s == *first)).cloned().unwrap_or_default(); + let mut fit = crate::chrome::Fit::new(reserve); // Edit Symbol: the instances' symbol in symbol editing mode, when they share one. if !shared.is_empty() + && fit.room(ui, &[24.0]) && widgets::icon_button(ui, "dc-place-symbol", tl!("Edit Symbol"), false, 24.0).clicked() && let Err(e) = app.run("symbol.edit", json!({})) { app.status(e); } - ui.label(egui::RichText::new(tl!("Replace:")).size(12.0).color(Tokens::get(ui.ctx()).text)); - let options: Vec<&str> = names.iter().map(String::as_str).collect(); - if let Some(name) = widgets::dropdown_names(ui, "cb-symbol-replace", &shared, &options, 140.0).and_then(|i| names.get(i)) - && *name != shared - && let Err(e) = app.run("symbol.replace", json!({ "name": name })) - { - app.status(e); + let replace = egui::RichText::new(tl!("Replace:")).size(12.0).color(Tokens::get(ui.ctx()).text); + if fit.room(ui, &[widgets::text_width(ui, replace.clone()), 140.0]) { + ui.label(replace); + let options: Vec<&str> = names.iter().map(String::as_str).collect(); + if let Some(name) = widgets::dropdown_names(ui, "cb-symbol-replace", &shared, &options, 140.0).and_then(|i| names.get(i)) + && *name != shared + && let Err(e) = app.run("symbol.replace", json!({ "name": name })) + { + app.status(e); + } } // Reset: Reset Transformation, enabled while an instance's scale, rotation or shear differs // from its symbol's. let reset = transformed_instances_selected(app); - if ui.add_enabled_ui(reset, |ui| widgets::flat_button_fit(ui, tl!("Reset"), 48.0).on_hover_text(tl!("Reset Transformation"))).inner.clicked() + if fit.room(ui, &[widgets::flat_button_fit_width(ui, tl!("Reset"), 48.0)]) + && ui.add_enabled_ui(reset, |ui| widgets::flat_button_fit(ui, tl!("Reset"), 48.0).on_hover_text(tl!("Reset Transformation"))).inner.clicked() && let Err(e) = app.run("symbol.instance.resetTransform", json!({})) { app.status(e); } - ui.add_space(4.0); + if ui.available_width() >= 4.0 { + ui.add_space(4.0); + } } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -1325,7 +1335,7 @@ mod tests { let bar = |app: &mut VectorcraftApp, events: Vec| { let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { - ui.horizontal(|ui| control_bar(app, ui)); + ui.horizontal(|ui| control_bar(app, ui, 0.0)); }); out.textures_delta.clear(); out @@ -1505,7 +1515,7 @@ mod tests { fn bar(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { - ui.horizontal(|ui| control_bar(app, ui)); + ui.horizontal(|ui| control_bar(app, ui, 0.0)); }); out.textures_delta.clear(); out.shapes.into_iter().map(|c| c.shape).collect() @@ -1601,7 +1611,7 @@ mod tests { let bar = |app: &mut VectorcraftApp, events: Vec| { let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { - ui.horizontal(|ui| control_bar(app, ui)); + ui.horizontal(|ui| control_bar(app, ui, 0.0)); }); out.textures_delta.clear(); out diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index b759a2df7b..64bd1d7a08 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -501,9 +501,10 @@ fn link_text(ui: &Ui) -> egui::RichText { egui::RichText::new(tl!("Transform")).size(12.0).color(Tokens::get(ui.ctx()).text).underline() } -/// The width of the Transform [`link`] in `ui`. +/// The width of the Transform [`link`] in `ui`: its text, and the point that the underline's stroke +/// and the text's placement on the pixel grid can add at its right end. pub fn link_width(ui: &Ui) -> f32 { - widgets::text_width(ui, link_text(ui)) + widgets::text_width(ui, link_text(ui)) + 1.0 } /// The width and height of [`constrain_link`]'s button. diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index cc8c9e7c91..c67f46a603 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -364,7 +364,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if widgets::menu_item(ui, tl!("New Variable…"), !locked, false) { crate::menus::invoke(app, "variable.define", json!({})); } - // Variable Options renames the highlighted variable, else the first. + // Variable Options edits the name and kind of the highlighted variable, else the first. if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty() && !locked, false) { let name = highlighted.clone().or_else(|| vars.first().cloned()).unwrap_or_default(); crate::dialogs::variables::open(app, "variable.rename", Some(&name)); diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 3205446c89..80c2464f62 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -61,7 +61,7 @@ pub fn is_document(name: &str) -> bool { #[derive(Default)] pub struct PlaceState { /// Web: the bytes of the files picked for the Place dialog, by name. - picked: Vec<(String, Vec)>, + pub(crate) picked: Vec<(String, Vec)>, /// The loaded cursor's thumbnails by file name. thumbs: Vec<(String, Thumb)>, /// The Control bar's `image.info`, for (document uid, revision, image id). @@ -74,8 +74,20 @@ pub struct PlaceState { queue_asks: bool, /// The loaded cursor's files by name, with the size a click places them at. queue_sizes: Vec<(String, f64, f64)>, + /// The engine's count of cursor loads ([`vectorcraft_engine::Session::place_queue_loads`]) when + /// `queue_asks` and `queue_sizes` were set: a later load (`tool.setOption`, a script) clears them. + queue_load: u64, + /// The import options dialog a click of the cursor opened (the token in its params' + /// [`ASKED`]), with the automation roots the cursor was loaded in: its OK, pressed outside any + /// roots, places the file within them. Cleared when the cursor is loaded again. + asked: Option<(u64, std::sync::Arc)>, } +/// The `file.place` param that ties an import options dialog's OK to the click of the place cursor +/// that opened it ([`PlaceState::asked`]); hidden from the dialog's fields and removed before the +/// file is placed. +const ASKED: &str = "__placeCursorAsk"; + /// A place cursor thumbnail: decoded when the cursor is loaded, uploaded when first drawn (and /// again in a new graphics context). struct Thumb { @@ -113,10 +125,11 @@ fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result Result { place_file(app, p, Read::AtOnce) } @@ -137,6 +150,20 @@ enum Read { } fn place_file(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result { + // The OK of import options a click of the place cursor asked for places within the roots the + // cursor was loaded in. A caller with roots of its own places within those. + let Some(token) = p.get(ASKED) else { return place_file_in(app, p, read) }; + forget_older_queue(app); + let Some(roots) = app.place.asked.as_ref().filter(|(t, _)| Some(*t) == token.as_u64()).map(|(_, r)| r.clone()) else { + return Err("the place cursor that asked for these import options is no longer loaded".into()); + }; + if vectorcraft_engine::file_access::current().is_some() { + return place_file_in(app, p, read); + } + vectorcraft_engine::file_access::confine(Some(&roots), || place_file_in(app, p, read)) +} + +fn place_file_in(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result { let show = match p.get("showImportOptions") { None | Some(Value::Null) => None, Some(Value::Bool(b)) => Some(*b), @@ -155,6 +182,7 @@ fn place_file(app: &mut VectorcraftApp, p: &Value, read: Read) -> Result Result { let r = crate::placing::start(app, &p, dropped); @@ -380,9 +417,15 @@ pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result = many.iter().filter(|s| s.get("path").is_none()).collect(); let mut q = json!({ "paths": paths, "files": files, "link": link, "template": template }); if !ask { - // Each text file and DXF drawing in the cursor takes the options confirmed last. + // Each text file, DXF drawing and embedded PSD file in the cursor takes the options + // confirmed last. q["text"] = crate::dialogs::text_import::last_options(app); q["dxf"] = crate::dialogs::dxf_import::remembered(app); + if !link || paths.is_empty() { + // Files from bytes have no file to link to; they may carry the dialog's Link. + q["link"] = json!(false); + crate::dialogs::psd_import::queue_options(app, &mut q); + } } queue_files(app, &q, Read::ForUser { dropped: false }, ask) } @@ -425,10 +468,19 @@ pub(crate) fn place_from_cursor(app: &mut VectorcraftApp, p: &Value) { // Read within the automation roots of whoever loaded the cursor, even on a click of the person // at the keyboard: an agent's queue reaches no file it wasn't given. let roots = app.session.place_queue_roots(); + forget_older_queue(app); vectorcraft_engine::file_access::confine(roots.as_ref(), || { if app.place.queue_asks { + let mut p = clicked_at_full_size(app, p); + // The import options it may ask for keep the token, and their OK places within the + // same roots ([`place_file`]). + app.place.asked = roots.clone().map(|r| { + static TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let token = TOKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + p[ASKED] = json!(token); + (token, r) + }); // Placing shows its own errors in the status bar. - let p = clicked_at_full_size(app, p); let _ = place_file(app, &p, Read::ForUser { dropped: false }); } else if crate::placing::available(app) { if let Err(e) = crate::placing::start(app, p, false) { @@ -440,6 +492,16 @@ pub(crate) fn place_from_cursor(app: &mut VectorcraftApp, p: &Value) { }); } +/// The cursor was loaded again since [`show_thumbnails`] (`tool.setOption` with a `queue`, a +/// script): its files no longer ask for their import options, and their queued sizes are unknown. +fn forget_older_queue(app: &mut VectorcraftApp) { + if app.place.queue_load != app.session.place_queue_loads() { + app.place.queue_asks = false; + app.place.queue_sizes.clear(); + app.place.asked = None; + } +} + /// The params of a cursor click (`rect` at the file's queued size) on a PDF or DXF file, whose /// import options may change its size (another page, another scale): placed as its options place /// it at the click (`at`, the center of the box the click gave), as File › Place of that one file @@ -467,7 +529,9 @@ fn clicked_at_full_size(app: &VectorcraftApp, p: &Value) -> Value { /// The cursor's thumbnails and its files' sizes from `file.place.queue`'s reply `r` once the cursor /// is loaded; `asks`: its files ask for their import options when placed. pub(crate) fn show_thumbnails(app: &mut VectorcraftApp, r: &Value, asks: bool) { + app.place.queue_load = app.session.place_queue_loads(); app.place.queue_asks = asks; + app.place.asked = None; app.place.queue_sizes = r["files"] .as_array() .into_iter() @@ -664,36 +728,61 @@ pub fn image_summary(i: &Value) -> (String, String) { /// The Control bar for the one selected image: Linked File or Embedded, its file name, colour mode /// and effective resolution, Embed or Unembed, Edit Original (a linked file), Image Trace, Mask, -/// Crop Image and Remove Background. Whether one image is selected. -pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { +/// Crop Image and Remove Background. Whether one image is selected. A file name the row has no room +/// for is left out alone; after it, each control shows only while the row has room for it and +/// `reserve` after it, and the controls after one left out are left out too. +pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui, reserve: f32) -> bool { let Some(i) = selected_image_info(app) else { return false }; let (name, details) = image_summary(i); let (Some(id), linked) = (i["id"].as_u64(), i["linked"] == true) else { return false }; let t = Tokens::get(ui.ctx()); // The file name is a link: its menu relinks (replaces) the image and reaches the Links // panel's other actions for it. - let resp = ui - .add(egui::Label::new(egui::RichText::new(&name).size(12.0).color(t.accent).underline()).sense(egui::Sense::click())) - .on_hover_cursor(egui::CursorIcon::PointingHand); - egui::Popup::menu(&resp).show(|ui| link_menu(app, ui, id, linked)); - ui.separator(); - ui.label(egui::RichText::new(details).size(12.0).color(t.text_dim)); - ui.separator(); - link_buttons(app, ui, id, linked, &name, None); - crate::panels::image_trace::trace_button(app, ui, crate::panels::image_trace::TRACE_BUTTON_W); + let name_text = egui::RichText::new(&name).size(12.0).color(t.accent).underline(); + if crate::chrome::Fit::new(reserve).room(ui, &[widgets::text_width(ui, name_text.clone())]) { + let resp = ui.add(egui::Label::new(name_text).sense(egui::Sense::click())).on_hover_cursor(egui::CursorIcon::PointingHand); + egui::Popup::menu(&resp).show(|ui| link_menu(app, ui, id, linked)); + crate::chrome::group_separator(ui); + } + let mut fit = crate::chrome::Fit::new(reserve); + let details = egui::RichText::new(details).size(12.0).color(t.text_dim); + if fit.room(ui, &[widgets::text_width(ui, details.clone())]) { + ui.label(details); + } + fit.separator(ui); + link_buttons(app, ui, id, linked, &name, LinkButtons::Fit(&mut fit)); + // The Image Trace button grows past its usual width for a longer translated label. + let arrow = widgets::SPLIT_ARROW; + let trace = widgets::flat_button_fit_width(ui, tl!("Image Trace"), crate::panels::image_trace::TRACE_BUTTON_W - arrow) + arrow; + if fit.room(ui, &[trace]) { + crate::panels::image_trace::trace_button(app, ui, trace); + } for (label, cmd, w) in [ (tl!("Mask"), "object.maskImage", 52.0), (tl!("Crop Image"), "ui.cropImage", 84.0), (tl!("Remove Background"), crate::addons::image::REMOVE_BACKGROUND, 128.0), ] { - if widgets::flat_button(ui, label, w).clicked() { + if fit.room(ui, &[widgets::flat_button_fit_width(ui, label, w)]) && widgets::flat_button_fit(ui, label, w).clicked() { crate::menus::invoke(app, cmd, json!({})); } } - crate::chrome::group_separator(ui); + fit.separator(ui); true } +/// How [`link_buttons`] sizes its buttons. +pub(crate) enum LinkButtons<'a> { + /// Each button is this wide (Properties). + Width(f32), + /// Each button fits its label and shows only while the Control bar's row has room for it. + Fit(&'a mut crate::chrome::Fit), +} + +/// The least widths of the Embed, Edit Original and Unembed… buttons fitted to their labels. +const EMBED_WIDTH: f32 = 60.0; +const EDIT_ORIGINAL_WIDTH: f32 = 92.0; +const UNEMBED_WIDTH: f32 = 80.0; + /// The menu of the Control bar's file name for image `id`: Replace… (another file takes the /// image's place with its own aspect ratio; an embedded image becomes linked), Go To Link, Edit /// Original, Update Link and Placement Options…. @@ -721,17 +810,22 @@ fn link_menu(app: &mut VectorcraftApp, ui: &mut egui::Ui, id: u64, linked: bool) } /// Embed (and Edit Original) for linked image `id`, Unembed… for an embedded one named `name` -/// (Control bar, Properties). The buttons are `width` wide, or fit their labels. -pub fn link_buttons(app: &mut VectorcraftApp, ui: &mut egui::Ui, id: u64, linked: bool, name: &str, width: Option) { - let w = |fit: f32| width.unwrap_or(fit); +/// (Control bar, Properties), sized as `size` says. +pub(crate) fn link_buttons(app: &mut VectorcraftApp, ui: &mut egui::Ui, id: u64, linked: bool, name: &str, mut size: LinkButtons) { + let mut button = |ui: &mut egui::Ui, label: &str, least: f32| match &mut size { + LinkButtons::Width(w) => widgets::flat_button(ui, label, *w).clicked(), + LinkButtons::Fit(fit) => { + fit.room(ui, &[widgets::flat_button_fit_width(ui, label, least)]) && widgets::flat_button_fit(ui, label, least).clicked() + } + }; if linked { - if widgets::flat_button(ui, tl!("Embed"), w(60.0)).clicked() { + if button(ui, tl!("Embed"), EMBED_WIDTH) { app.run("links.embed", json!({ "ids": [id] })).ok(); } - if widgets::flat_button(ui, tl!("Edit Original"), w(92.0)).clicked() { + if button(ui, tl!("Edit Original"), EDIT_ORIGINAL_WIDTH) { crate::menus::invoke(app, "links.editOriginal", json!({ "id": id })); } - } else if widgets::flat_button(ui, tl!("Unembed…"), w(80.0)).clicked() { + } else if button(ui, tl!("Unembed…"), UNEMBED_WIDTH) { crate::panels::links::unembed(app, id, name); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a4af5b70f5..26d3753b51 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -550,6 +550,10 @@ pub struct UiState { #[serde(default)] pub toolbar_right: bool, pub status_bar: bool, + /// What the status bar's info field shows (its Show menu, `view.statusBarShow`): Current Tool by + /// default and in state saved without it ([`crate::chrome::deserialize_status_bar_show`]). + #[serde(default, deserialize_with = "crate::chrome::deserialize_status_bar_show")] + pub status_bar_show: crate::chrome::StatusBarShow, pub help_bar: bool, pub dock: bool, /// The bars that Tab (`window.dock`) hid, which the next Tab shows again; none when Tab has not @@ -827,6 +831,9 @@ pub struct UiState { /// never used). #[serde(default)] pub dxf_import: Value, + /// The PSD Import Options chosen last (layers, hiddenLayers, importSlices; null: never used). + #[serde(default)] + pub psd_import: Value, /// The Home screen is shown over the open documents (`app.home`): the active document's uid /// and the document count when it opened. Choosing a tab, or a document opening, closing or /// becoming active, leaves it. @@ -1177,6 +1184,7 @@ impl Default for UiState { tools_beside_canvas: false, toolbar_right: false, status_bar: true, + status_bar_show: crate::chrome::StatusBarShow::CurrentTool, help_bar: true, dock: true, tab_hid: None, @@ -1255,6 +1263,7 @@ impl Default for UiState { transform_each: Value::Null, rearrange_artboards_spacing: Value::Null, dxf_import: Value::Null, + psd_import: Value::Null, home: None, trace_hold_source: None, layers_panel: Default::default(), diff --git a/crates/ui-egui/src/tests/place.rs b/crates/ui-egui/src/tests/place.rs index cbddfbf2c1..f470ef49bc 100644 --- a/crates/ui-egui/src/tests/place.rs +++ b/crates/ui-egui/src/tests/place.rs @@ -1071,6 +1071,101 @@ fn the_place_cursors_files_ask_for_their_import_options_when_show_import_options assert_eq!(dialog_kind(&app), None); } +/// A cursor loaded again after the Place dialog loaded it with Show Import Options on (a queue set +/// on the Place tool) places its files at once. +#[test] +fn a_place_cursor_loaded_again_no_longer_asks_for_import_options() { + let mut app = app(); + let dir = Folder::new("ui-place-cursor-reloaded"); + let a = dir.file("a.png", &png(10, 10, 72.0)); + let notes = dir.file("notes.txt", NOTES); + app.run("file.place", json!({"paths": [a, notes]})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + let entries: Vec = [(&a, "a.png"), (¬es, "notes.txt")] + .iter() + .map(|(path, name)| json!({"params": {"path": path}, "name": name, "width": 190, "height": 90})) + .collect(); + assert_eq!(app.session.tool_id(), "place"); + app.run("tool.setOption", json!({"values": {"queue": {"entries": entries}}})).unwrap(); + crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 190, 90]})); + assert_eq!(dialog_kind(&app), None); + assert!(selected_text(&app).contains("three")); +} + +/// The import options a click of a cursor loaded within automation roots asks for place within +/// those roots when OK is pressed, by the person at the keyboard too: a file outside them is not +/// read. One inside them is placed. +#[test] +fn the_import_options_a_place_cursor_asks_for_place_within_its_roots() { + use vectorcraft_engine::file_access::{AutomationRoots, confine, unconfined}; + let mut app = app(); + let dir = Folder::new("ui-place-cursor-ask-roots"); + std::fs::create_dir_all(dir.join("in")).unwrap(); + let a = dir.file("in/a.png", &png(10, 10, 72.0)); + let notes = dir.file("in/notes.txt", NOTES); + let pages = [PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), PdfPage::new(300.0, 100.0, "1 0 0 rg 0 0 30 30 re f")]; + let two = dir.file("in/two.pdf", &pdf(&pages, None)); + let outside_notes = dir.file("outside.txt", NOTES); + let outside_pdf = dir.file("outside.pdf", &pdf(&pages, None)); + let roots = AutomationRoots::new(&[&dir.join("in")], &[]).unwrap(); + confine(roots.as_ref(), || { + app.run("file.place", json!({"paths": [a, notes, two]})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false)); + crate::dialogs::confirm(&mut app).unwrap(); + }); + assert_eq!(app.session.tool_options()["count"], 3); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let before = undo(&app); + // The text file's OK, its params naming a file outside the roots: nothing is placed. + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + app.ui.dialog.as_mut().unwrap().fields.get_mut("__params").unwrap()["path"] = json!(outside_notes); + let _ = unconfined(|| crate::dialogs::confirm(&mut app)); + assert_eq!(undo(&app), before, "the text file outside the roots"); + // The PDF's OK, its file outside the roots: nothing is placed. + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": two, "link": false, "rect": [10, 10, 100, 100]}))); + assert_eq!(dialog_kind(&app), Some("importPdf")); + app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(outside_pdf)); + let _ = unconfined(|| crate::dialogs::confirm(&mut app)); + assert_eq!(undo(&app), before, "the PDF outside the roots"); + app.ui.dialog = None; + // A caller with roots of its own confirms within those: the file in the cursor's roots but + // outside the caller's is not placed. + std::fs::create_dir_all(dir.join("b")).unwrap(); + let narrower = AutomationRoots::new(&[&dir.join("b")], &[]).unwrap(); + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + let _ = confine(narrower.as_ref(), || crate::dialogs::confirm(&mut app)); + assert_eq!(undo(&app), before, "outside the caller's roots"); + // The text file inside the roots is placed. + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + unconfined(|| crate::dialogs::confirm(&mut app)).unwrap(); + assert!(selected_text(&app).contains("three")); + // Once the cursor is loaded again, the earlier click's import options place nothing. + let after = undo(&app); + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + app.run("file.place.queue", json!({"paths": [a]})).unwrap(); + assert!(unconfined(|| crate::dialogs::confirm(&mut app)).is_err()); + assert_eq!(undo(&app), after); + // So do those of a click before a queue set on the Place tool. + confine(roots.as_ref(), || { + app.run("file.place", json!({"paths": [a, notes]})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + }); + unconfined(|| crate::place::place_from_cursor(&mut app, &json!({"path": notes, "rect": [10, 10, 200, 100]}))); + assert_eq!(dialog_kind(&app), Some(crate::dialogs::text_import::KIND)); + let entry = json!({"params": {"path": a}, "name": "a.png", "width": 10, "height": 10}); + app.run("tool.setOption", json!({"values": {"queue": {"entries": [entry]}}})).unwrap(); + let saved = app.ui.dialog.clone(); + assert!(unconfined(|| crate::dialogs::confirm(&mut app)).is_err()); + assert_eq!(undo(&app), after); + // Also when confirmed by a caller with roots that hold the file. + app.ui.dialog = saved; + assert!(confine(roots.as_ref(), || crate::dialogs::confirm(&mut app)).is_err()); + assert_eq!(undo(&app), after); +} + #[test] fn a_file_placed_while_idle_follows_its_changes() { let mut app = app(); diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index fe01b5c3a6..4a48309dbe 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -1,7 +1,8 @@ //! The Contextual Task Bar's actions for each kind of selection: open paths offer Join and Edit //! Path, compound paths and clipping masks their Release, images selected with vector art Make //! Clipping Mask, type converts between point and area type, type on a path opens its options, and -//! a basic shape shows its properties in place of Lock. Type, and the Type tools while they edit it, +//! a basic shape shows its properties in place of Lock. The basic shape tools show Edit Path, Fill, +//! Stroke and Shape Properties for the shape they draw. Type, and the Type tools while they edit it, //! show the font family, font style and Font Size controls. use egui::{Event, PointerButton, Pos2, Rect, vec2}; @@ -314,7 +315,7 @@ fn point_type_offers_dynamic_text() { /// The path's anchors (its first subpath). fn anchors(app: &VectorcraftApp, id: u64) -> Vec { match kind(app, id) { - NodeKind::Path { path, .. } => path.subpaths[0].anchors.clone(), + NodeKind::Path { path, .. } => path.subpaths[0].anchors.to_vec(), k => panic!("not a path: {k:?}"), } } @@ -571,6 +572,155 @@ fn other_selections_keep_lock() { } } +/// The basic shape tools: the tool ids, and the Shape Properties heading each tool's shape shows +/// (none for the star, a plain path). +const SHAPE_TOOLS: [(&str, Option<&str>); 5] = [ + ("rectangle", Some("Rectangle Properties:")), + ("roundedRectangle", Some("Rectangle Properties:")), + ("ellipse", Some("Ellipse Properties:")), + ("polygon", Some("Polygon Properties:")), + ("star", None), +]; + +/// Draw with `tool` by dragging across the canvas from the document point `from` to `to`, and return +/// whether the task bar showed partway through the drag. +fn drag_with(app: &mut VectorcraftApp, tool: &str, from: (f64, f64), to: (f64, f64)) -> bool { + app.run("tool.select", json!({ "tool": tool })).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(app, &ctx, vec![]); + frame(app, &ctx, vec![]); + let xf = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let (a, b) = (xf.to_screen(vectorcraft_geom::Point::new(from.0, from.1)), xf.to_screen(vectorcraft_geom::Point::new(to.0, to.1))); + let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(app, &ctx, vec![Event::PointerMoved(a), press(a, true)]); + frame(app, &ctx, vec![Event::PointerMoved(a + (b - a) / 2.0)]); + frame(app, &ctx, vec![Event::PointerMoved(b)]); + let shown_mid_drag = canvas::task_bar_rect(&ctx).is_some(); + frame(app, &ctx, vec![press(b, false)]); + frame(app, &ctx, vec![]); + shown_mid_drag +} + +fn names(named: &[(String, Rect)]) -> Vec<&str> { + named.iter().map(|(n, _)| n.as_str()).collect() +} + +/// Each basic shape tool, just after it draws a shape, shows Edit Path, Fill and Stroke for it, and +/// Shape Properties for a live rectangle, rounded rectangle, ellipse or polygon. The bar leaves out +/// the selection tools' Duplicate, Offset Path, Simplify, Recolor Artwork and Lock, and shows +/// nothing during the drag that draws the shape. +#[test] +fn shape_tools_show_edit_path_paint_and_properties_after_drawing() { + let mut app = app(); + for (i, (tool, heading)) in SHAPE_TOOLS.into_iter().enumerate() { + let x = 30.0 + 70.0 * i as f64; + let shown_mid_drag = drag_with(&mut app, tool, (x, 60.0), (x + 50.0, 120.0)); + assert!(!shown_mid_drag, "{tool}: no bar while the shape is dragged out"); + assert_eq!(app.session.tool_id(), tool); + let st = app.session.active().unwrap(); + assert_eq!(st.selection.objects.len(), 1, "{tool} drew and selected a shape"); + let shape = st.selection.objects[0].0; + let (_, named) = buttons(&mut app); + let names = names(&named); + for name in ["Edit Path", "Fill", "Stroke", "More Options"] { + assert!(names.contains(&name), "{tool}: {name} in {names:?}"); + } + assert_eq!(names.contains(&"Shape Properties"), heading.is_some(), "{tool}: {names:?}"); + for name in ["Duplicate", "Offset Path", "Simplify", "Recolor Artwork", "Lock (⌘2)"] { + assert!(!names.contains(&name), "{tool}: {name} is left out of {names:?}"); + } + if let Some(heading) = heading { + let (_, texts) = click_lock_slot(&mut app); + assert!(has(&texts, heading), "{tool}: {heading} in {texts:?}"); + assert!(!locked(&app, shape), "{tool}: the button doesn't lock the shape"); + } + } +} + +/// With a basic shape tool in use, the bar shows only for one closed path selected whole: not with +/// nothing selected, several objects, a group, an open path, type or picked-out anchors. The Line +/// Segment tool shows no bar for a selected rectangle. +#[test] +fn shape_tools_have_no_shape_bar_without_a_single_shape() { + let mut app = app(); + let a = id(app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap()); + let b = id(app.run("shape.ellipse", json!({"x": 60, "y": 20, "width": 30, "height": 30})).unwrap()); + let c = id(app.run("shape.rectangle", json!({"x": 20, "y": 250, "width": 30, "height": 30})).unwrap()); + let d = id(app.run("shape.rectangle", json!({"x": 60, "y": 250, "width": 30, "height": 30})).unwrap()); + select(&mut app, &[c, d]); + let group = id(app.run("object.group", json!({})).unwrap()); + let line = id(app.run("shape.line", json!({"x1": 150, "y1": 50, "x2": 300, "y2": 120})).unwrap()); + let text = id(app.run("text.create", json!({"x": 150, "y": 200, "text": "Type"})).unwrap()); + let cases: [(&str, Vec); 5] = + [("nothing", vec![]), ("several", vec![a, b]), ("a group", vec![group]), ("an open path", vec![line]), ("type", vec![text])]; + for (tool, _) in SHAPE_TOOLS { + app.run("tool.select", json!({ "tool": tool })).unwrap(); + for (what, ids) in &cases { + select(&mut app, ids); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "{tool} with {what}: no bar"); + } + app.run("select.anchors", json!({"id": a, "anchors": [[0, 1]]})).unwrap(); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "{tool} with anchors: no bar"); + select(&mut app, &[a]); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_some(), "{tool} with the rectangle: the bar"); + } + app.run("tool.select", json!({"tool": "lineSegment"})).unwrap(); + select(&mut app, &[a]); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "the Line Segment tool: no bar"); +} + +/// A basic shape tool's bar follows the selection: Shape Properties shows the shape drawn last, +/// edits it with Undo restoring it, and after an Undo of the drawing shows the shape selected +/// then. Fill makes the Fill active, Stroke the Stroke, and Edit Path switches to the Direct +/// Selection tool. +#[test] +fn shape_tool_bar_retargets_after_another_shape_and_undo() { + let mut app = app(); + app.run("tool.select", json!({"tool": "ellipse"})).unwrap(); + let rect = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60, "radius": 10})).unwrap()); + let (ctx, texts) = click_lock_slot(&mut app); + assert!(has(&texts, "Rectangle Properties:"), "{texts:?}"); + type_into(&mut app, &ctx, &texts, "10 pt", "20"); + let corners = |app: &VectorcraftApp| match live(app, rect) { + vectorcraft_doc::LiveShape::Rectangle { radii, .. } => radii, + l => panic!("{l:?}"), + }; + assert_eq!(corners(&app), [20.0; 4]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(corners(&app), [10.0; 4]); + + let ellipse = id(app.run("shape.ellipse", json!({"x": 200, "y": 40, "width": 80, "height": 60})).unwrap()); + let (_, texts) = click_lock_slot(&mut app); + assert!(has(&texts, "Ellipse Properties:") && !has(&texts, "Rectangle Properties:"), "{texts:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert!(app.session.active().unwrap().doc.node(NodeId(ellipse)).is_none(), "the ellipse is undone"); + let selected: Vec = app.session.active().unwrap().selection.objects.iter().map(|i| i.0).collect(); + let (ctx, texts) = bar(&mut app); + assert!(!has(&texts, "Ellipse Properties:"), "{texts:?}"); + if selected == [rect] { + assert!(canvas::task_bar_rect(&ctx).is_some(), "the rectangle selected again: the bar"); + } else { + assert!(canvas::task_bar_rect(&ctx).is_none(), "{selected:?}: no bar"); + select(&mut app, &[rect]); + } + let (_, texts) = click_lock_slot(&mut app); + assert!(has(&texts, "Rectangle Properties:"), "{texts:?}"); + + app.session.fill_active = false; + click_button(&mut app, "Fill"); + assert!(app.session.fill_active, "Fill makes the Fill active"); + click_button(&mut app, "Stroke"); + assert!(!app.session.fill_active, "Stroke makes the Stroke active"); + assert_eq!(app.session.tool_id(), "ellipse"); + click_button(&mut app, "Edit Path"); + assert_eq!(app.session.tool_id(), "directSelection"); +} + /// A compound path under the Direct Selection tool isn't offered Edit Path, which would only switch /// to the tool already in use; the Selection tool still offers it. #[test] diff --git a/crates/ui-egui/src/tests/trace_runs.rs b/crates/ui-egui/src/tests/trace_runs.rs index d2de2f00f5..8dd72efe91 100644 --- a/crates/ui-egui/src/tests/trace_runs.rs +++ b/crates/ui-egui/src/tests/trace_runs.rs @@ -162,6 +162,23 @@ fn a_trace_needs_its_image_as_it_was() { assert!(app.ui.status.contains("changed meanwhile: trace it again"), "{}", app.ui.status); } +/// A trace started before File › Revert doesn't apply after it, even when the document was +/// reverted to the same image: nothing changes, and the status bar says why. +#[test] +fn a_trace_started_before_a_revert_does_not_apply() { + let mut app = app(); + let i = app.session.active_index().unwrap(); + let copy = (*app.session.doc().unwrap().doc).clone(); + let [img] = app.session.doc().unwrap().selection.objects[..] else { panic!("the image is selected") }; + crate::menus::invoke(&mut app, "imageTrace.make", json!({"preset": "Default"})); + assert!(app.session.replace_document(i, copy)); + wait(&mut app); + assert_eq!(undos(&app), 0, "nothing changes"); + let st = app.session.doc().unwrap(); + assert!(matches!(st.doc.node(img).unwrap().kind, vectorcraft_doc::NodeKind::Image(_)), "the image is not traced"); + assert_eq!(app.ui.status, "Image Trace finished, but its document was reverted: trace it again"); +} + /// The progress window opens after its delay with Cancel and Continue in Background; Continue in /// Background closes it and the trace goes on. #[test] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 29d89ebf6d..c4da70d84f 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -496,13 +496,15 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result = choices.iter().map(|(_, l)| *l).collect(); - if let Some((value, _)) = widgets::dropdown(ui, ("cb-tool", key), shown, &labels, 96.0).and_then(|i| choices.get(i)) { + if let Some((value, _)) = widgets::dropdown(ui, ("cb-tool", key), shown, &labels, MIRROR_DROPDOWN_WIDTH).and_then(|i| choices.get(i)) { app.run("tool.setOption", json!({ "key": key, "value": value })).ok(); } } - crate::chrome::group_separator(ui); + fit.separator(ui); } +/// The width of Mirror & Cut's Axis and Keep menus in the Control bar. +const MIRROR_DROPDOWN_WIDTH: f32 = 96.0; + /// Crop Image's box: its centre (X, Y) and size (W, H) in the general unit, set through the tool's -/// `rect` option, then Apply (Enter) and Cancel (Escape). -fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) { +/// `rect` option, then Apply (Enter) and Cancel (Escape), each while the row has room for it and +/// `reserve` after it. +fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) { let set = app.session.tool_options()["rect"].as_array().and_then(|v| match v.iter().filter_map(Value::as_f64).collect::>()[..] { [x, y, w, h] => Some(vectorcraft_geom::Rect::new(x, y, x + w, y + h)), _ => None, @@ -544,8 +555,13 @@ fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let units = app.session.general_unit(); let c = r.center(); + let mut fit = crate::chrome::Fit::new(reserve); for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", r.width()), ("height", "H:", r.height())] { - widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim)); + let text = egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim); + if !fit.room(ui, &[widgets::text_width(ui, text.clone()), 80.0]) { + break; + } + widgets::field_label(ui, text); if let Some(nv) = widgets::num_field(ui, ("cb-crop", k), Some(v), units, 80.0) { let (mut c, mut w, mut h) = (c, r.width(), r.height()); match k { @@ -558,15 +574,15 @@ fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("tool.setOption", json!({ "key": "rect", "value": rect })).ok(); } } - ui.separator(); + fit.separator(ui); for (label, key) in [(tl!("Apply"), ToolKey::Enter), (tl!("Cancel"), ToolKey::Escape)] { - if widgets::flat_button(ui, label, 64.0).clicked() { + if fit.room(ui, &[widgets::flat_button_fit_width(ui, label, 64.0)]) && widgets::flat_button_fit(ui, label, 64.0).clicked() { let view = app.view_info(); let r = app.session.tool_key(key, Mods::default(), view); crate::canvas::apply_requests(app, r); } } - crate::chrome::group_separator(ui); + fit.separator(ui); } /// The Fill/Stroke proxy, Color/Gradient/None, the drawing modes, screen mode and Edit Toolbar, as @@ -1141,23 +1157,29 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio } /// The Puppet Warp tool's Control bar: Expand (how far the mesh reaches past the art), Show Mesh -/// and Select All Pins. -fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) { +/// and Select All Pins, each while the row has room for it and `reserve` after it. +fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) { let t = Tokens::get(ui.ctx()); let opts = app.session.tool_options(); - ui.label(egui::RichText::new(tl!("Expand:")).size(12.0).color(t.text)); - let unit = app.session.general_unit(); - if let Some(v) = widgets::num_field(ui, ("cb-tool", "expand"), opts["expand"].as_f64(), unit, 64.0) { - app.run("tool.setOption", json!({ "key": "expand", "value": v })).ok(); + let mut fit = crate::chrome::Fit::new(reserve); + let text = egui::RichText::new(tl!("Expand:")).size(12.0).color(t.text); + if fit.room(ui, &[widgets::text_width(ui, text.clone()), 64.0]) { + ui.label(text); + let unit = app.session.general_unit(); + if let Some(v) = widgets::num_field(ui, ("cb-tool", "expand"), opts["expand"].as_f64(), unit, 64.0) { + app.run("tool.setOption", json!({ "key": "expand", "value": v })).ok(); + } } let show = opts["showMesh"].as_bool().unwrap_or(true); - if widgets::check(ui, "Show Mesh", show, true) { + if fit.room(ui, &[widgets::choice_width(ui, "Show Mesh")]) && widgets::check(ui, "Show Mesh", show, true) { app.run("tool.setOption", json!({ "key": "showMesh", "value": !show })).ok(); } - if widgets::flat_button(ui, "Select All Pins", 104.0).clicked() { + if fit.room(ui, &[widgets::flat_button_fit_width(ui, "Select All Pins", 104.0)]) + && widgets::flat_button_fit(ui, "Select All Pins", 104.0).clicked() + { app.run("tool.setOption", json!({ "key": "selectAllPins", "value": true })).ok(); } - crate::chrome::group_separator(ui); + fit.separator(ui); } #[cfg(test)] diff --git a/crates/ui-egui/src/trace_runs.rs b/crates/ui-egui/src/trace_runs.rs index 3d80c1190a..d9800e6668 100644 --- a/crates/ui-egui/src/trace_runs.rs +++ b/crates/ui-egui/src/trace_runs.rs @@ -8,7 +8,8 @@ //! //! When it is done, what it traced is handed to its command with the same parameters //! ([`Traced::hand_in`]), so the edit is one undo step like any trace, unless the image or the -//! settings changed meanwhile: then nothing changes and the status bar says to trace it again. The +//! settings changed meanwhile or File › Revert replaced the document's content: then nothing +//! changes and the status bar says to trace it again. The //! control channel, MCP and scripts run the commands at once, and so does every trace on the web, //! in unit tests that don't set `off_the_ui_thread`, and with `VECTORCRAFT_SYNC_OPEN` set //! ([`available`]). @@ -158,8 +159,8 @@ pub fn poll(app: &mut VectorcraftApp) { } /// Applies trace `r`'s result in its document, which becomes the active one again, as its command -/// would while the object, its image and the settings are as they were; else why it has none, in -/// the status bar. +/// would while the object, its image and the settings are as they were and the document was not +/// reverted; else why it has none, in the status bar. fn finish(app: &mut VectorcraftApp, r: Running, done: Option) { // Cancel changes nothing, even when the trace ended just before it. if r.token.is_cancelled() { @@ -184,6 +185,10 @@ fn finish(app: &mut VectorcraftApp, r: Running, done: Option) { return; }; app.session.set_active(i); + if run.reverted(&app.session) { + app.status("Image Trace finished, but its document was reverted: trace it again"); + return; + } if !run.current(&app.session) { app.status("Image Trace finished, but the image or its settings changed meanwhile: trace it again"); return; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0738144ade..d4d677a0a6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -30,7 +30,7 @@ pub use buttons::{ SPLIT_ARROW, flat_button, flat_button_fit, icon_button, icon_button_enabled, menu_item, menu_item_name, paint_icon_button, primary_button, primary_button_enabled, secondary_button, split_button, split_button_named, toggle_icon, }; -pub(crate) use buttons::{button_width, icon_button_info, primary_button_width, secondary_button_width}; +pub(crate) use buttons::{button_width, flat_button_fit_width, icon_button_info, primary_button_width, secondary_button_width}; #[cfg(test)] use chips::gradient_chip_colors; pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile}; @@ -53,7 +53,7 @@ pub use dropdowns::{ #[cfg(test)] pub(crate) use dropdowns::{menu_floor, menu_room_either}; pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_label, section_header, section_toggle, section_toggle_name, subheader}; -pub(crate) use labels::{label_field, take_previous_label, text_width}; +pub(crate) use labels::{dim_label_width, label_field, take_previous_label, text_width}; pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit}; pub use numeric::{ SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_refusing, num_field_within, @@ -73,6 +73,7 @@ pub use text::{ exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets, text_presets_named, }; +pub(crate) use toggles::choice_width; pub use toggles::{check, check_box, check_name, check_tip, check3, check3_tip, radio, radio_button}; #[cfg(test)] pub(crate) use wheel_stream::WHEEL_STREAM_GAP; diff --git a/crates/ui-egui/src/widgets/buttons.rs b/crates/ui-egui/src/widgets/buttons.rs index a33f7cac84..a8b8486bfa 100644 --- a/crates/ui-egui/src/widgets/buttons.rs +++ b/crates/ui-egui/src/widgets/buttons.rs @@ -40,8 +40,7 @@ pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { /// A [`flat_button`] that expands to fit localized labels wider than `min_width`. pub fn flat_button_fit(ui: &mut Ui, text: &str, min_width: f32) -> Response { let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong); - let width = (galley.size().x + 24.0).max(min_width); + let width = flat_button_fit_width(ui, text, min_width); let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); let rect = rect.shrink2(vec2(0.0, 0.5)); ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); @@ -51,6 +50,13 @@ pub fn flat_button_fit(ui: &mut Ui, text: &str, min_width: f32) -> Response { resp } +/// The width [`flat_button_fit`] gives `text`, translated: its label and padding, at least +/// `min_width`. +pub(crate) fn flat_button_fit_width(ui: &Ui, text: &str, min_width: f32) -> f32 { + let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), Color32::PLACEHOLDER); + (galley.size().x + 24.0).max(min_width) +} + /// Width of a [`split_button`]'s arrow. pub const SPLIT_ARROW: f32 = 18.0; diff --git a/crates/ui-egui/src/widgets/labels.rs b/crates/ui-egui/src/widgets/labels.rs index 8c04de54b9..103f53ab65 100644 --- a/crates/ui-egui/src/widgets/labels.rs +++ b/crates/ui-egui/src/widgets/labels.rs @@ -6,6 +6,11 @@ pub(crate) fn text_width(ui: &Ui, text: egui::RichText) -> f32 { egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x } +/// The width [`dim_label`] gives `text`, translated. +pub(crate) fn dim_label_width(ui: &Ui, text: &str) -> f32 { + text_width(ui, egui::RichText::new(tl!(text)).size(12.5)) +} + /// Bold section header (Properties panel). pub fn section_header(ui: &mut Ui, text: &str) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/widgets/toggles.rs b/crates/ui-egui/src/widgets/toggles.rs index 5cac2023ef..20df99a52c 100644 --- a/crates/ui-egui/src/widgets/toggles.rs +++ b/crates/ui-egui/src/widgets/toggles.rs @@ -1,5 +1,11 @@ use super::{Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, pos2, vec2}; +/// The width a [`check`] or [`radio`] row takes with `label`, translated: the box and the text +/// after it, as [`choice_row`] allocates them. +pub(crate) fn choice_width(ui: &Ui, label: &str) -> f32 { + 18.0 + ui.painter().layout_no_wrap(tl!(label).to_string(), egui::FontId::proportional(12.5), Color32::PLACEHOLDER).size().x +} + /// The row of a checkbox or radio button: allocates a 13 pt box plus `text` (already translated), /// draws the text and returns the box, the response and the box's border colour. fn choice_row(ui: &mut Ui, text: &str, enabled: bool) -> (Rect, Response, Color32) { diff --git a/docs/development.md b/docs/development.md index 7c22d6ae98..100786bb83 100644 --- a/docs/development.md +++ b/docs/development.md @@ -294,7 +294,10 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE control channel, MCP and tests read them) and are translated only where the status bar draws them (`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as `Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the - placeholders are translated in turn (the reason after `: {e}` is often a message too). A literal brace in a + placeholders are translated in turn (the reason after `: {e}` is often a message too). A value that starts with + `{` is translated only when it has an exact row, so a JSON object stays as it is; a value that starts with `[` is + also matched against a template that starts with `[` and an index of digits, such as an effect's refusal + ``[{j}] `{_1}`: {why}``, so its reason is translated while a JSON array stays as it is. A literal brace in a row is written `{{` or `}}`, in the English and in the translation: a message made with `format!` keeps the doubled braces of its format string, and a plain string literal has each of its braces doubled, so the row for `"expected {\"objects\": [...]}"` is `expected {{"objects": [...]}}`. Czech, German, Greek, Spanish, French, Georgian, Hindi, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish, Catalan, Dutch and Danish diff --git a/docs/effects-parity.md b/docs/effects-parity.md index be6a1937f4..290148b89d 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -27,7 +27,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( | Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 13/13 | Trap traps only solid fills (gradients and patterns hide what is below them but don't trap) and spreads each colour's visible area as a whole; the public page gives no default values, so Trap's 0.25 pt, 100% and 40% are VectorCraft's own | | Rasterize | Rasterize… (as a live effect) | 1/1 | — | | Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets | -| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists 14 default filters and then the document's own; the public page names no default set, so the set, names and values are VectorCraft's own, and a default is copied into the document when it is applied or edited. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned or skewed object in an imported SVG is left out with a warning | +| SVG Filters | Apply SVG Filter… and the filter list | 1/1 | The submenu lists 14 default filters and then the document's own; the public page names no default set, so the set, names and values are VectorCraft's own, and a default is copied into the document when it is applied or edited. A filter that is the object's last effect is written to SVG as a `` element; otherwise SVG export draws the object as an image. A filter on a turned, skewed or unevenly scaled object in an imported SVG is kept and runs in that object's user space, and SVG export writes it back under the same transform | | Warp | Arc, Arc Lower, Arc Upper, Arch, Bulge, Shell Lower, Shell Upper, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist | 15/15 | warp point editing in envelopes; each style bends the bounds as one Bézier patch (`version` 1), so Arc's edges are cubic curves close to concentric arcs; warps saved without a version keep the earlier map, where Arc is a circular arc in the object's own proportions (#1578) | Document Raster Effects Settings exists. When the 64 million pixel cap on an effect image lowers @@ -181,7 +181,7 @@ VectorCraft choices; an original approximation, never compared with another appl | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, overlapping faces drawn in the order of their depths where they overlap, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, Render Settings › Wireframe, which draws the live effect as the edges of its faces, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle, and wrapped around Revolve's side ([three-d.md](three-d.md#graphics)); no base materials, graphics on Inflate or on Extrude & Bevel's walls and bevels, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Preserve Spot Colors in all three, unavailable with a Custom Shading Color ([Preserve Spot Color](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=260)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 466491552f..7a73bdc4c1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (`.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; `.ai` and EPS import: a top-object envelope whose mesh saves its sides' curves in pieces draws its contents along each whole curve, as its drawn art does, #7097; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: `.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type keeps the manual kerning of its text document, #6899; `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (PDF import: stroked type keeps the line cap, line join and miter limit the file strokes it with, not butt caps and miter joins, #7741; `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; `.ai` and EPS import: a top-object envelope whose mesh saves its sides' curves in pieces draws its contents along each whole curve, as its drawn art does, #7097; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: `.ai` and EPS import: type from the text document, type a PostScript program sets and legacy `.ai` type are set in an installed face of their font's PostScript name, exact name first, in the version a text document's record keeps; the `.ai` save writes a static font's version when its name alone gives another copy, #6983; `.ai` and EPS import: type keeps the manual kerning of its text document, #6899; `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, its strokes' miter tips and strokes a Transform effect moves included, after its glyphs grown by half its strokes' weight, #6824; `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern, from the art style its text document paint names, #6856; #6865: explicitly requested oversized editing artboard; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). @@ -106,7 +106,9 @@ private editing data. VectorCraft: Round Corners, Scribble, Convert to Shape, Offset Path, Outline Object or Outline Stroke, and groups with Pathfinder › Add. Groups keep them when their members are paths, compound paths or such groups, without effects of their own; when the group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern - fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects with + fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. + This holds for objects on the layers and in opacity masks' art, the masks' art in a symbol's art and in a pattern's tile + included; a symbol's or a pattern's other objects are written as described next. Other objects with live effects are written as their vector art with their geometry effects applied, and their other effects, such as Drop Shadow, the glows and Feather, are left out with a warning. A path, compound path or group linked to a graphic style names it when it has the style's appearance and the style is written; other links to graphic styles, those on a styled group's members included, are @@ -272,7 +274,7 @@ The app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri A run of type in the editing data whose baseline direction is 1 opens with Standard Vertical Roman Alignment (https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html) on, which is VectorCraft's own mapping: its half-width characters stand upright in vertical type, and horizontal type keeps the mark with no change to its layout. A run whose baseline direction is 2 or 3 (Tate-chu-yoko), or that names none in the run, its character style or the document's defaults, opens with it off. The scripting reference lists 1, 2 and 3 as the Standard, vertical rotated and Tate-chu-yoko baseline directions (https://web.archive.org/web/20170621180804/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2015_3/Illustrator%20VBScript%20Reference.pdf, page 256). A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. -An object in a pattern's art or in a brush's art takes the file's art styles in the same way as an object on a layer: with a style VectorCraft reads, it comes in as itself with its effects live, not as the look it draws. An object in a pattern's art with a style in the file's style list keeps that graphic style's id. An art styles section inside a symbol's, a pattern's or a brush's art is skipped. +An object in a pattern's art or in a brush's art takes the file's art styles in the same way as an object on a layer: with a style VectorCraft reads, it comes in as itself with its effects live, not as the look it draws. An object in a pattern's art with a style in the file's style list keeps that graphic style's id, as does an object in an opacity mask's art. An art styles section inside a symbol's, a pattern's or a brush's art is skipped. Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, gradients, patterns, graphic styles, swatches, brushes, symbols, variables and data sets) are read as Windows-1252 unless `legacyNameEncoding` (`convert --legacy-name-encoding`) picks `macintosh`, `windows-949`, `shift_jis`, `gbk` or `big5`; the open result's warnings list the first three such names as read and count the rest. The text values of data sets are read in the same encoding. @@ -292,7 +294,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | yes (`.plt`: each visible path once along its line, a pen per color or a pen table, type as outlines, clipping applied, HP-GL or HP-GL/2 with pen colors and encoded polylines) | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `crates/cad/src/hpgl/write/tests.rs` (plots read back by the reader), `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | -| PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image; the merged image comes in clipped by the file's clipping path; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | placing as layers and a PSD import options dialog missing; the embedded ICC profile's tags are 4-byte aligned (#1584) | +| PSD | open, place, export | yes (open: layers as layers, or the merged image with `layers: false`; place: merged image, or the layers embedded as a group of objects with `layers: true`; the merged image comes in clipped by the file's clipping path; every mode and depth; `.psd` and `.pdd`) | yes (layered, max editability) | PSD Import Options on File › Open and on File › Place without Link (Convert Layers to Objects, Flatten Layers to a Single Image, Import Hidden Layers, Import Slices; `tests_psdlayers.rs`, `dialogs/psd_import.rs`); slices (image resource 1050, version 6 and the version 7 and 8 descriptor) open and embed as user slices with `importSlices` (`tests_psdslices.rs`); layer comps aren't read; the embedded ICC profile's tags are 4-byte aligned (#1584) | | PSB | open | yes (layers as layers, or the merged image; the merged image comes in clipped by the file's clipping path) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | @@ -328,6 +330,8 @@ Stop opacity on translated artboards remains outside this regression coverage. - [x] Authored ordering is protected per stretch; ordinary stretches still reconstruct visual-order text. Outlines mode is unchanged. - [ ] Exact redistribution of one replacement over several lines/styles, and general mixed-direction paragraph layout. Replacement uses the first placed glyph's line/style. Tags deeper than 128 and replacements over 64 KiB encoded (at most 192 KiB decoded) fall back to glyph mappings with a warning; an outer accepted owner survives deeper tags. +- [x] Stroked type (text rendering modes 1, 2, 5 and 6, with or without `ActualText`) keeps the line cap, line join and miter limit of the graphics state it is stroked with, as paths do; glyphs stroked with another cap, join or miter limit are a run of their own, also a glyph filled and then stroked otherwise than the run its fill joined (#7741, `imported_stroked_type_keeps_the_files_cap_join_and_miter_limit` in `crates/pdf/src/tests/textstroke.rs`). The dash array is not carried to the characters yet. + - [x] Place from bytes or unlinked paths, and PDF clipboard paste, keep the selected page's live text. Synthetic two-page regressions in `crates/engine/src/cmd/fileio/tests_pdffidelity.rs` cover both sources, page selection and explicit outlines. The single-page importer honors the same text choice as Open; existing PDF fallback rules are unchanged. ## Corpus download integrity @@ -338,6 +342,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-15 | minor | PDF import: stroked type (text rendering modes 1, 2, 5 and 6, and glyphs under `ActualText`) came in with butt caps, miter joins and a miter limit of 10 whatever the file stroked it with, while paths kept the graphics state's. The characters' stroke now takes the line cap (`J`), line join (`j`) and miter limit (`M`) of the graphics state, as a path's stroke does, and glyphs stroked with other ones start a run of their own, also a glyph filled and then stroked otherwise than the run its fill joined, so the glyphs before it keep their stroke (#7741). Test `imported_stroked_type_keeps_the_files_cap_join_and_miter_limit` in `crates/pdf/src/tests/textstroke.rs`. Scores and estimates unchanged. | | 2026-11-13 | minor | `.ai` and EPS import: layers that don't draw as the page, even with the drawn art of every live envelope put back, no longer put back the drawn art of every envelope of type. Each live envelope of type is weighed in the page check's own drawing of the pages it lies on, on the pixels that it and its drawn art ink there (the two drawn together on a layer of their own, without the rest of the layers; a pixel inks with an alpha above 8 of 255): it keeps its drawn art and its type, hidden, with the note, where the layers with it differ from the page in more of those pixels than its own check allows against its drawn art (10% of them, or 3% differing with all their neighbours, away from type), or where the envelopes of type that stay live, in the order they were read, would together differ there in more of a page than half of what the page check allows (a budget of the same size as the one their own checks share, kept apart from it). Art that differs elsewhere on the page no longer costs them their editable type; art that differs on their pixels still does. An envelope of type that a clip group or a clipping sublayer clips, an opacity mask masks, or a group with an appearance of its own, another envelope, a repeat or a blend holds keeps its drawn art as before, wherever it lies: drawn on its own it would be weighed elsewhere than it draws, and what holds it can draw it onto the page from off it. An envelope on no page stays live, as the page check doesn't see it, unless the page holds all the art that prints (an EPS's page that is its art's box, where art past the page is compared too): there one reaching past the page keeps its drawn art. Past 12 of them on the pages, all keep it. This holds where the editing data is trusted, where the objects that differ come in as the page's art (the placed files' other placement weighed on its own, as it draws them elsewhere under or over the envelopes; layers that draw as the page with only the envelopes the page holds put back are used as they are; where none of the objects that differ can then come in as the page's art, because an object holds type, the drawn art of every envelope of type is put back and that is tried again, as before), and for a `.ai` whose layers would open alone, which still opens alone: its envelopes of type are all settled again as they were with the page, in the same order and with the same checks, so each that opens live draws as it was weighed, and those the page held get their drawn art back. Where putting the drawn art of every live envelope back makes the layers draw as the page, it is still put back (#5065). In 224 users' files (used locally, never committed): envelopes of type that open live go from 174 to 186, in 8 files, none fewer in any file; no file changes between its layers and its PDF part; 7 renders change, by at most 0.7% of their pixels at 40 ppi (1 closer to the page, 1 alike, 5 further, by at most 0.7 of a percentage point, where VectorCraft's own envelope draws a little apart from the drawn art, within its check); the note for type in an envelope goes from 6 files. Of the 14 envelopes of type that had opened live and lost it to the page check, 2 keep their drawn art: one where the page draws something else where it is, and one in a file that comes in as its PDF part (#7141). Tests `build::candidate_tests::the_page_holds_back_only_envelopes_of_type_where_it_differs_from_them`, `envelopes_of_type_stay_live_within_half_of_what_the_page_check_allows`, `an_envelope_of_type_differing_in_more_of_its_pixels_than_its_check_allows_is_held_back`, `envelopes_of_type_the_layers_dont_draw_in_place_are_held_back`, `on_a_page_that_holds_all_the_art_an_envelope_of_type_past_it_is_held_back`, `objects_come_in_as_the_page_art_with_every_envelope_of_type_put_back_where_a_live_one_holds_a_difference`, `tests_native::layers_that_would_open_alone_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page`, `layers_that_would_open_alone_keep_live_only_the_envelopes_of_type_the_page_doesnt_hold`, `trusted_layers_that_would_open_alone_open_alone_with_an_envelope_of_type_the_page_doesnt_hold_live`, `layers_with_objects_that_come_in_as_the_page_art_keep_an_envelope_of_type_that_draws_as_the_page_live`, `trusted_layers_keep_an_envelope_of_type_live_where_other_art_differs_from_the_page`, `trusted_layers_leave_an_envelope_of_type_the_page_draws_otherwise_its_drawn_art`, `build::candidate_tests::the_other_placement_of_placed_files_is_weighed_on_its_own`, `layers_that_draw_as_the_page_with_the_held_envelopes_of_type_put_back_are_used_so` | | 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | `.ai` and EPS import, and what the canvas, every export and `.ai` save draw: a top-object envelope's mesh can save a side's curve in pieces. In users' files (used locally, never committed), 10 top objects save a grid of three patches across whose top and bottom edges are each one cubic split into thirds, and their drawn art follows the whole curve's parameter. VectorCraft gave each piece the share of its control polygon, so their type came out up to about half a point off the drawn art along the side, and one envelope of type kept its drawn art instead of opening live. A side whose pieces are all one curve split at anchors (each within 0.02 pt of that curve's piece) now follows that curve's parameter (#7097). Synthetic test: `a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live`. Scores unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index ef9bf6e150..05483e81d4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (G5: built-in Neon & Glow graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); estimate unchanged (3–13 h); previously: Tech Circuit brush library, G5; previously: G5: built-in Weather Icons symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow); estimate 5–15 → 4–14 h; previously: 3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and the face seams of the 3D (Classic) effects, have round joins instead of miters; previously: PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (Type: the Middle Eastern & South Asian composers are done as a per-paragraph choice with the Standard composers (#4935; G5: built-in Neon & Glow graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); estimate unchanged (3–13 h); previously: Tech Circuit brush library, G5; previously: G5: built-in Weather Icons symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow); estimate 5–15 → 4–14 h; previously: 3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and the face seams of the 3D (Classic) effects, have round joins instead of miters; previously: PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -459,7 +459,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi own pixels until a document takes them in; estimate unchanged. - **Missing:** CJK vertical composition (custom mojikumi sets and the Mojikumi Settings dialog, kinsoku settings #633, manual tate-chu-yoko), Middle Eastern - features beyond bidi, digit types, Character Direction and Kashidas (Middle Eastern composers, a split caret), spell check (its engine is done, #4936: `text.spelling.check` and + features beyond bidi, digit types, Character Direction and Kashidas (a split caret; the Middle Eastern & South Asian composers are done, #4935), spell check (its engine is done, #4936: `text.spelling.check` and `text.spelling.suggest` on craft-proof; the custom dictionary and Ignore All commands; Auto Spell Check, `edit.autoSpellCheck`, with a right-click on an underlined word offering its suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type; the Edit Custom Dictionary dialog; the Check Spelling dialog remains), Touch Type, Retype, Snap to Glyph; optional preservation of font sizes during group scaling (#1034 enhancement). Itemized in [type-parity.md](type-parity.md). @@ -890,6 +890,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Type: Middle Eastern & South Asian and Standard composers per paragraph (#4935): Paragraph panel menu, paragraph styles, `text.setFormat {composer}` and the native file; the Standard composers set Indic, Arabic and Hebrew type glyph by glyph. `.ai`, EPS and PDF files carry no composer. Scores and estimates unchanged. | | 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. | | 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 2fa44a769e..91e4dc6b4c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -56,7 +56,7 @@ plus review. Native-speaker review is human work and isn't in the hours. | Italian | `it` | 8,924 rows, 3,560 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 54 missing messages for it, which open #7116, #7211, #7378 and #7405 translate | full | | Dutch | `nl` | 3,447 `@msg` and 48 `@msgplural` rows (counted 2026-11-13): in `COMPLETE_MESSAGES`, and the untranslated report lists 41 missing messages for it; one/other plurals | full | | Danish | `da` | 7,422 rows, 1,636 of them `@msg` and 48 `@msgplural` (counted 2026-11-14 on `main` at `edb228ac3`): in `COMPLETE_MESSAGES`, and the untranslated report lists the same missing messages for it as for Greek, Spanish and Finnish; one/other plurals | full | -| Russian | `ru` | 4,009 / 4,020 | full | +| Russian | `ru` | 9,259 rows, 3,444 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): in `COMPLETE_MESSAGES`; the untranslated report lists 46 menu strings, 21 `tl!` literals and 11 undo labels as missing | full | | Ukrainian | `uk` | 8,802 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13); one/few/many plurals | full | | Hungarian | `hu` | 9,166 rows, 3,444 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it, and lists 47 menu strings, 25 `tl!` strings, 11 undo labels and 45 swatch names without a row; one plural form | full | | Polish | `pl` | 9,044 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 46 missing messages for it, and 47 menu strings, 26 `tl!` literals, 11 undo labels and 45 swatch names; one/few/many plurals | full; native review pending | diff --git a/docs/mcp.md b/docs/mcp.md index 66708d4e2b..3b6a75a310 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # VectorCraft MCP server -> **Last updated:** 2026-11-14 · **Change:** Pencil ignores Smart Guides and Snap to Point and shows no Smart Guides, #7252; no response-shape change. Previously: Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; no response-shape change. Previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. +> **Last updated:** 2026-11-14 · **Change:** `text.setFormat {composer}` and paragraph styles also take `standardSingleLine` and `standardEveryLine` (#4935); existing values and responses unchanged. Previously: Pencil ignores Smart Guides and Snap to Point and shows no Smart Guides, #7252; no response-shape change. Previously: Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; no response-shape change. Previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. > > Tech Circuit update (2026-11-14): three original procedural brushes; ten built-in brush libraries (G5). @@ -964,7 +964,8 @@ them in the Swatches panel does); give colour group names instead to reorder the in the app runs `paint.setFill` (or `paint.setStroke`, the active proxy) with `ids: [the object under the pointer]`. The Swatches panel menu's commands: `swatch.addUsedColors {selection?, global?}`, `swatch.unused` (a query: the names Select All Unused selects; a spot swatch counts as used while an image placed from a DCS file has a plate of -its ink), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and +its ink), `swatch.merge {names}` (the first is kept; gradient stops linked to the others drop their process +plates), `swatch.ungroup {name}` and `swatch.sortByKind`. `brush.move {name | names, to?, type?}` reorders brushes among the brushes of their type, as dragging one onto another of its type in the Brushes panel does. `to` is an index among the brushes of that type in `brush.list` @@ -1666,10 +1667,18 @@ preferences (the folder the desktop app uses, so both see the same ones; `VECTOR command, so `edit.colorSettings {"cmyk": "…"}` or a preset's output intent can name it in a later run. The copy is what counts: the original may move or be deleted. `color.removeProfile {name}` forgets a profile and its copy (not while it is a working space). -- **Color Settings** (`edit.colorSettings`): the RGB and CMYK working spaces, intent and black point - compensation go into `Color Settings.json` when they change, and are the active settings again at the next - start, after the profiles above are registered (#6335). Saved settings that name a profile that is no longer - there, or a damaged file, leave the defaults (logged), and the next change writes over the file. +- **Color Settings** (`edit.colorSettings`): the RGB and CMYK working spaces, intent, black point + compensation and color management policies go into `Color Settings.json` when they change, and are the active + settings again at the next start, after the profiles above are registered (#6335). Saved settings that name a + profile that is no longer there, or a damaged file, leave the defaults (logged), and the next change writes over + the file; settings saved without policies take the default, `preserveEmbedded`. `rgbPolicy` (`off`, + `preserveEmbedded`, `convert`) and `cmykPolicy` (the same and `preserveNumbers`) decide what `document.open`, + `file.newFromTemplate` and `file.revert` do with an RGB or CMYK profile the file is tagged with that is not the + working space: keep it, take it off and keep the color numbers (`off`, `preserveNumbers`), or convert the colors + of that model into the working space with the intent and black point compensation and take it off (`convert`; a + profile that is not available stays). The reply's `warnings` name each profile, and a document a policy changed + opens with unsaved changes. A profile that is the working space stays whatever the policy. `document.open`'s + `colorMode` converts the colors after the policies, through the profiles the document keeps. `edit.assignProfile` tags the document only: colors show and convert through the profiles it assigns, and the working spaces and the file stay as they are. The profiles in use are those of the document in front, after `file.new`, `document.open`, `document.activate`, `file.close`, `file.revert`, `edit.undo`, `edit.redo` and a @@ -1824,6 +1833,15 @@ and the action still plays. `Session::replay` plays a journal the same way. The hides the dock, no panel is checked, and the panel shows again with the dock in its earlier place; the toolbar and the Control bar stay as they are. +While Tab or Shift+Tab hides the panels, resting the pointer for 0.3 s on a 6 pt band along the left or right edge of +the area below the bars shows the panels docked on that side of the canvas, over the canvas's edge; the canvas keeps its +size, so the art does not move. They hide again once the pointer has been away from them for 0.3 s, and stay while a +flyout opened from them, a menu or a pressed button is open. Floating panels, the toolbar and the Control bar stay +hidden, and Tab or Shift+Tab shows the panels for good. `window.panels.peek {side: "left" | "right"}` shows them as the +pointer does, `{side: null}` hides them, and `ui.docking`'s `peek` names the side shown (null for none). The command +refuses another side, a side with no docked panel, panels that are not hidden and Presentation Mode, and changes +nothing. + The export commands limit those keys. `document.export`, `document.exportSelection`, `document.serialize`, `app.export` and `file.export` refuse a key that no format takes. They accept a key that only another format takes and do not use it: `{"format": "pdf", "quality": 80}` writes the PDF, and the reply's `warnings` name the key @@ -2002,8 +2020,9 @@ and `type.fractionalWidths`; `flip` in `type.dynamicTextPreset`; `remove` and `o These commands refuse a string or another value that is not a number with `` `radius` must be a number, not "2" ``, naming the key, and change nothing: `radius` in `shape.rectangle`; `sides` and `rotation` in `shape.polygon`; `radius2`, `points` and `rotation` in `shape.star`; `diameter`, `opacity`, `brightness`, `growth`, `fuzziness`, -`rays`, `longest`, `rayFuzziness`, `rotation`, `rings`, `largest`, `x2`, `y2`, `direction`, `pathLength` and `seed` -in `shape.flare`; `decay` and `segments` in `shape.spiral`; `rows` and `columns` in `shape.rectangularGrid`; `concentric` +`rays`, `longest`, `rayFuzziness`, `rotation`, `rings`, `largest`, `x2`, `y2`, `direction`, `pathLength`, `seed` and +`ringSeed` in `shape.flare` (`seed` must also be a whole number of at least 0, and `ringSeed` a whole number from +0 to 2^53 - 1); `decay` and `segments` in `shape.spiral`; `rows` and `columns` in `shape.rectangularGrid`; `concentric` and `radial` in `shape.polarGrid`; `size` and the `width`, `height` and `fitMinPercent` of `area` in `text.create`; `size` and `fitMinPercent` in `text.createInPath`; `start`, `end`, `size`, `tracking`, `baselineShift`, `hScale`, `vScale`, `rotation` and `strokeWidth` in `text.setRangeStyle`; `strokeWidth` in `text.setStyle`; `start` and `end` in @@ -2736,6 +2755,16 @@ A click with the Selection tool on a guide selected with objects runs `select.ke The key guide goes when it leaves the selection or no object is left selected, and an object key replaces it (and the other way round). It returns `{key}`, the key object's id or null, with `guide`, the key guide's index, when a guide is the key. `document.inspect` (the `inspect_document` tool) reports the key object as `key`, null without one, and the key guide as `keyGuide`. + +`select.key {id, anchor: [subpath, anchor]}` makes an anchor point selected with Direct Selection the key anchor, while at least one other point (or a path selected whole) is selected too. +`select.anchors` with mode `add` or `toggle` and one point makes that point the key anchor when it selects it; a Shift-click on an anchor with Direct Selection runs it, and a plain click still selects that point alone. +Aligning to the key, `object.align` moves the other selected points to the key anchor's x or y and the key anchor stays put; a pixel-aligned path is then snapped to the pixel grid, as after any other edit. +`object.distribute {spacing}` and `object.distributeSpacing {spacing}` keep the key anchor in place and put the other points `spacing` apart, outward from it, in their order along the axis. +With `to: "selection"` or `"artboard"`, `object.align`, `object.distribute` and `object.distributeSpacing` do not treat the key anchor as the key: it moves with the other points, as without a key anchor. +The key anchor goes when it is deselected or deleted, when it is the only point left selected, when `select.key {}` cancels it, and when a key object or key guide replaces it. +An anchor that is not a selected anchor point (a selected segment's end is not one) is an error, and the key stays as it was. +The result has `keyAnchor: {id, anchor}` with a key anchor, and `document.inspect` reports it as `keyAnchor`. +The canvas draws the key anchor as a larger filled square in a frame, and the Align panel and the Control bar then name the Align To choice Align to Key Anchor. The `ui.pointer` and `pointer_gesture` replies list it as `key` beside `selection`. The command palette lists `select.key` as Cancel Key Object and runs it without an id. @@ -2832,7 +2861,10 @@ group selected on its own, or, for `symbolInstance`, an object that is not a sym leaves the selection empty. A selected object that is hidden or locked, inside a hidden or locked group or layer, on a template layer, on a locked artboard while nothing is isolated, or outside the isolated group is passed over. New art, such as a symbol instance just placed, is not passed over for being on a locked artboard while it stays selected. When -the selection holds no other object, they return the error "nothing selected". +the selection holds no other object, they return the error "nothing selected". `appearanceAttribute {item?}` takes +the fill or stroke at appearance item `item`, or the Appearance panel's active item when the call gives none or null; a +value that is not a whole number of 0 or more fails with "`item` must be a whole number" and leaves the selection as it +was. `select.similar {by?}` is the Control bar's Select Similar Objects button and the Select Similar Options menu beside it ([help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=165)). It selects the @@ -3172,7 +3204,9 @@ number. `guide.add {vertical, pos, artboard?}` makes one (the x of a vertical guide, the y of a horizontal one, in points) → `{index}`. With `artboard` (an artboard's index) it is an artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in `artboard.setProps`, `artboard.rearrange`), scales with it (`artboard.setProps {scaleArt}`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is -deleted with it (`artboard.delete`); without, a canvas guide runs across the whole canvas. `guide.list` → +deleted with it (`artboard.delete`); without, or with null, a canvas guide runs across the whole canvas. An `artboard` +that is not a whole number of 0 or more fails with "`artboard` must be a whole number", and an index past the last +artboard with `no such artboard`; neither adds a guide. `guide.list` → `[{index, id, vertical, pos, selected, artboard?, layer?, hidden, locked, shown, editable}…]` (`artboard` only for artboard guides; `id` is the guide's node and `layer` the layer it is on; `hidden` and `locked` are its own state, `shown` and `editable` also take its layer into account). `layer.setProps {ids: [id…], visible?, locked?}` hides or @@ -3351,9 +3385,12 @@ already walks Image Trace. Short agent entry points: `brush.duplicate {name, newName?} | {names}`, `brush.freehand {…path.freehand params, brush?}`, `object.expandBrush {ids?}`. `brush.freehand` draws the plain path when `brush` is left out or null. It refuses a `brush` that is not a string with "`brush` must be a string", a name that no brush in the document has (an empty - name included) with "no brush named ``", and a `style` other than "pencil" or "brush"; the document does not - change and no undo step is recorded. The Paintbrush skips a tool brush or current brush that has been deleted and - paints with the other, or draws the plain path when neither is in the document. A document or brush + name included) with "no brush named ``", 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) + with "cannot apply brush ``: path has no stroke", and a key that neither `path.freehand` nor `brush` names + with "unknown parameter"; the document does not change and no undo step is recorded. A stroke drawn with a brush + is one undo step: Undo removes the path and its brush together. The Paintbrush skips a tool brush or current + brush that has been deleted and paints with the other, or draws the plain path when neither is in the document. A document or brush library file whose brushes repeat a name is read with the first brush of that name as stored and each later one numbered with the first "`` 2", "`` 3"… the file does not have; strokes of the name keep the first brush. `brush.new`, @@ -3409,7 +3446,12 @@ already walks Image Trace. Short agent entry points: `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic` (`variable.bind` and `variable.makeTextDynamic` refuse type that already has another text variable and name it), `variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph - values CSV as `graph.setData`). `variable.lock {locked?}` (Lock Variables) locks or unlocks the variables; + values CSV as `graph.setData`). `variable.rename {name, newName?, kind?}` is Variable Options: it renames a + variable, changes its kind, or both, in one undo step, and adds none when the name and kind are the ones it has. + A kind change unbinds each object that can't hold the new kind (a text variable keeps type with no other text + variable, an image variable keeps image objects, a graph variable keeps graph roots, a visibility variable keeps + every object; locks and hidden layers don't unbind anything) and drops the variable's values in every data set. + That rule is VectorCraft's own. `variable.lock {locked?}` (Lock Variables) locks or unlocks the variables; without `locked` it toggles, and the lock is saved with the document. While the variables are locked, `variable.define`, `variable.delete`, `variable.rename`, the four `variable.make…Dynamic` commands and a `variable.loadLibrary` that would delete or define a variable (below) fail with "variables are locked"; @@ -3793,6 +3835,16 @@ already walks Image Trace. Short agent entry points: black ring around a white disc around a black dot, black gets two groups. `layer.releaseToLayers` on the expanded group then gives each group a sublayer. A trace made with `autoGrouping` is refused. Expanding by color also refuses a trace with a live Pathfinder effect. + An Abutting trace with Fills (not Flat Logo) also holds a group named `Seam Fills` under its paths: for each color, a + stroke 1.5 image pixels wide (at the image's larger scale, when it is stretched more one way) with butt caps and round joins along the edges where a filled path of that color meets a + filled path drawn after it, in that color (a gradient path's stroke takes its gradient). Without it, a renderer that + anti-aliases each path on its own shows a light hairline along every shared edge. The traced paths and their shared + edges are the same with or without it, and edges against the image's border, a transparent area or a color left out + get no stroke. A trace whose paths have more than half of the trace's anchor limit (8,000,000, 2,000,000 on the web) + has no seam fills, so that paths and seam fills together stay within the limit. Both Expand commands keep the group, and `byColor` leaves it under every color's group. The strokes + are separate objects: after Expand, recoloring, moving or deleting a traced path leaves them as they are, and deleting + the group removes them. The reply's + `paths` and `anchors` count the traced paths only. With `keepColors` in `params`, the reply's `warnings` name each listed color that no traced color matches, and say when nothing was traced. `imageTrace.presets.save {name?, newName?, preset?, enhanced?, params?}` saves tracing settings as a preset kept with @@ -3908,8 +3960,13 @@ already walks Image Trace. Short agent entry points: arrives and is its own undo step. `match` is the paint the face or edge had before the first of the double-click's two clicks or the triple-click's three that painted it, or before the double-click when the triple-click's double-click reached it, so the clicks before the last do not change what it matches; - a first click that made the Live Paint group counts when the face it filled is the clicked face. Hidden faces and edges are not painted. An edge takes the - paint and weight in its topmost stroke that is shown, or in a new stroke above the others when it has none. These rules + a first click that made the Live Paint group counts when the face it filled is the clicked face. Hidden faces and edges are not painted. A face that + the Bucket fills has all its fills replaced by one fill with the paint, shown, fully opaque and blending normally, in the + place of its topmost fill; its strokes, their order and the face's own visibility, opacity and blending mode stay as + they are. An edge that it strokes has all its strokes replaced the same way by one stroke with the paint and weight, + which keeps the dashes, caps, joins and arrowheads of the edge's topmost shown stroke (of its topmost stroke when every + stroke is hidden), and its fills stay as they are. A part with no fill or stroke gets a fill below its strokes or a stroke + above its fills. A fill or stroke of none leaves one row of none, so no fill or stroke of the part draws. These rules for what faces and edges join and what paints match are VectorCraft's own. The help page describes the gestures: https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html. `livePaint.fill {scope}` and `livePaint.strokeEdge {scope}` take `scope` as `"one"` (the default), `"connected"` or @@ -3933,8 +3990,9 @@ already walks Image Trace. Short agent entry points: error. With `ids`, at a point on a hidden face it fills the face there of another Live Paint group among the objects; otherwise it returns an error and does not merge the objects. A Live Paint command that changes nothing records no undo step, keeps the redo steps, leaves a saved document saved - and replies as usual: `livePaint.fill` (a click, or a Bucket drag) when every face it reaches already has the paint, - `livePaint.strokeEdge` when the topmost shown stroke of each edge it reaches already has the paint and the width, + and replies as usual: `livePaint.fill` (a click, or a Bucket drag) when every face it reaches already has one fill, shown, + fully opaque, blending normally and with the paint (or no fill, for none), `livePaint.strokeEdge` when every edge it + reaches already has one such stroke with the paint and the width (at any width for none, or no stroke), `livePaint.gapOptions` (and the Gap Options dialog's OK) when every group already has the options and `closeGaps` finds no gap, and `livePaint.make` or `livePaint.merge` given one Live Paint group alone, which they select. Such a fill or stroke also leaves the recent @@ -4304,6 +4362,13 @@ is tall), `bevelInside` (false adds the bevel around the shape, true carves it i - `shadingColor`: `black` (the default), `custom` (darkens toward `shadingCustom`, a `#rrggbb` color) or `none` (the paint is kept and only the highlight lightens it). `drawHiddenFaces` also draws the faces turned away from the viewer, behind the others. +- `preserveSpotColors` (default false) is the Lighting tab's Preserve Spot Colors checkbox. When it is on, a fill or + stroke linked to a spot or global swatch stays linked on every face, so a spot color prints on its own plate in + separations and PDF. The highlight lowers the swatch's tint, and a face that darkens gets black at the shade's + strength over the swatch, multiplied and overprinting. These rules for drawing the shading are VectorCraft's own. The + option does nothing while `shadingColor` is `custom`, since a custom shading color cannot keep a spot color + ([Preserve Spot Color](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=260)). + Unlinked colors, gradients, patterns and the Wireframe surface draw as they do with it off. The defaults are VectorCraft's own. An unknown parameter, a number out of range, a flag that is not true or false, or an unknown surface or shading color is refused with an error and the document is not changed. @@ -4320,8 +4385,8 @@ for a full turn, and draws every face, back to front, with the classic surfaces. 0..360, default 360), `cap` (true closes the profile's open ends, false leaves the surface hollow), `offset` (points, 0..1000, default 0; the axis's distance from the profile, so a value above 0 makes a ring), `edge` (`left`, the default, or `right`: the edge of the profile's bounds the axis is at), `rotationX/Y/Z` and `perspectiveAngle` as -above, and `surface`, the lights, `ambient`, `highlightIntensity`, `highlightSize`, `blendSteps`, `shadingColor` and -`shadingCustom` as Extrude & Bevel (Classic) takes them. It has no `drawHiddenFaces`. The effect also goes on a group +above, and `surface`, the lights, `ambient`, `highlightIntensity`, `highlightSize`, `blendSteps`, `shadingColor`, +`shadingCustom` and `preserveSpotColors` as Extrude & Bevel (Classic) takes them. It has no `drawHiddenFaces`. The effect also goes on a group or layer, whose members turn about one shared axis, or on a fill or stroke. The public documentation describes the dialog ([Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)) and its Angle (0 to 360), Cap, Offset (0 to 1000) and Left Edge or Right Edge settings @@ -4340,8 +4405,8 @@ changed. 3D (Classic) › Rotate (Classic)…. It turns every fill and stroke as one flat plane about the center of the object's bounds, as `threeD.rotate` does, each in its own paint, and draws the plane from either side. Parameters: `rotationX/Y/Z` and `perspectiveAngle` as above; `surface` (`diffuse`, the default, lights the art softly, and -`none` keeps the paint unlit); and the lights, `ambient`, `blendSteps`, `shadingColor` and `shadingCustom` as Extrude & -Bevel (Classic) takes them. It has no highlight, no `drawHiddenFaces` and no shadow. The effect also goes on a group +`none` keeps the paint unlit); and the lights, `ambient`, `blendSteps`, `shadingColor`, `shadingCustom` and +`preserveSpotColors` as Extrude & Bevel (Classic) takes them. It has no highlight, no `drawHiddenFaces` and no shadow. The effect also goes on a group or layer, whose members turn about one shared center, or on a fill or stroke. The public documentation describes the dialog's Position and Surface settings ([Rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)), @@ -5506,17 +5571,30 @@ SVG Filter (New, Edit, Delete, Import…), and from the Appearance panel it edit The canvas, raster export, printing and PDF draw the filter with the object as its source graphic, its user space starting at `origin` (`[x, y]` in points, `[0, 0]` by default) with its units `scale` points (1 by default), its -object bounding box the drawn object's, and at the Document Raster Effects resolution when the view is finer. The +axes along `axes` (`[a, b, c, d]`: one unit along the filter's x axis points `[a, b]` in the document and one along +its y axis `[c, d]`, before `scale`; absent or `[1, 0, 0, 1]`, the document's axes, and `effect.setParams` sets them +back with `[1, 0, 0, 1]`, since the commands refuse `null`; each number -1000 to 1000 and `a·d - b·c` at least +0.001 from 0, or the command refuses it), its object bounding box the drawn object's, and at the Document Raster Effects resolution when the view is finer. The filter runs on coarser pixels when a morphology radius or kernel unit would span more than 32 of them, or a standard -deviation more than 512. An effect whose XML these rules refuse (from a file or a script) is not drawn or exported. The +deviation more than 512. With `axes`, the filter runs on pixels lined up with its own axes, so offsets, blurs and the +region run along them, and the result is drawn onto the object's pixels through `axes`; those pixels are about as fine +along each axis as the object's and coarser when there would be more than 4 times as many, or when a radius, kernel +unit or standard deviation would span more than 32 or 512 of them. The object's box is then +its box in that user space: a region measured against it, and a region or primitive lengths in `objectBoundingBox` +units, are measured against that box. Moving, turning, scaling or skewing the object moves `origin` with it and +leaves `axes` as they are, and `scale` too unless Scale Strokes & Effects scales it. An effect whose XML or `axes` these rules refuse (from a file or a script) is not drawn or exported. The filter is drawn and written rebuilt from its filter elements and their filter attributes alone, without comments, text, descriptions or other attributes. An image in a filter is drawn only when its bytes are a PNG, JPEG, GIF or WebP within the pixel limit. A filter region in user space given in percentages is measured against the object's box (x and y from its top-left corner), and one in em or ex or left out takes the default region of that box (10% beyond each side); these are VectorCraft's own rules. Scale Strokes & Effects scales -`scale`. SVG export writes the filter as a `` element when it is the object's last effect, its only pixel -effect and `scale` is 1 (with a region measured against the object's box, its only raster effect), with the object in a -group that places the filter's user space and such a region written in user units. The filter is written in SVG 1.1 +`scale`. SVG export writes the filter as a `` element when it is the object's last effect and its only pixel +effect (with a region measured against the object's box, its only raster effect) and SVG import can store its user +space again (`scale` and `axes` together within the import limits below), with the object in a +group that places the filter's user space and such a region written in user units. When `scale` isn't 1 or the effect +has `axes`, that group's transform is the filter's user space and the object is written in document coordinates in a +group inside the filter whose transform is the inverse, so its strokes keep their widths; SVG import reads that back +as the same `scale`, `origin` and `axes`. The filter is written in SVG 1.1 terms: `feImage`'s reference as `xlink:href`; each `feDropShadow` as the blur, offset, flood, composite and merge that [Filter Effects](https://www.w3.org/TR/filter-effects-1/#feDropShadowElement) gives as its equivalent; colors as `#rrggbb` with a flood color's alpha in `flood-opacity`; `image-rendering` as `optimizeSpeed` or `optimizeQuality`; @@ -5528,9 +5606,12 @@ whose `flood-color` is `currentColor` or with `primitiveUnits="objectBoundingBox its shadows are expanded, has no SVG 1.1 form. Neither does a filter whose written form, with its region and id, is one SVG import would not read, such as one longer than 64 KB. In that case, otherwise, and with the SVG Tiny 1.2 profile, the object becomes an image. SVG import keeps a filter that the live effects (Drop Shadow, the -glows, Gaussian Blur, Feather) can't express as an `svg.filter` effect holding the filter's XML, with `scale` and -`origin` from the object's user space, unless that user space is turned, skewed or scaled past 0.001 to 1000 points -to a unit, or the filter is styled by the document's style sheets: such a filter is left out with a warning. The native format keeps the document's filters and the effects. +glows, Gaussian Blur, Feather) can't express as an `svg.filter` effect holding the filter's XML, with `scale`, +`origin` and, when the object's user space is turned, skewed or scaled unevenly (a `rotate`, `skewX`, `skewY`, uneven +`scale` or `matrix` transform), `axes` from that user space, so the filter runs in the element's user space as +[SVG 1.1](https://www.w3.org/TR/SVG11/filters.html#FilterEffectsRegion) defines it. A filter is left out with a +warning when that user space is scaled past 0.001 to 1000 points to a unit (the square root of its area scale), its +`axes` would pass the limits above, or the filter is styled by the document's style sheets. The native format keeps the document's filters and the effects. ```json {"name":"run_command","arguments":{"command":"svgFilter.define","params":{"xml":""}}} @@ -6059,8 +6140,12 @@ below 3 or above 1000 counts as 3 or 1000, and a fractional or negative `sides` Without `artboard`, or with an index the document doesn't have, it aligns them to the artboard under the center of the selection, or to the first artboard when no artboard is under the center. With `to: "selection"` or `"key"`, `object.align` does not use `artboard`. `select.allOnArtboard {artboard?}` (Select › All on Active Artboard) selects -the objects on artboard `artboard`, or on the first artboard when the call gives none; an index the document doesn't -have is an error. +the objects on artboard `artboard`, or on the first artboard when the call gives none or null. A value that is not a +whole number of 0 or more fails with "`artboard` must be a whole number", and an index the document doesn't have with +`no such artboard`; either leaves the selection as it was. `select.font {family, style?, scope?}` (Select Text +by Font) takes `scope: {artboards: [index…]}` the same way: an entry that is not a whole number fails with +"`scope.artboards` must contain whole numbers" before any entry is looked up, and an index the document doesn't have +fails with `no such artboard`. `object.distribute {to: "artboard", artboard?}` and `object.distributeSpacing {to: "artboard", artboard?}` (the Distribute Objects and Distribute Spacing buttons with Align to Artboard) use the same artboard as `object.align`. @@ -6074,14 +6159,15 @@ With `to: "selection"` or `"key"`, or without `to`, `object.distribute` and `obj keep the first and last objects where they are, and `object.distribute` without `spacing` moves nothing with fewer than three objects. With `spacing` and a key object, `object.distribute` and `object.distributeSpacing` do not use `to`; with a key guide, `to` decides whether the guide is the key. With anchor points selected (Direct Selection), both commands space the points -among themselves and do not use `to` or `artboard`. +among themselves and do not use `artboard`; with `to: "selection"` or `"artboard"`, the key anchor is not the key, and +all the points are spaced as without one. `object.distribute {spacing}` (the Distribute Objects buttons while the selection has a key object, `select.key`) keeps the key object where it is, with the compound path or selected group that contains it. It places the objects' lines (left edges, centers or right edges; top edges, centers or bottom edges) `spacing` points apart, outward from the key object's line, in their order on that line. With `spacing: 0`, every object ends on the key object's line. Two objects -are enough. Without a key object or key guide, or with anchor points selected, `object.distribute` does not use -`spacing`. A `spacing` that is not a number is an error. +are enough. Without a key object, key guide or key anchor, `object.distribute` does not use `spacing`; with anchor +points selected, it uses `spacing` only with a key anchor (`select.key {id, anchor}`). A `spacing` that is not a number is an error. `object.distribute` and `object.distributeSpacing` space the selected ruler guides across the axis (vertical guides for the horizontal options, horizontal guides for the vertical ones) with the selected objects, as items with no size @@ -6537,6 +6623,22 @@ Reset Rotate View is the command `view.rotateReset`: the angle the selected objects share (`selectionRotation` in `document.inspect`, 0 when their angles differ), so their bounding box is square to the window. It returns `{angle}`, and with nothing selected it returns "nothing selected". +`view.statusBarShow` (desktop app, the Show menu that a click on the status bar's info field opens) chooses what that +field shows, with `show` set to one of these values: + +- `artboardName`: the name of the artboard the status bar's navigator shows. +- `currentTool`: the name of the tool in use. This is the default. +- `dateTime`: the date and time, as `2026-11-15 14:03`. The desktop app shows local time, and the web build shows UTC + with ` UTC` after it. +- `undos`: the active document's undo and redo step counts, as `Undos: 3 Redos: 1`. +- `colorProfile`: the profile the active document's colors use. That is the profile assigned for its color mode + (Edit › Assign Profile), or that mode's working space (Edit › Color Settings) when none is assigned or the + assigned one is not available. + +It returns the value set. The choice is kept with the UI state. A missing or other `show` fails with +``` `show` must be one of artboardName, currentTool, dateTime, undos, colorProfile ``` and leaves the choice as it was. +Without a document, the readouts that need one show a dash. + The Smart Guides preferences (Preferences › Smart Guides) apply to `pointer_gesture` with Smart Guides on (the default view) and to the mouse; they change what the tools show and how far a target pulls, and only Construction Guides change where a snapped point lands: @@ -7086,10 +7188,12 @@ with `pointer_gesture` (a click places at 100% with the top-left corner there, a number, naming the key, and place nothing; null is the same as leaving the key out. In the app, `file.place` asks for a file's import options before placing it: a text file without `text` (Text Import -Options), a DXF drawing without `dxf` (DXF Import Options), and a PDF, `.ai` or `.ait` file with several pages or a -password and no `page` (Place PDF). `showImportOptions: false` places such a file without asking, with the options -last confirmed in its dialog: the text options, and the DXF fit, lineweight, centering and layer choices with the -drawing at 1:1 in its own unit. A PDF is placed on page 1 (or `page`) and its crop box, and an encrypted PDF given no +Options), a DXF drawing without `dxf` (DXF Import Options), a PDF, `.ai` or `.ait` file with several pages or a +password and no `page` (Place PDF), and a PSD or PSB file embedded without `layers` that has two layers or more or +slices (Place PSD). `showImportOptions: false` places such a file without asking, with the options last confirmed in +its dialog: the text options, the DXF fit, lineweight, centering and layer choices with the drawing at 1:1 in its own +unit, and the PSD conversion, hidden-layer and Import Slices choices. A PDF is placed on page 1 (or `page`) and its +crop box, and an encrypted PDF given no `password` still asks for the password. A `showImportOptions` that is not true or false is refused, and nothing is placed. The Place dialog (File › Place, or `file.place {paths}`) has a Show Import Options checkbox after Link, Template and Replace ([Place multiple files in a document](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/place-multiple-files-in-a-document.html)). @@ -7121,6 +7225,11 @@ added is reused), which the pasted objects follow. Linked colours show the docum spot colour as its Spot Colors option says). The result reports `{ids, added, merged, renamed: [{kind, from, to}]}`. +`edit.pasteWithoutFormatting` sets the labels of a pasted graph in a new graph's label character style and +paragraph attributes, keeping each label's alignment and where it starts. The pasted graph no longer keeps its labels' character and +paragraph formats, so a later `graph.setData` or `graph.setType` sets the labels the same way. The rest of the +graph's art is pasted as it was copied. + A global or spot swatch whose name the document gives another colour is a conflict: `clipboard.conflicts` lists them, and `swatchConflict` answers: `"merge"` (the default: the objects take the document's swatch), `"add"` (the pasted swatch comes in renamed) or one answer per name. Pasting back into the document the objects came from uses @@ -8184,7 +8293,10 @@ or in `options` (`file.formatOptions {format}` lists them with their values): - `separateArtboards: true` also writes each artboard of `range` (`"1-3, 5"`, default `"all"`) to `-.` beside the file: that artboard and the art touching it. The result's `files` lists every file written, the master file first (without a path: `files: [{name, dataBase64}]`). 3 artboards give 4 files. -- `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews. +- `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews. Linked + images in the art of the stored brushes count as those in the layers do, and an image key that the layers or a + brush's art shows embedded keeps its pixels either way. A `version` older than the current one saves the placed + documents in a brush's art as groups of the art they show. - `embedProfiles` (default true) carries the ICC profiles the document is tagged with that were loaded from files; opening the file installs them where they are missing (a warning names one that can't be used). - `pdfCompatible`: native files (default true) also carry a PDF of every artboard (`pdf` in the file). This makes saves @@ -8510,6 +8622,65 @@ flattened picture every PSD file carries: Bitmap, Grayscale, Duotone, Indexed, R A document with transparency keeps it. `file.place {path}` links the file like any image (`links.update` reads it again when it changes); Multichannel files are refused. +`file.place` with `layers: true` embeds a PSD or PSB file's layers as objects instead, and `hiddenLayers: true` +brings the hidden layers in, hidden. The +[PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) +offer the conversion and the hidden layers only for a file that is opened or embedded, and a linked file shows its +flat image. The rest are VectorCraft's own rules: + +- The layers are read as `document.open` reads them (below) and placed as one group named after the file. A layer + becomes an image and a layer group becomes a group, each with the layer's name, visibility, opacity, blend mode + and opacity mask. A Pass Through group is not isolated; a group in any other mode is. +- The group takes the place the merged image would take: `at` centers the file's canvas and `rect` fits it. When + layers reach past the canvas, hidden layers included, the group is a clip group whose clipping path is the canvas. +- The layers are never linked. With `layers: true`, `link` is false unless it is given, and `link: true` is an + error. +- `hiddenLayers: true` without `layers: true` is an error. +- A file without layers is embedded as its merged image, and so is a file whose layers are all left out, with the + import's warnings. A file whose layers can't be read is embedded as its merged image with a warning. +- `file.place.queue` and `file.place.info` take `layers` and `hiddenLayers` too. + +`document.psdInfo` reads a PSD or PSB file without opening it: its `format` (`psd` or `psb`), the canvas `width` +and `height` in pixels, and the counts `layers` (group markers left out), `hiddenLayers`, `groups` and `slices` +(the slices `importSlices` brings in). A file without layer records, or whose layer records can't be read, has +`layers: 0`; a file without slices, or whose slices can't be read, has `slices: 0`. A file that is not a PSD or PSB +file is an error. + +`importSlices` brings a PSD or PSB file's slices in as user slices (Object › Slice), which Save for Web exports. +The [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) +keep the slices a file holds, and offer it only for a file that is opened or embedded. The slices are read from +image resource 1050 as the +[Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/) describes it, +in version 6 and in the descriptor of versions 7 and 8. The rest are VectorCraft's own rules: + +- `document.open` takes `importSlices`, default true, with the layers as objects and with `layers: false` alike. + The slices lie over the canvas, each clipped to it. +- `file.place` takes `importSlices`, default true when the file is embedded and false when it is linked; + `importSlices: true` with a linked file is an error. The slices move and scale with the placed art, in the same + undo step, and the reply lists their ids in `slices`. `file.place.info` counts them in `slices`, and + `file.place.queue` takes `importSlices` too. +- Each slice keeps its name, its type (Image or No Image), its link, target, message, alternative text and + background color, and a No Image slice keeps its cell text (plain text escaped as HTML). Text alignment isn't + read. A slice that follows a layer comes in as a user slice over the rectangle the file stores for it. +- Slices the file generated automatically are left out: the document lays out its own auto slices around the + others. A slice wholly outside the canvas, or without a size, is left out with a warning, and so are slices that + can't be read; the file's art comes in either way. A file without slices brings no warning. + +In the app, File › Open of a PSD or PSB file with two or more layers, or with slices, shows PSD Import Options: +Convert Layers to Objects (with Import Hidden Layers) or Flatten Layers to a Single Image, and Import Slices, which +is offered only for a file with slices and applies to either choice. File › Place shows the same dialog for such a +file placed without Link, and so does a file dropped on the canvas with Shift held. The dialog starts from the +choices made last, at first Convert Layers to Objects without hidden layers, with Import Slices. A file with fewer +than two layers and no slices, a file placed with Link, and `document.open` and `file.place` run as commands don't +show it. Several files placed together without Link load the place cursor without asking, and each PSD or PSB file +among them comes in with the choices made last. + +```json +{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/poster.psd","layers":true,"hiddenLayers":true}}} +{"name":"run_command","arguments":{"command":"document.psdInfo","params":{"path":"/tmp/poster.psd"}}} +{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/poster.psd","importSlices":false}}} +``` + A PSD or PSB file that names a clipping path (the saved path its clipping path resource names, in the [published PSD file format specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/)) places, and opens as its merged image (`layers: false`, a file with no layers, or a file whose layers can't be read), inside a clip group whose clipping @@ -8569,8 +8740,7 @@ Each layer's pixels must fit the memory allowed for one image, and the pixels of canvases the merges make, must together fit eight times that; a file past either limit opens as its merged image, with a warning. The canvases one step of a merge holds at once must also fit the memory allowed for one image; when they don't, the layer that needed the merge comes in alone, with a warning: in the Normal mode, or as a layer of its -own instead of clipped. File › Open in the app has no PSD import options dialog: it converts the layers and leaves -hidden layers out. +own instead of clipped. ```json {"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/poster.psd","hiddenLayers":true}}} @@ -9005,6 +9175,11 @@ Appearance panel's Characters row, not the characters' formatting) go on paths o below the Characters row on one path under the glyph paths, the others on one path over them, in their order and with their opacity, blend mode, effects and stroke settings. Their gradients that are not placed are fixed to the type's layout box, as the canvas paints them, so one gradient spans every glyph. The group itself has no fills or strokes. +It takes the type object's name, opacity, blend mode, Isolate Blending, Opacity & Mask Define Knockout Shape, opacity +mask and effects. The effects (the object's own, which apply to the type as a whole) keep their order, settings and +hidden state and stay live on the group; none of them is copied onto the glyph paths. Geometry effects on the group +(Warp, Free Distort, Transform) move its placed gradients with the whole outline, as they do on the type, not with each +glyph. `object.expand` outlines type with this command, so Fill and Stroke expand those paths' gradients, patterns and strokes. `type.createOutlines` (Type › Create Outlines) and `effect.expandAppearance` (when it outlines type, as Expand @@ -9469,8 +9644,19 @@ value out of range, a scale on another class, an unknown class or key, a name th characters, shared by two fonts (in any case) or the name of an installed font, and a part set in a composite font are each refused with an error, and nothing changes. A document keeps at most 256. +`custom` lists a font's custom character sets +([Customize characters in a composite font](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348)), +each `{name, characters?, font?, style?, size?, baseline?, hScale?, vScale?}`. A set sets the characters it lists in +its own font, style, size, baseline and scales, ahead of their class; a value left out is the `kanji` part's. When two +sets list a character, the later set sets it. A set's name is trimmed and unique in its font in any case. Its +characters are each listed once, none a control character or past U+FFFF. A set may list no characters, and then sets +none. A font keeps at most 64 sets and a set at most 8,192 characters, VectorCraft's own limits. A set name that is +empty or shared, a character listed twice or not allowed, `scaleAtCenter` on a set, or an unknown key is refused with +an error, and nothing changes. + Type set in a composite font names it as its family (`text.setStyle {font: "My Composite"}`), and the font menus list -composite fonts first. The layout sets each character in its class's font, a letter and its combining marks together. +composite fonts first. The layout sets each character in its custom set's or its class's font, a letter and its +combining marks together. A composite font taken out of the document leaves its type naming it, and the type shows as missing. Composite fonts are saved with the document. SVG and legacy `.ai` write type in one of the document's composite fonts as outlines, and SVG, PDF, EPS and legacy `.ai` export draw it in the font's parts also when the document was read without an open app. @@ -9483,18 +9669,25 @@ parts its characters are drawn in, and `missingGlyphs` counts the characters tha characters are drawn in. `text.glyphAlternates` lists the alternates of a character from its part's font. `type.compositeFonts.export {path?, names?, fonts?}` writes the document's composite fonts (all, or those `names` -lists), or the `fonts` given, to a `.vccomposite` file; without `path` it returns the file's text as `data`. +lists), or the `fonts` given, to a `.vccomposite` file; without `path` it returns the file's text as `data`. Fonts +whose file would be larger than 4 MB, the most Import reads, are refused with an error. `type.compositeFonts.import {path | data | dataBase64, apply?}` reads a file of at most 4 MB. With `apply` (the default) its fonts join the document's, replacing a font of the same name, as one undo step; with `apply: false` it only returns them. Opening a `.vccomposite` file imports it. Type › Composite Fonts… (shown with Show East Asian Options) opens the Composite Fonts dialog on the document's fonts: -Font picks one, and a table sets each class's font, style, size, baseline and scales. New adds a font based on the one -picked, Delete Font takes it out, Import… adds the fonts of a file and Export… writes the one picked. OK runs -`type.compositeFonts.set` with every font as one undo step; a refused font keeps the dialog open. +Font picks one, and a table sets each class's and each custom set's font, style, size, baseline and scales. New adds a +font based on the one picked, Delete Font takes it out, Import… adds the fonts of a file and Export… writes the one +picked. Custom shows the font's custom character sets: Character Set picks one, New adds a set based on the one picked, +Delete Set takes it out, and Name renames it. Character › Direct Entry adds the characters typed, and Character › +Unicode adds the characters of hexadecimal codes (`3042`, `U+3042`, several separated by spaces or commas); Add skips +characters the set already lists. Delete takes the character picked out of the set. OK runs `type.compositeFonts.set` +with every font as one undo step; a refused font keeps the dialog open. `ui.dialog.set` takes `custom`, `entry` and +`char` (the index of the character picked in the set) as soon as the dialog opens. ```json {"name":"run_command","arguments":{"command":"type.compositeFonts.set","params":{"fonts":[{"name":"My Composite","parts":{"kanji":{"font":"Source Han Sans"},"roman":{"font":"Source Sans 3","size":95,"baseline":-2},"numerals":{"font":"Source Sans 3"}}}]}}} +{"name":"run_command","arguments":{"command":"type.compositeFonts.set","params":{"fonts":[{"name":"My Composite","parts":{"kanji":{"font":"Source Han Sans"}},"custom":[{"name":"Marks","characters":"※〒","font":"Source Sans 3","size":90}]}]}}} ``` ## Optical Margin Alignment @@ -10253,7 +10446,7 @@ The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `ta 100000 pt); it is disabled at 256 stops or when a stop is already at that position. Delete Tab removes the selected stop, and Clear All Tabs leaves the style with no stops, which it then sets. OK sorts the stops by position; of two stops at one position it keeps the one edited last. -- Composition: `composer` (`singleLine` or `everyLine`) and the `roman_hanging` checkbox. +- Composition: `composer` (`singleLine`, `everyLine`, `standardSingleLine` or `standardEveryLine`) and the `roman_hanging` checkbox. - Hyphenation: the `hyphenate` checkbox, `words_longer_than`, `after_first`, `before_last`, `hyphen_limit`, `hyphenation_zone` and the `hyphenate_capitalized` checkbox. The options are inactive while Hyphenation is off, and Hyphenation Zone is inactive with the Every-line Composer. @@ -10635,7 +10828,14 @@ same dialog, and its OK also draws a flare there. While the center is dragged ou pointer's document y grows adds one degree per document point to `shape.flare`'s `rotation` (it turns only the rays), decreasing y subtracts the same rate, and unchanged y leaves it. This application rate is independent of event frequency; Shift held keeps the turn as it is. Space held keeps the turn as well. Once the center drag has passed its starting distance, and in the -drag that places the rings' end point, Space held moves the flare at its current size. The Artboard tool's are the active artboard's Artboard Options +drag that places the rings' end point, Space held moves the flare at its current size. In the center drag, Up and Down +Arrow add and remove rays, and Ctrl (Command on macOS; `mods.cmd`) keeps the center's diameter as the last drag showed +it while the drag sizes the halo: the halo's radius keeps the ratio to the pointer's distance that it had when the key +went down, through `growth` within 0 to 300 %. Letting the key go sizes the center with the drag again, and the flare +keeps the new `growth`. While the rings' end point is placed, Up and Down Arrow add and remove rings (0 to 50; with +Rings off they do nothing), and each press of backquote (the ~ key; a pointer event's `backquote` turning true) sends a +new `ringSeed`, which moves the rings along the path and keeps their count and sizes. Each new flare starts from the +options' counts and growth, without a `ringSeed`. The Artboard tool's are the active artboard's Artboard Options (dialog `artboardOptions`; the Artboard tool's active artboard while it is in use, else the window's; its `x` and `y` are the artboard's top-left corner in document points, while the dialog shows X and Y at the reference point the Transform panel uses, counted from the ruler origin), a graph tool's @@ -11229,9 +11429,17 @@ of the group in the Layers panel (`layer.move`). ## Paragraph composer -`text.setFormat {ids?, composer: "singleLine"|"everyLine"}` sets how a text object's lines are broken (the Paragraph -panel menu's Single-line and Every-line Composer; stored in the paragraph attributes as `para.composer`, saved only -when it is Single-line). Every-line, the default, picks the breaks of the whole paragraph together in area type: +`text.setFormat {ids?, composer: "singleLine"|"everyLine"|"standardSingleLine"|"standardEveryLine"}` sets how a text +object's lines are broken and whether complex scripts are shaped (the Paragraph panel menu's four composers: Middle +Eastern & South Asian Single-line and Every-line, and Standard Single-line and Every-line; stored in the paragraph +attributes as `para.composer`, saved only when it is not Every-line, which is the default and what all type used before the +Standard composers existed). `singleLine` and `everyLine` shape Indic, Arabic and Hebrew type with the font's layout +tables, as all type is set; `standardSingleLine` and `standardEveryLine` break lines the same way but set such type +glyph by glyph as the font maps its characters (no conjuncts, reordering, mark placement or joining forms), for legacy +fonts that keep a script on Latin character codes. The scripts a Standard composer leaves unshaped are Indic, Arabic and the +other joining scripts (Syriac, N'Ko, Mongolian, Adlam…) and Hebrew; Latin, Greek, Cyrillic, CJK, Thai and the rest keep their +kerning and ligatures under every composer. Letters left unshaped do not join, so Insert Kashidas adds no tatweel between them. An older VectorCraft cannot open a file that uses a Standard composer. `.ai`, EPS and PDF files carry no composer. +Every-line, the default, picks the breaks of the whole paragraph together in area type: justified text gets even word spacing, ragged text (left, centre or right aligned) an even rag, so it may move a word that fits to the next line to avoid a lone short word on the last line or a full line next to a short one. Single-line fills each line as far as it goes. Point type and type on a path have no line width, so both composers @@ -11251,8 +11459,10 @@ new height and broken again in the room there. When that leaves the taller chara set at its own height where the room there is no narrower, with the words before that character that fit there, and otherwise stays where it was placed. The UI command `type.lineComposer` (Cmd+Alt+Shift+C, in remote mode `press_key {key: "C", mods: {cmd: true, alt: true, -shift: true}}` or `invoke_menu`) sets `singleLine` when every paragraph of the selected type (or every paragraph the -Type tool's selection touches) uses Every-line, else `everyLine`, and returns the composer it set. +shift: true}}` or `invoke_menu`) sets the Single-line form when every paragraph of the selected type (or every paragraph the +Type tool's selection touches) uses an Every-line one, else the Every-line form. Paragraphs that all use a Standard composer +keep it (`standardSingleLine` or `standardEveryLine`); any other mix takes the Middle Eastern & South Asian one (`singleLine` +or `everyLine`). It returns the composer it set. ```json {"name":"run_command","arguments":{"command":"text.setFormat","params":{"ids":[7],"composer":"singleLine"}}} @@ -11361,7 +11571,11 @@ that starts with `/version`) without changing anything. Give exactly one of `pat the set's name (`set`, else the file's name, else empty), its `version`, and its `actions`, each with its `name` and `steps`. A step lists its `name` (the file's `/internalName`), its `label` (the `/localizedName` it is shown with, or null when it has none or it is blank), `on` (false when the file sets `/isOn 0`), `params` (how many recorded values it -has) and its `line`. `unread` lists the tokens the reader skipped, with their lines. The reader stops at 8 MiB, 16 open +has), `values` and its `line`. `values` lists the step's recorded values in order, each as `{key, type, value, bytes?}`: +the block's `/key` and `/type` (null when it has none), and its `/value` when that is a whole number, or text in +parentheses or hexadecimal text that is UTF-8, with `bytes` giving the text's length in bytes; any other value, and text +that is not UTF-8, is null. Only the first 100,000 recorded values in the set are listed, so a step's `values` can be +shorter than its `params`. `unread` lists the tokens the reader skipped, with their lines. The reader stops at 8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action and 100,000 steps in the set, and cuts names at 1,024 bytes. It sets `truncated` when it reaches one of these limits, 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 @@ -11376,8 +11590,12 @@ label, in English or in the interface language, in any case and without a final that label and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (Group, Ungroup). A submenu's items, file and document commands, Paste, Undo, Redo and add-on commands are never matched. The step runs with no params, and a step that is off in the file is excluded from -playback. Other steps are left out, including every step with no label and every step recorded with values (recorded -menu items too). The reply lists the first 1,000 in `skipped` (`{action, step}`, the step by its label, else its name), +playback. A Set Selection step, found by its `/internalName` whatever its label, comes in as a Select Object step +(`select.byNote` with `{note, caseSensitive, wholeWord}`) when it has exactly three recorded values: the note (key +`text`, 1952807028, of type `ustring`, UTF-8), Whole Word (key `word`, 2003792484) and Case Sensitive (key `case`, +1667330917), each of type `boolean` and 0 or 1. The note loses its leading and trailing spaces and must then be 1 to +1,024 bytes long. A Set Selection step with any other values is left out; no option is filled in. Other steps are left +out, including every other step with no label and every other step recorded with values (recorded menu items too). The reply lists the first 1,000 in `skipped` (`{action, step}`, the step by its label, else its name), `leftOut` counts them, and the status bar says how many were left out. An action with no name is called `Action N`, by its place in the file. Function keys and colors are not read. A file marked `truncated` is refused, and no set is added. Text that starts `/version` and is not an action set gives "not an action set file". Tokens in `unread` do not stop a @@ -11396,9 +11614,10 @@ Plug-ins › Artwork Cleanup and in its panel (`window.panel {panel: "artworkCle `addon.cleanup.simplify {tolerance?, protectCorners?, ids?}` removes the anchors each selected path can do without and refits the handles beside each one, so the outline keeps its shape. `tolerance` runs from 1 to 100 (20 by default); the outline stays within tolerance × 0.02 pt of where it was, measured against the original outline, so removals don't add -up. With `protectCorners`, anchors where the path turns by more than 30° stay. Open ends always stay. Locked and hidden -paths, and paths in locked or hidden groups, are left out. It reports the anchor counts `before` and `after`, as one -undo step. +up. With `protectCorners`, anchors where the path turns by more than 30° stay. Open ends always stay. From the +selection, locked and hidden paths, and paths in locked or hidden groups, are left out. With `ids`, it also takes the +locked and hidden paths of the objects, groups and layers the ids name, but not paths on a locked artboard. It reports +the paths it took as `ids` and their anchor counts `before` and `after`, as one undo step when a path changed. The health check looks for issues in the selection, or in the whole document while nothing is selected (`scope: "selection"|"document"` picks one). Locked and hidden objects, and the insides of live or generated art (blends, @@ -11432,13 +11651,17 @@ envelopes, graphs, symbols), are left out. `addon.cleanup.deepUngroup {includeClipGroups?, ids?}` takes apart every level of grouping in the selection. Clipping groups stay whole unless `includeClipGroups` is true; their clipping paths then become plain paths. Objects keep the -opacity of the groups they were in. Locked and hidden groups stay whole, and locked and hidden objects stay locked or -hidden and are left out of the selection. +opacity of the groups they were in. From the selection, locked and hidden groups stay whole. With `ids`, it also takes +apart the locked and hidden groups the ids name and the groups inside the groups and layers they name (a named layer +stays a layer), but not groups on a locked artboard; an object of a hidden group becomes hidden, and an object of a +locked group becomes locked. Locked and hidden objects stay locked or hidden and are left out of the selection. `addon.cleanup.rejoin {gap?, ids?}` welds the selected open paths whose ends lie within `gap` (0 to 6 pt, 0.1 by default) back into paths. Each end joins one other end: where several are in reach, the one the path turns least to go on into. Pieces may run either way, and a chain whose ends meet closes. Each new path takes the place and paint of its -topmost piece. +topmost piece. With `ids`, it also takes locked and hidden open paths, but not paths on a locked artboard. Only pieces +that are alike in being hidden and in being locked (by themselves or by a group or layer above them) join, and locked +and hidden paths it makes are not selected. ```json {"name":"run_command","arguments":{"command":"addon.cleanup.check","params":{"scope":"document"}}} @@ -12054,8 +12277,10 @@ overlapping the artboard or the centre on it), `inside` (the bounds wholly withi straddling two touching artboards is inside neither) or `outside` (on none of them). It looks at the objects Select All takes, or the isolated container's in isolation mode, so 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. It -returns the `count` selected. An empty list, an index with no artboard, a value that isn't an index, or another -relation is an error, and the selection is left as it was. +returns the `count` selected. An empty list, an index with no artboard (`no such artboard`), a value that isn't a list +("`artboards` must be a list of artboard indices"), an entry that isn't a whole number of 0 or more ("`artboards` must +contain whole numbers", checked before any entry is looked up), or another relation is an error, and the selection is +left as it was. ```json {"name":"run_command","arguments":{"command":"addon.select.byArtboards","params":{"artboards":[2,6,11],"relation":"inside"}}} diff --git a/docs/plugins.md b/docs/plugins.md index dd3df7f7ac..2702e4fe7b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -804,9 +804,10 @@ is a piece drawn by itself, in the order returned, with paints of its own: anchors, 256 fills and 256 strokes per piece, 256 stops per gradient), finite numbers in range with nothing clamped (color components, tints, offsets, opacities, midpoints 0.13 to 0.87, stops in order of `offset`, stroke widths 0 to 1,000 pt, miter limits 1 to 500), and every swatch a solid paint, gradient or stop links to and every - pattern named in the document. One fault refuses the whole result: that step of the stack draws its input as it was, - the effect record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for example `Test Tone: - plug-in output: objects[1].strokes[0].width: …`. + pattern named in the document. A gradient stop's `plates` need that stop's own link to a spot swatch of the document; + the gradient's `swatch` does not link its stops. One fault refuses the whole result: that step of the stack draws + its input as it was, the effect record stays, and `plugin.info`'s `lastError` names the plug-in and the field, for + example `Test Tone: plug-in output: objects[1].strokes[0].width: …`. - The pieces are never written into the document while it is drawn, measured, previewed, saved or exported. PDF and SVG export draw them as the canvas does, from a copy of the document. Object › Expand Appearance writes each piece as an ordinary path or compound path with its fills and strokes, as one undo step. Object › Expand of gradient and pattern diff --git a/docs/roadmap.md b/docs/roadmap.md index 48def446ea..742fef93ed 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (M10/G5: Neon & Glow built-in graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); previously: Tech Circuit brush library, G5; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (PDF import: stroked type keeps the line cap, line join and miter limit the file strokes it with, a glyph stroked otherwise than the run it joins coming in as a run of its own, #7741; M10/G5: Neon & Glow built-in graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); previously: Tech Circuit brush library, G5; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -159,7 +159,7 @@ Gujarati interface follow-up (2026-11-14): the catalog and zero/one plural rule | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Rename artboards in Export for Screens (#7275); Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF import: stroked type keeps the file's line cap, line join and miter limit (#7741; estimates unchanged); Rename artboards in Export for Screens (#7275); Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | @@ -207,6 +207,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-15 | minor | PDF import: stroked type (text rendering modes 1, 2, 5 and 6, and glyphs under `ActualText`) keeps the line cap, line join and miter limit of the graphics state it is stroked with, as paths do, instead of butt caps, miter joins and a miter limit of 10; a glyph filled and then stroked otherwise than the run its fill joined comes in as a run of its own, so the glyphs before it keep their stroke (#7741; `imported_stroked_type_keeps_the_files_cap_join_and_miter_limit` in `crates/pdf/src/tests/textstroke.rs`). Scores and estimates unchanged. | | 2026-11-15 | minor | M10/G5: Neon & Glow built-in graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); estimates unchanged | | 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9dc030cb02..d6862db9b6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (G5: Neon & Glow joins built-in graphic style libraries (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); scores and estimates unchanged; previously: Pencil ignores Smart Guides and Snap to Point and shows no Smart Guides, #7252; previously: Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; previously: G5: Weather Icons joins built-in symbol libraries (Sun, Moon, Cloud, Rain, Thunder, Snow); scores and estimates unchanged; previously: a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (Paragraph composers (#4935): the Paragraph panel menu and paragraph styles offer four composers, the Middle Eastern & South Asian Single-line and Every-line (how all type is set, and the default) and a Standard Single-line and Every-line that set Indic, Arabic and Hebrew type glyph by glyph as the font maps it, for legacy fonts that keep a script on Latin character codes; PDF import: stroked type keeps the line cap, line join and miter limit the file strokes it with, a glyph stroked otherwise than the run it joins coming in as a run of its own, #7741; G5: Neon & Glow joins built-in graphic style libraries (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); scores and estimates unchanged; previously: Pencil ignores Smart Guides and Snap to Point and shows no Smart Guides, #7252; previously: Pencil continuation, straight-line and closing cursors; scores and estimates unchanged; previously: G5: Weather Icons joins built-in symbol libraries (Sun, Moon, Cloud, Rain, Thunder, Snow); scores and estimates unchanged; previously: a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Tech Circuit update (2026-11-14): three original procedural brushes; ten built-in brush libraries (G5). > @@ -61,6 +61,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-15 | minor | PDF import: stroked type (text rendering modes 1, 2, 5 and 6, and glyphs under `ActualText`) keeps the line cap, line join and miter limit of the graphics state it is stroked with, as paths do, instead of butt caps, miter joins and a miter limit of 10; a glyph filled and then stroked otherwise than the run its fill joined comes in as a run of its own, so the glyphs before it keep their stroke (#7741; `imported_stroked_type_keeps_the_files_cap_join_and_miter_limit` in `crates/pdf/src/tests/textstroke.rs`). Scores and estimates unchanged. | | 2026-11-15 | minor | G5: Neon & Glow joins Shadows and Glows, Outlines and Rules, Hand-Drawn, Textures, Painted and Sketched, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD and Status Effects as an eleventh built-in graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor; original procedural styles generated in code); area score and estimate unchanged. | | 2026-11-14 | minor | G5: Weather Icons joins Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons as a sixth built-in symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow; original procedural vector symbols generated in code, no default 9-slice); area score and estimate unchanged. | | 2026-11-14 | minor | G5: Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. | @@ -246,7 +247,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (ten generated libraries including Tech Circuit, Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | -| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | +| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout (the Middle Eastern & South Asian and Standard composers are done per paragraph, #4935; `.ai`/EPS/PDF files carry no composer), optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons and Weather Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | @@ -261,9 +262,9 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers row menu commands beyond Collect for Export and menus on guide and blend spine rows (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); DXF export's Include Placed Images and Include Fill Hatches options, off in CNC / CAM (`images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`; score, gaps and hours unchanged); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects other than grouped column, stacked column, line and area graphs the `.ai` reader reads back whole as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. Graphs saved to `.ai` in either form open again as live graphs (`ai_editing::tests::graphs_are_written_with_their_records_and_open_live`, `legacy_ai::tests::graphs_are_written_with_their_records_and_open_live`, `cmd::graph::tests::a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns`); other graphs are still written as their art (`graphs_the_records_cant_keep_are_written_as_their_art`); score and hours unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects other than grouped column, stacked column, line and area graphs the `.ai` reader reads back whole as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. Graphs saved to `.ai` in either form open again as live graphs (`ai_editing::tests::graphs_are_written_with_their_records_and_open_live`, `legacy_ai::tests::graphs_are_written_with_their_records_and_open_live`, `cmd::graph::tests::a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns`); other graphs are still written as their art (`graphs_the_records_cant_keep_are_written_as_their_art`); score and hours unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); DXF export's Include Placed Images and Include Fill Hatches options, off in CNC / CAM (`images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`; score, gaps and hours unchanged); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. PDF import: stroked type (text rendering modes 1, 2, 5 and 6) keeps the file's line cap, line join and miter limit, as paths do (#7741, `imported_stroked_type_keeps_the_files_cap_join_and_miter_limit` in `crates/pdf/src/tests/textstroke.rs`); score, gaps and hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects other than grouped column, stacked column, line and area graphs the `.ai` reader reads back whole as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. Graphs saved to `.ai` in either form open again as live graphs (`ai_editing::tests::graphs_are_written_with_their_records_and_open_live`, `legacy_ai::tests::graphs_are_written_with_their_records_and_open_live`, `cmd::graph::tests::a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns`); other graphs are still written as their art (`graphs_the_records_cant_keep_are_written_as_their_art`); score and hours unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. PDF import: stroked type (text rendering modes 1, 2, 5 and 6) keeps the file's line cap, line join and miter limit, as paths do (#7741, `imported_stroked_type_keeps_the_files_cap_join_and_miter_limit` in `crates/pdf/src/tests/textstroke.rs`); score, gaps and hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects other than grouped column, stacked column, line and area graphs the `.ai` reader reads back whole as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. Graphs saved to `.ai` in either form open again as live graphs (`ai_editing::tests::graphs_are_written_with_their_records_and_open_live`, `legacy_ai::tests::graphs_are_written_with_their_records_and_open_live`, `cmd::graph::tests::a_drawn_graph_saved_as_ai_opens_as_a_graph_unless_the_save_warns`); other graphs are still written as their art (`graphs_the_records_cant_keep_are_written_as_their_art`); score and hours unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. Legacy `.ai` type keeps its manual kerning pairs (`Tk`, `TK`), discretionary hyphens (`T+`, `T-`) and hyphenation language (`XL`) when it opens, and legacy `.ai` saves write manual kerning as `Tk` and soft hyphens as `T+` (`import::ai::tests::legacy_manual_pair_kerning_is_kept`, `legacy_ai::tests::manual_kerning_and_soft_hyphens_are_written_and_read_back`); score, gaps and hours unchanged. PDF import: stroked type (text rendering modes 1, 2, 5 and 6) keeps the file's line cap, line join and miter limit, as paths do (#7741, `imported_stroked_type_keeps_the_files_cap_join_and_miter_limit` in `crates/pdf/src/tests/textstroke.rs`); score, gaps and hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658) | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | @@ -520,13 +521,13 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (its engine, custom dictionary commands, the Edit Custom Dictionary dialog and Auto Spell Check are done, #4936: the Check Spelling dialog remains; a right-click on an underlined word offers its suggestions, Ignore All and Add to Dictionary, and a right-click on type has Spelling › Auto Spell Check), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | -| Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | +| Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (editable bidirectional SVG text, a split caret where directions meet; the Middle Eastern & South Asian composers are done, #4935, but `.ai`/EPS/PDF files carry no composer); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons and Weather Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, row menu commands beyond Collect for Export (now on every layer, sublayer, group and object row), menus on guide and blend spine rows, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812; distance and angle labels while drawing, #2983) | 2–4; status-bar X/Y uses the active ruler origin and General units | -| File formats | 6 | 85% | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Legacy `.ai` files that name but omit their prolog open with their layers, or with their art on one layer when the format has no layers; graph data and settings in these files still come in only as drawn art. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | +| File formats | 6 | 85% | PDF import: stroked type keeps the file's line cap, line join and miter limit (#7741; scores and estimates unchanged); PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Legacy `.ai` files that name but omit their prolog open with their layers, or with their art on one layer when the format has no layers; graph data and settings in these files still come in only as drawn art. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | @@ -579,6 +580,7 @@ since 2026-10-01. - **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, suggesting the original name and format, preserving options for the unchanged format and opening the chosen format’s Save options on a change. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; focused UI regressions passed; engine regression execution and native panel acceptance pending. Scores and hours unchanged. - **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h. +- **PDF stroked type (#7741):** `imported_stroked_type_keeps_the_files_cap_join_and_miter_limit` in `crates/pdf/src/tests/textstroke.rs` covers round, projecting and butt caps, round, bevel and miter joins and a miter limit of 3 on type stroked in rendering modes 1, 2, 5 and 6, shown as a word, a glyph at a time and under `ActualText`; glyphs stroked with another join as runs of their own; and a glyph filled and stroked after one stroked otherwise, which keeps the join of the glyph before it. Synthetic PDFs written in the test (standard Helvetica, not embedded); no fonts or fixtures added. The dash array is not yet carried to the characters. - **PDF Indic type (#5235, #6906):** `crates/pdf/src/tests/reshaped.rs` covers a vowel sign the file draws before its consonant (the line keeps the file's glyphs as outlines, with a note), text in reading order (stays type), a mark with no advance (stays on its line, not vertical type of its own), outlined glyphs stroked with different dashes and translucent outlined glyphs (each keeps its own path). Synthetic font only; one real Gujarati file checked by eye on page 1. Indic type in an uninstalled font keeps the file's glyphs as outlines; reordering signs so more lines stay editable is not done. Scores and hours stay at 75% / 10-20 h. @@ -590,6 +592,7 @@ since 2026-10-01. - 3D lighting widget: the Lighting tab of the 3D and Materials panel shows every light on a sphere at its Rotation and Height; a drag moves a light in one undo step, a press selects it, Move Light to Back and Move Light to Front change its side, and the arrow keys move the focused light ([three-d.md](three-d.md#lighting-widget)). `crates/ui-egui/src/panels/tests_three_d.rs` covers the drag, selection, side and keyboard. - 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged. - 3D shadows: with Shadows on, Revolve, Extrude & Bevel, Inflate and Rotate cast the shadow from the faces kept by the build that draws each solid, so drawing an object builds each of its solids once. The art and the shadow are unchanged. Evidence: `crates/three-d/src/shadow/tests.rs` (`a_solid_and_its_shadow_are_built_once` and the tests that compare each solid and its shadow with the two built apart), `solids::tests_shadow::a_shadowed_3d_object_builds_each_solid_once`, `solids::tests_shadow::the_shadow_cast_from_the_solids_drawn_is_the_shadow_of_solids_built_for_it`, `revolve::tests::the_shadow_cast_from_the_surfaces_drawn_is_the_shadow_of_surfaces_swept_for_it`. Scores unchanged. +- 3D shadows: in Revolve, Extrude & Bevel, Inflate and Rotate, a fill, stroke or member whose opacity is NaN or infinite casts no part of the shadow, as one at 0 % does, whether the shadow is cast from the solids drawn or from solids built for it alone; the art beside it still casts. This is VectorCraft's own rule. Evidence: `solids::tests_shadow::art_whose_opacity_is_not_a_finite_number_casts_no_shadow`, `revolve::tests::a_surface_whose_opacity_is_not_a_finite_number_casts_no_shadow`. Scores unchanged. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. - 3D and Materials › Quick Actions › Expand as Wireframes (#6354): `effect.expandAsWireframes` replaces each selected 3D object with a group of 0.5 pt black stroked paths, the edges of the faces its effect draws, each edge once and joined end to end, curves kept, in one undo step ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs`, `crates/engine/src/cmd/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged. - 3D and Materials › Render Settings › Wireframe: `render: {mode: "wireframe"}` on Extrude & Bevel, Revolve, Inflate and Rotate draws the live effect as the edges of its faces, 0.5 pt black strokes with no shadow, in every output and in Expand Appearance ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged. @@ -626,8 +629,9 @@ since 2026-10-01. - **Selected Grid Repeat handles (#2691):** `tools/src/repeat_grid.rs` tests place the handles outside the grid at zoom 0.5, 1 and 4, preview rows, columns and spacing, leave no undo step for a drag that ends where it began, and give bounding-box handles priority; a headless canvas regression drags the row handle two tiles down in a zoomed view (3 → 5 rows, the source unchanged, one Undo back) and the column-spacing slider. Scores and hours unchanged. - **Selected Mirror Repeat handles:** a headless canvas regression creates the default mirror repeat and drags its Top bounding-box handle where the axis crosses it; the repeat grows vertically while its width stays fixed. Existing mirror-axis checks cover center movement and end-handle hit priority. The focused UI mirror tests pass. Scores and hours unchanged. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. -- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. Scores and hours unchanged. +- **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; the angles and light directions of pixel filters do not turn or reflect with the art. Scores and hours unchanged. - **Raster effects before a Transform effect that makes copies:** the Transform effect can make copies with its transformation applied ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html)). Giving each copy the raster effects listed before that effect is VectorCraft's own rule. On a path or compound path, each copy now carries the Drop Shadow, glow, blur, Feather or pixel filter listed before the last Transform effect that makes copies, with its sizes scaled by that copy's mean scale and a Drop Shadow's offset turned, reflected and scaled with the copy; a raster effect listed after it paints once around all the copies. Before, one shadow was drawn under all the copies at the object's own distances. The canvas, the object's bounds, export and Expand Appearance draw the same copies, and copies that stop at the effect budget each keep their effects (effects `tests::rasterfollow::each_copy_of_a_transform_effect_carries_the_shadow_listed_before_it`, `tests::rasterfollow::a_shadow_after_a_copying_transform_paints_once_around_the_copies`, `tests::rasterfollow::copies_past_the_budget_each_keep_their_shadow`; render `objectfx::each_copy_of_a_transform_effect_draws_its_own_shadow`; engine `objexpand::each_copys_shadow_expands_as_the_canvas_draws_it`). A Transform effect without copies draws as before. Not yet: the copies of type, images, symbol instances and groups, and a fill's or stroke's own copying Transform effect, still share one set of raster effects, and so does a path with Rasterize, an SVG filter or another effect that is neither a geometry effect nor one of those raster effects before the copying effect. Scores and hours unchanged. +- **Rasterize before a Transform effect:** effects apply in the order they are listed, each one taking the result of the one before ([the help page](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). On a path or compound path, a Transform effect listed after the object's own Rasterize effect now moves the image that Rasterize makes: the image is made of the art before the Transform effect, at the Rasterize effect's resolution and with its room around the art, and the Transform effect scales, turns, reflects and moves the image with its pixel grid. Before, the Transform effect moved the art and Rasterize made a new image of the moved art on a grid square to the page. Keeping the image's pixel grid under the Transform effect is VectorCraft's own rule. A hidden Transform effect, or one with Transform Objects off, leaves the image where it is, and one that scales it to nothing leaves nothing drawn. The canvas, the object's bounds, Expand Appearance and PDF export draw the same image (effects `tests::rasterfollow::rasterize_following_ignores_hidden_and_objects_off`, `tests::rasterfollow::the_image_moves_by_each_transform_about_the_art_it_takes`; render `rasterize::rasterize_then_scale_uses_the_pretransform_pixels`, `rasterize::rasterize_then_rotate_and_reflect_moves_the_pixel_grid`, `rasterize::rasterize_then_nonuniform_scale_and_zero_scale_are_bounded`; engine `rastersettings::expand_rasterize_then_transform_matches_canvas`). Not yet: a Transform effect that makes copies or moves at random, a Transform effect followed by another kind of effect, a fill's or stroke's own Rasterize effect, and Rasterize on type, images, symbol instances and groups keep the earlier behavior. Scores and hours unchanged. - **Raster effects before a Transform effect past the effect budget:** VectorCraft leaves out a Transform effect whose result would pass its geometry budget of 2 million anchors or 8 million lines drawn, and the art stays where it was. The raster effects listed before that Transform effect now stay with the art too: a Drop Shadow keeps its own offset and blur, and the Transform effects after it that fit still move both. Before, the art stayed while the shadow scaled with the Transform effect it did not take. This is VectorCraft's own rule. It applies to a path's own effects and to its fills' and strokes' own, also when the path is in a group whose geometry effects run after its members' own, on the canvas and in its bounds, in export, Expand Appearance, Object › Expand and Outline Stroke, and a hidden shadow keeps its values once shown (effects `tests::rasterfollow::a_transform_past_the_line_budget_moves_the_art_with_its_shadow`, `tests::rasterfollow::only_transforms_that_ran_move_a_preceding_shadow`, `tests::rasterfollow::a_skipped_item_transform_keeps_its_shadow`, `tests::rasterfollow::the_shape_art_of_a_skipped_transform_keeps_its_shadows`, `tests::rasterfollow::a_members_skipped_transform_keeps_its_shadow_under_its_groups_effects`; render `objectfx::a_shadow_before_a_skipped_transform_reaches_as_far_as_it_is_drawn`, `fx::renderer::tests::a_path_paints_from_the_transforms_that_moved_it`; engine `objexpand::a_shadow_before_a_skipped_transform_expands_with_its_own_offset`). Not yet: the shadow of type, an image, a symbol instance or a group whose art a Transform effect leaves in place past the budget still follows that effect. Scores and hours unchanged. - **Raster effects inside an object or group with a Transform effect:** effects applied to a group affect everything in it ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). How a Transform effect changes the raster effects inside a group or object is VectorCraft's own rule. A group's Transform effect that moves it once now transforms the raster effects of its members, of their fills and strokes, of nested groups and of the art of the linked opacity masks in it, after the members' own Transform effects. A raster effect that a member's own Transform effect with copies or random holds back keeps its value through that effect and still transforms with the group's. VectorCraft applies the same rule to the raster effects of an object's fills and strokes under the object's Transform effect. Sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, with Scale Strokes & Effects on or off. Before, they kept their values, or with Scale Strokes & Effects on scaled by the mean scale with the offset unturned. A group's or object's Transform effect that makes copies or moves at random leaves them as before. The canvas, the redraw region, export, Expand Appearance, Object › Expand and Outline Stroke use the same values (effects `tests::rasterfollow`, `group::tests::a_compound_shapes_transform_turns_its_fills_shadow`; render `a_transform_scales_the_shadows_of_fills_and_members_with_the_art`; engine `objexpand::a_fills_shadow_under_the_objects_transform_expands_as_the_canvas_draws_it`, `objexpand::shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canvas_draws_them`, `outlinestroke::a_strokes_shadow_under_the_objects_transform_outlines_as_the_canvas_draws_it`). Scores and hours unchanged. - **A group's geometry effects after its members' own:** effects applied to a group affect everything in it, and effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). Running a group's effects after its members' own is VectorCraft's own rule. The geometry effects of a group or layer (the effects that reshape outlines, such as Distort & Transform, Offset Path and Warp) now reshape what its members draw: the geometry effects of each visible path and compound path in it and of their fills and strokes, and a nested group's own geometry and Pathfinder effects, run first, at any depth. A symbol instance's own geometry effects run after those of its symbol's members in the same way. Before, the group's effects reshaped the members' outlines and the members' own effects ran afterwards: a 100 pt square with its own 10 pt Offset Path in a group with a 50% Transform drew 70 pt wide, and it now draws 60 pt wide. Existing documents with such groups draw differently. The canvas, export, Expand Appearance and the art that Pathfinder operations and compound shapes take from such a group use the same order (effects `group::tests::a_members_own_offset_runs_before_its_groups_scale`, `group::tests::a_members_own_scale_runs_before_its_groups_offset`, `group::tests::a_member_of_a_plain_nested_group_takes_its_own_effects_first`, `group::tests::a_nested_groups_own_effects_run_before_its_groups`, `group::tests::a_nested_groups_pathfinder_runs_before_its_groups_effects`, `group::tests::a_members_fill_effects_run_before_its_groups`, `group::tests::shape_art_of_a_group_applies_its_members_effects_first`, `group::tests::a_symbol_instances_effects_run_after_its_symbols_members_own`, `group::tests::a_baked_group_applies_its_members_effects_first`, `group::tests::a_baked_group_that_keeps_its_type_applies_its_members_effects_first`, `tests::rasterfollow::a_groups_transform_applies_after_its_members_own`; engine `objexpand::a_groups_effects_after_its_members_own_expand_as_the_canvas_draws_them`). Not yet: the own geometry effects of images, symbol instances, paths with a linked opacity mask or a 3D effect, and nested groups with a 3D effect in the group still run after the group's. Scores and hours unchanged. @@ -640,7 +644,9 @@ since 2026-10-01. - **`.ai` files keep a linked image's Warp live and its link:** a linked image whose appearance is one Warp effect, with no opacity mask, is saved to the `.ai` editing data with an art style of that Warp, as an embedded one is: its bent pieces are its drawn look, and the image itself follows on `%_` lines as a placed file with its path, its folder relative to the document's and its preview. Before, it was saved as its drawn art and its live Warp was lost. Opening the file reads it as the linked image with its Warp live, its link, pixels, placement, name, opacity, hide and lock kept, under the same drawn-look comparison; a hidden image with a Warp, embedded or linked, is now compared with its look drawn shown, and keeps its Warp too. A linked image with a Warp in an opacity mask's art keeps its Warp and is embedded, with the existing warning for linked images in masks. Linked files stay connected to their source and track it by relative path ([the help page](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/manage-linked-and-embedded-files/links-panel-overview.html)); the placed-file form is the one the `.ai` reader reads (`crates/eps/src/import/ai/read/art.rs` `end_place`). eps `ai_editing::tests::a_linked_image_with_a_warp_reads_back_linked_with_its_warp_live`, `a_large_linked_image_with_a_warp_reads_back_from_its_preview`, `a_hidden_or_locked_image_with_a_warp_keeps_its_state`, `a_linked_image_with_a_warp_in_a_mask_is_embedded_with_its_warp_live`. Scores and hours unchanged. - **EPS writes a repeated image's samples once:** an image drawn more than once in an EPS file (the pieces of an envelope or a Warp effect, copies of one image, or one image in a symbol's instances) has its compressed samples written once in the setup, and each placement reads them from there: a reusable stream rewound for each placement at Language Level 3 ([PostScript Language Reference, 3rd ed., §3.13](https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf)), and an array of strings of at most 49,152 bytes fed to the image by a procedure at Level 2. An image drawn once is written where it is drawn, as before. The 256 MB limit on the page counts the samples once, so an envelope of a large photo no longer loses pieces to it. A colorized image is reused per ink (eps `tests::an_image_placed_twice_writes_its_samples_once_at_both_levels`, `tests::an_envelopes_image_pieces_draw_one_copy_of_its_samples`, `tests::each_ink_of_a_colorized_image_placed_twice_is_written_once`, `tests::samples_longer_than_a_string_are_reused_at_both_levels`, `tests::the_page_limit_counts_a_repeated_images_samples_once`). Scores and hours unchanged. - **`.ai` files write a warped image's pieces as the parts they show:** in the `.ai` editing data and in legacy `.ai` files, an embedded image cut to a clipping path (in the editing data, also an image under an opacity mask that clips) is written with all of its pixels the first time, and every later copy of the same pixels is written as the rectangle of them that the path or mask shows, 2 pixels more on each side, placed where it lies in the image. The pieces of a Warp effect, an envelope or their expanded art, 200 copies of the image for 10 × 10 cells before, now hold about 7 times the image's pixels in all for a 64 × 64 image. A copy whose opacity mask is left out, nested too deep or past the object limit, keeps all of its pixels. Each image's pixels are read once for the pieces that follow one another. An image cut to a rectangle of half its pixels or more, and the first copy written, keep all of their pixels. The `XI` operator's arguments give an image's location and size ([AI file-format specification, §8.1](https://web.archive.org/web/20050126105313/https://partners.adobe.com/public/developer/en/illustrator/sdk/AI7FileFormat.pdf)); the 2-pixel margin and the half-the-pixels limit are VectorCraft's own rules (eps `ai_editing::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `masked_pieces_of_a_warped_image_are_written_as_the_parts_they_show`, `an_image_with_a_live_warp_writes_its_pixels_whole_once`, `a_clipped_image_written_first_keeps_all_of_its_pixels`, `a_masked_copy_whose_mask_is_left_out_keeps_all_of_its_pixels`, `a_masked_copy_nested_too_deep_for_its_mask_keeps_all_of_its_pixels`, `legacy_ai::tests::the_pieces_of_a_warped_image_are_written_as_the_parts_they_show`). Scores and hours unchanged. +- **Legacy `.ai` files work out a colorized warped image's coverage once:** for the pieces of a colorized image cut by a Warp effect, an envelope or their expanded art, a legacy `.ai` save works out how much of the color each pixel shows once for the pieces that follow one another, and no longer decodes the image again for each piece. The file written is unchanged (eps `legacy_ai::tests::the_pieces_of_a_colorized_warped_image_work_out_its_coverage_once`). Scores and hours unchanged. - **EPS writes a repeated freeform gradient's image once:** a freeform gradient whose image samples the same on more than one fill (copies of a shape moved with their points, or one shape in a symbol's instances) has its samples written once in the setup, as a repeated image's are, and a fill whose image differs (other colors, another size) gets its own. A freeform fill drawn once is written where it is drawn, as before, and a fill whose placement cannot be drawn, or that arrives when the page is already at the 256 MB limit, is no longer sampled first (eps `tests::a_freeform_gradients_image_drawn_twice_writes_its_samples_once`, `tests::freeform_gradients_sampled_differently_dont_share_an_image`, `tests::a_field_taken_back_at_the_page_limit_leaves_no_image_behind`). Scores and hours unchanged. +- **EPS releases the images of art left out at the page limit:** art taken back because it passes the 256 MB limit on the page no longer keeps the encoded samples of the placed images, colorized images and freeform fills it drew, and an image whose first placement does not fit under the limit is not kept after it is encoded. Later placements of that image are left out with the same warning without encoding it again while it still does not fit; once art taken back leaves room, it is encoded again and written where it is drawn. The page the EPS file draws is the same as before; a repeated image or field written after art taken back can take a lower resource number in the setup (eps `tests::a_rolled_back_node_frees_its_image_codes`, `tests::a_rolled_back_image_does_not_pin_an_earlier_field_code`, `tests::an_image_rejected_by_the_page_limit_keeps_no_unused_code`, `tests::an_image_rejected_by_the_page_limit_is_encoded_once`, `tests::an_image_rejected_in_art_taken_back_is_encoded_again_when_it_fits`). Scores and hours unchanged. - **PDF writes a warped image once:** with downsampling on, the pieces of an image bent by a Warp effect are resampled for the widest and the highest size any piece places the image at, so every piece paints one image XObject (a colorized image: one colorized image and its soft mask), instead of one image for each size a piece is placed at. Another placement of the same image is still resampled for its own size. Without downsampling, a colorized image and a CMYK image with a grayscale image mask on its grid are decoded and encoded once for all the sizes they are placed at (pdf `tests::compression::a_warped_image_is_written_once_for_all_its_pieces`, `tests::colorize::a_colorized_warped_image_is_written_once_for_all_its_pieces`, `export::images::tests::colorized_and_grid_masked_images_are_made_once_for_every_size_without_downsampling`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. @@ -652,6 +658,7 @@ since 2026-10-01. - **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Selection passes through raster effects:** clicks with the Selection, Direct Selection and Group Selection tools (Group Selection's repeated click included), the Selection tool's pointer and marquee, Direct Selection's marquee, pointer and anchor highlight, and Group Selection's marquee (E for enclosed objects included) leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, a repeated Group Selection click there adds the groups of the object under it, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). Over a shadow, Direct Selection highlights only the anchors of the object under it, and Group Selection keeps its own pointer and highlights no anchor, as it does everywhere (VectorCraft's own rule). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. +- **Symbol instances are selected where their art draws past their box:** a click or a marquee selects a symbol instance where a clipping path in its art, moved or reshaped by its live geometry effects, shows the art past the instance's box, as it selects a group of the same art; a click also selects it where a stroke in its art paints past the box ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects.html)). The rest is VectorCraft's own rule: inside the box the instance is selected as before; in Outline view and below a layer whose Preview is off only the box counts; hidden art and the area a raster effect paints are left out. This covers instances in groups, nested in other symbols' art, moved and scaled. Regression tests: `a_symbol_instance_is_hit_where_a_moved_clipping_path_in_its_art_shows_the_art` and `a_symbol_instance_is_hit_on_the_stroke_of_its_art_past_its_box` in `crates/doc/src/hit/tests.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit leaves out groups holding type or images:** Select › Start Global Edit on a group that holds type or an image, at any depth, refuses to start, and such a group is not taken as a match, as Global Edit does not take images or text ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test for a group holding type: `global_edit_needs_one_supported_object` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. @@ -664,6 +671,7 @@ since 2026-10-01. - **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`. - **Control bar separators stand only between groups:** the Control bar draws a separator between two groups of options only when a control follows it in the bar. When the groups after a separator are off in the panel menu, do not apply to the selection or the tool, or have no room in the window, the bar ends with the last control and no separator; two separators with nothing between them show as one. Each separator keeps its room, so the groups keep their places. This is VectorCraft's own rule. Regression test: `no_separator_is_left_at_the_end_of_the_control_bar` in `crates/ui-egui/src/chrome/tests.rs`. - **Fill and Stroke and Opacity stay inside a narrow Control bar:** the Control bar leaves out a Fill and Stroke, Opacity or Graphic Style control it has no room for, whole, instead of drawing it past the panel menu button or under it. The bar draws its controls from left to right, each only when the room left still holds it, so a narrower control can still show after a wider one that was left out: the Opacity link with its field can show when the Stroke link with its weight field did not fit, the Style link with its chip can show when the Opacity link with its field did not, and with nothing selected Preferences can show when Document Setup did not fit. With something selected, the Fill and Stroke, Opacity, Graphic Style and Character controls show only while the Transform link still has room after them, so the bar keeps the link and leaves those controls out first. The Character controls show whole or not at all, and while they are left out the Paragraph, vertical alignment and Align controls after them are left out too. The Snapping menu, the selection's label, the Convert and Anchors groups, the isolation button, Document Setup and Preferences also show only while they fit, and a group separator only while it has room. This is VectorCraft's own rule. Regression tests: `the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window`, `a_narrow_control_bar_shows_the_style_link_without_the_opacity_field`, `a_narrow_control_bar_shows_preferences_without_document_setup` and `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`, `the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right` in `crates/ui-egui/src/panels/paragraph/tests.rs`. +- **Tool, image, trace, envelope, symbol and artboard controls stay inside a narrow Control bar:** a shape tool's options, the Dimension, Mirror & Cut, Puppet Warp and Crop Image tools' options, a linked or embedded image's controls, an Image Trace object's, a warp or mesh envelope's, symbol instances' and the Artboard tool's controls each show a control, or a label with its field or menu, only while the room left in the bar holds it and, with something selected, the Transform link's room after it. Once one is left out, the rest of its group is left out too, so each group loses controls from its right end; an image's file name too long for the room is left out alone, and the Artboard tool's Transform link shows whenever it fits. The panel menu button shows in every width, docked or floating. Buttons in these groups grow to hold a longer translated label instead of cutting it off, and the Transform link keeps room for its underline. While one of these controls is left out, Select Similar Objects is left out too. This is VectorCraft's own rule. Regression tests: `the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window` (in English and German, docked to the top or bottom and floating), `a_narrow_control_bar_shows_embed_without_edit_original_and_the_artboard_link_without_its_options` and `select_similar_stays_out_while_an_image_control_is_left_out` in `crates/ui-egui/src/chrome/tests.rs`. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`. @@ -694,10 +702,12 @@ since 2026-10-01. - **COLR colour fonts:** v0 and v1 glyphs draw as vector layers (solid colours, linear and radial gradients, including ones that repeat or reflect, clips, blend modes, the foreground colour from the run's fill) on the canvas, as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as Type 3 text in PDF, and as editable groups with Create Outlines; sweep gradients, Porter-Duff compositing and radial gradients that repeat or reflect and start at a circle draw the glyph as an image. Measured by `tests_colr`, `render::tests_colrglyphs`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colr_glyphs_*`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups`. Scores unchanged (estimated). - **OpenType SVG colour fonts:** each glyph's SVG document is read by the SVG importer (registered by the engine) into vector art in the run's colours, then drawn, exported and outlined as COLR glyphs are; PDF real text writes them as text that draws nothing and draws their art where they sit. Measured by `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`. Scores unchanged (estimated). - **Create Outlines keeps the type object's own appearance:** Type › Create Outlines and Object › Expand put the type object's own fills and strokes (the Appearance panel's items around its Characters row) on paths of the whole outline, below or above the glyph paths as that row places them, with one gradient across every glyph, so the outlines draw what the type drew; `object.expand.info` reports those strokes, pattern fills and gradient fills. Outlined characters keep their stroke and fill (the help page, ); a type object's appearance is separate from its characters' (the help page, ); where the object's items go is VectorCraft's own rule. Measured by `tests::pathops::create_outlines_keeps_the_objects_own_fills_and_strokes`, `tests::objexpand::create_outlines_draws_the_type_objects_own_paint`, `tests::objexpand::type_objects_own_stroke_pattern_and_gradient_are_reported`, `tests::objexpand::type_without_an_outline_reports_no_own_stroke_or_fill`, `tests::objexpand::a_stroke_under_an_unpainted_stroke_is_reported_and_outlined` and `tests::objexpand::expand_keeps_the_type_objects_own_paint`. Scores unchanged (estimated). +- **Create Outlines keeps the type object's own effects and transparency:** Type › Create Outlines gives the group of outlines the type object's own effects, once, in their order, with their settings and hidden effects still hidden, so they stay live and editable and the outlines draw what the type drew (a Warp or Free Distort effect moves a gradient on the characters across the whole outline, as on the type); the effects of the object's own fills and strokes stay on their paths of the whole outline. The group also takes the object's opacity, blend mode, Isolate Blending, Opacity & Mask Define Knockout Shape and opacity mask. Outlined characters keep their stroke and fill (the help page, ); putting the object's effects on the group is VectorCraft's own rule. Measured by `tests::pathops::create_outlines_keeps_the_objects_own_effects_on_the_group`, `tests::pathops::create_outlines_keeps_the_objects_transparency_and_opacity_mask`, `tests::objexpand::create_outlines_draws_the_type_objects_own_effects` and `tests::objexpand::create_outlines_keeps_a_warped_character_gradient_across_the_type`. Scores unchanged (estimated). - **Create Outlines keeps the characters' gradients across the type:** Type › Create Outlines and Object › Expand give every glyph, ruby glyph and underline or strikethrough bar the gradient its characters drew with: a gradient with no vector of its own stays fitted to the whole layout rather than to each glyph, and one with a vector keeps its place, both moved, scaled and rotated with the type; the characters' stroke keeps the weight it was drawn at on scaled type. `object.expand.info` measures gradient fills on the same paths. Outlined characters keep their stroke and fill (the help page, ); the gradient fitted across the whole layout is VectorCraft's own rule. Measured by `tests::objexpand::create_outlines_keeps_a_character_gradient_across_the_type` and `tests::objexpand::expand_keeps_a_character_gradient_across_the_type`. Scores unchanged (estimated). - **Create Outlines keeps the characters' stroke on unevenly scaled type:** on type scaled unevenly, sheared or flattened, Type › Create Outlines, Object › Expand and geometry effects on the type turn each character stroke into a filled path of the area the canvas strokes: laid out around the glyphs before the type's transform (dashes, caps, joins and inside or outside alignment), then transformed with them, so a stroke scaled 300% across is three times as thick across upright stems as across level bars. Moved, rotated, reflected and uniformly scaled type keeps a live stroke with its weight and dashes scaled. `object.expand.info` reports a gradient on such a filled stroke. Outlined characters keep their stroke and fill (the help page, ); turning the stroke into a filled path only under such transforms is VectorCraft's own rule. Measured by `tests::charstroke::create_outlines_keeps_the_character_strokes_thickness_under_an_uneven_scale`, `tests::charstroke::create_outlines_keeps_a_live_character_stroke_under_a_uniform_scale_and_rotation`, `tests::objexpand::type_reports_a_stroke_gradient_its_uneven_scale_turns_into_a_fill` and `tests::typestroke` (`crates/effects`). Scores unchanged (estimated). - **Composite fonts that two documents define differently:** when two open documents each define a composite font (Type › Composite Fonts…) by the same name with different parts, each document draws and exports its type in its own definition: in a tiled or floating window drawn while the front document draws, when it is placed in a document that defines the name differently, and in an export made while another document is in front. Evidence: `tests::composite::nested_loans_of_one_name_use_each_documents_definition` and `threads_lending_one_name_at_once_use_their_own_definitions` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` (`crates/render`). Scores unchanged (estimated). - **Composite fonts only another document defines:** a document draws, measures and exports its type only in the composite fonts it defines itself. Type naming a composite font that only another open document, or the document a placed document sits in, defines is set as in a missing font, also when the document defines no composite font: in a window, a thumbnail or an export of a document in the back, in a file being opened and in a placed document. A temporary document drawn while a document draws keeps that document's composite fonts. Evidence: `tests::composite::a_lent_document_does_not_use_another_documents_composite_font` and `a_document_without_composite_fonts_uses_none_and_a_preview_is_unchanged` (`crates/text`), `tests::fontchange::a_placed_document_draws_its_own_definition_of_a_name_the_page_defines` and `a_document_draws_type_in_another_documents_composite_font_as_missing` (`crates/render`), `a_document_replaced_in_the_back_measures_a_font_it_lacks_as_missing` (`crates/engine`). Scores unchanged (estimated). +- **Composite fonts' custom character sets:** Type › Composite Fonts… › Custom adds named character sets to a composite font, each setting the characters it lists in its own font, style, size, baseline and scales ahead of their class; the later of two sets that list a character sets it ([the help page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348)). Characters are added by Direct Entry or by Unicode code and deleted one at a time; the sets are saved with the document and in `.vccomposite` files (`custom` in `type.compositeFonts.set`). Evidence: `tests::composite::custom_character_sets_take_their_characters_first` (`crates/text`), `compositefonts::tests::custom_character_sets_set_their_characters` (`crates/engine`), `dialogs::composite_fonts::tests::custom_character_sets_are_edited_in_the_dialog` (`crates/ui-egui`). Scores unchanged (estimated). - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html). - **Character-style numeric bounds:** `charStyle.new`, `charStyle.edit`, and `charStyle.setAttrs` accept size from 0.1 to 1296 pt, explicit leading from 0.1 to 5000 pt, horizontal and vertical scale from 1% to 10,000%, and baseline shift and Tate-chu-yoko offsets from -1296 to 1296 pt. Out-of-range input leaves the style and Undo history unchanged; Auto leading remains available. These limits match the Character panel controls. - **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). @@ -756,6 +766,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Translucent art blended before the display profile:** with Use Monitor Profile for Canvas converting, translucent art and the anti-aliased edges of art over an artboard's paper, a colored artboard or the transparency grid are blended with it in sRGB and the blend is converted, so the monitor's profile translates the document's colors as they are composited ([the help page](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=151)). The frame rendered whole, the part an edit renders again, the render worker's frame, the pan fill and the other windows' panes do the same; over the pasteboard and in Outline the art is converted on its own, and with no profile to convert into nothing changes. Tests `display_profile::tests::translucent_pixels_over_the_paper_are_blended_before_they_are_converted`, `the_transparency_grid_is_blended_cell_by_cell`, `the_backdrop_is_found_through_the_view` and `canvas::tests::translucent_art_over_the_paper_is_blended_before_the_display_profile`. - **Out-of-gamut warning after a profile change:** the out-of-gamut warning and its closest-color correction in the Color panel and Color Picker ([the help page](https://helpx.adobe.com/illustrator/using/adjusting-colors.html)) check the color against the document's assigned CMYK profile, else the working CMYK space, and update when Edit › Assign Profile… or Edit › Color Settings… changes that space or a loaded profile changes, without a change of color. Which CMYK space the warning uses is VectorCraft's own rule. Tests: `gamut_warning_follows_a_profile_change` and `gamut_warning_follows_a_profile_loaded_again` in `dialogs/color_picker/tests.rs`. - **Assign Profile and the working spaces:** Edit › Assign Profile… tags the document and leaves the working spaces of Edit › Color Settings… as they are, in Color Settings, the Separations panel and the settings saved for the next start. Colors show and convert through the profiles assigned; a change in Color Settings keeps the profiles the document in front is assigned in use, and removing an assigned profile in use puts the working space back in its place. The profiles in use follow the document in front: a new, opened or reverted document, a switch to another tab, a close, Undo and Redo of Assign Profile, a failed batch, each step of a batch, Export All Open and the update of placed documents put that document's profiles in use, and the working spaces where it is assigned none or no document is open. With Window › Arrange › Tile or floating document windows, each document behind the front one draws its art, paper and transparency grid through its own assigned profiles, and loading or removing a profile redraws the documents assigned it; placed documents inside it still draw through the front document's profiles. How documents behind the front one draw is VectorCraft's own rule. View › Proof Setup › Working CMYK simulates the working CMYK space of Color Settings in Proof Colors and Separations Preview, also in a document assigned another CMYK profile. Public references: [Changing the color profile for a document and About color working spaces](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=150) and [Soft-proof presets](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=134). Tests in `engine/tests/color_settings.rs`. +- **Color management policies:** Edit › Color Settings… (the Separations panel's Color Settings section and `edit.colorSettings`) has an RGB and a CMYK policy: Off, Preserve Embedded Profiles and Convert to Working Space, and Preserve Numbers for CMYK. When a document tagged with a profile other than the working space is opened, reverted or opened as a template, the policy keeps the profile, takes it off with the color numbers kept, or converts that model's colors into the working space, with black point compensation as Color Settings set it, and takes it off. The policies are kept in `Color Settings.json`. Preserve Embedded Profiles as the default for both, and the per-model policy for a document tagged with both an RGB and a CMYK profile, are VectorCraft's own rules; placed images and the Profile Mismatches and Missing Profiles prompts are not covered. Public reference: [Color management policy options](https://help.adobe.com/archive/en_US/illustrator/cs4/illustrator_cs4_help.pdf#page=157). Tests in `engine/tests/color_settings.rs`, `color/src/cms/tests.rs` and `color/src/cms/tests_icc.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **New Document profiles:** each built-in category's presets start documents from that category's profile, a native document holding the starting swatches, graphic styles, brushes, symbols and other assets, the ruler and Document Setup options and the print settings ([Create a custom new document profile](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-a-custom-new-document-profile.html)). New Document's Manage Profiles pop-up opens a profile to edit and resets one to the shipped profile (`file.newProfiles`, `file.newProfiles.edit`, `file.newProfiles.reset`; `file.new {profile}`). The profiles live in VectorCraft's own `New Document Profiles` folder next to the preferences; a profile that cannot be read or is larger than 64 MiB leaves the defaults and is reported. Tests: `tests::newdoc::an_edited_profile_starts_the_documents_of_its_category`, `the_profile_follows_the_preset_access_and_reset`, `profile_errors_name_the_file_by_its_name_outside_the_read_roots`, `a_damaged_or_oversized_profile_leaves_the_defaults_and_is_reported`, `profile_names_are_checked_and_editing_needs_the_folder`, `dialogs::new_document::tests::manage_profiles_edits_and_resets_the_profiles`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). @@ -797,6 +808,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel menu: Load Actions… keeps a step whose command this build or its installed plug-ins do not have, and reports each one when the set loads: the `ui.actionSet.load` answer lists the action, step and command id (`unknownCommands`, `unknownCount`), and the status bar names the first and the count. An action with such a step included does not play, and the error names the step and its id before any step runs; an excluded one does not stop it. These are VectorCraft's own rules. Tests `panels::actions::tests::{load_actions_reports_steps_with_unknown_commands, load_actions_bounds_what_it_reports_of_unknown_commands, an_action_with_an_unknown_command_does_not_play}`. - Actions panel menu: Save Actions… does not write a set with a step that sets a folder preference (the Fonts, Plug-ins, Templates and Recovery folders and the two scratch disks) to a folder, and names the action and the preference instead; an empty folder and the scratch disk choices Startup and None save. Export Preferences leaves out both scratch disks, and `ui.inspect` under automation read roots leaves such a folder outside them out of recorded steps. These are VectorCraft's own rules (`ui.actionSet.save`, `prefs.export`). - Actions panel menu: Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `actionSet.read`, `ui.actionSet.load`). VectorCraft's own rules: the set comes in with all its actions. A step with a label and no recorded values comes in as the one menu command with the step's label, when that command is directly in a top menu, is what its menu bar item runs with no values and opens no dialog. A step that is off in the file stays excluded from playback. The other steps, recorded menu items included, are left out, and the status bar says how many. + - Actions panel menu: Load Actions… reads an `.aia` file's Set Selection step, which selects objects by the note typed in its dialog ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html)), as a Select Object step with its note, Case Sensitive and Whole Word (`ui.actionSet.load`; `actionSet.read` now lists each step's recorded values with their keys and types). VectorCraft's own rules: the step is found by its internal name whatever its label, and comes in only when it has exactly the note (UTF-8, 1 to 1,024 bytes once trimmed) and the two options (each 0 or 1); any other Set Selection step is left out and counted, with no option filled in. Tests `panels::actions::tests::{load_actions_reads_set_selection_steps_as_select_object, set_selection_steps_with_bad_values_are_left_out}`, `tests::actionset::{recorded_values_come_back_with_their_keys_and_types, recorded_values_past_the_cap_are_counted_but_not_kept}`. - Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color. - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is the color of the action's button in Button Mode. - Actions panel: the panel menu's Button Mode shows the actions as buttons with their names in their Action Options colors, without sets or individual commands; a click on a button plays its action without its excluded steps, and choosing Button Mode again returns to the list ([the help page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=467) and [the next page](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=468); [the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) says commands cannot be excluded in Button Mode; `ui.action.buttonMode`, `ui.action.playAt`). VectorCraft's own rules, which the pages do not cover: the menu item shows a check mark while Button Mode is on; every set's actions show, in set order and then action order; an action without a color is an ordinary button; function keys still play their actions; the bottom bar shows only Stop Playing/Recording while an action plays or records, and Play Current Selection while one waits at a stop; Button Mode cannot be turned on while an action records; and the mode is kept with the UI preferences. @@ -832,6 +844,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: Import Data Sets… (`dataset.import`) defines the variables a file's header names that the document lacks, unbound. `#Shown` defines a visibility variable, `@Photo` defines an image variable, and a header without a prefix defines a text variable. A header that is a variable's exact name keeps naming it, and any other `%` header (graph data files) is refused. - Variables: Load Variable Library… in the Variables panel menu (`variable.loadLibrary`) replaces the document's variables and data sets with a variable library's, read from its XML file, in one undo step: every data set is deleted, and every variable is deleted with its bindings and values, except a variable the library also declares with the same kind, which stays bound so the library's data sets change its objects (#4136); then the library's other variables are defined, unbound: text, visibility, image (linked file) and graph variables, and variables without a known `trait` as unknown. Its data sets take text values from each value's `p` elements, one paragraph each, visibility values from `true` or `false`, image values from a file's absolute path or `file:` URL, and graph values from a `data` element of series names and rows. - Variables: Save Variable Library… in the Variables panel menu (`variable.saveLibrary`) writes the document's variables of every kind and its data sets as a variable library's XML file, which Load Variable Library reads back with the same variables, kinds, data set names and values. + - Variables: Variable Options… in the Variables panel menu, or a double click on a variable, shows the variable's name and kind, and OK changes either or both in one undo step (`variable.rename {name, newName?, kind?}`), as the [Work with variables](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/work-with-variables.html) page describes. VectorCraft's own rule for a kind change: objects that can't hold the new kind are unbound, and the variable's values in every data set are dropped. - JSON-lines control channel with real egui pointer and keyboard injection. - Variables: a type object holds one text variable. `variable.bind` and Make Text Dynamic refuse a second one and name the text variable the object has; a visibility variable can still share the object, and a document saved with two text variables on one object opens with both. - Synthetic input validates complete gestures before queue mutation: at most 256 drag steps, 16 clicks and 1,024 pending events; up to 64 control requests per frame, with later requests retained for the next frame (#1709). @@ -891,7 +904,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With automation read roots in force, `svgInteractivity.info`, `addFile` and `removeFile`, the JavaScript Files dialog in a frame a request drives and `ui_inspect`'s selected file name a linked JavaScript file outside the roots by its file name alone, as `document.json` does; `removeFile` unlinks it by that name and refuses a name that fits more than one linked file (`tests/interactivity.rs`). - Numeric fields keep the pointer's pressed character as the text-drag start when movement exceeds the click threshold (#1326); a completed focus click selects the unit too. Pixel/point, plain and optional fields are regression-tested in both directions, before/after focus, with replacement text; keyboard focus, double-click, arrows and wheel keep their prior behavior. Native egui input confirms partial replacement gives 17 px instead of 700 px, one Undo/Redo, window captures and an exact saved-document read in a separate CLI process. A focus click released more than 1 pt from its press keeps the caret, and a tap within 1 pt still selects all (#6659; short-drag and jitter-tap regressions in `tests/numfields.rs`). - Unit fields and length strings take `%` in arithmetic: `3cm*50%` reads as 1.5 cm, and `50pt+25%` reads as 62.5 pt. - - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's variants for other tools (only the selection tools, the Type tools and the Artboard tool show it). + - The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's variants for other tools (only the selection tools, the Type tools, the basic shape tools and the Artboard tool show it). - A click on another object moves the contextual task bar under it without hiding it for the press; it gives way only to a drag (#4681). - With one image selected, the contextual task bar offers Image Trace, which traces the image at once with the Default preset (the Image Trace panel is left as it was), and Crop Image; with an Image Trace object it offers Expand, which keeps only the traced paths. This follows Illustrator's bar: for a selected image it includes Image Trace, Retype and Crop Image, and traced artwork can be expanded from it ([Contextual Task Bar announcement](https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580)). Offset Path and Simplify, which don't apply to an image, aren't offered for one. Not done: Retype, which the app doesn't have. - With the Artboard tool, the contextual task bar sits under the selected artboards and offers Resize (Artboard Options for the active artboard), Duplicate (each selected artboard, in one undo step, the copies then selected), Rearrange All (with two or more artboards) and Export (Export for Screens with the selected artboards checked). This follows Illustrator's bar: while the Artboard tool is in use, it has options for resizing, duplicating and exporting artboards (the [Contextual Task Bar announcement](https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580); Rearrange and Export on the bar: [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/)). @@ -900,6 +913,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - While a Type tool (Type, Area Type, Type on a Path and their vertical versions) adds or edits type, the contextual task bar shows the font family menu, the font style menu, the Font Size combo and the Fill chip (); the Touch Type tool shows no bar, as the same page describes. Type selected with a selection tool shows the same three font controls beside its Create Outlines, conversion and Type on a Path items (). The controls act as the Character panel's do: on the characters a Type tool selects, else on every selected type object, with the size left blank where the sizes differ. VectorCraft's own rules: a Type tool's bar leaves out the object actions, the Stroke chip, Recolor Artwork and Lock; a Type tool shows no bar before it adds type or with other art selected; type selected with other art shows no font controls; the font controls go on a second row when one row would be wider than the canvas, and narrow to fit a canvas narrower than they are; while a Type tool edits type, the Fill chip shows the edited characters' fill ("?" where it differs). Evidence: `the_type_tools_show_font_and_fill_controls`, `the_type_tools_show_no_bar_without_type`, `selected_type_shows_font_controls_beside_its_actions`, `the_task_bars_font_size_sets_what_the_character_panel_would`, `the_type_tools_fill_chip_activates_the_fill`, `the_type_tools_bar_is_named_and_stays_on_the_canvas`, `selected_types_font_controls_take_a_second_row_on_a_narrow_canvas` and `the_type_tools_fill_chip_shows_the_edited_characters_fill` in `crates/ui-egui/src/tests/taskbar.rs`. - The help page describes the contextual task bar as one horizontal row () and gives no rule for a canvas narrower than that row. VectorCraft's own rule: the bar stays whole on the canvas. When its actions don't fit in one row, they wrap, in order, onto rows under the row with the handle, the chips and More Options; an action wider than any row, or one that would need a row past the canvas's height, is listed in More Options instead; when the chips don't fit beside the handle and More Options, they take rows after the actions; with no row to spare for the font controls, the bar leaves them out; a canvas too small for the handle and More Options shows no bar. Evidence: `a_task_bars_actions_wrap_on_a_narrow_canvas` and `a_task_bar_action_wider_than_the_canvas_goes_in_more_options` in `crates/ui-egui/src/tests/taskbar.rs`. - With a live rectangle, rounded rectangle, ellipse or polygon selected with the Selection tool, the contextual task bar shows a Shape Properties button in place of Lock. It opens a popup with the shape's rows from the Transform panel (corner radius, pie angles, or sides, angle, corner and radius), which edit it through `object.setLiveShape`. The overview describes this: when a shape drawn with a shape tool (Rectangle, Star) is selected, the bar offers to change its properties where Lock Object would be ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Object › Lock and ⌘2 still lock the shape. Not done: stars, which the app draws as plain paths. Evidence: `a_basic_shape_shows_its_properties_in_place_of_lock`, `the_shape_properties_popup_edits_the_shape` and `other_selections_keep_lock` in `crates/ui-egui/src/tests/taskbar.rs`. + - While the Rectangle, Rounded Rectangle, Ellipse, Polygon or Star tool is in use, the contextual task bar offers Edit Path (the Direct Selection tool), the Fill and Stroke chips, and Shape Properties for the shape it draws, as the help page lists for those five tools (). VectorCraft's own rules: the bar shows while one of those tools has one closed path selected whole, as right after it draws one, and not with nothing selected, several objects, a group, an open path, type or picked-out anchors; it shows nothing during the drag that draws the shape; it leaves out the selection tools' Duplicate, Offset Path, Simplify, Recolor Artwork and Lock; the Line Segment tool shows no bar. Not done: Shape Properties for a star, which the app draws as a plain path, and Gen Shape Fill and Generate Vectors, which the app doesn't have. Evidence: `shape_tools_show_edit_path_paint_and_properties_after_drawing`, `shape_tools_have_no_shape_bar_without_a_single_shape` and `shape_tool_bar_retargets_after_another_shape_and_undo` in `crates/ui-egui/src/tests/taskbar.rs`. - The contextual task bar has a Stroke chip beside the Fill chip, drawn with the paint the Stroke proxy shows (a "?" when the selected objects' strokes differ, as on the Fill chip); a click brings the Stroke proxy forward and shows the Swatches panel wherever it is docked, floating or closed, as a click on the Fill chip does for the Fill proxy. The reference app's bar has Fill and Stroke controls for changing an object's colors ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). Evidence: `the_task_bar_shows_a_stroke_chip_beside_fill` and `the_task_bar_chips_follow_the_proxies_and_show_swatches_anywhere` in `crates/ui-egui/src/canvas/tests.rs`. - With anchor points selected with Direct Selection, the contextual task bar offers Remove Anchor Points, Connect (`path.join`), Cut Path, and Convert Selected Anchor Points to Corner and to Smooth (`path.convertAnchors` with `to` set to `corner` or `smooth`), the commands of the Control bar's anchor buttons; the two convert buttons show their icons alone, named by their tool tips as on the Control bar (VectorCraft's own choice). The overview lists items that delete the selected anchors, join selected end points, split the path at the selected anchors, and make the selected anchors corner or smooth points ([Contextual Task Bar overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html)). With a path selected whole with Direct Selection, the bar also offers Simplify and Smooth, which open the Simplify and Smooth sliders as Object › Path › Simplify… and Smooth… do; the same overview names Smooth as adjusting the path's curvature (#6784). VectorCraft's own rule: while anchors are picked out, the bar offers the anchor items without Simplify and Smooth. Evidence: `direct_selection_smooth_opens_the_smooth_slider` in `crates/ui-egui/src/tests/taskbar.rs`. - Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it. @@ -938,6 +952,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Polish catalog (`crates/ui-egui/src/i18n/pl.tsv`) has an `@msg` row for the status and error messages the untranslated report (`untranslated_report`) finds in the sources, 3,439 `@msg` rows in all: the errors of commands, scripts, file import and export and plug-ins. The report lists 46 Polish messages: 44 that open #7211, #7214, #7116, #7330 and #1931 translate, and 2 that no language has a row for. A message with two placeholders in a row (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`) is still shown in English. - The status bar translates the limits a plug-in run can exceed (its instruction, time and memory budgets and its output size) and the error of a plug-in removal that leaves the plug-in's grant in the plug-in store, in every language that translates every message. The untranslated-strings report also lists messages that an error or a status takes from a `&str` constant (`plugin_limit_errors_are_translated`, `message_scan_reads_messages_held_in_constants`). - The status bar translates the plug-in host's other errors (manifest, module and run checks, the output an importer, exporter or effect returns, and the approval a plug-in's permissions need) in every language that translates every message. A JSON object or array inside a message stays as written, and a message that wraps another one, such as `plug-in manifest: {0}`, is matched by its own row before a row that starts with a placeholder. The reasons inside these errors (what is wrong with a parameter's spec or dialog keys, the value a parameter needs, what is wrong with an object, stroke, paint or effect in a plug-in's output) are translated with them, and the untranslated-strings report lists the reasons the plug-in host's error helpers take (`plugin_messages_are_translated_in_every_complete_catalog`, `plugin_errors_translate_with_their_values`, `plugin_error_reasons_are_translated_with_the_error`, `message_scan_reads_plugin_helpers`, `templates_that_start_with_text_match_first`). + - The status bar translates `script.run`'s refusals of `answers` in every language that translates every message: a value that is not a list, more than 10,000 answers, more than 1 MiB, an answer at an index that is not an object, does not have exactly `kind` and `value`, has a `value` its kind of dialog does not take, holds a prompt's text longer than 16 KiB or has an unknown `kind`, and `answers` given to a run in the app. The index, the counts and the code names are shown as they are, and the command's error stays in English. The untranslated-strings report also lists the refusals of `script.run`'s parameters (`script_answers_errors_are_translated`, `message_scan_reads_script_run_param_refusals`). - The Japanese catalog (`crates/ui-egui/src/i18n/ja.tsv`) has 3,562 `@msg` rows and 49 `@msgplural` rows (counted 2026-11-14). The untranslated report (`untranslated_report`) lists 98 status and error messages in the sources that have no Japanese row: 77 that every complete-message language lacks, and 21 more. Open #7211 translates 34 of them, among them the composite font errors, and open #7116 translates the justification order error. The status bar does not use the rows for ``unknown parameter `{within}{key}`; {why}`` and `{name} must be {lo}–{hi}{unit} (got {v})`, because a message with two placeholders in a row cannot be matched (`Template::parse` in `crates/ui-egui/src/i18n/catalog.rs`). The row for `{k} has control characters{_1}` does not match the File Info error for a multi-line field, where the last placeholder is empty. A native speaker has not reviewed the wording. - A status or error message whose braces are literal, such as a plug-in's `expected {"objects": [...]}` or a command's `quotes must be {double?, single?}`, is translated only when the whole message matches its row: the row writes those braces doubled and has no placeholder, so it no longer replaces a different message that starts with the same words, such as a JSON reason `expected value at line 1 column 1`, which stays in English. A row without placeholders is found at any message length (`plain_message_keys_double_their_braces`, `literal_brace_rows_match_only_their_message`). - The status bar translates the errors a script's `File` and `Folder` objects, `vc.run`, `$.evalFile`, the `$` object's properties and functions, `alert`, `confirm` and `prompt`, `vc.document` and `vc.log` raise, the limits a run can pass, and the errors of Drop Script Engine, and the line for a run that fails (a syntax error, a thrown error, a limit, a stop or an internal error, with the script's name, the line and column, the included file it is in and the files left written), in every language that translates every message. The script's name, paths, numbers, command ids and the text a script throws itself are shown as they are. A failure with no line and column, such as an error in code `eval` compiled, is shown with one colon after the script's name (`an_error_without_a_place_names_the_script_and_kind`). A message template that names a value twice matches only when both places hold the same text, so the line for a failed run is not taken for the message inside it (`a_repeated_placeholder_matches_the_same_text`). The untranslated-strings report also lists the messages the script host's `fail`, `value` and `over` helpers take and the errors the script interpreter throws, and a constant's text comes from the file or crate that declares it, so the Scripts menu's headers are not listed as messages (`script_errors_are_translated`, `script_host_errors_are_translated`, `message_scan_reads_script_interpreter_errors`, `message_scan_reads_constants_of_the_file_and_its_crate`, `message_scan_skips_test_modules_with_any_test_cfg`). @@ -979,7 +994,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the 3D effects (Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms) show in the status bar in the interface language: the art a 3D effect cannot build a solid from, invalid light, graphics and surface settings, and the notes for an object that draws no 3D faces or more wireframe edges than the limit. Each has a row in every catalog that translates all status and error messages, and the effect's name and the values in the message are translated or kept as written (`three_d_refusals_are_translated_in_every_complete_catalog`, `three_d_refusals_translate_with_their_values`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). + - File Info's (`file.info`) error for a value with control characters shows in the status bar in the interface language for every field: "description has control characters" for Description and Copyright Notice, which take line breaks and tabs, and "title has control characters or line breaks" for the other text fields and each keyword in a list. Each is its own message with a row in every catalog that translates all status and error messages, and the field's key stays as written (`file_info_control_character_refusals_translate_in_every_complete_catalog`, `control_characters_are_refused_with_the_field_rule`). - A parameter schema's errors show in the status bar with what the value must be in the interface language, for every type a schema has (a number with each kind of range and with a unit, an integer and its range, true or false, a string, a list of names, an object id, a list of object ids, a color, a point, an object and a list), in every catalog that translates all status and error messages; the path, the refused value, the bounds, the unit and the names stay as the English gives them. The error for a value that is not one of its names is also translated in Bulgarian, Bosnian, Danish, Indonesian and Dutch (`parameter_schema_expected_phrases_are_translated`, `bracketed_message_parts_translate_only_as_exact_messages`). + - An effect a plug-in's output or an importer plug-in's objects carry that VectorCraft refuses (``[0] `id`: reason``) shows in the status bar with its reason in the interface language inside `plug-in output: …` and `can't open …`, in every catalog that translates all status and error messages; the index and the effect id stay as they are, and a JSON array in a message stays as it is (`nested_effect_refusals_translate_with_their_reason`, `bracketed_message_parts_translate_only_as_exact_messages`). - The untranslated report lists the status and error messages of the effects, swatch libraries, raster sizes and encoders, 3D solids and path operations that a catalog lacks, the messages that a `.map_err` closure or a named error handler returns, and the phrases a parameter schema's error builds for what a value must be (`untranslated_report_checks_every_message_crate_and_schema_phrase`, `message_scan_reads_map_err_closures`, `message_scan_reads_named_map_err_handlers`). - Spanish messages (2026-11-13): the Spanish catalog (`crates/ui-egui/src/i18n/es.tsv`) has an `@msg` row for 114 of the 167 status and error messages that the untranslated report (`untranslated_report`) lists as missing in all 23 complete-message languages: the errors of the image encoders, color profiles, swatch exchange files and color books, 3D and Revolve, path operations, DXF import, the script engine and the parameter checks. Open #7211 and #7378 translate 51 of the others (the composite font and SVG filter errors), and 2 stay in English. The rows keep the placeholders, backticked names, accepted values and limits of their English (`spanish_translates_encoder_profile_swatch_revolve_and_parameter_errors`); native review remains; scores and estimates unchanged. - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and warn about a key that only another format takes (`{"format": "pdf", "quality": 80}` writes the PDF with the warning "`quality` was not used: PDF files don't read it"; `an_option_only_another_format_reads_gets_a_note`, `control_export_notes_an_option_the_format_does_not_read`, `convert_warns_about_an_unused_option_only_when_one_was_given`), and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path. @@ -1038,6 +1055,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. A field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere, such as a panel scrolled under a still pointer, stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field (VectorCraft's own rule and value). - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). + - While a flare's center is dragged out, Ctrl (Command on macOS) keeps the center's size and the drag sizes the halo; while its rings' end point is placed, Up/Down Arrow add and remove rings and ~ places them anew (`shape.flare`'s `ringSeed`) ([Draw and edit flares](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/draw-shapes/draw-and-edit-flares.html); `flare_arrows_change_the_rays_then_the_rings`, `flare_tilde_places_the_rings_anew`, `flare_ctrl_keeps_the_center_and_the_drag_sizes_the_halo`). - A plain step of a number field (Up/Down or a wheel notch) goes to the next whole unit in its direction and a Ctrl/Cmd step goes to the next tenth, so 5.1 mm steps up to 6 mm and down to 5 mm (#2881). A Shift step adds or takes ten and keeps the fraction. - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. - The Pattern Options panel's Name field and the name fields that a double-click opens in the Layers, Artboards and Asset Export panels take Shift+Enter (Shift+Return) the same way: it applies the name and keeps the field active with the name selected. @@ -1173,6 +1191,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`. `path.setHandle`, `path.moveAnchor`, `text.setPathHandle` (`anchor`), `path.insertAnchor` (`segment`), `artboard.reorder`, `library.moveGroup`, `graphicStyle.move` (`to`), `path.appendAnchor`, `path.close`, `path.reverse` (`subpath`) and the ruler origin commands (`artboard`) refuse a value that is not a whole number of 0 or more, such as `"3"` or `2.5`, with `` `anchor` must be a whole number ``, naming the key, instead of reporting it as missing or reading it as the default subpath or artboard; a null `artboard` is artboard 0 (`tests::params::a_malformed_index_is_not_missing_or_its_default`). The artboard commands that take `index`, `indices` or a replayed `selectedArtboards`, the artboard Align and Distribute commands (`artboard`, `activeArtboard`), `edit.newDocumentFromState` (`index`) and Paste in Place, in Front and in Back (`artboard`, `artboards`) report a value that is not a whole number as such, naming the key, and keep `no such artboard`, `no history state N` and the paste range messages for a whole number that names nothing; a null artboard `index` is `missing index`, and an `indices` value that is not a list, an empty list and a malformed entry each have their own message (`tests::panelcmds::artboard_lists_name_the_fault_and_the_key`). + `guide.add` and `select.allOnArtboard` (`artboard`), `select.font` (`scope.artboards`) and `addon.select.byArtboards` (`artboards`) refuse an artboard that is not a whole number of 0 or more, such as `"1"`, `-1`, `1.5` or `true`, naming the key, instead of reporting it as a missing artboard or reading it as artboard 0, and leave the document and selection unchanged; a whole number past the last artboard is still `no such artboard`, and a null or left-out `artboard` keeps its default (`tests::menucmds::guide_add_refuses_an_artboard_that_is_not_a_whole_number`). `select.same.appearanceAttribute` refuses such an `item` the same way instead of using the Appearance panel's active item (`tests::styles::select_same_appearance_attribute_refuses_an_item_that_is_not_a_whole_number`). `path.close` and `path.setHandle` (`independent`), `path.moveAnchors` (`patterns`), the Pencil (`closed`, `keepSelected`, `fill`), Curvature (`closed` and a point's `corner`), `path.curvatureEdit` (`corner`), `path.reshapeSegment` (`perpendicular`) and the Blob Brush (`keepSelected`, `mergeOnlySelection`, `merge`) refuse a value other than true, false or null, such as "true", and leave the document unchanged. Direct Selection's marquee selects the frame anchors of area type that lie inside it, and a click on a frame anchor selects that one; Shift toggles them. The selected frame anchors show filled. A drag of one of them moves all of them, the arrow keys nudge them, and the type reflows at its size (`text.reshapeArea`, which takes `items` to move the anchors of several frames in one step), as [Create text](http://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) and [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describe. When the selection also holds other objects, path anchors or ruler guides, or when another change to the document or the selection follows the pick, the type is selected whole. A press on a selected object's centre point (Attributes panel › Show Center) selects the object whole, and a drag from it moves the object as one Move, held by that point (#4891; test `the_centre_point_selects_and_moves_the_object_whole`). @@ -1223,6 +1242,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Pencil's and the Paintbrush's Fidelity is in screen pixels, the same at any zoom: their Tool Options give it in px, from 0.01 px on the logarithmic slider to 20 px and up to 100 px in the field, and the tool sends `path.freehand` the value in points at the current zoom (#5729). The Smooth tool's and the Blob Brush's Fidelity stays in points. `path.freehand` and `brush.freehand` take a Fidelity of up to 100 / 0.0313 pt (about 3194.89 pt), the 100 px field maximum at the lowest zoom (3.13 %), and reject a value outside their range with an error, so a Pencil or Paintbrush stroke drawn zoomed out keeps the Fidelity set in pixels (engine tests `pencil_fit_keeps_its_anchors_on_a_stroke_drawn_zoomed_out` and `pencil_takes_fidelity_within_its_range_and_rejects_the_rest`). `brush.freehand` refuses a `brush` that names no brush in the document, or is not a string, and a `style` other than "pencil" or "brush", with an error and no change or undo step, instead of drawing the plain path; the Paintbrush skips a chosen brush that has since been deleted (engine tests `brush_freehand_refuses_a_brush_or_style_it_cannot_use` and `paintbrush_tool_skips_a_deleted_brush`). + A `brush.freehand` stroke and its brush are one undo step, so one Undo removes both. 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) is refused with "cannot apply brush ``: path has no stroke", and a key the command does not take with "unknown parameter", with no change or undo step; the no-stroke refusal is VectorCraft's own rule (engine tests `brush_freehand_direct_call_has_one_undo_step`, `brush_freehand_refuses_a_brush_on_a_path_without_a_stroke` and `brush_freehand_refuses_unknown_keys`). Each drag of the Smooth tool smooths the stretch of a selected path in its reach along its length before refitting it within Fidelity, so a wobble wider than Fidelity is smoothed out and another drag over the same place smooths it further, as [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) describes (`path.smoothRegion`). The smoothing spread (a third of the reach) and the rule that each refit stays within twice the distance the smoothing moved the path, so repeated drags do not drift from it, are VectorCraft's own. A drag over the whole of a closed path smooths it across its first anchor too, so a bump there is smoothed like any other and no corner is left; Object › Path › Smooth smooths a closed path across its first anchor the same way. When a Smooth tool drag reaches the first anchor of a closed path, the smoothed path starts at its point nearest where that anchor was: at an anchor there, or at a new anchor split into the curve without changing its outline. Dashes and type on the path start from that point. A drag that does not reach the first anchor leaves it first. This is VectorCraft's own rule (engine tests `smoothing_over_a_closed_paths_first_anchor_keeps_its_start` and `smoothing_a_whole_closed_path_with_a_spike_keeps_its_start`). The Smooth tool shows the Not Allowed cursor while no path is selected that it can act on (an editable path, not a guide, with three or more anchors on one of its subpaths or two anchors joined by a curve), and the release of a drag then sends no command and changes nothing; [the help page](https://helpx.adobe.com/illustrator/using/tool-techniques/smooth-tool.html) has the path selected first. A selected path anywhere on the canvas gives the Smooth cursor wherever the pointer is. The Pencil with Alt key toggles to Smooth Tool on and the Blob Brush show Not Allowed the same way when Alt gives them the Smooth tool; the Paintbrush keeps its closing cursor, since its release with Alt closes the stroke. Use Precise Cursors leaves Not Allowed as it is. This cursor rule is VectorCraft's own (tools `smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `borrowed_smooth_shows_not_allowed_without_a_selected_path_to_smooth`, `smoothing_release_sends_nothing_without_a_selected_path_to_smooth`; engine `use_precise_cursors_keeps_the_smooth_tools_not_allowed_pointer`). @@ -1468,7 +1488,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Selection tool, the Tabs panel shows the ruler and stops of the first paragraph of the first selected text object, also when the text is inside a selected group or a shape is selected before it; a selection without text shows "No text selected". A stop set on that ruler is set on every selected text object and the text in selected groups, and other objects are left as they were. This is VectorCraft's own rule. Tests `panels::tabs::tests::the_ruler_shows_text_selected_after_an_object_that_is_not_text`, `the_ruler_shows_text_inside_a_selected_group`, `a_stop_added_on_a_mixed_selection_sets_every_selected_text` and `a_selection_without_text_shows_no_ruler`. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent (18 pt further in for each list level past the first) when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Georgian, Hindi, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Finnish, Turkish, Catalan, Dutch and Danish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line; on a vertical path, upright glyphs and tate-chu-yoko blocks face the path's start, and letters on their side are a quarter turn clockwise from them (with Skew, 3D Ribbon and Stair Step only on a path that runs to the right), in one column that Align to Path Baseline or Center centers on the path and that the caret and selection span); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). + The Russian catalog (`crates/ui-egui/src/i18n/ru.tsv`) has 1,811 more `@msg` rows, for status and error messages of commands, scripts and file import and export, 3,444 `@msg` rows in all counted on 2026-11-13 (`russian_translates_command_script_and_file_messages` in `crates/ui-egui/src/i18n/tests.rs`). Scores and estimates are unchanged. With Line-end Punctuation Half Width, both composers measure a line with its last character set as narrow as Mojikumi sets it ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/use-mojikumi-for-japanese-text-composition.html)): a closing bracket, comma or full stop that fits at half width ends the line, and the character before it no longer goes to the next line with it. A mark that already lost its half to the punctuation after it and a line that ends in a hyphen take nothing more off, and the quarter em between Japanese and Latin text counts only where the line ends there. Kinsoku Shori Type and Burasagari Regular measure lines the same way; right-to-left lines and Mojikumi None break as before. In a frame that is not a rectangle, or with text wrap, a line's first word is measured as it is set (an opening bracket at its start flush, its last character narrowed) when layout looks for a line wide enough for it, and it ends only where the line may break, so kinsoku keeps an opening bracket with the character after it and a No Break run counts whole (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/vertical.rs`). Scores and estimates are unchanged. + Burasagari ([the help page](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)) leaves a right-to-left paragraph's comma or full stop inside its line, which then ends at a break where it fits with the mark, and in a left-to-right line with right-to-left text and a tab it hangs a mark only when the mark is drawn at the line's right end and the rest of the line fits as drawn; otherwise the line ends at the latest break that fits as drawn. Both are VectorCraft's own rules (`crates/text/src/layout/wrapping_tests.rs`, `crates/text/src/tests/hang.rs`). Scores and estimates are unchanged. The Paragraph panel's Mojikumi Set lists the five predefined sets the help page names (None, YakumonoHankaku, GyomatsuYakumonoHankaku, GyomatsuYakumonoZenkaku and YakumonoZenkaku), then Line-end Punctuation Half Width, and `text.setFormat {mojikumi}` takes each of them. YakumonoHankaku sets every bracket, comma and full stop half width and takes a quarter em off each side of a middle dot; GyomatsuYakumonoHankaku keeps punctuation full width inside a line, sets an opening bracket flush at a line's start and a closing mark half width at its end, and takes the quarter em after a middle dot off at a line's end; GyomatsuYakumonoZenkaku keeps punctuation full width and adds a quarter em between Japanese and Latin text; YakumonoZenkaku sets characters as None does. These widths are VectorCraft's own rules, from the help page's one-line description of each set. Both composers and vertical type measure each line as it is set, the sets save with the document and paragraph styles, and a `.ai` save reports the new sets as left out (`crates/text/src/tests/vertical.rs`, `crates/ui-egui/src/panels/paragraph/tests.rs`). Scores and estimates are unchanged. Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. @@ -1495,6 +1517,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel. - **Image Trace, Document Library colors:** with Document Swatches, traced fills and strokes use the displayed colors of process, global, spot and tint swatches without links, so editing a swatch leaves the trace unchanged; swatch-library colors are also unlinked; code-drawn tests in `crates/engine`. - **Image Trace, flattened slivers:** in a Color, Grayscale or Black and White trace with Fills, a traced region whose outline straightens onto one line (a single pixel, or a one-pixel row along an edge, its pieces shared with the regions beside it) is left out instead of becoming a selectable path with no area, which paints nothing. Snap Curves To Lines straightens more outlines, so it leaves out more of these. Without Fills, the region's 1 px stroke draws it, and it is kept. Code-drawn tests in `crates/trace`. +- **Image Trace, Abutting seam fills:** an Abutting trace with Fills holds a `Seam Fills` group under its paths, with 1.5 px strokes along the edges two filled paths share, in the lower path's color (VectorCraft's own width), so renderers that anti-alias each path on its own show no light hairline along those edges. The paths and their shared edges are unchanged. Tests: `seam::tests` in `crates/trace`, `image_trace_abutting_seam_fills_sit_under_the_paths` in `crates/engine`. Scores and hours unchanged. - **Image Trace, Auto Color rare colours:** the Automatic palette keeps colours too rare for its area count when they are distinct (Oklab) and drawn as coherent regions, at most six, protected from mixed-colour merging and Noise; code-drawn stem, highlight, low-contrast and scattered-noise tests (see [quality notes](image-trace-quality.md#auto-color-keeps-rare-colours)). - **Image Trace, nearly flat dark grounds:** with a Limited palette of 9 to 16 colors (16 Colors, and Document Library without swatches), by VectorCraft's own rule, a cut whose two sides both have a dark gray mean (Oklab lightness under 0.30, chroma under 0.03) and lie less than 0.03 apart in Oklab has its gain scaled by (distance / 0.03)². A near-black ground with faint noise then traces in one color, and the color it would have taken goes to another cut. Code-drawn tests `a_nearly_flat_dark_ground_keeps_one_color`, `only_a_limited_palette_of_nine_to_sixteen_colors_weighs_alike_sides` and `a_dark_colored_figure_on_a_nearly_flat_dark_ground_keeps_its_color` in `crates/trace`. - **Image Trace, Document Library keeps neutral tones neutral:** each pixel takes the library color nearest in Oklab with differences in hue and chroma counted three times, so a gray or a muted brown traces as a neutral library color instead of a saturated one of similar lightness; a code-drawn test of gray and brown patches with a black, light and blue library in `crates/trace`. @@ -1505,6 +1528,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, Flat Logo working size:** Flat Logo traces at most 1024 px on a side; a larger image is averaged down by a whole factor first, and the trace's `warnings` name the size it was traced at and the image's own size, in place of the slower-trace and 64-megapixel notes; code-drawn 1024 and 1025 px tests in `crates/trace` and `crates/engine`. - **Image Trace, Preview:** the Image Trace panel's Preview checkbox, before Trace, is on by default, and a setting change traces the selected Image Trace object again at once. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected. When Preview is turned on with that object not selected, the object is traced with its changes when it is selected again. Preview is kept with the UI preferences, and Reset to Default leaves it. The `imageTrace.preview` command turns it on or off. - **Image Trace, traces made off the UI thread stay with their document:** a trace made away from the command (`TraceRun`) applies only in the document it was made in. In another open document with the same image, object and settings, `imageTrace.make` and `imageTrace.makeAndExpand` drop it and trace that document's image, as one undo step (VectorCraft's own rule). Test: `a_trace_run_applies_only_in_its_own_document` in `crates/engine`. Scores and hours unchanged. +- **Image Trace, a trace started before File › Revert does not apply after it:** the reverted document keeps its tab, but a trace prepared before the revert no longer applies, even when the restored image, object and settings are the same. A background trace that finishes after the revert changes nothing and the status bar says the document was reverted; a result handed to `imageTrace.make` or `imageTrace.makeAndExpand` is dropped and the restored image is traced again, as one undo step. Reverting another document, or a revert that fails, does not affect the trace (VectorCraft's own rule). Tests: `a_trace_run_prepared_before_a_revert_does_not_apply_after_it` in `crates/engine`, `a_trace_started_before_a_revert_does_not_apply` in `crates/ui-egui`. Scores and hours unchanged. - **Image Trace small contours:** closed fits too small to form an editable subpath retain their positioned outline, including holes. Source-generated island, hole/winding and stepped-region regressions cover Corners and Noise; defaults, parity scores and estimates are unchanged. - **Image Trace, custom presets:** Manage Presets next to the Image Trace panel's Preset dropdown saves the panel's settings as a new preset under a name (Save as New Preset…) and renames or deletes the saved preset shown; built-in presets don't change. Saved presets are kept with the preferences and follow the built-in ones in the panel, Control bar and Properties preset lists. `imageTrace.presets.save` and `imageTrace.presets.delete` save, rename and delete them, `imageTrace.presets` lists them, and `imageTrace.make {preset}` traces with one by name. - **Image Trace, preset buttons:** six buttons above the Image Trace panel's Preset dropdown choose a preset as the dropdown does, for the options the [help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/image-trace-panel-options.html) lists before Preset. The presets each button chooses are VectorCraft's own: Auto Color chooses Auto Color, High Color chooses High Fidelity Photo, Low Color chooses Low Fidelity Photo, Grayscale chooses Shades of Gray, Black and White chooses Black and White Logo, and Outline chooses Outline. With Preview on, a click traces the selected Image Trace object again as one undo step; with nothing traced selected, it changes the panel's settings only. The button of the panel's preset shows as selected. @@ -1561,6 +1585,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Live Paint Selection tool opens its Tool Options ([Live Paint Selection tool options](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html)): with Select Fills off, a click selects an edge instead of a face; with Select Strokes off, Alt-click selects a face instead of an edge; Color and Width set the color and width of the highlight on the face or edge under the pointer (red and 4 px by default). The options last across tool switches and are saved with the preferences (`tool.setOption {tool: "livePaintSelection"}`). As VectorCraft's own rule, OK in Live Paint Selection Tool Options, Live Paint Bucket Options, Gap Options and Tile Edge Color gives an error for a color with an alpha part (`#rrggbbaa` or `#rgba`) and changes nothing. As VectorCraft's own rule, a Live Paint edit that changes nothing records no undo step, keeps the redo steps and leaves a saved document saved: a Live Paint Bucket click or drag over faces that already have the fill, a stroke on an edge that already has the paint and weight, Gap Options OK with the options the groups have and no gap to close, and Make or Merge with one Live Paint group alone. Such a fill or stroke also leaves the recent colors as they are. Regression tests: `live_paint_fill_same_paint_is_no_op`, `live_paint_bucket_click_with_the_face_paint_is_no_op`, `live_paint_stroke_edge_same_paint_and_width_is_no_op`, `live_paint_gap_options_same_values_are_no_op`, `live_paint_gap_options_close_without_gaps_is_no_op`, `live_paint_make_existing_group_is_no_op` and `live_paint_merge_single_group_is_no_op` in `crates/engine/src/tests/build.rs`, and `ok_on_unchanged_options_records_no_undo_step` in `crates/ui-egui/src/dialogs/live_paint_gaps.rs`. + As VectorCraft's own rule, the Live Paint Bucket replaces all of a face's fills, or all of an edge's strokes, with one fill or stroke of the paint, shown, fully opaque and blending normally, so a lower fill or stroke no longer draws under the new paint and a fill or stroke of none leaves nothing of that kind drawing; the face's strokes, the edge's fills, the part's own opacity and the topmost shown stroke's dashes, caps and arrowheads stay as they are, and a part that already has only that one fill or stroke records no undo step. [The help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/paint-with-the-live-paint-bucket-tool.html) says the tool paints faces with the current fill and edges with the current stroke. Regression tests: `live_paint_bucket_replaces_every_fill_row`, `live_paint_stroke_edge_replaces_every_stroke_row` and `live_paint_stroke_edge_paints_the_shown_stroke` in `crates/engine/src/tests/build.rs`. As VectorCraft's own rule, `tool.setOption` for the Live Paint Bucket and the Live Paint Selection tool, chosen or not, checks every value before it sets any: a switch that is not `true` or `false`, a `color` that is not an RGB color or a layer color's name, or that has an alpha part, a `width` that is not a number and an option the tool does not have give an error naming the option, and nothing is set or saved. A `width` outside 1 to 20 px is still set to the nearest end of that range. Saved settings that hold an invalid value still load, and the tool keeps its default for that option. Evidence: `tests::build::{live_paint_tool_options_refuse_invalid_values_and_change_nothing, live_paint_tool_options_take_rgb_colors_and_clamp_the_width, live_paint_tools_load_saved_settings_with_invalid_values}`. As VectorCraft's own rule, `tool.setOption` for the Shape Builder, chosen or not, checks every value the same way: a switch that is not `true` or `false`, a `pickColorFrom` or `selection` that is not one of its names, a `gapLength` that is not a number, a `highlightColor` that is not an RGB color or a layer color's name, or that has an alpha part, and an option the tool does not have give an error naming the option, and nothing is set or saved. A `gapLength` outside 0 to 72 pt is still set to the nearest end of that range. Evidence: `tests::build::{shape_builder_tool_options_refuse_invalid_values_and_change_nothing, shape_builder_tool_options_take_valid_values_and_clamp_the_gap}`. A drag of 3 screen pixels or more with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. @@ -1619,6 +1644,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/it/stroke_visibility.rs`). - **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles, both ends of a vector drawn on the art and the start of a clicked one snap as other drawn points do (path segments, construction guides and View › Snap to Glyph's guides included) and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh. A gradient stop that loses its link to a spot swatch (the swatch deleted, a recolor or adjustment that unlinks it, a library gradient, or a library character style linked to a swatch the document lacks) keeps the color it shows at full tint and drops the process plates it printed with that ink, as a stop given a plain color does; this is VectorCraft's own rule. Evidence: `tests::mapping_links_drops_the_process_plates_of_unlinked_stops` in `crates/color`, `tests::linked_stops::deleting_a_spot_swatch_takes_the_process_plates_of_the_stops_it_unlinks` and `tests::library::library_stops_unlinked_from_their_swatch_keep_no_process_plates` in `crates/engine`. + Merge Swatches (`swatch.merge`) also drops the process plates of the gradient stops linked to a swatch it merges, whether the kept swatch is spot, global process or process that isn't global, in the art, gradient swatches, graphic styles, the art of brushes and the fill and stroke for new art; a stop linked to another swatch keeps its plates, and Undo puts the links and plates back. This is VectorCraft's own rule. Evidence: `tests::linked_stops::merging_swatches_takes_the_process_plates_of_the_stops_it_relinks` in `crates/engine`. - The Gradient panel's Delete Stop sits at the right end of the ramp, level with it, so a panel too short to show the stop fields still shows it; it deletes the selected stop (never below two), as dragging a stop down off the ramp does (#5403). - Conic gradients, VectorCraft's own gradient type: the stops sweep once around the center from the vector, clockwise on the page (a negative aspect, or a reflection, turns the sweep the other way), with a Conic button in the Gradient panel's Type row and the Gradient tool's extent ellipse and aspect handle; they follow transforms, Expand into wedges or a mesh, open from `.af` files, and export to PDF as a function-based shading that PDF import reads back as a conic gradient; SVG, EPS and legacy `.ai` files get solid wedges, and CSS a `conic-gradient`. - Diamond gradients, VectorCraft's own gradient type: the stops run from the center out to a square with a corner at the end of the vector (the aspect stretches it into a rhombus), with a Diamond button in the Gradient panel's Type row and the Gradient tool's square and aspect handle; they follow transforms, Expand into nested squares or a mesh, and export to PDF, SVG, EPS and legacy `.ai` as four linear gradients, one for each quarter of the square (an image in PDF and SVG for translucent stops, a dithered fill, the Perceptual method other than spot color shadings in PDF, or a square with no area), and on type characters in SVG as a radial gradient; PDF import reads a function-based shading whose color is the same along each square about its origin as a diamond gradient. @@ -1678,6 +1704,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. PDF export writes a knockout group as one PDF knockout group (`/K true`, with `/I true` when the group isolates; ISO 32000-1, 11.4.6.3), so a knockout group of any size opens again in VectorCraft as a knockout group, and the form nesting of the file does not grow with the number of elements. When an element's opacity or mask defines its knockout shape, or it paints translucently, each element is drawn through one soft mask of the overlapping elements above it instead; past 65,536 objects in a group's masks (or 2,048 elements) the group is a PDF knockout group, with a warning that its translucent elements knock out fully. SVG export draws each element of a knockout group through one mask of the overlapping elements above it, and the masks refer to one copy of each element's shape, so the elements stay siblings and the file nests no deeper with more elements; past 65,536 shapes in a group's masks (or 2,048 elements) the group is written as a plain group, with a warning. Evidence: `pdf/src/tests/blend.rs`, `svg/tests/it/blend_export.rs`, `pdf/tests/gradient_forms.rs` (a gradient in a knockout group reads back in place). + A knockout group in Normal mode at full opacity that does not isolate, with nothing blending inside, is written without an isolated group around its elements, clipped or not, so the art below the group is its initial backdrop in the PDF as on the canvas (ISO 32000-1, 11.4.5): neither its `/K` group nor a form around a clipped group carries `/I true`, and an overprinting element in it overprints the art below the group. Evidence: `pdf/src/tests/blend.rs` (`a_knockout_group_without_blending_inside_is_isolated_only_when_it_isolates`, `an_overprinting_element_of_a_knockout_group_that_does_not_isolate_overprints_the_art_below`). - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; eleven generated style libraries (including Game HUD, Status Effects, Neon & Glow, Painted and Sketched, whose seven styles each repaint a solid fill with one watercolor, dry brush, pastel, charcoal or ink effect, and Textures) save and load as `.vcstyles`. The built-in Textures graphic style library (`graphicStyle.addFromLibrary {library: "textures"}`) has eight styles that paint their fills and strokes with the texture effects: Fine Grain (Grain), Canvas Weave and Burlap Sack (Texturizer), Cracked Glaze (Craquelure), Mosaic Floor (Mosaic Tiles), Quilted Squares (Patchwork), Leaded Glass (Stained Glass) and Damp Paper (Water Paper). The help page names new graphic styles for textures ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html)) without naming them, so the styles, their names and their settings are VectorCraft's own (`tests::stylelib::textures_styles_apply_their_texture_effects_and_keep_their_params_through_save_and_open`). In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. @@ -1728,9 +1755,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`). A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests/import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them. The stops of an axial or radial shading in a DeviceN space that mixes one spot ink with process plates (a spot ink over a constant cyan, for example) link to the ink's swatch at its tint and keep the process plates, and PDF and EPS (Level 3) export write those stops in a DeviceN space of the ink and those plates, so the spot plate is not lost (#3235, `import_color::a_device_n_gradient_of_a_spot_ink_over_a_process_plate_keeps_both_inks`). Solid fills that mix a spot ink with process plates, and stops that mix several spot inks, still import as RGB, with the spot inks kept as swatches. Print separations and Separations Preview print such a stop's process plates on the Cyan, Magenta, Yellow and Black plates beside its tint on the spot plate; with spots printed as process, each process plate prints the stop's plate layered with the spot tint's process equivalent. A freeform gradient's points separate into plates as its stops do, and converting the document's color mode converts them (`print::a_spot_gradient_stop_prints_its_process_plates`, `print::a_freeform_gradients_points_separate_into_plates`, `colorize::separations_preview_shows_a_spot_gradient_stops_process_plates`, `colormgmt::rgb_cmyk_converts_each_freeform_point`). + Print separations and Separations Preview clamp the tint of a fill, stroke or gradient stop linked to a spot swatch or to Registration to 0–1, and a tint that is not a finite number prints no ink, so the plate's coverage stays a number; a stop's process plates still print beside it. This is VectorCraft's own rule (`inks::tests::a_spot_or_registration_tint_that_is_not_a_finite_number_prints_no_ink`, `print::a_spot_gradient_stop_whose_tint_is_not_a_number_prints_no_spot_ink`). A `.ai` or EPS opened from its editing data keeps both swatches when a pattern's name, or a named color's, is also another swatch's: a pattern's swatch named as a default swatch, a named color or a palette color becomes "Name 2", and a named color named as a gradient or pattern swatch of the palette becomes "Name 2", with the fills, strokes and gradient stops linked to it following it (`ai::tests::a_pattern_swatch_named_as_another_swatch_is_numbered`, `ai::tests::a_color_named_as_a_palette_gradient_or_pattern_is_numbered`). PDF export writes a linear or radial gradient that mixes a spot color with process colors, or two spot colors, as a DeviceN shading of the spot inks and the process inks its stops use, so each spot ink prints on its own plate; an off-center midpoint is written as a stop with the inks halfway between its two stops. A gradient that mixes a spot color with RGB or Lab colors is still written without the spot ink, with a warning. A CMYK document, or an export that converts colors to a CMYK destination profile, writes RGB and Lab stops in CMYK first (unless the export converts colors to an RGB destination profile), so there such a gradient, Classic or Perceptual, is written as a DeviceN shading that keeps the spot ink. A Perceptual gradient of spot inks is written as a Separation or DeviceN shading of the stops on its curve; a dithered gradient fill, and a Perceptual gradient with RGB or Lab stops other than white or more than 4,096 stops on its curve, are written as images in process colors, with a warning. A Lab spot color in such a shading is named by the same Separation space, with the Lab alternate, as its flat fills (`engine/src/tests/linked_stops.rs`, `engine/src/tests/labspots.rs`). PDF and EPS export name a spot ink in a gradient's Separation or DeviceN space, and EPS in `%%DocumentCustomColors`, only when a stop linked to it at a tint above 0, or with process plates, colors part of the gradient (VectorCraft's own rule): a stop out of order whose neighboring stretches have no width, and in PDF a stop whose stretches are fully transparent, adds no ink and no empty plate. EPS writes every stop opaque, so there a transparent spot stop keeps its ink (`crates/pdf/src/tests/output.rs`, `crates/eps/src/tests.rs`). + PDF export applies the same rule to a linear or radial gradient drawn as an image: a spot stop that colors only fully transparent stretches, sits out of order where its neighboring stretches have no width, or has a tint of 0 without process plates, leaves a gradient of CMYK stops a CMYK image with no spot color warning. A drawn spot stop at a tint above 0, or with process plates, still makes it an RGB image with the warning (VectorCraft's own rule; `pdf/src/tests/cmykimages.rs`, `gradients_whose_spot_stops_print_nothing_stay_cmyk_images`). PDF export writes a spot color named All or None (in a Separation space, the names of every plate and of no plate: [ISO 32000-1:2008](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf), 8.6.6.4) as the ink All Spot or None Spot (All Spot 2 and on when a spot swatch already has that name) in its fills, strokes, gradients, colorized images and Lab alternates; Registration still prints on every plate (`pdf/src/tests/output.rs`). - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604). Preferences › File Handling › Data Recovery › Folder has a Choose… button on the desktop that picks the folder Data Recovery keeps its copies in ([Illustrator reference, Set Data Recovery preferences](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)); a picked folder that Data Recovery cannot create its folders in or list is refused with a status message, and the field keeps its value. OK applies the same check to a folder typed into the field (VectorCraft's own rule): when it fails, the dialog stays open, the status bar says why and no preference changes; an empty field (the default folder) and the folder already saved are not checked. Preferences › Plug-ins & Scratch Disks is where additional plug-ins folders are set ([Illustrator preferences help](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html)); its Additional Plug-ins Folder has a Choose… button on the desktop as well, VectorCraft's own, matching Data Recovery's, and VectorCraft's own rule refuses a picked or typed folder whose contents can't be listed. Additional Fonts Folder and Templates Folder stay typed only. @@ -1769,10 +1798,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A top object whose top and bottom sides, or left and right sides, have different numbers of segments is also saved live: the side with fewer segments is split into as many as the side across from it has (each segment into equal spans of its curve, a straight one at even steps), up to the 50 a side the grid holds, so the file opens with the same corners and the same mapping of the contents, its outline holding the added anchors. Where the split side would share out the contents another way, as a side of two curves that aren't one curve can, or a side has more than 50 segments, the envelope is written as its distorted art, with the warning (`a_top_object_with_uneven_sides_is_read_back_live`, `a_top_object_split_to_the_grids_limit_is_read_back_live` and `a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`). + Opacity masks in a symbol's art and in a pattern's tile are written the same way, so a symbol or pattern whose mask art has Offset Path opens with the effect live in its definition, and its instances and pattern fills draw as they did; each definition's mask art keeps its own style, and mask art linked to a graphic style opens still linked to it (`a_symbols_mask_art_keeps_live_offset_path`, `a_pattern_tiles_mask_art_keeps_live_offset_path`, `mask_art_styles_stay_with_their_own_art` and `opacity_mask_art_keeps_its_graphic_style_link` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps the document's variables, data sets and the bindings of objects to variables in its editing copy, in the form the variable library reader above reads, so they come back when the file is opened. Bindings to layers, to type written as outlines, to graphs and to linked images written as other objects, data set values of graphs, and variables, data sets, bindings and values past the writer's limits are left out with a warning (#6024; `crates/eps/src/ai_editing/tests.rs` `variables_data_sets_and_bindings_are_read_back`; `crates/eps/src/ai_editing/writer/variables.rs` lists what is left out). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps each wrap object's Text Wrap Options in its editing copy: the Offset, positive or negative, and Invert Wrap ([the help page](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/wrap-or-unwrap-text-around-objects.html)), in the form the `.ai` reader reads them (`text_wrap` in `crates/eps/src/import/ai/read/document.rs`), so they come back when the file is opened. An offset past the -1000 to 1000 pt the reader keeps is written as the nearer end of that range. On a large canvas the offset is written at the art's scale and comes back at its size. Art the writer draws for an object, such as a blend's steps or a symbol instance written as its art, has only that object's options, and a clipping path written as its fills and its strokes has its options once (`crates/eps/src/ai_editing/tests.rs` `text_wrap_options_are_written_and_read_back`, `drawn_art_keeps_only_its_objects_text_wrap`, `text_wrap_offsets_on_a_large_canvas_come_back_at_their_size`, `text_wrap_options_of_type_and_envelopes_are_read_back`, `a_clipping_path_written_as_its_paint_keeps_one_text_wrap`). Type written as its outlines in the editing copy still wraps area type by its bounds, not by its glyphs, when the file is opened: the outlines' group ends with an unpainted rectangle at the type's bounds that carries its Text Wrap Options, in the form the `.ai` reader reads a path's options (`text_wrap` in `crates/eps/src/import/ai/read/document.rs`), and the group keeps the type's name (`crates/eps/src/ai_editing/tests.rs` `type_written_as_outlines_keeps_wrapping_by_its_bounds`). + A symbol instance written as its art, and a blend, mesh, repeat, placed document, intertwine or envelope written as a group of its art, likewise still wraps area type by its bounds when the file is opened, as it did before saving, not by the members of that art: the group starts with the same unpainted rectangle, which takes the object's lock and visibility, and the group keeps the object's name and transparency without options of its own. Area type in that art is above the rectangle, so the rectangle does not wrap it. The rectangle takes its place in the object limit before the art; without room for it, the options are left out with the limit's warning. An object without finite bounds keeps the options on its group. This is VectorCraft's own round-trip rule (`crates/eps/src/ai_editing/tests.rs` `live_art_written_as_a_group_keeps_wrapping_by_its_bounds`, `wrap_bounds_of_live_art_fit_the_object_limit_and_leave_area_type_in_the_art_unwrapped`, `live_art_without_bounds_keeps_its_text_wrap_on_the_group`). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. `.ai` editing-data import restores the document's listed graphic styles in panel order and links objects that use a listed style; unsupported appearances keep their drawn look (`crates/eps/src/import/ai/tests.rs`). A `.ai` or EPS graphic style library, whose editing data holds the styles and their list but no layers, opens with those styles in list order. Other Library… loads another document's graphic styles ([Apply graphic styles](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/apply-graphic-styles.html)) and lists such a library's styles in a library panel. A `.ai` or EPS read as its PDF part or page because its layers can't be used also gets the styles of its list in place of the default ones (`crates/eps/src/import/tests_native.rs`, `cmd::fileio::tests_aiimport::a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles`). @@ -1845,8 +1876,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur EPS export and EPS Save make each file's PNG thumbnail from its TIFF preview, averaging the preview's pixels down to the thumbnail's size on white, instead of drawing the document a second time; a file without a preview still draws its thumbnail (`crates/engine/src/cmd/fileio/tests_eps.rs`). - Targa files (`.tga`, `.vda`, `.icb`, `.vst`) open, place linked or embedded, relink and update at their pixel size: color-mapped, true-color and black-and-white, raw or run-length encoded, in any of the four row orders. The image descriptor's attribute bits and the extension area's Attributes Type decide the transparency: a file with no attribute bits, or with attribute data the Attributes Type calls undefined, opens opaque; with one bit, a 16-bit true-color pixel takes that bit as its alpha, and a color-mapped pixel takes the bit of the 16-bit color map entry it indexes; premultiplied colors are divided by their alpha. Open and Place recognize a Targa file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a Targa file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/tests/links.rs`). - BMP files named `.dib` or `.rle` (run-length encoded bitmaps included), Photoshop files named `.pdd` and PNG files named `.pns` open and place, linked or embedded, as BMP, PSD and PNG files, in any letter case: the Open and Place dialogs list them with their format, the Links panel names their format, and Edit Original opens them. Exports write only `.bmp`, `.psd` and `.png` (`crates/engine/src/cmd/fileio/tests_tiffbmp.rs`, `crates/engine/src/cmd/fileio/tests_psdread.rs`, `crates/engine/src/tests/links.rs`). - - File › Open converts a PSD or PSB file's layers to objects, keeping the layer structure, merging clipped layers into one layer and leaving out hidden layers that would need a merge, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for the option that converts layers to objects; hidden layers come in with `hiddenLayers`, as Import Hidden Layers brings them in, and `layers: false` opens the merged image. VectorCraft's own rules: each layer becomes a layer holding its pixels with its name, visibility, opacity and blend mode, groups become layers of sublayers, layers in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, layer masks become opacity masks, fill opacity multiplies the opacity (in a layer that others are clipped to, it fades only that layer's own pixels), and adjustment layers, layer effects, knockouts and vector masks aren't applied and are counted in the warnings. 8-, 16- and 32-bit files, PSB, raw, PackBits and zipped channels. There is no PSD import options dialog yet (`crates/engine/src/cmd/fileio/tests_psdlayers.rs`). + - File › Open converts a PSD or PSB file's layers to objects, keeping the layer structure, merging clipped layers into one layer and leaving out hidden layers that would need a merge, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for the option that converts layers to objects; hidden layers come in with `hiddenLayers`, as Import Hidden Layers brings them in, and `layers: false` opens the merged image. VectorCraft's own rules: each layer becomes a layer holding its pixels with its name, visibility, opacity and blend mode, groups become layers of sublayers, layers in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, layer masks become opacity masks, fill opacity multiplies the opacity (in a layer that others are clipped to, it fades only that layer's own pixels), and adjustment layers, layer effects, knockouts and vector masks aren't applied and are counted in the warnings. 8-, 16- and 32-bit files, PSB, raw, PackBits and zipped channels (`crates/engine/src/cmd/fileio/tests_psdlayers.rs`). + - File › Open of a PSD or PSB file with two or more layers, and File › Place of one without Link, show PSD Import Options: Convert Layers to Objects with Import Hidden Layers, or Flatten Layers to a Single Image, the options [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for a file that is opened or embedded. A file placed with Link shows its flat image and isn't asked about. `file.place` takes `layers` and `hiddenLayers`, and `document.psdInfo` counts a file's layers without opening it. VectorCraft's own rules: placed layers come in as one group named after the file, a layer as an image and a layer group as a group, at the place the merged image would take and clipped to the canvas when layers reach past it; the dialog asks from two layers up and starts from the choices made last. Layer comps aren't read (`crates/engine/src/cmd/fileio/tests_psdlayers.rs`, `crates/ui-egui/src/dialogs/psd_import.rs`). - A PSD or PSB file's clipping path, the saved path that image resource 2999 names in the [PSD file format specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), clips its merged image on File › Place and on File › Open of the merged image, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for a file imported as one flattened image. The image comes in a clip group whose clipping path has no paint and uses the even-odd rule, and the path can be edited or released. `links.update` and `links.relink` keep the clip group, and files that name no clipping path come in as before (`crates/engine/src/cmd/fileio/tests_psdread.rs`). + - Import Slices: File › Open of a PSD or PSB file with slices, and File › Place of one without Link, bring its slices in as user slices, as [PSD import options](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) describes for a file that is opened or embedded; PSD Import Options offers Import Slices for such a file. `document.open` and `file.place` take `importSlices`, and `document.psdInfo` counts the slices. The slices are read from image resource 1050 in version 6 and in the descriptor of versions 7 and 8, as the [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/) describes it. VectorCraft's own rules: Import Slices applies to Convert Layers to Objects and to Flatten Layers to a Single Image alike, and makes the dialog ask for a file with fewer than two layers; placed slices move and scale with the art in the same undo step; each slice keeps its name, type, link, target, message, alternative text, cell text and background color; slices the file generated automatically, and slices wholly outside the canvas, are left out; slices that can't be read are left out with a warning while the art comes in; with Show Import Options off, a PSD file is placed with the PSD Import Options chosen last, and on, each PSD file of the place cursor asks for them (`crates/engine/src/cmd/fileio/tests_psdslices.rs`, `crates/ui-egui/src/dialogs/psd_import.rs`). - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). @@ -1859,6 +1892,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - A 16-bit CMYK or grayscale TIFF with an alpha channel is converted from its 16-bit samples, and only the resulting color and alpha are rounded to 8 bits; associated alpha is divided out at 16 bits, and a pixel whose associated alpha is zero has no color. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives extra samples the depth in BitsPerSample, and its Associated Alpha Handling section states the arithmetic and the zero-alpha rule for RGB data; applying them to CMYK and gray samples is VectorCraft's own rule. Each sample was cut to its high byte first, so a faint ink or a low alpha could be lost (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). + - An opaque 16-bit CMYK TIFF, chunky or planar, shows each ink rounded to the nearest of 256 levels, `round(s × 255 / 65535)`, as the [PNG specification's sample depth rescaling](https://www.w3.org/TR/png-3/#13Sample-depth-rescaling) gives it; [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) sets 0 and 65535 as no ink and full ink, and using that rounding for TIFF inks is VectorCraft's own rule. Each ink was cut to its high byte before, so an ink of 200 showed as none. The stored image keeps its 16-bit samples (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - A CMYK or grayscale TIFF whose alpha channel comes after another extra channel places and opens with that alpha, 8 and 16 bits, chunky or planar, and the other extra channels are left out. A channel that the file does not mark as alpha is not read as transparency, even when its ExtraSamples tag has fewer values than the file has extra channels. A CMYK TIFF with two or more extra channels past its ExtraSamples values, or with no ExtraSamples tag, opens opaque when none of them is marked as alpha, and a white-is-zero grayscale TIFF with an alpha channel opens too. A document saved with such a CMYK TIFF as a CMYK image keeps it as one, with its ink amounts. Such a TIFF is read when its samples take more than 256 MiB, as long as its samples, the copy a planar file needs, the row the decoder reads each strip or tile into and the RGBA pixels together fit the decoding allowance; a TIFF whose SamplesPerPixel is more than 64 is not read, which is VectorCraft's own limit, and both checks run on the file's tags before any decoding. [TIFF 6.0](https://printtechnologies.org/standards/files/tiff-v6.pdf) gives each extra channel its own ExtraSamples value, in order (pp. 31–32), and tells readers to skip extra channels they do not use (p. 27); reading the first channel marked as alpha is VectorCraft's own rule (`crates/doc/src/cmyk.rs`, `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. @@ -1897,6 +1931,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. - PDF export writes a CMYK image that keeps its numbers in an `ICCBased` color space holding, byte for byte, the CMYK profile the image carries (a CMYK TIFF's tag 34675, or a CMYK JPEG's `ICC_PROFILE` APP2 segments) when `output.profiles` is `all` or `taggedSource`, and in PDF/A-2b, PDF/X-3 and PDF/X-4 unless it is `destination` ([the help page](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/pdf-marks-bleeds-and-color-output-options.html)). This covers a CMYK JPEG embedded as it is, one the Compression settings resample or compress again, a TIFF written again, and an image written with its mask as its soft mask. With `none`, and in PDF/X-1a, the image is DeviceCMYK and a JPEG's own profile is left out, so PDF/X-1a export writes a CMYK JPEG that carries a profile. Pixels the Output settings convert are never tagged with the profile of their old numbers. A profile tags an image only when its size, signature, class, CMYK color space and tag table are well formed and it is at most 16 MiB (VectorCraft's own limit). Transparency in a tagged CMYK document blends in the document's CMYK profile; when the file holds no space of that profile it blends in DeviceCMYK, or keeps its RGB space in PDF/A, and never in an image's own profile (`crates/pdf/src/tests/cmykimages.rs`, `export::writer::tests`). + - PDF import keeps the CMYK profile of an image in a four-component `ICCBased` color space, whose stream holds the profile ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)), byte for byte with the image's ink amounts, so PDF export can write the image in it again. A CMYK TIFF carries it as tag 34675, whatever the image's Decode array and bit depth ([ICC.1:2010, Annex B](https://www.color.org/specification/ICC1v43_2010-12.pdf)). A CMYK JPEG kept as it is carries it in `ICC_PROFILE` APP2 segments in place of a profile of its own, with its compressed samples unchanged, and one whose segments can't hold it is stored as a CMYK TIFF carrying it. A profile that isn't well formed, isn't of CMYK colors or is over 16 MiB (VectorCraft's own limit) leaves the image's inks as they are, untagged. DeviceCMYK and DeviceN images carry no profile, and a CMYK TIFF's tag 34675 is read up to 16 MiB. EPS and PostScript import does not keep one: PostScript defines no `ICCBased` profile stream (`crates/pdf/src/tests/cmykimages.rs`, `cmyk::tests`). - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - PDF export writes a stored gray or RGB TIFF in an `ICCBased` color space that holds the gray or RGB profile of its tag 34675, at 16 bits a sample from PDF 1.5 on and 8 before, with its alpha as a soft mask only when some pixel is not opaque ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)). A gray or RGB image, a PNG included, that the Compression settings resample or compress with JPEG keeps its profile too, and gray pixels in an RGB profile stay RGB. Pixels the Output settings convert, a profile whose color space differs from the samples', and a malformed profile, a PNG's or JPEG's kept as it is included, are written without it; such a JPEG keeps its JPEG data. PDF/X-1a allows no ICC-based color spaces, so there a gray image with a gray profile, kept as it is, resampled or compressed with JPEG, is written in DeviceGray without the profile, a JPEG kept as JPEG with its JPEG data unchanged, and RGB images are converted to CMYK as before. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`). - PDF export with Output › Convert to Destination and an sRGB destination converts a gray or RGB image that carries an ICC profile from that profile to sRGB, with the color settings' rendering intent, and writes it without the profile, as the conversion applies to every color ([PDF options](https://helpx.adobe.com/illustrator/using/pdf-options.html)): 8-bit and 16-bit TIFFs (16 bits a sample from PDF 1.5 on when not resampled or recompressed), PNGs, JPEGs (kept as JPEG) and resampled images. A JPEG in an sRGB profile keeps its JPEG data, a black-and-white image whose converted levels are gray is written as grayscale, and without conversion every image keeps its profile. An image whose profile the color engine can't read, or whose pixels can't be decoded, is not converted: it keeps its profile, with a warning (`crates/pdf/src/tests/compression.rs`). @@ -1926,8 +1961,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Links panel menu's Sort by Size ([Links panel](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/links-panel-in-illustrator.html)) lists the links by file size, smallest first (`links.list {sort: "size"}`). VectorCraft's own rules: a file's size is the one Link Info shows, rows without one (embedded images, a missing file whose size the document does not keep) come last, and equal sizes go by name. - When a document opens with notes and with linked files that changed on disk (Update Links: Manually) or, on the web, can't be read, the status bar shows the line about the linked files, then counts the notes and names the first, and hovering it lists the notes. The same holds when the line about modified files follows the Linked File Not Found dialog, and File › New from Template… shows the notes of the file it opens as File › Open does (`an_open_with_modified_links_keeps_link_notice_and_import_notes_reachable`, `new_from_template_preserves_open_warnings`, `an_open_with_unreadable_links_keeps_import_notes_reachable`, `modified_links_after_the_missing_ones_keep_the_open_notes`, `combined_open_and_link_status_lists_notes_on_hover_and_for_accessibility`). - On the web, when a document opens with linked files that can't be read and linked files that changed on disk (Update Links: Manually), the status bar shows the line about the unreadable files, and the line about the changed files is the first of the notes its hover lists, ahead of the open's notes. With Ask When Modified the update question opens and the line about the unreadable files stays (`a_web_open_with_unreadable_and_modified_links_keeps_both_lines`). + - After the last answer in the Linked File Not Found dialog (Replace, with a path or a picked file, Ignore, either with Apply to All, or Relink in the Find Linked Files dialog its Find in Folder… opens), and after Cancel in either dialog, the status bar shows the open's notes again, after the line about modified files when Update Links is Manually, and hovering it lists them, also when the relink or another action showed a message meanwhile. A canceled file picker leaves the question open (`the_last_missing_link_answer_keeps_the_open_notes`, `the_last_replace_keeps_the_open_notes`, `the_last_find_in_folder_answer_keeps_the_open_notes`). - Linked images and placed documents kept only in symbols, graph designs, patterns or opacity masks are listed in the Links panel after the layers' own, one row per stored object, with their status in its Missing and Modified filters, in Check Links, in the missing and modified files reported when a document opens, in the link watch and in Find in Folder; Relink, Update, Embed, Placement Options and Link Info act on them, and Go To says they have no place on the canvas. Opening a document gives such an object whose id is 0 or repeats another object's a new id. - Linked images and placed documents in the art of the stored brushes (a scatter or art brush's art, a pattern brush's tiles) get Links panel rows of their own the same way, one per linked or placed object in each brush tile, naming the brush; Relink, Update, Embed, Placement Options, Link Info and Go To act on that art and write it back into the brush library, and the ids they are listed by are kept there. Evidence: `links_in_stored_brush_art_are_listed_and_checked_with_ids`, `links_commands_act_on_images_in_stored_brush_art_by_id`, `links_in_stored_brush_art_open_by_id_and_unreadable_entries_stay`. + - Saving a `.vectorcraft` file reads the images and placed documents in the art of the stored brushes as it reads those in the layers. A linked image shown only in a brush's art is saved as its preview, or with Include Linked Files as its file's bytes, and an image key that the layers or a brush's art shows embedded keeps its full bytes. Saving for an older version replaces the placed documents in a brush's art with groups of the art they show, with that art's resources, before a precision rounds it, so the preview and PDF match the file. Data Recovery copies keep the same images. These are VectorCraft's own rules for its native format; the [help page](https://helpx.adobe.com/illustrator/using/saving-artwork.html) describes Include Linked Files as embedding the files linked to the artwork. Evidence: `brush_only_link_saves_preview_unless_included`, `shared_brush_image_key_keeps_full_bytes_for_embedded_use` (vectorcraft-format), `saved_for_older_apps_placed_documents_in_brush_art_become_their_art`, `recovery_brush_links_keep_proxy_and_embedded_full_bytes` (vectorcraft-engine). - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). - A look at the linked files whose worker thread ends without a result, such as after a panic, no longer stops later looks: the app clears it, stops asking for a frame every 100 ms, and the next look starts on schedule (`crates/engine/src/tests/linkwatch.rs`, `crates/ui-egui/src/tests/place.rs`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). @@ -1972,7 +2009,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). When an edit changes a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve), the canvas draws again where its art was and is, measured by that art: type and groups were measured by their letters or members, so a shallower or turned solid left pieces of the old one on the canvas. When the canvas draws the part on its own thread, it measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type, where they cropped it to the letters, and Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once unless the object has brushed strokes: 30 extruded type objects measured over 30 artboards take about 2 ms in a release build, where measuring their letters took under 1 ms. A document's first frame builds every 3D object's solid, off the view too, to know whether it reaches into the view. When only anchors of a path or compound path move, the canvas draws again only around the segments that start or end at a moved anchor, grown by the farthest its strokes reach, instead of the whole object before and after. This holds while every fill and stroke is a solid color, and the strokes have no dashes, width profile, arrowheads or brush, and the object has no effects or opacity mask; any other object still counts whole. On a loaded Windows machine at 1600 × 1200, single-threaded in a development build, the frame after one anchor of a filled closed path moves takes 16.6 million CPU cycles instead of 56.4 million with 50,000 anchors and 65.6 million instead of 180.0 million with 200,000 anchors (medians of 15 edits, the two builds run in turn three times). - The check after an edit that a selected path or compound path stays within the canvas passes without measuring the path when every anchor and handle of it is within the canvas, and measures it only when one is not. Moving one anchor of a selected open path of 10,000 anchors (`path.moveAnchor`, `path.setHandle`, `path.moveAnchors` or a nudge) measured all 9,999 segments of the path and now measures at most 16 (counted in the engine's tests). The edit still copies the path's anchors for undo, and the renderer still compares every anchor of the path to find the moved ones. + The check after an edit that a selected path or compound path stays within the canvas passes without measuring the path when every anchor and handle of it is within the canvas, and measures it only when one is not. Moving one anchor of a selected open path of 10,000 anchors (`path.moveAnchor`, `path.setHandle`, `path.moveAnchors` or a nudge) measured all 9,999 segments of the path and now measures at most 16 (counted in the engine's tests). + A subpath keeps its anchors in chunks of up to 256 anchors, and the versions of a path share the chunks they have not changed. Editing one anchor copies only the chunk that holds it, so its undo step keeps that chunk instead of a copy of every anchor; adding or deleting an anchor copies the chunk it is in and rebuilds the subpath's table of chunks. When the renderer looks for the anchors that moved, it compares only the chunks the edit copied. Moving one anchor of a selected open path of 10,000 or 200,000 anchors (`path.moveAnchor`, `path.setHandle`, `path.moveAnchors` or a nudge) keeps at most 48 KiB in the undo step and in the redo step, where it kept a copy of every anchor at 56 bytes each (counted in the engine's tests). Files are written and read as before, each subpath's anchors as one list. Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200, in medians of seven interleaved rounds of a local example, counted in the main thread's CPU cycles because other builds were running: with 5,000 point type objects in view the first frame takes 409 million cycles instead of 894 million and the open 816 million instead of 665 million, so the two together take 1,225 million instead of 1,559 million; with one area type object of 500 paragraphs the first frame takes 697 million instead of 1,193 million and the open 535 million instead of 568 million. Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460). @@ -2119,6 +2157,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **The warning of a preview applied when a command ends a drag:** a preview that Real-time Drawing off left unapplied, and that Undo, Clear History or another command applies before it runs, keeps its warning: the command's result carries it as `previewWarning`, and as `warning` when the command has none of its own, and the status bar shows it, as releasing the drag shows it. Typing kept as its own step when a tool's drag begins, or when Cmd lends the Selection tool, shows its applied preview's warning in the status bar too. Regressions: `settling_a_deferred_preview_keeps_its_warning` and `settling_typing_for_a_tool_shows_its_preview_warning` in `crates/engine/src/tests/prefs.rs`, `settled_warnings_join_the_result` in `crates/engine/src/session/execute.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Painted effects refuse gradient stop plates without a spot link (G17):** a gradient stop with process `plates` in a painted live effect's fill or stroke must link to a spot swatch of the document itself; a stop with no link, a `null` link or only the gradient's own `swatch` refuses the whole result, and so does a link to a process swatch or Registration, also when a cached result's swatch stops being a spot swatch. `lastError` names the stop's `plates`. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/effect.rs` (`cached_painted_plates_refuse_after_spot_becomes_process`). Scores and hours unchanged. - **Later effects on every fill or stroke a painted effect returns (G17):** when a painted effect on a fill or stroke returns several fills or strokes, a later live-effect plug-in in that fill's or stroke's stack, and a later geometry effect on strokes, runs once on each of them in paint order, given its own paint and its own `_context.item` among them. Each run's pieces replace only that fill or stroke. A run that fails, is refused, is deferred or would pass the effects' budget leaves only its own fill or stroke as it was, and the others still run; a deferred stage is listed once, at the source fill's or stroke's place. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/effects/src/tests/painted.rs`. Scores and hours unchanged. - **Painted pieces with brushed strokes are culled and measured by their brush art (G17):** a piece that keeps the object's brushed stroke draws the brush along the piece's own path at the piece's stroke width. Culling leaves VectorCraft's usual room for brush art (16 × the stroke width + 8 pt) past each piece, from the piece's width rather than the object's, and so does culling of a group, layer, symbol instance, blend, envelope or repeat that holds the object. A group or layer also leaves that room for each brushed path among its members, painted or not. Object › Rasterize takes the brush art drawn along each piece and leaves out the object's own brush art, which is not drawn. Pieces without a brush are culled as before. Regressions: `crates/render/src/tests/paintedbrush.rs`. Scores and hours unchanged. - **Brushed paths just off the page are exported and printed:** PDF and EPS export keep a path or compound path whose brush art reaches the page while the path lies just off it, alone or in a group, by leaving the canvas's room for brush art (16 × the widest stroke + 8 pt, VectorCraft's own value) past its box. Skip Blank Artboards keeps an artboard such brush art reaches, and Print with Ignore Artboards frames the page with that room around brushed paths and around instances whose art holds brushed strokes. A clip group leaves room only for its clipping path. Art without brushed strokes is written as before. Regressions: `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/print.rs`, `crates/eps/src/tests.rs`. Scores and hours unchanged. @@ -2219,6 +2258,13 @@ With `ids`, the text tools act on the type the ids name even when it, or its gro rule). Tests: `named_locked_and_hidden_type`, `remove_breaks_named_locked_and_hidden_type` and `remove_breaks_named_story_with_a_locked_frame` in `cmd::addons::cleanup::text::tests`. +With `ids`, Simplify Anchors, Deep Ungroup and Rejoin Path Pieces act the same way on the locked and hidden paths and +groups the ids name, and on those in the groups and layers they name, but not on art on a locked artboard; from the +selection they still take only art that can be edited (VectorCraft's own rule). Rejoin Path Pieces joins only pieces +alike in being hidden and in being locked, and the objects of a group Deep Ungroup takes apart keep its hidden or locked +state. Tests: `simplify_named_locked_and_hidden_paths`, `deep_ungroup_named_locked_and_hidden_groups` and +`rejoin_named_locked_and_hidden_pieces` in `cmd::addons::cleanup::tests`. + ## Path Precision Tools add-on (2026-10-30) A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools, @@ -2525,6 +2571,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Paragraph composers (#4935): `text.setFormat {composer}` and paragraph styles take `singleLine` and `everyLine` (the Middle Eastern & South Asian composers, what all type used and the default) and `standardSingleLine` and `standardEveryLine`, which break lines the same way and set a complex script (Indic, Arabic, Hebrew) glyph by glyph as the font maps its characters, with no conjuncts, reordering, marks placed or joining forms. The choice is per paragraph, saved in the native file only when it is not Every-line, and not written to `.ai`, EPS or PDF files, whose composer setting this app has no recorded format for. Scores and estimates unchanged. Tests `tests::composers`. | | 2026-11-14 | minor | G5: Weather Icons joins Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons as a sixth built-in symbol library (Sun, Moon, Cloud, Rain, Thunder, Snow; original procedural vector symbols generated in code, no default 9-slice); area score and estimate unchanged. | | 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | diff --git a/docs/three-d.md b/docs/three-d.md index 82ce5633d0..2861bee0b7 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -548,8 +548,13 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. - **3D (Classic)**: Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) are built, with their surface types and blend steps. Revolve (Classic) sweeps the surface Revolve sweeps, at 64 segments for a full turn, and draws every face back to front, so it has no Draw Hidden Faces setting. Rotate (Classic) turns the plane Rotate turns, seen - from either side, with the No Shading and Diffuse surfaces only. The track cube, map art, Turntable and 3D export - are separate, unbuilt items. + from either side, with the No Shading and Diffuse surfaces only. Preserve Spot Colors, on the Lighting tab of all + three, keeps a color linked to a spot or global swatch linked on every face: the highlight lowers its tint, and a + face that darkens gets black over it, multiplied and overprinting, so a spot color stays on its own plate. The + checkbox is unavailable while Shading Color is Custom + ([Preserve Spot Color](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=260)); how + the faces are shaded with it is VectorCraft's own. The track cube, map art, Turntable and 3D export are separate, + unbuilt items. Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md). diff --git a/docs/type-parity.md b/docs/type-parity.md index 9a54795581..5b332efd5e 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Paragraph composers (#4935): the Paragraph panel menu and paragraph styles offer four composers, the Middle Eastern & South Asian Single-line and Every-line (how all type is set, and the default) and a Standard Single-line and Every-line that set Indic, Arabic and Hebrew type glyph by glyph as the font maps it, for legacy fonts that keep a script on Latin character codes; `.ai` and EPS files do not carry the choice; scores and estimates unchanged; previously minor: kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: type's strokes in an envelope paint along its bent glyph outlines at their own weight, Distort Appearance on or off, as users' `.ai` files draw them, #6644; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) Glyphs review (#6084): a positional variant no supported feature draws is accepted as a glyph choice. Entire Font respects the selection's variable axes and font version; the default cell can reset an unlisted choice without GSUB alternates. Contextual choices use the supplying face, including fallback. Feature-only boundaries preserve ligature clusters and Arabic mark attachment. Encoded characters insert unchanged except Arabic presentation forms with a joining base letter; normalization reports success only after insertion succeeds. Latest follow-up regressions await runtime verification. Scores and estimates unchanged. @@ -41,7 +41,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` | | Fallback glyphs keep the text's weight and slant | done | #2491: a character from a fallback face is set in that family's face nearest the run's weight and slant; Bold Italic of a missing family is drawn Bold (Source Sans 3 has no Bold Italic) rather than Italic; a letter and its combining marks (a grapheme cluster) are drawn in one face, the first that has all of them, so marks the text's font lacks attach to their letter instead of drawing as missing glyphs (#4491). Vietnamese letters with two marks (Ể, Ứ, Ự) sit on their letter whether typed precomposed, decomposed or as a vowel plus a tone mark, built from GPOS mark-to-mark in fonts without the precomposed letters. Evidence: `tests::vietnamese` | | Colour fonts (bitmap, COLR, OpenType SVG) | partial | bitmap glyphs (`sbix`, `CBDT`) done: the largest strike's PNG, transformed with the type, fill and stroke ignored; PDF Type 3 glyphs or images, SVG images with outlined text (live text leaves emoji to the viewer, their fonts not embedded), EPS and legacy `.ai` images, Create Outlines images; not `sbix` duplicate, flipped, JPEG or TIFF glyphs, or `CBDT` BGRA or mask data, or ruby; in envelopes they warp as images do, and a Warp effect moves them without bending them. COLR (v0 and v1) done: a glyph's layers as vector art (solid colours, linear and radial gradients that pad, repeat or reflect (the [COLR specification](https://learn.microsoft.com/en-us/typography/opentype/spec/colr)'s extend modes), clip glyphs and boxes, blend modes, transforms, palette 0, the foreground colour from the run's fill, variable fonts at the face's instance), transformed with the type; drawn on the canvas, written as paths in SVG, EPS, legacy `.ai` and PDF with outlined text, as text in PDF (Type 3 glyphs), and Create Outlines makes groups of editable paths. Paints documents have no form for draw the glyph as an image (256 px per em): sweep gradients, Porter-Duff compositing, radial gradients from a circle that isn't the centre, radial gradients that repeat or reflect and start at a circle, gradients that repeat or reflect with a stop whose color isn't in the palette. Not ruby. COLR and SVG glyphs also go with type that geometry effects reshape (on the canvas and in exports), into Expand Appearance and EMF/WMF export, as their art, and bitmap glyphs as images. OpenType SVG done: a glyph's SVG document (its element `glyph`) is read by the SVG importer into vector art in font units, `currentColor` in the run's fill (and the legacy `context-fill` and `context-stroke` in its fill and stroke), a `viewBox` mapped onto the em square, and then drawn, exported and outlined as COLR glyphs are; in PDF real text such glyphs are text that draws nothing, with their art drawn where they sit. Images in glyph documents are in the art, holding their pixels until a document takes them in; text, scripts and embedded content are left out, as the table rules them out. Not CPAL palette variables. Evidence: `tests_svg_glyphs`, `svg/tests/it/svg_glyphs.rs`, `tests_colourfonts::svg_glyph_images_draw_and_outline`, `tests_colourfonts::create_outlines_turns_svg_glyphs_into_groups`, `tests_colourfonts::pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters`, `tests_colourfonts::expand_appearance_keeps_the_colour_glyphs_of_type_with_effects`, `render::tests_colrglyphs::colour_glyphs_go_with_type_that_a_geometry_effect_reshapes`, `tests_bitmaps`, `tests_colr`, `render::tests_bitmapglyphs`, `render::tests_colrglyphs`, `pdf::tests::colour_bitmap_glyphs_export_as_images`, `pdf::tests::colr_glyphs_export_as_vector_art`, `svg/tests/it/colour_glyphs.rs`, `eps` `colour_bitmap_glyphs_*`, `colr_glyphs_*` and `type_taken_back_for_the_page_limit_takes_its_glyph_images_with_it`, `tests_pathops::create_outlines_turns_bitmap_glyphs_into_images`, `tests_pathops::create_outlines_turns_colr_glyphs_into_layer_groups` | -| Every-line and Single-line composers | done | tuned by eye, never compared side by side | +| Every-line and Single-line composers | done | tuned by eye, never compared side by side; each in a Middle Eastern & South Asian and a Standard form (#4935), see below | | Optical Margin Alignment | done | Type › Optical Margin Alignment, `type.opticalMarginAlignment`: per type object, with one setting for all the frames of a thread, in point and area type of left-to-right paragraphs, and at the brackets of type on a path whose first paragraph runs left to right; right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, do not hang | | System Layout | done | Character panel menu › System Layout, `type.systemLayout`: the whole document, off by default and for documents from before it, saved only when on. On, every text object advances by whole points (at least one when a character takes space). New point, area and path type, threaded frames, placed text, graph and dimension labels and pasted plain text inherit it. Evidence: `tests::system_layout_rounds_positive_advances_to_whole_points`, `typecmd::area_tests::system_layout_sets_the_document_and_every_text_object`, `panels::character::tests::system_layout_menu_item_toggles_the_document` | | Fractional Widths | done | Character panel menu › Fractional Widths, `type.fractionalWidths`: per type object, and every frame of its threads. On by default and for documents from before it; saved only when off. Off, each character that takes space advances by a whole number of points, at least one, so small type does not run together. Evidence: `tests::fractional_widths_off_rounds_positive_advances_to_whole_points`, `typecmd::area_tests::fractional_widths_is_set_per_object_and_saved_only_when_off`, `panels::character::tests::fractional_widths_menu_item_toggles_the_type_object` | @@ -62,7 +62,8 @@ against Illustrator's public documentation; **estimated** unless noted. | Middle Eastern: bidirectional layout, Paragraph Direction | done | PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | Middle Eastern and Indic: digit types (Character panel menu › Digits: Default, Arabic, Hindi, Farsi; Devanagari, Bengali, Gurmukhi, Gujarati, Odia, Tamil, Telugu, Kannada, Malayalam) | done | `text.setFormat` / `text.setRangeStyle {digits}` (`"devanagari"`, `"bengali"`, `"gurmukhi"`, `"gujarati"`, `"odia"`, `"tamil"`, `"telugu"`, `"kannada"`, `"malayalam"` added, #4942); the text keeps the typed digits, the glyphs and SVG export follow the style; carried by character styles. Not yet: `.ai` export of the setting; Indic digits typed as such stay as typed under another system. Evidence: `text::tests::indic_digits_map_to_their_scripts_block`, `tests::bidi::indic_digits_draw_the_scripts_own_digits`, `typecmd::area_tests::indic_digits_round_trip_through_the_commands_and_the_file` | | Middle Eastern: Kashidas | done | Paragraph Insert Kashidas sets the length for justified Arabic area type; Character-menu Kashidas allows or excludes selected characters, on by default. A kashida is inserted only between two allowed joined characters. The character switch is retained in character styles and the native file; the length is retained in paragraph styles and the native file. Paint-only harakat (coloured or hidden marks) stay in one shaping cluster so letters keep joining; when the character-level pass matches, a glyph may take a later in-cluster character's run only if GDEF names it a mark (class 3) and that character is a harakat—otherwise the cluster start (a mark may miss its colour; a base never takes a mark's). Inserted tatweels take the base letter's paint—not a trailing mark's. Fonts without GDEF and some AAT/`morx` ligatures (e.g. Geeza Pro Allah) can leave a mark painted as its letter. No Character-panel harakat colour command yet. Evidence: `tests::harakat`. [Public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html). | -| Middle Eastern: Middle Eastern composers, split caret | missing | Character Direction is done; editable bidirectional SVG text remains incomplete. | +| Middle Eastern & South Asian composers and Standard composers (Paragraph panel menu, paragraph styles, `text.setFormat {composer}`) | done / partial | #4935: per paragraph, four choices. Middle Eastern & South Asian Single-line and Every-line are how all type was already set (complex-script shaping on) and stay the default, so existing documents do not change. Standard Single-line and Every-line break lines the same way and set Indic, Arabic and Hebrew type glyph by glyph as the font maps its characters (no conjuncts, reordering, mark placement or joining), for legacy fonts that keep a script on Latin character codes. Indic, Arabic and the other joining scripts and Hebrew are the scripts left unshaped (their letters do not join, so Insert Kashidas adds no tatweel); Latin, Greek, Cyrillic, CJK, Thai and the rest keep their kerning and ligatures. The native file and paragraph styles keep the choice (`standardSingleLine` / `standardEveryLine`; an older VectorCraft cannot read a file that uses them). `.ai`, EPS and PDF import and export carry no composer: this app has no recorded format for it, so a Standard paragraph opened from or saved to those files comes back Middle Eastern & South Asian. Evidence: `tests::composers`, `tests::panelcmds::text_set_format_takes_the_four_composers`, `native_v3::all_four_composers_round_trip` | +| Middle Eastern: split caret | missing | Character Direction is done; editable bidirectional SVG text remains incomplete. | | CJK: input methods (marked text, candidate window) | done | | | CJK: kinsoku (Hard, Soft, None), the predefined mojikumi sets (None, YakumonoHankaku, GyomatsuYakumonoHankaku, GyomatsuYakumonoZenkaku, YakumonoZenkaku, with VectorCraft's own spacing for each) and Line-end Punctuation Half Width, tate-chu-yoko (auto) | done / partial | custom kinsoku sets, custom mojikumi sets and the Mojikumi Settings dialog missing | | CJK: Kinsoku Shori Type (Push In First, Push Out First, Push Out Only) | done | Paragraph panel menu › Kinsoku Shori Type (`text.setFormat {kinsokuType}`), with the East Asian options ([Formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); Push Out Only is the default, and a push-in closes the line's gaps next to CJK characters by at most a tenth of an em each (VectorCraft's own limit). Evidence: `layout::wrapping_tests::kinsoku_shori_type_pushes_a_closing_bracket_in_or_out`, `tests::vertical::kinsoku_shori_type_pushes_a_closing_bracket_in_or_out`, `typecmd::area_tests::kinsoku_shori_type_is_set_per_paragraph_and_saved_only_when_not_push_out_only` | @@ -72,7 +73,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: Standard Vertical Roman Alignment | done | Character panel menu › Standard Vertical Roman Alignment (`text.setFormat` / `text.setRangeStyle {standardVerticalRoman}`), with Show East Asian Options, off by default: in vertical type the marked half-width characters (Latin letters, digits and other characters that would lie on their side) stand upright one below the other, each in its upright cell; spaces keep their width and tate-chu-yoko blocks stay across the column; horizontal type keeps the mark and does not change. Character Style Options has its own checkbox for it. Evidence: `tests_vertical::standard_vertical_roman_alignment_stands_half_width_characters_upright`, `typecmd::standard_vertical_roman_is_a_character_attribute_saved_only_when_on` | | CJK: East Asian glyph forms | done | Glyphs panel menu › Traditional Forms, Expert Forms, JIS 04, JIS 90, JIS 78 and JIS 83 Forms, Monospaced Full-Width, Half-Width, Third-Width and Quarter-Width Forms, and Revert To Default Forms, with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): the selected characters are drawn with the font's OpenType feature for that form (`trad`, `expt`, `jp04`, `jp90`, `jp78`, `jp83`, `fwid`, `hwid`, `twid`, `qwid`; [feature list](https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist)), one form at a time (`text.setFormat` / `text.setRangeStyle {glyphForm}`); a form none of the faces that draw the selected text (fallback faces included) has a feature for (in `GSUB`, or for the width forms `GSUB` or `GPOS`) is disabled, and Revert To Default Forms draws the default glyphs. A form other than the default takes away a character's glyph choice, and with a width form Proportional Metrics' `palt` is not applied and the font's pair kerning is off. Saved natively only when set, carried by character styles, drawn on the canvas and in PDF, and written to SVG as `font-feature-settings`. Character Style Options has no field for it. Evidence: `tests::typo::glyph_forms_draw_the_fonts_east_asian_forms`, `typecmd::area_tests::glyph_form_is_one_character_attribute_saved_only_when_set`, `typecmd::area_tests::a_glyph_form_takes_away_the_characters_glyph_choice`, `features::tests::width_forms_turn_off_proportional_metrics`, `panels::glyphs::tests::east_asian_glyph_forms_in_the_panel_menu_set_the_selected_characters_form`, `panels::glyphs::tests::glyph_form_items_at_a_caret_follow_all_the_text`, `panels::glyphs::tests::a_glyph_choice_lets_the_current_form_be_chosen_again` | | CJK: warichu | done | Character panel menu › Warichu and Warichu Settings… (`text.setFormat` / `text.setRangeStyle {warichu, warichuLines, warichuGap, warichuScale, warichuAlign}`, `ui.warichuSettings`), with Show East Asian Options ([Format East Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)): marked characters set at a percentage of their size and stacked on 1 to 6 lines inside their line, one above the other in horizontal type and side by side in vertical type, with a line gap and eight alignments; a block stays whole on one line. Saved natively, carried by character styles, drawn on the canvas and in every export (SVG, PDF and `.ai` write such type as outlines). Type on a path sets the characters at their own size. Evidence: `tests::warichu`, `dialogs::warichu::tests`, `typecmd::area_tests::warichu_is_a_character_attribute_set_saved_and_exported` | -| CJK: composite fonts | partial | Type › Composite Fonts… (`type.compositeFonts.set`), with Show East Asian Options: a font made of a font per class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals), each with its own style, size and baseline in percent of the type's size, Kana, Roman and Numerals with horizontal and vertical scale, and Kana with Scale at Center; New, Delete Font, Import… and Export… (`.vccomposite`, `type.compositeFonts.import` and `.export`) ([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per class; SVG and legacy `.ai` write it as outlines. Not yet: custom character sets, the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `compositefonts::tests`, `dialogs::composite_fonts::tests` | +| CJK: composite fonts | partial | Type › Composite Fonts… (`type.compositeFonts.set`), with Show East Asian Options: a font made of a font per class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals), each with its own style, size and baseline in percent of the type's size, Kana, Roman and Numerals with horizontal and vertical scale, and Kana with Scale at Center; New, Delete Font, Import… and Export… (`.vccomposite`, `type.compositeFonts.import` and `.export`) ([Creating composite fonts](https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347)); custom character sets (Custom: New, Delete Set, characters added by Direct Entry or Unicode code and deleted), each with its own font, style, size, baseline and scales, ahead of the classes, the later set winning a character two list (`custom` in `type.compositeFonts.set`) ([Customize characters in a composite font](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf#page=348)). Composite fonts are listed first in the font menus, saved with the document, and their type is laid out per set and class; SVG and legacy `.ai` write it as outlines. Not yet: the Units menu, Show Sample. Evidence: `text::composite::tests::characters_fall_in_their_classes`, `text::composite::tests::custom_sets_take_their_characters_before_the_classes`, `tests::composite::composite_type_draws_each_class_in_its_font`, `tests::composite::custom_character_sets_take_their_characters_first`, `compositefonts::tests`, `dialogs::composite_fonts::tests` | | CJK: ruby | partial | ruby (ふりがな) for live text (`type.ruby`): a reading over a byte range, above (right of the column in vertical type) or below, start/centre/end alignment, size, overhang or squeeze; saved natively, laid out with its line growing, drawn on the canvas, in SVG and PDF (as outlines where ruby is present), in EPS, EMF/WMF and legacy `.ai` (legacy `.ai` writes type with ruby as outlines), and kept by Create Outlines, Expand Appearance and flattening, each glyph where the layout puts it (`typecmd::ruby_tests::create_outlines_keeps_the_ruby_where_it_is_laid_out`) | #633 | | Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Assamese and Sanskrit added, #4947; Serbian: Cyrillic, Serbian: Latin and Croatian); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). English and eleven Indian languages hyphenate by run language; Sanskrit automatic hyphenation awaits allowlisted patterns (#4947). Not yet: `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set`, `tests::typo::indic_language_runs_hyphenate_with_both_composers`, `hyphen::tests::sanskrit_without_allowlisted_patterns_uses_only_exceptions`; spelling is checked in each run's Language (`text.spelling.check`, #4936) | | Language per character (Character panel › Language, character styles), text shaped in it | done | `text.setFormat` / `text.setRangeStyle {language}` by name or BCP 47 tag, one of Document Setup's languages (Hindi, Marathi, Gujarati, Bengali, Punjabi, Tamil, Telugu, Kannada, Malayalam, Odia and Urdu added, #4943; Serbian: Cyrillic, Serbian: Latin and Croatian; Bulgarian, #5945); the shaper gets the tag, so the font's language forms apply (OpenType `locl`: Turkish keeps `fi` apart and the dot on its i, #4940); new type takes the document's language; saved as the tag only when set, carried by character styles (#4946). Not yet: hyphenation by it in languages other than English and the eleven Indian languages above, `.ai`/PDF import of a text's language, paragraph styles. Evidence: `features::tests::language_picks_the_fonts_forms`, `features::tests::serbian_gets_the_fonts_serbian_forms`, `typecmd::area_tests::language_is_a_character_attribute_saved_only_when_set`; spelling is checked in each run's Language (`text.spelling.check`, #4936) | @@ -87,6 +88,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Paragraph composers (#4935): `text.setFormat {composer}` and paragraph styles take `singleLine` and `everyLine` (the Middle Eastern & South Asian composers, what all type used and the default) and `standardSingleLine` and `standardEveryLine`, which break lines the same way and set a complex script (Indic, Arabic, Hebrew) glyph by glyph as the font maps its characters, with no conjuncts, reordering, marks placed or joining forms. The choice is per paragraph, saved in the native file only when it is not Every-line, and not written to `.ai`, EPS or PDF files, whose composer setting this app has no recorded format for. Scores and estimates unchanged. Tests `tests::composers`. | | 2026-11-12 | minor | Glyphs review (#6084): validate contextual glyph choices against their supplying face, including fallback and overflow; keep feature-only boundaries inside ligatures and Arabic mark clusters while retaining drawing-attribute splits; preserve insertion failures instead of reporting normalization success. Deterministic engine, shaping and panel regression sources added; ligature range assertions use the fixture's unencoded glyph identity. Runtime proof pending. Scores and estimates unchanged. | | 2026-11-12 | minor | Glyphs review (#6084): Entire Font resolves the selected font's version and variable axes; the default cell remains available to reset an unlisted choice with no listed alternates. Character insertion normalizes only Arabic presentation forms, preserving ordinary characters whose glyphs share Arabic substitution provenance. Add deterministic panel apply/reset/Undo regressions without OS-font dependencies. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): the engine accepts a positional alternate as a glyph choice when no supported feature draws it, not only an isolated character's alternates; both panel views exercise apply and Undo. OpenType style options preserve only a valid last on-value when enabling a stylistic set. Scores and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index af9f35a695..071c8d4719 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -203,7 +203,7 @@ tools, never checked side by side) isn't captured row by row. | Deleting segments or anchors that disconnect an ordinary path creates independent selected objects | done | #3380, deletion only: first piece retains its ID, styles and transforms carry over in sibling order, bounds-fitted gradients keep their original placement, and one Undo restores the original. Existing multi-subpath paths and compound members stay intact. `delete_path_pieces` covers transformed/styled rectangles, cubic handles and Undo/Redo. Endpoint highlighting remains open. | | Click/Shift-click anchors; marquee toggles with Shift | done | #483 | | Align selected anchors to their bounds or the artboard; repeating an alignment that moves nothing keeps Undo, Redo and saved state | done | #1180 follow-up; engine regressions include rounding-only moves and unchanged handles; #1641 (fixes #1636): Align and Distribute act on the anchors selected with Direct Selection, the points of one path aligning to each other and Distribute spacing them | -| Align selected anchors to a key anchor | missing | #1636 | +| Align selected anchors to a key anchor | done | #1636; a Shift-click with Direct Selection makes the point it adds the key anchor, drawn as a larger filled square in a frame, and Align To shows Align to Key Anchor; Align moves the other points to it and Distribute with a spacing places them from it; `select.key {id, anchor}` | | A ruler guide as the key object: a click on a guide selected with objects (or Align to Key Object with one object and a guide) makes it the key, drawn thicker; Align lines the objects (or selected anchors) up on it and the guide stays put | done | #2143; `select.key {guide}`; a vertical guide sets x only, a horizontal one y | | Distribute Objects and Distribute Spacing with a spacing from a key guide: the guide stays put and the other items are spaced the Distribute Spacing value from it, as from a key object with no size | done | Key guide from #3165; a vertical guide spaces the horizontal options, a horizontal one the vertical ones, and a guide along the axis leaves everything | | Distribute ruler guides: the Distribute Objects and Distribute Spacing buttons space the selected guides across the axis with the selected objects, as items with no size; two or more guides selected alone are enough | done | #1597 in part; guides alone: select them, choose Align to Artboard and click Horizontal (Vertical) Distribute Center for equal columns (rows) ([public forum answer, 2017](https://community.adobe.com/questions-652/illustrator-equal-guides-822924)); with objects: a guide on each artboard edge, included in the selection, keeps the outermost lines there ([public forum answer, 2025](https://www.signs101.com/threads/is-there-a-way-to-distribute-shapes-like-this-in-illustrator.179556/)); a guide has no width, so the three options of an axis are the same for it; guides along the axis stay | @@ -384,6 +384,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | | A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | +| Panels hidden by Tab or Shift+Tab show for a while when the pointer rests on the left or right edge of the window, over the canvas, and hide again when it moves away; Tab shows them for good | done | Part of #5661; `window.panels.peek` ([Workspace basics](https://web.archive.org/web/2014/https://helpx.adobe.com/illustrator/using/workspace-basics.html), "Hide or show all panels"); VectorCraft's own choices: the 6 pt band and the 0.3 s delays, only the docked panels on that side show (floating panels, Tools and the Control bar stay hidden), and nothing shows in Presentation Mode; `panel_docking::tests::resting_on_the_right_edge_shows_the_hidden_panels_over_the_canvas_until_the_pointer_leaves` and the tests after it | | Behaviour | Status | Notes | |---|---|---| @@ -414,7 +415,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Image Trace panel: the Preview checkbox before Trace chooses whether a setting change traces the selected Image Trace object again at once | done | Per the public documentation, Preview shows the result of the current settings and Trace traces with them (). Preview is on by default and kept with the UI preferences; Reset to Default leaves it. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected, and come back when it is selected again. `imageTrace.preview {on?}`, `ui.set {imageTracePreview}`; `panels/image_trace.rs` headless tests (`with_preview_off_a_slider_change_waits_for_trace`, `turning_preview_on_traces_the_changes_that_waited`, `preview_turned_on_with_nothing_selected_traces_when_the_object_is_selected_again`, `settings_that_wait_stay_with_their_object`, `settings_that_wait_come_back_when_their_object_is_selected_again`, `settings_that_wait_are_dropped_when_their_object_changed_since`, `preview_is_kept_with_the_preferences_and_reset_to_default_leaves_it`) | | Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls | | Actions panel: ≡ › Save Actions… saves the current set with all its actions to a file and ≡ › Load Actions… adds a saved set ([Manage a set of actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/manage-a-set-of-actions.html)) | partial | `ui.actionSet.save`, `ui.actionSet.load`; `panels/actions.rs` tests. The file is VectorCraft's own `.vcactions` JSON; Reset, Clear and Replace Actions remain | -| Actions panel: ≡ › Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `actionSet.read`, `ui.actionSet.load`; `tests/actionset.rs`, `panels/actions.rs` tests, `import_fuzz`. Only steps with a label and no recorded values come in, as menu commands that run with no values and open no dialog, matched by label; the other steps, recorded menu items included, are left out and counted | +| Actions panel: ≡ › Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `actionSet.read`, `ui.actionSet.load`; `tests/actionset.rs`, `panels/actions.rs` tests, `import_fuzz`. Steps with a label and no recorded values come in as menu commands that run with no values and open no dialog, matched by label, and Set Selection steps come in as Select Object steps; the other steps, recorded menu items included, are left out and counted | | Actions panel: ≡ › Action Options… renames the selected action and sets its function key and color ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | partial | `ui.action.options`; `panels/actions.rs` tests (part of #1629). Button Mode shows the color | | Actions panel: ≡ › Button Mode shows each action by name as a button in its Action Options color, without sets or steps; a click on a button plays the action, and choosing Button Mode again shows the list ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html)) | done | `ui.action.buttonMode`, `ui.action.playAt`; `panels/actions.rs` tests. VectorCraft's own rules: every set's actions show in set order, an action without a color is an ordinary button, the mode is kept with the UI preferences, and it cannot be turned on while an action records | | Actions panel: ≡ › Playback Options… plays actions Accelerated, Step by Step or with a pause between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html)) | done | `ui.action.playbackOptions`; `panels/actions.rs` tests. The pause is a whole number of seconds from 1 to 60 (VectorCraft's own range); Batch… plays at full speed | @@ -455,6 +456,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Workspaces (save, reset, switch); each remembers the layout changes made in it across switches and restarts until Reset | done | `UiState::workspace_changes` | | Opening a file: after 0.4 s a progress window with the file name, a moving bar (reading counts no steps), how many more files wait, Cancel (Esc: the file doesn't open) and Continue in Background; never over another dialog or progress window | done | #5930; `opening::show`, `file.cancelOpen`, `file.openInBackground`. Save, Place and Close still show only the status bar's spinner (Place and Save run off the UI thread; Close is immediate) | | Window › Help Bar shows or hides the tool-help strip apart from the status bar; kept with the preferences and in saved workspaces; Presentation Mode hides both bars | done | `window.helpBar`; `help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar` | +| Status bar: a click on the info field opens a Show menu that chooses its readout: Artboard Name, Current Tool (the default), Date and Time, Number of Undos (undo and redo counts) or Document Color Profile; kept with the UI state | done | `view.statusBarShow`; [public behavior reference](https://web.archive.org/web/2014/https://helpx.adobe.com/illustrator/using/workspace-basics.html); `the_status_bar_info_field_shows_the_chosen_readout`, `a_click_on_the_info_field_opens_the_show_menu`, `the_status_bar_show_choice_is_checked_and_saved`. The menu labels are VectorCraft's own | | Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet | | Swatch, graphic style and symbol library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again; when its file is missing the panel reopens showing the file's path with Locate Library…, and the status bar names the file) | done | #2603; panel ids `library::`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`, `ui.libraryPanel.locate {panel, path?}`; the library panels have no sort option, as before | | DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500) |