diff --git a/Cargo.lock b/Cargo.lock index 7e85c13b5d..ce5569cde9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6476,6 +6476,7 @@ dependencies = [ "vectorcraft-plugins", "vectorcraft-text", "vectorcraft-three-d", + "vectorcraft-trace", ] [[package]] diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index f94f0ec4a8..ea2c44215e 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -150,7 +150,7 @@ fn planar(d: &mut tiff::decoder::Decoder>) -> bool { /// 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 MB of samples). -fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { +pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { let mut buf = tiff::decoder::DecodingResult::U8(Vec::new()); let layout = d.read_image_to_buffer(&mut buf).ok()?; let data = match buf { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3c4bcc20a5..15de79660f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -62,6 +62,7 @@ pub mod rastersettings; mod reach; pub mod recolor; pub mod selection; +pub mod separated; pub mod setup; pub mod shaper; pub mod slices; diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 415b2946e3..415fc1cad1 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -819,7 +819,8 @@ impl Node { impl Node { /// Whether this object shows any transparency, itself or anything inside it: object, fill or /// stroke opacity below 100%, a blend mode, an opacity mask, effects, transparent gradient - /// stops or a colorized image. Hidden objects show none. + /// stops (characters' fills and strokes included) or a colorized image. Hidden objects show + /// none. pub fn shows_transparency(&self) -> bool { self.shows_transparency_with(&|_| true) } @@ -827,21 +828,31 @@ impl Node { /// [`Self::shows_transparency`], counting a colorized image by itself only when `colorize` /// accepts it, for files that write some colorized images in their colorize paint (EPS). pub fn shows_transparency_with(&self, colorize: &dyn Fn(&Node) -> bool) -> bool { + self.visible && (self.own_transparency(colorize) || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize)))) + } + + /// [`Self::shows_transparency`] of the object alone, what it holds left out. + pub fn shows_own_transparency(&self) -> bool { + self.visible && self.own_transparency(&|_| true) + } + + /// The object's own transparency, as [`Self::shows_transparency_with`] counts it. + fn own_transparency(&self, colorize: &dyn Fn(&Node) -> bool) -> bool { use vectorcraft_color::Paint; let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible); - self.visible - && (self.opacity < 1.0 - || self.blend != BlendMode::Normal - || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) - || (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self)) - || effects(&self.appearance.effects) - || self - .appearance - .items - .iter() - .any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects()))) - || self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize)))) + self.opacity < 1.0 + || self.blend != BlendMode::Normal + || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) + || (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self)) + || effects(&self.appearance.effects) + || self + .appearance + .items + .iter() + .any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects()))) + // Characters' own fills and strokes, which draw with the object. + || matches!(&self.kind, NodeKind::Text(t) if t.runs.iter().any(|r| see_through(&r.style.fill) || (r.style.stroke_width > 0.0 && see_through(&r.style.stroke)))) } } diff --git a/crates/doc/src/separated.rs b/crates/doc/src/separated.rs new file mode 100644 index 0000000000..8eb7cefe66 --- /dev/null +++ b/crates/doc/src/separated.rs @@ -0,0 +1,533 @@ +//! Separated images: an image whose inks come as one plate each (a DCS file placed linked), kept +//! as a TIFF of several images. The first is the composite, the plates mixed into CMYK +//! ([`Separated::composite`]), which every reader of a CMYK TIFF sees. Each later image is one +//! plate's ink amounts in grayscale (0 is no ink), with its ink's name and CMYK equivalent in its +//! ImageDescription. Print separations take each plate from these ([`Separated::coverage`]). + +use std::io::Cursor; +use std::sync::Arc; + +use vectorcraft_color::cms::PROCESS_PLATES; +use vectorcraft_geom::PathData; + +use crate::ImageBlob; +use crate::cmyk::{Inks, MAX_CMYK_PIXELS}; + +/// Most plates a separated image holds. +pub const MAX_PLATES: usize = 64; +/// Most directories a separated image's TIFF holds: its composite and its plates. +const MAX_DIRECTORIES: usize = MAX_PLATES + 1; +/// Longest ink name kept (bytes). +pub const MAX_NAME: usize = 255; +/// Most ink amounts the plates of one image hold together (512 MB). +pub const MAX_SAMPLES: u64 = 1 << 29; +/// How a plate's ImageDescription starts. +const MARK: &str = "VectorCraft plate "; + +/// How the composite's ImageDescription starts when plates were read from files of their own. +const FILES_MARK: &str = "VectorCraft plate files"; + +/// A plate file of its own that a separated image was read from, as it was then: a change to it +/// changes the image as a change to the DCS file does. +#[derive(Clone, Debug, PartialEq)] +pub struct PlateFile { + /// Its file name, in the DCS file's folder. + pub name: String, + /// Its size (bytes). + pub size: u64, + /// Its modification time (ms since the Unix epoch; 0 when the file system keeps none). + pub modified: u64, +} + +/// `text` in hexadecimal (TIFF text is ASCII). +fn hex(text: &str) -> String { + text.bytes().map(|b| format!("{b:02x}")).collect() +} + +/// The text [`hex`] wrote, of at most `max` bytes. +fn unhex(hex: &str, max: usize) -> Option { + if !hex.len().is_multiple_of(2) || hex.len() > max * 2 { + return None; + } + let bytes: Option> = (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok()).collect(); + String::from_utf8(bytes?).ok() +} + +/// One ink's plate: its ink amounts, one byte a pixel (0 is none, 255 is solid), row by row from +/// the top. +#[derive(Clone, Debug, PartialEq)] +pub struct InkPlate { + /// "Cyan", "Magenta", "Yellow", "Black", or a spot ink's name. + pub name: String, + /// The process inks the ink stands for (C, M, Y, K, 0..1): one process ink at 100% for a + /// process plate. + pub cmyk: [f32; 4], + pub data: Vec, +} + +impl InkPlate { + /// Is it a spot ink's plate (not one of the four process inks)? + pub fn spot(&self) -> bool { + !PROCESS_PLATES.contains(&self.name.as_str()) + } +} + +/// The plates of a separated image, all of one size. +#[derive(Clone, Debug, PartialEq)] +pub struct Separated { + pub width: u32, + pub height: u32, + pub plates: Vec, +} + +impl Separated { + /// `plates` as a `width` × `height` image; `None` when there are none or more than + /// [`MAX_PLATES`], two have the same name, a name is empty or longer than [`MAX_NAME`], a plate + /// doesn't hold that many pixels, an equivalent isn't within 0..1, or the image is empty, has + /// more than [`MAX_CMYK_PIXELS`], or its plates together hold more than [`MAX_SAMPLES`] + /// ([`Self::fits`]). A process plate's equivalent is its own ink. + pub fn new(width: u32, height: u32, mut plates: Vec) -> Option { + let n = u64::from(width) * u64::from(height); + if !Self::fits(width, height, plates.len()) { + return None; + } + for p in plates.iter_mut() { + if p.name.is_empty() || p.name.len() > MAX_NAME || p.data.len() as u64 != n { + return None; + } + if let Some(j) = PROCESS_PLATES.iter().position(|q| *q == p.name) { + p.cmyk = [0.0; 4]; + p.cmyk[j] = 1.0; + } + if !p.cmyk.iter().all(|v| (0.0..=1.0).contains(v)) { + return None; + } + } + let mut names = std::collections::HashSet::new(); + if !plates.iter().all(|p| names.insert(p.name.as_str())) { + return None; + } + Some(Self { width, height, plates }) + } + + /// May an image of `width` × `height` pixels hold `plates` plates? Not when it is empty, has + /// more than [`MAX_CMYK_PIXELS`], has no plate or more than [`MAX_PLATES`], or the plates + /// together would hold more than [`MAX_SAMPLES`]. + pub fn fits(width: u32, height: u32, plates: usize) -> bool { + let n = u64::from(width) * u64::from(height); + n > 0 && n <= MAX_CMYK_PIXELS && (1..=MAX_PLATES).contains(&plates) && n * plates as u64 <= MAX_SAMPLES + } + + /// The plates of spot inks. + pub fn spots(&self) -> impl Iterator { + self.plates.iter().filter(|p| p.spot()) + } + + /// The plates mixed into process inks: each plate's ink amount times its equivalent, the + /// plates over one another as inks print (`1 − (1 − a)(1 − b)` for each process ink). + pub fn composite(&self) -> Inks { + let n = self.data_len(); + let mut data = vec![0u8; n * 4]; + for (i, px) in data.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let mut clear = [1.0f32; 4]; + for p in &self.plates { + let t = f32::from(p.data[i]) / 255.0; + for (c, e) in clear.iter_mut().zip(p.cmyk) { + *c *= 1.0 - t * e; + } + } + for (o, c) in px.iter_mut().zip(clear) { + *o = ((1.0 - c) * 255.0).round() as u8; + } + } + Inks { width: self.width, height: self.height, data } + } + + /// The ink amounts that print on plate `plate` (a process ink's name or a spot ink's), one byte + /// a pixel. A spot plate for which `own` is true prints on its own plate only; every other spot + /// plate (the job prints no plate of that name) prints as its process equivalent, mixed as + /// [`Self::composite`] mixes it. A plate name the image has no plate for, and a spot plate that + /// prints as process, print nothing. + pub fn coverage(&self, plate: &str, own: &dyn Fn(&str) -> bool) -> Vec { + self.coverage_as(plate, own, &|p| p.cmyk) + } + + /// [`Self::coverage`], a spot plate that prints as process printing as the process inks + /// `equivalent` gives it (the document's swatch of its ink, when there is one). + pub fn coverage_as(&self, plate: &str, own: &dyn Fn(&str) -> bool, equivalent: &dyn Fn(&InkPlate) -> [f32; 4]) -> Vec { + let n = self.data_len(); + if let Some(j) = PROCESS_PLATES.iter().position(|q| *q == plate) { + let mixed: Vec<(&InkPlate, f32)> = self + .plates + .iter() + .filter(|p| !p.spot() || !own(&p.name)) + .map(|p| (p, if p.spot() { equivalent(p)[j].clamp(0.0, 1.0) } else { p.cmyk[j] })) + .filter(|(_, e)| *e > 0.0) + .collect(); + return (0..n) + .map(|i| { + let clear = mixed.iter().fold(1.0f32, |c, (p, e)| c * (1.0 - f32::from(p.data[i]) / 255.0 * e)); + ((1.0 - clear) * 255.0).round() as u8 + }) + .collect(); + } + match self.plates.iter().find(|p| p.name == plate && p.spot() && own(&p.name)) { + Some(p) => p.data.clone(), + None => vec![0; n], + } + } + + /// [`Self::coverage_as`] as a grayscale PNG, black for solid ink and white for none, as a plate + /// prints; `None` when it can't be written. + pub fn plate_png(&self, plate: &str, own: &dyn Fn(&str) -> bool, equivalent: &dyn Fn(&InkPlate) -> [f32; 4]) -> Option { + let gray: Vec = self.coverage_as(plate, own, equivalent).into_iter().map(|v| 255 - v).collect(); + let img = image::GrayImage::from_raw(self.width, self.height, gray)?; + let mut png = Vec::new(); + img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(ImageBlob::png(png)) + } + + fn data_len(&self) -> usize { + self.width as usize * self.height as usize + } +} + +/// A plate's ImageDescription: the mark, its equivalent and its name in hexadecimal (TIFF text is +/// ASCII). +fn description(p: &InkPlate) -> String { + let [c, m, y, k] = p.cmyk; + format!("{MARK}{c} {m} {y} {k} {}", hex(&p.name)) +} + +/// A plate's name and equivalent from its ImageDescription; `None` when it isn't one. +fn parse_description(s: &str) -> Option<(String, [f32; 4])> { + let rest = s.strip_prefix(MARK)?; + let mut words = rest.split(' '); + let mut cmyk = [0.0f32; 4]; + for v in &mut cmyk { + *v = words.next()?.parse().ok().filter(|v: &f32| v.is_finite())?; + } + let name = unhex(words.next()?, MAX_NAME)?; + words.next().is_none().then_some((name, cmyk)) +} + +/// Longest plate file name kept (bytes). +const MAX_FILE_NAME: usize = 4096; + +/// The composite's ImageDescription line for `files`: the mark, then each file's name in +/// hexadecimal, its size and its modification time. +fn files_description(files: &[PlateFile]) -> String { + let mut out = String::from(FILES_MARK); + for f in files.iter().take(MAX_PLATES) { + out.push_str(&format!(" {} {} {}", hex(&f.name), f.size, f.modified)); + } + out +} + +/// How the composite's ImageDescription line holding the clipping path starts. +const CLIP_MARK: &str = "VectorCraft clip "; + +/// Longest clipping path kept (bytes of its JSON). +const MAX_CLIP: usize = 16 << 20; + +/// The composite's ImageDescription line for clipping path `clip`: the mark, then the path's JSON +/// in hexadecimal; `None` when it is longer than [`MAX_CLIP`]. +fn clip_description(clip: &PathData) -> Option { + let json = serde_json::to_string(clip).ok().filter(|j| j.len() <= MAX_CLIP)?; + Some(format!("{CLIP_MARK}{}", hex(&json))) +} + +/// The plate files of a composite's ImageDescription; none when it isn't one. +fn parse_files(s: &str) -> Vec { + let Some(rest) = s.lines().find_map(|l| l.strip_prefix(FILES_MARK)) else { return vec![] }; + let words: Vec<&str> = rest.split(' ').filter(|w| !w.is_empty()).collect(); + let file = + |w: &[&str]| Some(PlateFile { name: unhex(w.first()?, MAX_FILE_NAME)?, size: w.get(1)?.parse().ok()?, modified: w.get(2)?.parse().ok()? }); + words.chunks(3).take(MAX_PLATES).filter_map(file).collect() +} + +impl ImageBlob { + /// A TIFF of separated image `s`: its composite in CMYK, then its plates (Deflate-compressed), + /// declaring the resolution `ppi` when it is given and both are finite and above 0, and noting + /// `files`, the plate files of their own the plates were read from ([`Self::plate_files`]); + /// `None` when it can't be written. + pub fn separated_tiff(s: &Separated, ppi: Option<(f64, f64)>, files: &[PlateFile]) -> Option { + Self::separated_tiff_clipped(s, ppi, files, None) + } + + /// [`Self::separated_tiff`], noting `clip` too, the clipping path its file draws it through, in + /// its pixel space ([`Self::separated_clip`]). + pub fn separated_tiff_clipped(s: &Separated, ppi: Option<(f64, f64)>, files: &[PlateFile], clip: Option<&PathData>) -> Option { + use tiff::encoder::{Compression, Rational, TiffEncoder, colortype, compression::DeflateLevel}; + use tiff::tags::{ResolutionUnit, Tag}; + let ppi = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)); + // Ten-thousandths of a pixel per inch. + let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; + let mut out = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); + let mut img = enc.new_image::(s.width, s.height).ok()?; + if let Some((x, y)) = ppi { + img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; + img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; + img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; + } + // One line for the plate files, one for the clipping path. + let lines: Vec = + [(!files.is_empty()).then(|| files_description(files)), clip.and_then(clip_description)].into_iter().flatten().collect(); + if !lines.is_empty() { + img.encoder().write_tag(Tag::ImageDescription, lines.join("\n").as_str()).ok()?; + } + img.write_data(&s.composite().data).ok()?; + for p in &s.plates { + // White is no ink, so a viewer that shows a plate shows it as it prints. + let mut img = enc.new_image::(s.width, s.height).ok()?; + img.encoder().write_tag(Tag::ImageDescription, description(p).as_str()).ok()?; + let inverted: Vec = p.data.iter().map(|v| 255 - v).collect(); + img.write_data(&inverted).ok()?; + } + Some(Self::new("image/tiff", out.into_inner())) + } + + /// Does the image hold plates ([`ImageBlob::separated_tiff`]), at most [`MAX_PLATES`] of them? + /// One with more, or with more directories than its composite and [`MAX_PLATES`] plates, is the + /// CMYK image of its composite only, as [`Self::separated`] refuses it. Reads only the TIFF's + /// directories, at most [`MAX_DIRECTORIES`]. + pub fn is_separated(&self) -> bool { + let Some((_, mut d)) = crate::cmyk::tiff(&self.bytes) else { return false }; + let (mut plates, mut directories) = (0usize, 1usize); + while d.more_images() { + directories += 1; + if directories > MAX_DIRECTORIES || d.next_image().is_err() { + return false; + } + if d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().is_some_and(|s| s.starts_with(MARK)) { + plates += 1; + if plates > MAX_PLATES { + return false; + } + } + } + plates > 0 + } + + /// The plate files of their own a separated image was read from, as they were then; none for + /// other images. Reads only the TIFF's first directory. + pub fn plate_files(&self) -> Vec { + let Some((_, mut d)) = crate::cmyk::tiff(&self.bytes) else { return vec![] }; + d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().as_deref().map(parse_files).unwrap_or_default() + } + + /// The clipping path a separated image's file draws it through, in its pixel space, as + /// [`Self::separated_tiff_clipped`] noted it; `None` for other images. Reads only the TIFF's + /// first directory. + pub fn separated_clip(&self) -> Option { + let (_, mut d) = crate::cmyk::tiff(&self.bytes)?; + let text = d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok()?; + let json = unhex(text.lines().find_map(|l| l.strip_prefix(CLIP_MARK))?, MAX_CLIP)?; + serde_json::from_str(&json).ok() + } + + /// The spot inks a separated image has plates for, each with its CMYK equivalent; none for + /// other images. Reads only the TIFF's directories. + pub fn spot_plates(&self) -> Vec<(String, [f32; 4])> { + if !self.is_separated() { + return vec![]; + } + let Some((_, mut d)) = crate::cmyk::tiff(&self.bytes) else { return vec![] }; + let mut inks = vec![]; + while d.more_images() && inks.len() < MAX_PLATES { + if d.next_image().is_err() { + break; + } + let plate = d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().as_deref().and_then(parse_description); + inks.extend(plate.filter(|(name, _)| !PROCESS_PLATES.contains(&name.as_str()))); + } + inks + } + + /// The plates of a separated image; `None` for other images, and ones whose plates can't be + /// read. The plates of the last image read are kept for the next call (print separates one + /// image plate after plate). + pub fn separated(&self) -> Option> { + thread_local! { + static LAST: std::cell::RefCell)>> = const { std::cell::RefCell::new(None) }; + } + if !self.is_separated() { + return None; + } + let key = self.content_key(); + LAST.with_borrow_mut(|last| match last { + Some((k, s)) if *k == key => Some(s.clone()), + _ => { + let s = Arc::new(read_plates(&self.bytes)?); + *last = Some((key, s.clone())); + Some(s) + } + }) + } +} + +/// The plates of TIFF `bytes`, each the size of its first image. +fn read_plates(bytes: &[u8]) -> Option { + let (_, mut d) = crate::cmyk::tiff(bytes)?; + let (w, h) = d.dimensions().ok()?; + let n = usize::try_from(u64::from(w) * u64::from(h)).ok()?; + let mut plates = vec![]; + // Every plate is read: a file with more than [`MAX_PLATES`] is refused ([`Separated::fits`]), + // not cut short, and so is one with more than [`MAX_DIRECTORIES`] directories. + let mut directories = 1usize; + while d.more_images() { + directories += 1; + if directories > MAX_DIRECTORIES { + return None; + } + d.next_image().ok()?; + let Some((name, cmyk)) = d.get_tag_ascii_string(tiff::tags::Tag::ImageDescription).ok().as_deref().and_then(parse_description) else { + continue; + }; + // Checked before the plate is decoded. + if d.dimensions().ok()? != (w, h) || !matches!(d.colortype().ok()?, tiff::ColorType::Gray(8)) || !Separated::fits(w, h, plates.len() + 1) { + return None; + } + let data = crate::cmyk::read_samples(&mut d, n, 1)?.into_iter().map(|v| 255 - v).collect(); + plates.push(InkPlate { name, cmyk, data }); + } + Separated::new(w, h, plates) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plate(name: &str, cmyk: [f32; 4], data: &[u8]) -> InkPlate { + InkPlate { name: name.into(), cmyk, data: data.to_vec() } + } + + /// Two pixels: cyan 100% and a spot ink at 50% on the first, nothing on the second. + fn two() -> Separated { + let plates = vec![plate("Cyan", [0.0; 4], &[255, 0]), plate("Spot Orange", [0.0, 0.6, 1.0, 0.0], &[128, 0])]; + Separated::new(2, 1, plates).unwrap() + } + + #[test] + fn plates_mix_into_the_composite_as_inks_print() { + let s = two(); + assert_eq!(s.plates[0].cmyk, [1.0, 0.0, 0.0, 0.0], "a process plate stands for its own ink"); + let c = s.composite(); + // Magenta: 0.6 × 128/255; yellow: 128/255. + assert_eq!(&c.data[..4], &[255, 77, 128, 0]); + assert_eq!(&c.data[4..], &[0, 0, 0, 0]); + } + + #[test] + fn a_spot_plate_prints_on_its_own_plate_or_as_process() { + let s = two(); + let own = |_: &str| true; + assert_eq!(s.coverage("Spot Orange", &own), [128, 0]); + assert_eq!(s.coverage("Magenta", &own), [0, 0], "nothing of the spot on a process plate"); + assert_eq!(s.coverage("Cyan", &own), [255, 0]); + assert_eq!(s.coverage("Black", &own), [0, 0]); + let process = |_: &str| false; + assert_eq!(s.coverage("Spot Orange", &process), [0, 0]); + assert_eq!(s.coverage("Magenta", &process), [77, 0]); + assert_eq!(s.coverage("Yellow", &process), [128, 0]); + assert_eq!(s.coverage("Other", &own), [0, 0]); + } + + #[test] + fn the_tiff_keeps_every_plate_and_shows_the_composite() { + let s = two(); + let files = + [PlateFile { name: "pic (1).C".into(), size: 1234, modified: 1_700_000_000_000 }, PlateFile { name: "ü.K".into(), size: 0, modified: 0 }]; + let b = ImageBlob::separated_tiff(&s, Some((300.0, 300.0)), &files).unwrap(); + assert_eq!(b.plate_files(), files); + assert!(ImageBlob::separated_tiff(&s, None, &[]).unwrap().plate_files().is_empty()); + assert!(b.is_separated()); + assert!(b.is_cmyk(), "readers of a CMYK TIFF see the composite"); + assert_eq!(b.cmyk(), Some(s.composite())); + assert_eq!(*b.separated().unwrap(), s); + assert_eq!(b.spot_plates(), [("Spot Orange".to_string(), [0.0, 0.6, 1.0, 0.0])]); + assert!(ImageBlob::cmyk_tiff(&s.composite()).unwrap().spot_plates().is_empty()); + assert!(!ImageBlob::cmyk_tiff(&s.composite()).unwrap().is_separated()); + } + + #[test] + fn the_tiff_keeps_the_clipping_path_beside_the_plate_files() { + let s = two(); + let clip = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 1.5, 1.0)); + let files = [PlateFile { name: "a.C".into(), size: 1, modified: 2 }]; + let b = ImageBlob::separated_tiff_clipped(&s, None, &files, Some(&clip)).unwrap(); + assert_eq!(b.separated_clip(), Some(clip.clone())); + assert_eq!(b.plate_files(), files); + let alone = ImageBlob::separated_tiff_clipped(&s, None, &[], Some(&clip)).unwrap(); + assert_eq!(alone.separated_clip(), Some(clip)); + assert!(alone.plate_files().is_empty()); + assert_eq!(ImageBlob::separated_tiff(&s, None, &files).unwrap().separated_clip(), None); + } + + #[test] + fn a_tiff_with_more_plates_than_are_read_is_refused() { + use tiff::encoder::{TiffEncoder, colortype}; + let tiff = |plates: usize| { + let mut out = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut out).unwrap(); + enc.write_image::(1, 1, &[0, 0, 0, 0]).unwrap(); + for i in 0..plates { + let mut img = enc.new_image::(1, 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ImageDescription, description(&plate(&format!("S{i}"), [0.0; 4], &[0])).as_str()).unwrap(); + img.write_data(&[255]).unwrap(); + } + ImageBlob::new("image/tiff", out.into_inner()) + }; + assert_eq!(tiff(MAX_PLATES).separated().map(|s| s.plates.len()), Some(MAX_PLATES)); + let over = tiff(MAX_PLATES + 1); + assert!(over.separated().is_none(), "not cut short to the first {MAX_PLATES}"); + assert!(!over.is_separated() && over.spot_plates().is_empty(), "the CMYK image of its composite only"); + assert!(tiff(MAX_PLATES).is_separated()); + } + + #[test] + fn a_tiff_with_more_directories_than_a_composite_and_its_plates_is_refused() { + use tiff::encoder::{TiffEncoder, colortype}; + let tiff = |others: usize| { + let mut out = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut out).unwrap(); + enc.write_image::(1, 1, &[0, 0, 0, 0]).unwrap(); + let mut img = enc.new_image::(1, 1).unwrap(); + img.encoder().write_tag(tiff::tags::Tag::ImageDescription, description(&plate("S", [0.0; 4], &[0])).as_str()).unwrap(); + img.write_data(&[255]).unwrap(); + // Directories that are not plates. + for _ in 0..others { + enc.write_image::(1, 1, &[255]).unwrap(); + } + ImageBlob::new("image/tiff", out.into_inner()) + }; + let most = tiff(MAX_PLATES - 1); + assert!(most.is_separated() && most.separated().is_some()); + let over = tiff(MAX_PLATES); + assert!(!over.is_separated() && over.separated().is_none() && over.spot_plates().is_empty()); + assert!(read_plates(&over.bytes).is_none(), "read_plates stops at the limit too"); + } + + #[test] + fn names_that_are_not_ascii_are_kept() { + let s = Separated::new(1, 1, vec![plate("Grün 100 % (A)", [0.5, 0.0, 1.0, 0.0], &[9])]).unwrap(); + let b = ImageBlob::separated_tiff(&s, None, &[]).unwrap(); + assert_eq!(b.separated().unwrap().plates[0].name, "Grün 100 % (A)"); + } + + #[test] + fn bad_plates_are_refused() { + assert!(Separated::new(2, 1, vec![]).is_none(), "no plates"); + assert!(Separated::new(2, 1, vec![plate("Cyan", [0.0; 4], &[1])]).is_none(), "too few pixels"); + assert!(Separated::new(1, 1, vec![plate("A", [0.0; 4], &[1]), plate("A", [0.0; 4], &[1])]).is_none(), "the same name twice"); + assert!(Separated::new(1, 1, vec![plate("", [0.0; 4], &[1])]).is_none(), "no name"); + assert!(Separated::new(1, 1, vec![plate("A", [2.0, 0.0, 0.0, 0.0], &[1])]).is_none(), "equivalent past 100%"); + assert!(Separated::new(0, 1, vec![plate("A", [0.0; 4], &[])]).is_none(), "empty"); + let many: Vec = (0..=MAX_PLATES).map(|i| plate(&format!("S{i}"), [0.0; 4], &[1])).collect(); + assert!(Separated::new(1, 1, many).is_none(), "too many plates"); + assert!(Separated::fits(1 << 13, 1 << 13, 8), "512 MB of ink amounts"); + assert!(!Separated::fits(1 << 13, 1 << 13, 9), "more than 512 MB together"); + assert!(!Separated::fits(1 << 14, 1 << 13, 1), "more pixels than a CMYK image is decoded with"); + } +} diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index d5821607ec..14f9a0d08f 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -15,6 +15,7 @@ vectorcraft-doc = { workspace = true } vectorcraft-pathops = { workspace = true } vectorcraft-text = { workspace = true } vectorcraft-plugins = { workspace = true } +vectorcraft-trace = { workspace = true } image = { workspace = true } kurbo = { workspace = true } # Effect › SVG Filters draws its filters with resvg (MIT OR Apache-2.0), at the usvg version the diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 602e2d7fe9..52232cd54e 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -296,7 +296,7 @@ pub fn effect_catalog() -> Vec { "path.outlineObject", "Outline Object", PATH, - "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change", + "{} type is drawn, exported and expanded as its glyph outlines and stays editable, and the effects after it reshape those outlines; paths and images do not change. On an image's own fill or stroke it gives the outline of the image's pixels that are at least half opaque (VectorCraft's own cutoff), and nothing when no pixel is; it gives the image's frame when the image has no transparency, its pixels can't be read, or the image or its outline is too large to trace", json!({}), ), g( diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 89b41df9e7..fa06d00922 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -80,8 +80,8 @@ 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 reshape::{ - SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, - has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, + SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, + has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, }; pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 77977c24d1..2bda0aa88f 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -10,12 +10,13 @@ //! inline graphics whose own fills or strokes have geometry effects is outlined too: each of those //! effects reshapes the whole outline that its fill or stroke paints. An image's own fills and //! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives -//! them ([`image_rows`]). +//! them ([`image_rows`]): Outline Object follows the outline of the image's opaque area +//! ([`opaque_area`]). -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::rc::Rc; -use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak}; use vectorcraft_doc::live::Hooks; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, ImageObject, Node, NodeId, NodeKind}; @@ -287,8 +288,8 @@ pub fn gives_image_shape(effects: &[Effect]) -> bool { /// The fills and strokes of `items` (an image's own) that paint something ([`shapes_image`]), each /// without the effects before its first Convert to Shape or Outline Object. Convert to Shape sizes -/// its shape from the image's box, and Outline Object gives the image's frame. The others paint -/// nothing. +/// its shape from the image's box, and Outline Object gives the outline of the image's opaque +/// area ([`opaque_area`]), or its frame when it has no transparency. The others paint nothing. fn image_rows(items: &[AppearanceItem]) -> Vec { items .iter() @@ -303,11 +304,109 @@ fn image_rows(items: &[AppearanceItem]) -> Vec { .collect() } +/// Does `item`, one of [`image_rows`], start from the outline of the image's opaque area (is its +/// first effect Outline Object rather than Convert to Shape)? +fn follows_opaque_area(item: &AppearanceItem) -> bool { + item.effects().first().is_some_and(|e| e.id == "path.outlineObject") +} + +/// Is `n` an image with a fill or stroke of its own that follows the outline of the image's opaque +/// area ([`image_rows`], [`opaque_area`])? The shape that fill or stroke paints then changes with +/// the image's pixels in the document while `n` stays the same. +pub fn follows_image_pixels(n: &Node) -> bool { + matches!(n.kind, NodeKind::Image(_)) + && n.appearance + .items + .iter() + .any(|i| shapes_image(i) && i.effects().iter().find(|e| gives_shape(e)).is_some_and(|e| e.id == "path.outlineObject")) +} + +/// The outline of a set of pixels' opaque area, in the pixel space they decode to, with that size +/// ([`traced_opaque_area`]). `None` for pixels that have no transparency or could not be traced. +type Traced = Option<(Arc, (u32, u32))>; + +/// One of [`Outlines`]' entries: the pixels, held weakly, and their outline once it is traced. +type Entry = (Weak>, Arc>); + +/// The outlines [`opaque_area`] has traced, by the address of the pixels each was traced from. +struct Outlines { + /// Each outline with its pixels. An entry does not keep its pixels, and while it is kept no + /// other pixels can take their address. Entries whose pixels are gone are dropped before a + /// new outline is traced and every [`PRUNE_EVERY`] lookups. + kept: BTreeMap, + /// The lookups since the last pruning. + lookups: usize, +} + +static OUTLINES: Mutex = Mutex::new(Outlines { kept: BTreeMap::new(), lookups: 0 }); + +/// How many lookups [`OUTLINES`] takes between two prunings of its entries for pixels that are gone. +const PRUNE_EVERY: usize = 256; + +#[cfg(test)] +thread_local! { + /// The outlines [`traced_opaque_area`] has traced on this thread. + pub(crate) static TRACES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Does [`OUTLINES`] keep an entry for `pixels`? +#[cfg(test)] +pub(crate) fn keeps_outline(pixels: &Weak>) -> bool { + OUTLINES.lock().unwrap_or_else(PoisonError::into_inner).kept.values().any(|(w, _)| w.ptr_eq(pixels)) +} + +/// The outline of the opaque area of the image `im` in the document +/// ([`vectorcraft_trace::opaque_outline`]: its pixels that are at least half opaque, outlined with +/// fitted curves), its pixels taken from `im` itself or from the first of `docs` that holds them. +/// `None` when the image has no transparency, its pixels can't be read or are too large to read +/// ([`vectorcraft_trace::decode_alpha`]), or its outline would be too complex: Outline Object then +/// follows the image's frame. The outline is traced once for each set of pixels and kept while +/// those pixels are: a caller that asks while another thread traces the same pixels waits for that +/// trace. +pub(crate) fn opaque_area(docs: &[Option<&Document>], im: &ImageObject) -> Option { + let blob = im.pixels.as_deref().or_else(|| docs.iter().flatten().find_map(|d| d.images.get(&im.key)))?; + let bytes = &blob.bytes; + let cell = { + let mut o = OUTLINES.lock().unwrap_or_else(PoisonError::into_inner); + o.lookups += 1; + if o.lookups >= PRUNE_EVERY { + o.lookups = 0; + o.kept.retain(|_, (w, _)| w.strong_count() > 0); + } + let key = Arc::as_ptr(bytes).addr(); + match o.kept.get(&key) { + Some((_, cell)) => Arc::clone(cell), + None => { + o.kept.retain(|_, (w, _)| w.strong_count() > 0); + let cell = Arc::new(OnceLock::new()); + o.kept.insert(key, (Arc::downgrade(bytes), Arc::clone(&cell))); + cell + } + } + }; + // Traced without the lock: another image's outline need not wait for this one. + let (path, (w, h)) = cell.get_or_init(|| traced_opaque_area(bytes)).clone()?; + let to_image = Affine::scale_non_uniform(f64::from(im.width) / f64::from(w.max(1)), f64::from(im.height) / f64::from(h.max(1))); + Some(path.transformed(im.xf * to_image)) +} + +/// [`opaque_area`]'s outline of the encoded image `bytes`. An image whose format has no alpha is +/// not decoded. +fn traced_opaque_area(bytes: &[u8]) -> Traced { + #[cfg(test)] + TRACES.with(|t| t.set(t.get() + 1)); + let (w, h, alpha) = vectorcraft_trace::decode_alpha(bytes).ok()??; + let outline = vectorcraft_trace::opaque_outline(w, h, alpha).ok()??; + Some((Arc::new(outline), (w, h))) +} + /// The shapes that the image `n`'s own fills and strokes paint ([`image_rows`]), each with its fill -/// rule and the fill or stroke that paints it: the paths (or compound paths) along its frame that -/// [`reshape`] makes, with each fill's or stroke's geometry effects applied as the renderer applies -/// them. Empty for anything but an image with such fills or strokes. The swatches and patterns that -/// live-effect plug-ins painting their pieces name are looked up in `doc`. +/// rule and the fill or stroke that paints it: the paths (or compound paths) that [`reshape`] +/// makes along its frame or, for those whose first effect is Outline Object, along the outline of +/// its opaque area ([`opaque_area`]), with each fill's or stroke's geometry effects applied as the +/// renderer applies them. Empty for anything but an image with such fills or strokes. The swatches +/// and patterns that live-effect plug-ins painting their pieces name are looked up in `doc`, and +/// the image's pixels in `doc` when the image does not hold them. pub fn image_paint(doc: Option<&Document>, n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) { return vec![]; @@ -608,9 +707,10 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping /// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied: /// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped /// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own -/// fills and strokes that paint a shape ([`image_rows`]) become paths along its frame below and -/// above it, reshaped with it, and keep their effects from their first Convert to Shape or Outline -/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask +/// fills and strokes that paint a shape ([`image_rows`]) become paths below and above it, along +/// its frame or, from Outline Object, along the outline of its opaque area ([`opaque_area`]), +/// reshaped with it, and keep their effects from their first Convert to Shape or Outline Object +/// on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask /// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective /// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts, /// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is @@ -697,15 +797,33 @@ fn as_art_opts( } Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image.clone())], clip: true }) }; - // The fills and strokes that paint a shape, as paths along the frame below and above - // the image. + // The fills and strokes that paint a shape, as paths below and above the image: along + // the frame, or for those whose first effect is Outline Object along the outline of + // the image's opaque area (traced only when one of them needs it). A run of either + // kind is one path; a fill or stroke whose outline is empty (an image with no opaque + // pixel) paints nothing. let (below, above) = n.appearance.split_contents(); let (below, above) = (image_rows(below), image_rows(above)); if below.is_empty() && above.is_empty() { clipped() } else { + let opaque = std::cell::OnceCell::new(); let rows = |items: Vec| { - (!items.is_empty()).then(|| Arc::new(Node::path(n.id, frame.clone(), Appearance { items, ..Default::default() }))) + let mut out = vec![]; + let mut items = items.into_iter().peekable(); + while let Some(first) = items.next() { + let follows = follows_opaque_area(&first); + let mut run = vec![first]; + while let Some(next) = items.next_if(|i| follows_opaque_area(i) == follows) { + run.push(next); + } + let path = if follows { opaque.get_or_init(|| opaque_area(&[symbols, paint_doc], im)).as_ref() } else { None }; + let path = path.unwrap_or(&frame); + if !path.is_empty() { + out.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: run, ..Default::default() }))); + } + } + out }; // Without geometry effects, the image is not clipped. let image = if fx.is_empty() { image.clone() } else { clipped() }; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 93679ca71d..91f73049f1 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -866,7 +866,8 @@ fn masks_surface(paint: &Paint, colored: bool) -> bool { } /// The graphics of the solids `filled` ([`graphic_art`]), each with the index of the surface it is -/// drawn right after (the last of those solids holding its face) and its face. With `per_member` +/// drawn right after (the last of those solids holding its face) and its face. Solids in a run +/// with the same opacity and blending mode share their graphics. With `per_member` /// (a member's opacity mask or blending mode applies to its surfaces) each member's solids get /// graphics of their own, clipped to that member's caps, so they stay in that member's scopes. /// All the groups share one limit on the path elements drawn: once it is reached, later groups @@ -883,11 +884,12 @@ fn placed_graphics( for f in filled { // Solids whose fills or strokes differ in opacity or blending mode get graphics of their // own, each drawn as its fill or stroke is. A solid whose surface carries an opacity mask - // gets its own too, which takes that mask ([`with_graphics`]). + // gets its own too, which takes that mask ([`with_graphics`]). A solid joins only the + // group of the solid before it, so a graphic is never drawn past a solid painted between. let joins = |first: &Filled| { (!per_member || first.member == f.member) && first.opacity == f.opacity && first.blend == f.blend && !first.masked && !f.masked }; - match groups.iter_mut().find(|g| g.first().is_some_and(|first| joins(first))) { + match groups.last_mut().filter(|g| g.first().is_some_and(|first| joins(first))) { Some(g) => g.push(f), None => groups.push(vec![f]), } diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs index 64c947a9df..aa73535fc5 100644 --- a/crates/effects/src/solids/tests_graphics.rs +++ b/crates/effects/src/solids/tests_graphics.rs @@ -365,6 +365,41 @@ fn members_with_their_own_blending_get_graphics_of_their_own() { assert_eq!(blended, 1); } +#[test] +fn a_graphic_stays_under_a_solid_painted_after_its_own() { + let d = document(); + let member = |id: u64, r: Rect, color: Color, opacity: f32| { + let mut n = square(id, r, Paint::solid(color)); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.opacity = opacity; + } + Arc::new(n) + }; + let gray = Color::rgb(0.5, 0.5, 0.5); + // A and C share their fill's opacity; B, painted between them, has its own and covers part of A. + let a = member(1, Rect::new(0.0, 0.0, 60.0, 100.0), gray, 1.0); + let b = member(2, Rect::new(40.0, 0.0, 100.0, 100.0), Color::rgb(0.0, 0.0, 1.0), 0.5); + let c = member(4, Rect::new(200.0, 0.0, 260.0, 100.0), gray, 1.0); + let mut group = Node::group(NodeId(3), vec![a, b, c]); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false, "graphics": [{"symbol": "Mark", "scale": 3000.0}]}); + group.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + let art = solid_art_in(Some(&d), &group).unwrap(); + // In drawing order: 'G' for a graphic, 'B' for a face of B's solid. + let mut order = String::new(); + art.walk(&mut |n| { + if matches!(n.kind, NodeKind::Group { clip: true, .. }) && !graphics_in(n).is_empty() { + order.push('G'); + } else if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() + && f.paint.color().is_some_and(|c| c.to_rgb()[2] > 0.9 && c.to_rgb()[0] < 0.1) + { + order.push('B'); + } + }); + // A's graphic is drawn right after A, under B; C's is drawn after B. + assert_eq!(graphics_in(&art).len(), 3, "{order}"); + assert!(order.starts_with('G'), "{order}"); +} + /// [`document`] with symbol "Dense" too: a red polygon of 30,000 sides, straight or `curved`. fn dense_document(curved: bool) -> Document { let mut d = document(); diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 88300586b3..56f9d40633 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod imageoutline; mod rasterfollow; mod scale; mod transformfx; @@ -886,6 +887,108 @@ fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() { } } +/// Under a Warp effect a hidden path in a layer that keeps its shape past the budget does not hold +/// the layer's gradient in place either: it moves and scales from the layer's bounds to the warped +/// square's bounds, as the art and inside the art. +#[test] +fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_follow_a_warp() { + use std::sync::Arc; + + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::{AppearanceItem, FillLayer, LayerColor, NodeId, NodeKind, color::Paint}; + let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)]; + let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None }; + let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) })); + let ends = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.geom.map(|g| (g.start, g.end)), + _ => None, + }; + let oval = || shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)); + // A layer with a visible square and a hidden circle, both 0..100, and a gradient fill of its own. + let layer = || { + let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default())); + let mut circle = Node::path(NodeId(4), oval(), Appearance::default()); + circle.visible = false; + let mut layer = Node::layer(NodeId(2), "Layer 1", LayerColor::Preset(0)); + if let NodeKind::Layer { children, .. } = &mut layer.kind { + *children = vec![square, Arc::new(circle)]; + } + layer.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone()))); + layer + }; + // The box map from the layer's bounds (the square's) to `b`: (0, 50) and (100, 50) go to the + // middles of b's sides. + let near = |got: Option<(Point, Point)>, b: Rect| { + let mid = (b.y0 + b.y1) / 2.0; + got.is_some_and(|(s, e)| s.distance(Point::new(b.x0, mid)) < 1e-6 && e.distance(Point::new(b.x1, mid)) < 1e-6) + }; + for style in ["arc", "inflate", "flag"] { + let warp = fx(&format!("warp.{style}"), json!({"bend": 40.0, "horizontal": 20.0})); + // Room for the warped square beside the circle as it was, and not for the warped circle + // beside the warped square: the paths take the budget in turn, so the circle keeps its shape. + let mut probe = layer(); + crate::reshape::reshape_leaves_within(&mut probe, &[&warp], Budget::EFFECTS, None); + let warped = |k: usize| util::line_estimate(probe.children().unwrap()[k].path_data().unwrap()); + let circle = util::line_estimate(&oval()); + assert!(warped(1) > circle, "warp.{style}: the warped circle is drawn with more lines"); + let budget = Budget { anchors: usize::MAX, lines: warped(0) + circle }; + let mut art = layer(); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&warp], budget, None); + assert_eq!(art.children().unwrap()[1].path_data(), Some(&oval()), "{}: the hidden circle keeps its shape", warp.id); + let square = art.children().unwrap()[0].geometric_bounds().unwrap(); + assert!(square != Rect::new(0.0, 0.0, 100.0, 100.0), "{}: the square takes the effect: {square:?}", warp.id); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert!(near(ends(&own), square), "{}: the layer's gradient, as the art: {:?} for {square:?}", warp.id, ends(&own)); + let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]); + crate::reshape::reshape_leaves_within(&mut art, &[&warp], budget, None); + let got = ends(&art.children().unwrap()[0].appearance); + assert!(near(got, square), "{}: the layer's gradient, inside the art: {got:?} for {square:?}", warp.id); + } +} + +/// A freeform gradient whose points were edited moves its points with a Warp effect from the +/// bounds before the warp to the bounds after it, on a path, on a group inside the art and on the +/// art itself, as a placed gradient does. +#[test] +fn edited_freeform_points_follow_a_warp() { + use std::sync::Arc; + + use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint}; + use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint}; + let mut ramp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() }); + let pt = |x: f64, c| FreeformPoint::new(Point::new(x, 50.0), c); + ramp.set_freeform(Freeform { points: vec![pt(0.0, Color::BLACK), pt(100.0, Color::WHITE)], ..Default::default() }); + assert!(ramp.geom.is_none() && !ramp.is_placed()); + let freeform = Paint::Gradient(Box::new(ramp)); + let points = |ap: &Appearance| match ap.fill_paint() { + Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points.iter().map(|p| p.at).collect::>()), + _ => None, + }; + let square = |ap: Appearance| Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap)); + // The box map from the square's bounds to `b`: (0, 50) and (100, 50) go to the middles of b's sides. + let near = |got: Option>, b: Rect| { + let mid = (b.y0 + b.y1) / 2.0; + got.is_some_and(|p| p.len() == 2 && p[0].distance(Point::new(b.x0, mid)) < 1e-6 && p[1].distance(Point::new(b.x1, mid)) < 1e-6) + }; + for style in ["arc", "inflate", "rise"] { + let e = fx(&format!("warp.{style}"), json!({"bend": 40.0, "vertical": 20.0})); + let mut inner = Node::group(NodeId(2), vec![square(Appearance::default())]); + inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]); + art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone()))); + let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS, None); + let b = art.geometric_bounds().unwrap(); + assert!(b != Rect::new(0.0, 0.0, 100.0, 100.0), "{}: the warp changes the bounds: {b:?}", e.id); + let members = art.children().unwrap(); + assert!(near(points(&members[0].appearance), b), "{}: the path's points {:?} for {b:?}", e.id, points(&members[0].appearance)); + assert!(near(points(&members[1].appearance), b), "{}: the inner group's points {:?} for {b:?}", e.id, points(&members[1].appearance)); + let mut own = Appearance::clone(&art.appearance); + carry.apply(&mut own); + assert!(near(points(&own), b), "{}: the art's own points {:?} for {b:?}", e.id, points(&own)); + } +} + /// A freeform gradient whose points were edited (its vector still fitted to the bounds) moves its /// points with a Transform effect and Free Distort, on a path, on a group inside the art and on the /// art itself, as a placed gradient does. diff --git a/crates/effects/src/tests/imageoutline.rs b/crates/effects/src/tests/imageoutline.rs new file mode 100644 index 0000000000..5ef3955254 --- /dev/null +++ b/crates/effects/src/tests/imageoutline.rs @@ -0,0 +1,338 @@ +//! Outline Object on an image's own fill or stroke: the outline of the image's opaque area, for +//! every pixel format with alpha, and the frame for one without. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, Effect, FillLayer, ImageBlob, ImageObject, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{Affine, Rect}; + +use crate::*; + +fn fx(id: &str) -> Effect { + Effect { id: id.into(), params: json!({}), visible: true } +} + +/// `img` encoded as `format`. +fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec { + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out +} + +/// A 100×100 RGBA image, transparent but for an opaque disc of radius 30 in its middle. +fn disc() -> image::DynamicImage { + image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(100, 100, |x, y| { + let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0); + image::Rgba([255, 0, 0, if r <= 30.0 { 255 } else { 0 }]) + })) +} + +/// A document holding the encoded image `bytes` (key "img") and an image object of 100×100 pixels +/// placed at (50, 50) with the appearance items `items`, the image's own row at `contents`. +/// → (document, the image's node). +fn image_doc(bytes: Vec, items: Vec, contents: usize) -> (Document, Node) { + let mut d = Document::new(200.0, 200.0); + d.images.insert("img".into(), ImageBlob::new("image/png", bytes)); + let mut n = Node::new( + NodeId(0), + NodeKind::Image(Box::new(ImageObject { + key: "img".into(), + width: 100, + height: 100, + xf: Affine::translate((50.0, 50.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + n.id = d.alloc_id(); + n.appearance.items = items; + n.appearance.set_contents_at(contents); + let l = d.layers[0].id; + d.insert(Some(l), 0, n.clone()).unwrap(); + (d, n) +} + +/// A PNG of `w × h` pixels with the color type `color` at `depth` bits, the chunks `extra` before +/// its pixels, and the filtered rows `rows` stored without compression. +fn png(w: u32, h: u32, color: u8, depth: u8, extra: &[(&[u8; 4], Vec)], rows: &[u8]) -> Vec { + fn crc(data: &[u8]) -> u32 { + let mut c = !0u32; + for &b in data { + c ^= u32::from(b); + for _ in 0..8 { + c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut chunk = |kind: &[u8; 4], data: &[u8]| { + let mut body = kind.to_vec(); + body.extend_from_slice(data); + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(&body); + out.extend_from_slice(&crc(&body).to_be_bytes()); + }; + let mut ihdr = [w.to_be_bytes(), h.to_be_bytes()].concat(); + ihdr.extend([depth, color, 0, 0, 0]); + chunk(b"IHDR", &ihdr); + for (kind, data) in extra { + chunk(kind, data); + } + // A zlib stream of stored blocks. + let mut z = vec![0x78, 0x01]; + let blocks: Vec<&[u8]> = if rows.is_empty() { vec![rows] } else { rows.chunks(65_535).collect() }; + for (i, block) in blocks.iter().enumerate() { + let len = u16::try_from(block.len()).unwrap(); + z.push(u8::from(i + 1 == blocks.len())); + z.extend(len.to_le_bytes()); + z.extend((!len).to_le_bytes()); + z.extend_from_slice(block); + } + let (mut a, mut b) = (1u32, 0u32); + for &byte in rows { + a = (a + u32::from(byte)) % 65_521; + b = (b + a) % 65_521; + } + z.extend(((b << 16) | a).to_be_bytes()); + chunk(b"IDAT", &z); + chunk(b"IEND", &[]); + out +} + +fn stroke(effects: Vec) -> AppearanceItem { + let mut s = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + s.effects = effects; + AppearanceItem::Stroke(s) +} + +/// The bounds of the paths the image's own fills and strokes paint, below it first. +fn row_bounds(d: &Document, n: &Node) -> Vec { + let art = reshape::reshape(Some(d), n, None).unwrap(); + let mut out = vec![]; + for c in art.children().unwrap() { + if matches!(c.kind, NodeKind::Path { .. }) { + out.push(c.geometric_bounds().unwrap()); + } + } + out +} + +fn near(r: Rect, want: [f64; 4], tol: f64) -> bool { + [r.x0 - want[0], r.y0 - want[1], r.x1 - want[2], r.y1 - want[3]].iter().all(|d| d.abs() < tol) +} + +#[test] +fn outline_object_follows_the_opaque_area_of_an_image_with_alpha() { + let gray_alpha = image::DynamicImage::ImageLumaA8(disc().to_luma_alpha8()); + let deep = image::DynamicImage::ImageRgba16(disc().to_rgba16()); + for (name, img) in [("rgba", disc()), ("gray and alpha", gray_alpha), ("16 bits a channel", deep)] { + let (d, n) = image_doc(encoded(img, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let rows = row_bounds(&d, &n); + assert_eq!(rows.len(), 1, "{name}"); + // The disc runs from 70 to 130; the frame from 50 to 150. + assert!(near(rows[0], [70.0, 70.0, 130.0, 130.0], 0.5), "{name}: {:?}", rows[0]); + } +} + +#[test] +fn outline_object_follows_the_frame_of_an_image_without_transparency() { + let opaque = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(100, 100, image::Rgb([0, 0, 255]))); + let solid = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(100, 100, image::Rgba([0, 0, 255, 200]))); + let formats = [ + ("jpeg", encoded(opaque.clone(), image::ImageFormat::Jpeg)), + ("rgb png", encoded(opaque, image::ImageFormat::Png)), + ("rgba png, every pixel more than half opaque", encoded(solid, image::ImageFormat::Png)), + ]; + for (name, bytes) in formats { + let (d, n) = image_doc(bytes, vec![stroke(vec![fx("path.outlineObject")])], 1); + let rows = row_bounds(&d, &n); + assert!(rows.len() == 1 && near(rows[0], [50.0, 50.0, 150.0, 150.0], 1e-9), "{name}: {rows:?}"); + } + // Pixels that can't be read keep the frame too. + let (d, n) = image_doc(b"not an image".to_vec(), vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(near(row_bounds(&d, &n)[0], [50.0, 50.0, 150.0, 150.0], 1e-9)); +} + +#[test] +fn convert_to_shape_keeps_the_frame_beside_outline_object() { + // Below the image: a fill with Convert to Shape, then a stroke with Outline Object; above it + // a stroke with Convert to Shape before Outline Object, which starts from the frame. + let rect = Effect { id: "convertToShape.rectangle".into(), params: json!({"relative": true, "extraW": 0, "extraH": 0}), visible: true }; + let mut fill = FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + fill.effects = vec![rect.clone()]; + let items = vec![AppearanceItem::Fill(fill), stroke(vec![fx("path.outlineObject")]), stroke(vec![rect, fx("path.outlineObject")])]; + let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), items, 2); + let rows = row_bounds(&d, &n); + assert_eq!(rows.len(), 3, "{rows:?}"); + assert!(near(rows[0], [50.0, 50.0, 150.0, 150.0], 1e-9), "{rows:?}"); + assert!(near(rows[1], [70.0, 70.0, 130.0, 130.0], 0.5), "{rows:?}"); + assert!(near(rows[2], [50.0, 50.0, 150.0, 150.0], 1e-9), "{rows:?}"); + // The image keeps its place between them. + let art = reshape::reshape(Some(&d), &n, None).unwrap(); + assert!(matches!(art.children().unwrap()[2].kind, NodeKind::Image(_))); +} + +#[test] +fn a_transparent_image_gives_outline_object_nothing_to_paint() { + let clear = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(100, 100, image::Rgba([0, 0, 0, 100]))); + let (d, n) = image_doc(encoded(clear, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(row_bounds(&d, &n).is_empty()); + let art = reshape::reshape(Some(&d), &n, None).unwrap(); + assert!(matches!(art.children().unwrap().as_slice(), [only] if matches!(only.kind, NodeKind::Image(_)))); +} + +#[test] +fn the_outline_follows_the_images_own_pixels_and_placement() { + // Pixels kept on the image itself (art made outside a document), placed at half size. + let (_, mut n) = image_doc(vec![], vec![stroke(vec![fx("path.outlineObject")])], 1); + if let NodeKind::Image(im) = &mut n.kind { + im.pixels = Some(Arc::new(ImageBlob::new("image/png", encoded(disc(), image::ImageFormat::Png)))); + im.xf = Affine::translate((10.0, 20.0)) * Affine::scale(0.5); + } + let art = reshape::reshape(None, &n, None).unwrap(); + let b = art.children().unwrap()[0].geometric_bounds().unwrap(); + // The disc runs from 20 to 80 pixels: half that, moved by (10, 20). + assert!(near(b, [20.0, 30.0, 50.0, 60.0], 0.3), "{b:?}"); +} + +#[test] +fn an_off_center_outline_turns_and_reflects_with_a_smaller_copy_of_the_pixels() { + // A 120×60 image whose opaque area is the rectangle from (12, 6) to (40, 24), its pixels a + // copy at half size (as a linked image's preview is), turned a quarter and reflected. + let half = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(60, 30, |x, y| { + image::Rgba([255, 0, 0, if (6..20).contains(&x) && (3..12).contains(&y) { 255 } else { 0 }]) + })); + let (d, mut n) = image_doc(encoded(half, image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let xf = Affine::translate((100.0, 40.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale_non_uniform(-1.0, 1.0); + if let NodeKind::Image(im) = &mut n.kind { + (im.width, im.height, im.xf) = (120, 60, xf); + } + let rows = row_bounds(&d, &n); + let want = xf.transform_rect_bbox(Rect::new(12.0, 6.0, 40.0, 24.0)); + // The rectangle runs from (76, 0) to (94, 28); a flip in either direction, or the copy's + // width and height swapped, would move it. + assert!(near(want, [76.0, 0.0, 94.0, 28.0], 1e-9), "{want:?}"); + assert!(rows.len() == 1 && near(rows[0], [want.x0, want.y0, want.x1, want.y1], 0.5), "{rows:?}"); +} + +#[test] +fn an_image_in_a_group_exports_with_its_outline() { + let (mut d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let l = d.layers[0].id; + d.remove(n.id).unwrap(); + let g = d.alloc_id(); + d.insert(Some(l), 0, Node::group(g, vec![Arc::new(n)])).unwrap(); + let baked = bake_document(&d).unwrap(); + let mut paths = vec![]; + for layer in &baked.layers { + layer.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) && (c.appearance.stroke_width() - 4.0).abs() < 1e-9 { + paths.push(c.geometric_bounds().unwrap()); + } + }); + } + assert!(matches!(paths.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{paths:?}"); +} + +/// The bounds of the paths stroked 4 pt in `d` with its effects baked, as exports write it. +fn baked_strokes(d: &Document) -> Vec { + let baked = bake_document(d).unwrap(); + let mut paths = vec![]; + for layer in &baked.layers { + layer.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { .. }) && (c.appearance.stroke_width() - 4.0).abs() < 1e-9 { + paths.push(c.geometric_bounds().unwrap()); + } + }); + } + paths +} + +#[test] +fn an_image_under_a_clipping_mask_or_with_an_opacity_mask_exports_with_its_outline() { + let disc_outline = |paths: &[Rect]| matches!(paths, [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)); + // Under a clipping mask that covers the whole image. + let (mut d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let l = d.layers[0].id; + d.remove(n.id).unwrap(); + let clip = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Default::default()); + let g = d.alloc_id(); + d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(n)], clip: true })).unwrap(); + let paths = baked_strokes(&d); + assert!(disc_outline(&paths), "{paths:?}"); + // With an opacity mask. + let (mut d, mut n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let mut white = Node::path(NodeId(999), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Default::default()); + white.appearance.set_fill(Paint::solid(Color::WHITE)); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(Node::group(NodeId(998), vec![Arc::new(white)]), true))); + let id = n.id; + *d.node_mut(id).unwrap() = n; + let paths = baked_strokes(&d); + assert!(disc_outline(&paths), "{paths:?}"); +} + +#[test] +fn outline_object_on_a_fill_and_on_an_indexed_image_follows_the_opaque_area() { + let mut fill = FillLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + fill.effects = vec![fx("path.outlineObject")]; + let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![AppearanceItem::Fill(fill)], 1); + let rows = row_bounds(&d, &n); + assert!(matches!(rows.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{rows:?}"); + // A palette image whose first color is transparent (a tRNS chunk). + let mut rows = vec![]; + for y in 0..100 { + rows.push(0); + for x in 0..100 { + let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0); + rows.push(u8::from(r <= 30.0)); + } + } + let palette = png(100, 100, 3, 8, &[(b"PLTE", vec![0, 0, 0, 255, 0, 0]), (b"tRNS", vec![0, 255])], &rows); + let (d, n) = image_doc(palette, vec![stroke(vec![fx("path.outlineObject")])], 1); + let rows = row_bounds(&d, &n); + assert!(matches!(rows.as_slice(), [b] if near(*b, [70.0, 70.0, 130.0, 130.0], 0.5)), "{rows:?}"); +} + +#[test] +fn an_image_too_large_or_too_complex_to_outline_keeps_its_frame() { + // 72 megapixels at 8 bits a channel, and 42 at 16: refused before their pixels are read. + for (w, h, depth) in [(9000, 8000, 8), (7000, 6000, 16)] { + let bytes = png(w, h, 6, depth, &[], &[]); + assert!(matches!(vectorcraft_trace::decode_alpha(&bytes), Err(vectorcraft_trace::TraceError::TooLarge { .. })), "{w}×{h}"); + let (d, n) = image_doc(bytes, vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(matches!(row_bounds(&d, &n).as_slice(), [b] if near(*b, [50.0, 50.0, 150.0, 150.0], 1e-9)), "{w}×{h}"); + } + // A checkerboard of 2 px squares, whose outline would need more than 100,000 anchor points. + let checker = image::RgbaImage::from_fn(640, 640, |x, y| image::Rgba([0, 0, 0, if (x / 2 + y / 2) % 2 == 0 { 255 } else { 0 }])); + let (d, n) = + image_doc(encoded(image::DynamicImage::ImageRgba8(checker), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + assert!(matches!(row_bounds(&d, &n).as_slice(), [b] if near(*b, [50.0, 50.0, 150.0, 150.0], 1e-9))); +} + +#[test] +fn an_outline_is_traced_once_for_its_pixels_and_dropped_with_them() { + let traces = || reshape::TRACES.with(std::cell::Cell::get); + let (d, n) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![stroke(vec![fx("path.outlineObject")])], 1); + let NodeKind::Image(im) = &n.kind else { panic!("not an image") }; + let before = traces(); + let first = reshape::opaque_area(&[Some(&d)], im).unwrap(); + assert_eq!(traces(), before + 1); + for _ in 0..3 { + row_bounds(&d, &n); + } + assert_eq!(reshape::opaque_area(&[Some(&d)], im).unwrap(), first); + assert_eq!(traces(), before + 1); + let pixels = Arc::downgrade(&d.images.get(&im.key).unwrap().bytes); + assert!(reshape::keeps_outline(&pixels)); + // Once the document and its pixels are gone, the next trace drops their entry. + drop(d); + assert!(pixels.upgrade().is_none()); + let (other, _) = image_doc(encoded(disc(), image::ImageFormat::Png), vec![], 1); + reshape::opaque_area(&[Some(&other)], im).unwrap(); + assert_eq!(traces(), before + 2); + assert!(!reshape::keeps_outline(&pixels)); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index e02382b6b1..80ed19d145 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -82,7 +82,7 @@ pub mod rasterfx; mod recolor; pub mod recovery; pub mod scripts; -mod select; +pub(crate) mod select; pub(crate) mod shaper; pub mod shortcutset; pub(crate) mod slices; diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 6f0f8fd533..00c7808508 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -73,6 +73,8 @@ pub struct RasterImage { pub height: u32, /// The resolution the file declares (see [`super::ppi::resolution`]). pub ppi: Option<(f64, f64)>, + /// A DCS file's clipping path, in the image's pixel space ([`crate::cmd::place::dcs`]). + pub clip: Option, } pub(super) fn err(e: impl std::fmt::Display) -> EngineError { @@ -380,6 +382,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let (mut doc, mut warnings, restored) = match format.id { // EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read. _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { + // A DCS file's plates print from its file: it is only placed linked. + if crate::cmd::place::dcs::is_dcs(bytes) { + return Err(err(format!("can't open `{}`: {}", file_name(name), crate::cmd::place::dcs::LINK_ONLY))); + } let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); restore_or_import(editing, || { let r = vectorcraft_eps::import_names(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names) @@ -1014,13 +1020,13 @@ fn stored(bytes: Vec, mime: &'static str, (width, height): (u32, u32), ppi: return Err(err("the image is empty")); } let blob = ImageBlob::new(mime, bytes); - Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi }) + Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi, clip: None }) } /// An image as a document of its physical size at the resolution it declares (as Place sizes it; /// 72 ppi, 1 px = 1 pt, when it declares none), the image named after the file. pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result { - let RasterImage { key, blob, width, height, ppi } = raster_image(bytes)?; + let RasterImage { key, blob, width, height, ppi, .. } = raster_image(bytes)?; let (sx, sy) = crate::cmd::place::pt_per_px(ppi); let mut d = Document::new(width as f64 * sx, height as f64 * sy); let layer = d.layers[0].id; diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 74cece5af3..005427ded8 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -39,7 +39,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 objects painted with a pattern whose art uses it 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)} | {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 ), @@ -108,23 +108,85 @@ fn pattern_being_edited(d: &Document, name: &str) -> bool { d.pattern_edit.as_ref().is_some_and(|e| e.pattern == name) } +/// Whether fill or stroke `i` paints: it is visible, its opacity is above 0 and, a stroke, it is +/// wider than 0. +fn paints(i: &AppearanceItem) -> bool { + i.visible() && i.opacity() > 0.0 && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0) +} + +/// The name of pattern paint `p`. +fn pattern_name(p: &Paint) -> Option<&str> { + match p { + Paint::Pattern { pattern, .. } => Some(pattern.as_str()), + _ => None, + } +} + +/// Whether `r` is laid out as an inline graphic: it has one and holds only its character. +fn draws_inline(r: &TextRun) -> bool { + r.inline.is_some() && vectorcraft_text::edit::is_inline_text(&r.text) +} + +/// The fill and, when it is wider than 0, the stroke of the characters of `r`. +fn run_paints(r: &TextRun) -> impl Iterator { + [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)].into_iter().flatten() +} + +/// [`run_paints`] of run `r` of `t` when its characters draw with them. The characters of an +/// inline graphic draw no glyph: their fill and stroke paint only their underline and +/// strikethrough (not on a path or in vertical type, see `vectorcraft_text::decorations`) and the +/// ruby readings that layout draws with their run (`ruby`, as [`ruby_runs`] finds them). +fn drawn_run_paints<'a>(t: &TextObject, ruby: &HashSet<*const TextRun>, r: &'a TextRun) -> impl Iterator { + let drawn = !draws_inline(r) || inline_decorated(t, ruby, r); + run_paints(r).filter(move |_| drawn) +} + +/// The inline graphic runs of `t` that layout draws a ruby reading with. A reading takes the run +/// of the first base character on its line, and type on a path has no ruby. `t` is laid out only +/// when it has ruby and an inline graphic painted with a pattern. +fn ruby_runs(t: &TextObject) -> HashSet<*const TextRun> { + let patterned = |r: &TextRun| draws_inline(r) && run_paints(r).any(|p| pattern_name(p).is_some()); + if t.ruby.is_empty() || !t.runs.iter().any(patterned) { + return HashSet::new(); + } + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + lay.ruby.iter().filter_map(|g| t.runs.get(g.run)).filter(|r| draws_inline(r)).map(std::ptr::from_ref).collect() +} + +/// Whether inline graphic run `r` of `t` has an underline or strikethrough that draws, or a ruby +/// reading that layout draws with it (it is in `ruby`). +fn inline_decorated(t: &TextObject, ruby: &HashSet<*const TextRun>, r: &TextRun) -> bool { + let bars = !t.vertical && !matches!(t.kind, TextKind::OnPath { .. }); + if bars && (r.style.underline || r.style.strikethrough) { + return true; + } + ruby.contains(&std::ptr::from_ref(r)) +} + /// The patterns that the fills and strokes `n` draws name (of a text object, also those of its -/// characters): visible ones, and strokes wider than 0. -fn paint_patterns(n: &Node) -> impl Iterator { - let runs = match &n.kind { - NodeKind::Text(t) => t.runs.as_slice(), - _ => &[], - }; - n.appearance - .items - .iter() - .filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)) - .map(AppearanceItem::paint) - .chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten()) - .filter_map(|p| match p { - Paint::Pattern { pattern, .. } => Some(pattern.as_str()), - _ => None, - }) +/// characters): those that [`paints`] holds for, and the character paints of the runs that draw +/// (as [`drawn_runs`] finds them: not the overset runs of area type or type on a path) where they +/// draw (as [`drawn_run_paints`] says). +fn paint_patterns(n: &Node) -> Vec<&str> { + let mut out: Vec<&str> = n.appearance.items.iter().filter(|i| paints(i)).filter_map(|i| pattern_name(i.paint())).collect(); + if let NodeKind::Text(t) = &n.kind + && t.runs.iter().flat_map(run_paints).any(|p| pattern_name(p).is_some()) + { + let ruby = ruby_runs(t); + out.extend(drawn_runs(t).0.into_iter().flat_map(|r| drawn_run_paints(t, &ruby, r)).filter_map(pattern_name)); + } + out +} + +/// The symbols that the inline graphics in the type of `n` draw (as [`drawn_runs`] finds them: not +/// those in the overset runs of area type or type on a path). +fn inline_symbols(n: &Node) -> Vec<&str> { + match &n.kind { + NodeKind::Text(t) if t.runs.iter().any(draws_inline) => { + drawn_runs(t).0.into_iter().filter(|r| draws_inline(r)).filter_map(|r| r.inline.as_ref().map(|art| art.symbol.as_str())).collect() + } + _ => vec![], + } } /// Symbols and patterns, by name: those whose art is in scope. @@ -135,13 +197,15 @@ struct Resources { } impl Resources { - /// Adds what `n` itself shows: the symbol of an instance, the patterns its paints name, and - /// what the art of its opacity mask shows (not hidden or locked objects of that art). + /// Adds what `n` itself shows: the symbol of an instance, the symbols of the inline graphics in + /// its type, the patterns its paints name, and what the art of its opacity mask shows (not + /// hidden or locked objects of that art). fn add_own(&mut self, n: &Node) { if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { self.symbols.insert(symbol.clone()); } - self.patterns.extend(paint_patterns(n).map(str::to_string)); + self.symbols.extend(inline_symbols(n).into_iter().map(str::to_string)); + self.patterns.extend(paint_patterns(n).into_iter().map(str::to_string)); if let Some(m) = n.extras().mask.as_deref() { self.add(&m.art); } @@ -486,17 +550,16 @@ impl Printed<'_> { NodeKind::Text(t) => drawn_runs(t), _ => (vec![], false), }; - let items: Vec<&AppearanceItem> = - n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect(); - let patterns: Vec<&str> = items - .iter() - .map(|i| i.paint()) - .chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten()) - .filter_map(|p| match p { - Paint::Pattern { pattern, .. } => Some(pattern.as_str()), - _ => None, - }) - .collect(); + let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i)).collect(); + let ruby = match &n.kind { + NodeKind::Text(t) => ruby_runs(t), + _ => HashSet::new(), + }; + let run_patterns = runs.iter().flat_map(|r| match &n.kind { + NodeKind::Text(t) => Some(drawn_run_paints(t, &ruby, r)), + _ => None, + }); + let patterns: Vec<&str> = items.iter().map(|i| i.paint()).chain(run_patterns.flatten()).filter_map(pattern_name).collect(); let brushed = vectorcraft_brush::has_brush(n); if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) { if let Some(m) = mask { @@ -527,7 +590,7 @@ impl Printed<'_> { for r in runs { self.fonts.extend(run_fonts(r)); // Inline graphics draw their symbols' art. - if let Some(art) = &r.inline { + if let Some(art) = r.inline.as_ref().filter(|_| draws_inline(r)) { self.symbol(&art.symbol); } } @@ -910,14 +973,16 @@ fn replace(s: &mut Session, p: &Value) -> Result { } /// Whether `n` or a descendant that is not hidden or locked, nor in a hidden or locked group, -/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, a paint -/// naming a pattern in `found`, or such type in the art of its opacity mask. +/// shows type in a font `uses` matches: text set in it, an instance of a symbol in `found`, an +/// inline graphic of a symbol in `found`, a paint naming a pattern in `found`, or such type in the +/// art of its opacity mask. fn reaches(n: &Node, uses: &impl Fn(&TextObject) -> bool, found: &Resources) -> bool { n.visible && !n.locked && (matches!(&n.kind, NodeKind::Text(t) if uses(t)) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) - || paint_patterns(n).any(|p| found.patterns.contains(p)) + || inline_symbols(n).into_iter().any(|sym| found.symbols.contains(sym)) + || paint_patterns(n).into_iter().any(|p| found.patterns.contains(p)) || n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, uses, found)) || n.children().is_some_and(|ch| ch.iter().any(|c| reaches(c, uses, found)))) } @@ -964,14 +1029,16 @@ fn select_font(s: &mut Session, p: &Value) -> Result { found.patterns.extend(patterns); } // The objects the Selection tool can reach that show such type: the instances of those - // symbols, the objects that those patterns paint, and the objects whose opacity mask holds it - // (not layers, which the Selection tool doesn't select). + // symbols, the text whose inline graphics draw them, the objects that those patterns paint, + // and the objects whose opacity mask holds it (not layers, which the Selection tool doesn't + // select). let masked: HashSet = masked.into_iter().collect(); if !found.symbols.is_empty() || !found.patterns.is_empty() || !masked.is_empty() { let texts: HashSet = hits.iter().copied().collect(); d.walk(&mut |n: &Node| { let shows = matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if found.symbols.contains(symbol)) - || paint_patterns(n).any(|p| found.patterns.contains(p)) + || inline_symbols(n).into_iter().any(|sym| found.symbols.contains(sym)) + || paint_patterns(n).into_iter().any(|p| found.patterns.contains(p)) || (masked.contains(&n.id) && n.extras().mask.as_deref().is_some_and(|m| reaches(&m.art, &uses, &found))); if shows && !n.is_layer() && !texts.contains(&n.id) && editable(n.id) { hits.push(n.id); @@ -1069,6 +1136,37 @@ fn font_list(_: &mut Session, p: &Value) -> Result { } } +/// Gives the inline graphics in the type of [`art_texts_of`] the current bounds of their symbols' +/// art, and lays out again the type whose inline graphics changed. A symbol drawn inline in the +/// art of another symbol takes one more round, so there is at most one round per symbol. +fn resolve_art_inline(doc: &mut Document) { + for _ in 0..=doc.symbols.len() { + let names: BTreeSet = + art_texts_of(doc).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.symbol.clone()))).collect(); + let bounds: BTreeMap> = names + .into_iter() + .map(|name| { + let b = doc.symbol_art_bounds(&name); + (name, b) + }) + .collect(); + let stale = |t: &TextObject| t.runs.iter().any(|r| r.inline.as_ref().is_some_and(|a| bounds.get(&a.symbol).is_some_and(|b| *b != a.bounds))); + let mut changed = false; + edit_art_texts_of(doc, &stale, &mut |t| { + for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { + if let Some(b) = bounds.get(&a.symbol) { + a.bounds = *b; + } + } + refresh_bounds(t); + changed = true; + }); + if !changed { + break; + } + } +} + pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { let db = vectorcraft_text::FontDb::global(); #[cfg(not(target_arch = "wasm32"))] @@ -1076,7 +1174,9 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { #[cfg(target_arch = "wasm32")] let faces = 0; // The canvas redraws type in the fonts available now (renderers lay it out again when the - // fonts change), and the bounds follow; that isn't an edit to save. + // fonts change), and the bounds follow, also of the type in opacity masks and in the art of + // symbols and patterns; that isn't an edit to save. The type in art is laid out first, and the + // inline graphics of the type on the layers then take the new bounds of their symbols' art. for d in &mut s.docs { let mut texts = vec![]; d.doc.walk(|n| { @@ -1084,9 +1184,12 @@ pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result { texts.push(n.id); } }); - if !texts.is_empty() { + if !texts.is_empty() || !art_texts_of(&d.doc).is_empty() { d.change_unsaved(|doc| { let doc = std::sync::Arc::make_mut(doc); + edit_art_texts_of(doc, &|_| true, &mut refresh_bounds); + resolve_art_inline(doc); + doc.resolve_inline_art(); for id in texts { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { refresh_bounds(t); @@ -1751,6 +1854,309 @@ mod tests { assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3"); } + /// A pattern in a fill whose opacity is 0 paints nothing: Find doesn't select the object for + /// the pattern's type, a selection-only Change leaves that type in its font, and Print doesn't + /// count its font. + #[test] + fn find_font_skips_patterns_at_an_opacity_of_0() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [painted]})).unwrap(); + let printed = |s: &Session| { + let d = &s.doc().unwrap().doc; + printed_type(d, &d.layers, &[Rect::new(-1000.0, -1000.0, 1000.0, 1000.0)]).0.into_iter().map(|(family, ..)| family).collect::>() + }; + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + assert_eq!(printed(&s), ["Source Serif 4"]); + s.edit("Clear the fill", |d, _| { + for it in d.node_mut(NodeId(painted)).unwrap().appearance.items.iter_mut() { + if let AppearanceItem::Fill(f) = it { + f.opacity = 0.0; + } + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + assert!(printed(&s).is_empty(), "{:?}", printed(&s)); + s.execute("select.set", &json!({"ids": [painted]})).unwrap(); + let r = s + .execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true})) + .unwrap(); + assert_eq!(r, json!({"runs": 0})); + assert_eq!(pattern_families(&s, "Dots"), ["Source Serif 4"]); + } + + /// Characters of area type that don't fit its frame paint nothing: their pattern's type isn't + /// found through them until they are drawn. + #[test] + fn find_font_skips_patterns_of_overset_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let area = s + .execute( + "text.create", + &json!({"x": 10, "y": 60, "text": "A\nB", "font": "Source Sans 3", "size": 20, "area": {"width": 100, "height": 40}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.setRangeStyle", &json!({"id": area, "start": 2, "end": 3, "font": "Inter"})).unwrap(); + let paint_run = |s: &mut Session, letter: &'static str| { + s.edit("Paint a run with the pattern", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(area)).map(|n| &mut n.kind) { + for r in t.runs.iter_mut().filter(|r| r.text.contains(letter)) { + r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }; + } + } + Ok(()) + }) + .unwrap(); + }; + paint_run(&mut s, "B"); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), text(&s.doc().unwrap().doc, NodeId(area)).unwrap()); + assert!(laid.overflow, "the second line doesn't fit the frame"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 0); + paint_run(&mut s, "A"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + } + + /// An inline graphic that doesn't fit its area type draws nothing, and neither does an inline + /// graphic named on a run of other characters: Find doesn't select the text for its symbol. + #[test] + fn find_font_skips_undrawn_inline_graphics() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let mark = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Se", "font": "Inter"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let area = s + .execute( + "text.create", + &json!({"x": 10, "y": 60, "text": "A\nB", "font": "Source Sans 3", "size": 20, "area": {"width": 100, "height": 40}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("text.insertInline", &json!({"id": area, "at": 3, "symbol": "Mark"})).unwrap(); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), text(&s.doc().unwrap().doc, NodeId(area)).unwrap()); + assert!(laid.overflow && laid.inlines.is_empty(), "the inline graphic doesn't fit the frame"); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0); + let point = s.execute("text.create", &json!({"x": 10, "y": 140, "text": "Hi", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": point, "at": 0, "symbol": "Mark"})).unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]); + // A run that names an inline graphic but holds other characters is laid out as those + // characters: its fill paints them, and it draws no symbol. + let plain = s.execute("text.create", &json!({"x": 10, "y": 180, "text": "Ok", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap(); + let art = text(&s.doc().unwrap().doc, NodeId(point)).unwrap().runs.iter().find_map(|r| r.inline.clone()).unwrap(); + s.edit("Name an inline graphic on plain characters", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(plain)).map(|n| &mut n.kind) { + for r in &mut t.runs { + r.inline = Some(art.clone()); + r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }; + } + } + Ok(()) + }) + .unwrap(); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(plain)]); + assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(point)]); + } + + /// The characters of an inline graphic draw no glyph: their pattern fill paints only their + /// underline, strikethrough or ruby reading. Without one, Find doesn't select the text for the + /// pattern's type and Print doesn't count its font. + #[test] + fn find_font_skips_patterns_of_undecorated_inline_graphics() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let t = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Ab", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [t], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let mark = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [mark]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let point = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hi", "font": "Source Sans 3"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": point, "at": 1, "symbol": "Mark"})).unwrap(); + let style = |s: &mut Session, f: &dyn Fn(&mut TextObject)| { + s.edit("Style the inline graphic", |d, _| { + if let Some(NodeKind::Text(t)) = d.node_mut(NodeId(point)).map(|n| &mut n.kind) { + f(t); + } + Ok(()) + }) + .unwrap(); + }; + let found = |s: &mut Session| s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"].as_u64(); + let printed = |s: &Session| { + let d = &s.doc().unwrap().doc; + printed_type(d, &d.layers, &[Rect::new(-1000.0, -1000.0, 1000.0, 1000.0)]).0.into_iter().map(|(family, ..)| family).collect::>() + }; + style(&mut s, &|t| { + for r in t.runs.iter_mut().filter(|r| r.inline.is_some()) { + r.style.fill = Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }; + } + }); + assert_eq!(found(&mut s), Some(0)); + assert_eq!(printed(&s), ["Source Sans 3"]); + // An underline paints with the pattern. + style(&mut s, &|t| t.runs.iter_mut().filter(|r| r.inline.is_some()).for_each(|r| r.style.underline = true)); + assert_eq!(found(&mut s), Some(1)); + assert_eq!(printed(&s), ["Source Sans 3", "Source Serif 4"]); + // Vertical type draws no underline. + style(&mut s, &|t| t.vertical = true); + assert_eq!(found(&mut s), Some(0)); + // A ruby reading that starts over the character before the inline graphic draws with that + // character's paint. + style(&mut s, &|t| { + assert!(t.runs[1].inline.is_some(), "the inline graphic is the second run"); + t.ruby.push(vectorcraft_doc::RubySpan { + start: 0, + end: t.runs[0].text.len() + t.runs[1].text.len(), + text: "x".into(), + position: Default::default(), + align: Default::default(), + size_percent: 50.0, + overhang: true, + }); + }); + assert_eq!(found(&mut s), Some(0)); + // A ruby reading that starts over the inline graphic paints with the pattern. + style(&mut s, &|t| t.ruby[0].start = t.runs[0].text.len()); + assert_eq!(found(&mut s), Some(1)); + } + + /// Text whose inline graphic draws a symbol shows the symbol's type: Find selects that text + /// and the objects painted with a pattern whose art holds it, and a selection-only Change + /// changes the symbol's type. + #[test] + fn find_font_reaches_symbols_through_inline_graphics() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let serif = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Se", "font": "Source Serif 4"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [serif]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let inline = |s: &mut Session, y: i32| { + let id = s.execute("text.create", &json!({"x": 10, "y": y, "text": "In", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "Mark"})).unwrap(); + id + }; + let listed = inline(&mut s, 60); + let tiled = inline(&mut s, 100); + s.execute("object.pattern.make", &json!({"ids": [tiled], "name": "Loop", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [tiled]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let painted = s.execute("shape.rectangle", &json!({"x": 0, "y": 140, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Loop", "ids": [painted]})).unwrap(); + let mut instances = 0; + s.doc().unwrap().doc.walk(|n| instances += usize::from(matches!(n.kind, NodeKind::SymbolInstance { .. }))); + assert_eq!(instances, 0, "the symbol is drawn only by inline graphics"); + assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 2); + let mut selected = s.doc().unwrap().selection.objects.clone(); + selected.sort(); + assert_eq!(selected, [NodeId(listed), NodeId(painted)]); + s.execute("select.set", &json!({"ids": [painted]})).unwrap(); + let rows = font_rows(&mut s, json!({"selectionOnly": true})); + assert_eq!(rows, [("Inter".to_string(), 2, 1), ("Source Serif 4".to_string(), 1, 1)]); + let r = s + .execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}, "selectionOnly": true})) + .unwrap(); + assert_eq!(r, json!({"runs": 1})); + assert_eq!(symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family, "Source Sans 3"); + } + + /// Refresh Font List lays out again the type in opacity masks and in the art of symbols and + /// patterns, as it does the type on the layers, and the inline graphics of the type on the + /// layers follow the symbols' type as laid out again. + #[test] + fn rescanning_fonts_refreshes_the_bounds_of_type_in_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let owner = s.execute("shape.rectangle", &json!({"x": 0, "y": 20, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap(); + let masked = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Mask", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + mask_with(&mut s, owner, masked); + let tiled = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Tile", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.pattern.make", &json!({"ids": [tiled], "name": "Dots", "edit": false})).unwrap(); + s.execute("select.set", &json!({"ids": [tiled]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let stale = Rect::new(0.0, 0.0, 1.0, 1.0); + s.edit("Make the bounds stale", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale)); + Ok(()) + }) + .unwrap(); + let bounds = |s: &Session| art_texts_of(&s.doc().unwrap().doc).iter().map(|(_, t, _)| t.cached_bounds).collect::>(); + assert_eq!(bounds(&s), [Some(stale), Some(stale)]); + s.execute("text.rescanFonts", &json!({})).unwrap(); + let after = bounds(&s); + assert_eq!(after.len(), 2); + assert!(after.iter().all(|b| b.is_some_and(|b| b != stale && b.width() > 1.0)), "{after:?}"); + let marked = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Se", "font": "Inter"})).unwrap()["id"].clone(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [marked]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let line = s.execute("text.create", &json!({"x": 10, "y": 140, "text": "In", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": line, "at": 0, "symbol": "Mark"})).unwrap(); + s.edit("Make the bounds stale", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale)); + d.resolve_inline_art(); + Ok(()) + }) + .unwrap(); + let resolved = |s: &Session| { + let d = &s.doc().unwrap().doc; + matches!(d.inline_resolved(text(d, NodeId(line)).unwrap()), std::borrow::Cow::Borrowed(_)) + }; + assert!(resolved(&s), "the inline graphic took the stale bounds"); + s.execute("text.rescanFonts", &json!({})).unwrap(); + assert!(resolved(&s), "the inline graphic follows the symbol's type laid out again"); + // So does an inline graphic in the art of another symbol. + let held = s.execute("text.create", &json!({"x": 10, "y": 180, "text": "Out", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": held, "at": 0, "symbol": "Mark"})).unwrap(); + s.execute("symbol.new", &json!({"name": "Outer", "ids": [held]})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + s.edit("Make the bounds stale", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| t.cached_bounds = Some(stale)); + Ok(()) + }) + .unwrap(); + let shrunk = s.doc().unwrap().doc.symbol_art_bounds("Mark"); + s.edit("Take the stale bounds", |d, _| { + edit_art_texts_of(d, &|_| true, &mut |t| { + for a in t.runs.iter_mut().filter_map(|r| r.inline.as_mut()) { + a.bounds = shrunk; + } + }); + Ok(()) + }) + .unwrap(); + let nested = |s: &Session| { + let d = &s.doc().unwrap().doc; + art_texts_of(d).iter().flat_map(|(_, t, _)| t.runs.iter().filter_map(|r| r.inline.as_ref().map(|a| a.bounds))).collect::>() + }; + assert_eq!(nested(&s), [shrunk]); + s.execute("text.rescanFonts", &json!({})).unwrap(); + let mark = s.doc().unwrap().doc.symbol_art_bounds("Mark"); + assert_ne!(mark, shrunk); + assert_eq!(nested(&s), [mark], "the inline graphic in the art of Outer follows Mark's type laid out again"); + } + /// The font family of text object `id`'s first run. fn text_family(s: &Session, id: u64) -> String { text(&s.doc().unwrap().doc, NodeId(id)).unwrap().runs[0].style.font_family.clone() diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index d79320c304..720a906462 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -31,10 +31,10 @@ use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::{ Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, }; -use vectorcraft_geom::{Affine, PathData, Rect, shapes}; +use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, ids_param, object_param, str_param}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, ids_param, object_param, str_param, switch_or}; pub fn specs() -> Vec { vec![ @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}", has_doc, update ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used; an error for a DCS file that reads plate files of its own), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}", has_doc, relink ), @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -97,7 +97,7 @@ pub fn specs() -> Vec { "Embed Image", [], None, - "{ids?: [image or placed document ids] (default: the selected linked ones)} break the links (the Links panel's Break Link): images keep the linked files' pixels in the document; a placed document becomes an editable copy of its art, as placing its file without link gives (its symbols, patterns, swatches and images joining the document). An image whose file can't be found and that shows the saved preview, or a placed document whose file can't be read or changed, stays linked: relink or update it first. One undo step → {embedded: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the selected linked ones)} break the links (the Links panel's Break Link): images keep the linked files' pixels in the document; a placed document becomes an editable copy of its art, as placing its file without link gives (its symbols, patterns, swatches and images joining the document). An image whose file can't be found and that shows the saved preview, or a placed document whose file can't be read or changed, stays linked: relink or update it first. An image placed from a DCS file can't be embedded (an error, and nothing changes). One undo step → {embedded: [ids], missing: [ids]}", has_doc, embed ), @@ -160,6 +160,8 @@ struct Group { ids: Vec, /// Placed documents: what they show. document: Option, + /// An image placed from a DCS file: the plate files of their own its plates were read from. + plate_files: Vec, } /// What a placed document shows: whether the art's bounds (not the artboard or page) are shown, @@ -175,9 +177,17 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option)> { } } -/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]). +/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]): an +/// image placed from a DCS file has its plate files of their own too, beside its file. pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { - groups(d, None).into_iter().filter(|g| !g.ids.is_empty()).map(|g| (g.link.path, ids_json(&g.ids))).collect() + let mut out = vec![]; + for g in groups(d, None).into_iter().filter(|g| !g.ids.is_empty()) { + let ids = ids_json(&g.ids); + let plates: Vec = g.plate_files.iter().filter_map(|f| super::place::dcs::plate_file_path(&g.link.path, &f.name)).collect(); + out.push((g.link.path, ids.clone())); + out.extend(plates.into_iter().map(|p| (p, ids.clone()))); + } + out } /// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when @@ -189,7 +199,11 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { let wanted: Option> = ids.map(|ids| ids.iter().copied().collect()); let mut add = |link: &LinkInfo, key: &str, document: Option, id: Option| { let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| { - out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document }); + let plate_files = match document { + None => d.images.get(key).map(ImageBlob::plate_files).unwrap_or_default(), + Some(_) => vec![], + }; + out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document, plate_files }); out.len() - 1 }); if let (Some(id), Some(g)) = (id, out.get_mut(i)) { @@ -335,10 +349,14 @@ fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option (Status, Option) { let Some(mut f) = locate(&g.link, base) else { return (Status::Missing, None) }; let l = &g.link; + if !g.plate_files.is_empty() && super::place::dcs::plate_files_changed(&g.plate_files, &f.path) { + return (Status::Modified, Some(f)); + } if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified { return (Status::Ok, Some(f)); } @@ -346,7 +364,7 @@ fn probe(g: &Group, base: LinkBase) -> (Status, Option) { let same = match &l.hash { Some(h) => *h == hash_bytes(&bytes), // Linked before links kept a hash: the image the file decodes to. - None => g.document.is_none() && fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key), + None => g.document.is_none() && super::place::dcs::raster(&bytes, Some(&f.path)).is_ok_and(|img| img.key == g.key), }; f.bytes = Some(bytes); (if same { Status::Ok } else { Status::Modified }, Some(f)) @@ -433,7 +451,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a previews.insert(key); continue; }; - let Ok(img) = fileio::raster_image(&bytes) else { + let Ok(img) = super::place::dcs::raster(&bytes, Some(&f.path)) else { previews.insert(key); continue; }; @@ -512,10 +530,11 @@ pub(crate) fn readable_and_kept(blob: ImageBlob, unread: bool, linked: bool) -> Ok(blob) } -/// The linked image file `bytes`, read ([`fileio::raster_image`]) with its preview; an error when -/// its pixels can't be read ([`readable`]). -fn linked_image(bytes: &[u8]) -> Result { - let mut img = fileio::raster_image(bytes)?; +/// The linked image file `bytes` (read from `path`, when there is one), read with its preview: a +/// DCS file's plates, else its image ([`super::place::dcs::raster`]); an error when its pixels +/// can't be read ([`readable`]). +fn linked_image(bytes: &[u8], path: Option<&str>) -> Result { + let mut img = super::place::dcs::raster(bytes, path)?; img.blob = readable(img.blob, kept_unread(bytes), true)?; Ok(img) } @@ -536,7 +555,7 @@ fn read_image(f: &mut Found, base: &LinkInfo, reads: &mut ImageReads) -> Result< let img = match reads.get(&f.path) { Some((h, img)) if *h == hash => img.clone(), _ => { - let img = linked_image(&bytes)?; + let img = linked_image(&bytes, Some(&f.path))?; reads.insert(f.path.clone(), (hash.clone(), img.clone())); img } @@ -586,12 +605,89 @@ fn clip_shape((frame, room): Clip) -> PathData { shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame) } +/// What happens to the clip of an object a link update remakes. +enum ClipTo { + /// It stays as it is. + Keep, + /// It is clipped to this path: the clipping path of the clip group holding just it changes, + /// else it goes into a new clip group. + Path(PathData), + /// It goes into a new clip group of this path, inside the clip group that holds it. + Nest(PathData), + /// The clipping path of the clip group holding just it goes (a DCS file's clipping path that + /// its new file lacks): the group stays as a plain group of it, with its id, name, visibility + /// and transparency. + Off, +} + +/// The clipping path, with its fill rule, of the clip group holding just object `id` of `d`, when +/// one holds it. +fn clip_path_of(d: &Document, id: NodeId) -> Option<(&PathData, FillRule)> { + let g = d.node(d.parent_of(id)?)?; + match &g.kind { + NodeKind::Group { children, clip: true } => match &children[..] { + [c, o] if o.id == id => match &c.kind { + NodeKind::Path { path, rule, clipping: true, .. } => Some((path, *rule)), + _ => None, + }, + _ => None, + }, + _ => None, + } +} + +/// Do `a` and `b` have the same subpaths, each closed or open alike, and the same anchors, each +/// within a thousandth of a point? +fn same_path(a: &PathData, b: &PathData) -> bool { + let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| p.distance(q) < 1e-3; + a.anchor_count() == b.anchor_count() + && a.subpaths.len() == b.subpaths.len() + && a.subpaths.iter().zip(&b.subpaths).all(|(x, y)| x.closed == y.closed) + && a.anchors() + .zip(b.anchors()) + .all(|((si, ai, x), (sj, aj, y))| si == sj && ai == aj && near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out)) +} + +/// The clip an image placed with `old_xf` takes when it shows `img`, read from a file, placed with +/// `new_xf`: Placement Options' clip `clip` when they clip it, else the clipping path a DCS file +/// draws `img` through. When neither clips it, a clip group holding just it whose clipping path +/// `held` is its old DCS file's (`old_clip`, in the old pixels, under the nonzero rule its clip +/// groups get) goes. +fn clip_to( + old_xf: Affine, + old_clip: Option<&PathData>, + clip: Option, + img: &RasterImage, + new_xf: Affine, + held: Option<(&PathData, FillRule)>, +) -> ClipTo { + if let Some(clip) = clip { + return ClipTo::Path(clip_shape(clip)); + } + // Whether the clip group holding just the image is the old DCS file's: a clipping path whose + // fill rule was changed in the document is not. + let file_held = match (old_clip, held) { + (Some(old), Some((held, rule))) => rule == FillRule::NonZero && same_path(&old.transformed(old_xf), held), + _ => false, + }; + match img.clip.as_ref().map(|path| path.transformed(new_xf)) { + // A clipping mask made in the document stays, with the file's clip group inside it. + Some(path) if held.is_some() && !file_held => ClipTo::Nest(path), + Some(path) => ClipTo::Path(path), + None if file_held => ClipTo::Off, + None => ClipTo::Keep, + } +} + +/// The object an object becomes, and what happens to its clip, given the clipping path of the clip +/// group holding just it ([`ClipTo`]). +type Remake<'a> = dyn FnMut(&Node, Option<(&PathData, FillRule)>) -> (Node, ClipTo) + 'a; + /// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and /// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for. struct Nested<'a> { pick: &'a dyn Fn(&Node) -> bool, - /// The object an object becomes, and the clip it takes ([`placed_xf`]). - make: &'a mut dyn FnMut(&Node) -> (Node, Option), + make: &'a mut Remake<'a>, /// The next node id for a clip group (taken from the document after the walk). next: u64, } @@ -622,12 +718,12 @@ impl Nested<'_> { if !(self.pick)(n) { return self.update(n, true); } - let (new, clip) = (self.make)(n); + let (new, clip) = (self.make)(n, None); let mut new = Arc::new(new); self.update(&mut new, true); *n = match clip { - Some(clip) => Arc::new(self.clip_group(new, clip)), - None => new, + ClipTo::Path(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)), + ClipTo::Keep | ClipTo::Off => new, }; } @@ -640,6 +736,8 @@ impl Nested<'_> { (!Arc::ptr_eq(&art, &m.art)).then_some(art) }); let mut slots: Vec<(usize, Arc)> = vec![]; + // The object a clip group that loses its clipping path holds alone. + let mut declipped = None; if let Some(children) = n.children() { // A clip group holding just the object: its clipping path changes. let clip_path = match (&n.kind, &children[..]) { @@ -655,32 +753,45 @@ impl Nested<'_> { } continue; } - let (new, clip) = (self.make)(c); + let held = clip_path.filter(|_| i == 1).and_then(|p| match &p.kind { + NodeKind::Path { path, rule, .. } => Some((path, *rule)), + _ => None, + }); + let (new, clip) = (self.make)(c, held); let mut new = Arc::new(new); self.update(&mut new, true); match (clip, clip_path) { - (None, _) => slots.push((i, new)), - (Some(clip), Some(path)) if i == 1 => { + (ClipTo::Keep, _) => slots.push((i, new)), + (ClipTo::Path(clip), Some(path)) if i == 1 => { let mut path = Node::clone(path); if let NodeKind::Path { path: p, .. } = &mut path.kind { - *p = clip_shape(clip); + *p = clip; } slots.extend([(0, Arc::new(path)), (1, new)]); } - (Some(clip), _) => { + (ClipTo::Path(clip) | ClipTo::Nest(clip), _) => { let group = self.clip_group(new, clip); slots.push((i, Arc::new(group))); } + (ClipTo::Off, Some(_)) if i == 1 => declipped = Some(new), + (ClipTo::Off, _) => slots.push((i, new)), } } } - if mask.is_none() && slots.is_empty() { + if mask.is_none() && slots.is_empty() && declipped.is_none() { return; } let n = Arc::make_mut(n); if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) { m.art = art; } + // A clip group that stays as a plain group of just the object. + if let Some(object) = declipped { + if let NodeKind::Group { children, clip } = &mut n.kind { + (*children, *clip) = (vec![object], false); + } + return; + } if let Some(children) = n.children_mut() { for (i, c) in slots { if let Some(slot) = children.get_mut(i) { @@ -690,12 +801,12 @@ impl Nested<'_> { } } - /// A clip group of `object` clipped to `clip`, as [`clip_image`] makes one in the layers. - fn clip_group(&mut self, object: Arc, clip: Clip) -> Node { + /// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers. + fn clip_group(&mut self, object: Arc, clip: PathData) -> Node { let (path_id, group_id) = (NodeId(self.next), NodeId(self.next + 1)); self.next += 2; let none = vectorcraft_color::Paint::None; - let mut path = Node::path(path_id, clip_shape(clip), Appearance::basic(none.clone(), none, 0.0)); + let mut path = Node::path(path_id, clip, Appearance::basic(none.clone(), none, 0.0)); if let NodeKind::Path { clipping, .. } = &mut path.kind { *clipping = true; } @@ -723,13 +834,13 @@ fn install_document( }; if let Some(also) = also { let pick = |n: &Node| shows(n, also, true); - let mut make = |n: &Node| { + let mut make = |n: &Node, _: Option<(&PathData, FillRule)>| { let mut n = n.clone(); - let mut clip = None; + let mut clip = ClipTo::Keep; if let NodeKind::PlacedDocument(p) = &mut n.kind { let (xf, c) = placed(p); (p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone()); - clip = c; + clip = c.map_or(ClipTo::Keep, |c| ClipTo::Path(clip_shape(c))); } (n, clip) }; @@ -801,10 +912,9 @@ fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), n (frame * at * Affine::scale_non_uniform(size.0 / nw, size.1 / nh), (o.clip && over).then_some((frame, room))) } -/// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just -/// it changes, else the image goes into a new clip group. -fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> { - let path = clip_shape(clip); +/// The clipping path of the clip group of `d` holding just object `id` becomes `path`; false when +/// no such clip group holds it. +fn set_clip_path(d: &mut Document, id: NodeId, path: PathData) -> bool { let clip_path = d.parent_of(id).and_then(|p| match &d.node(p)?.kind { NodeKind::Group { children, clip: true } => match &children[..] { [c, im] if im.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c.id), @@ -812,12 +922,30 @@ fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> { }, _ => None, }); - if let Some(c) = clip_path { - if let Some(Node { kind: NodeKind::Path { path: p, .. }, .. }) = d.node_mut(c) { - *p = path; - } + let Some(c) = clip_path else { return false }; + if let Some(Node { kind: NodeKind::Path { path: p, .. }, .. }) = d.node_mut(c) { + *p = path; + } + true +} + +/// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just +/// it changes, else the image goes into a new clip group. +fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> { + clip_image_to(d, id, clip_shape(clip)) +} + +/// Clip object `id` of `d` to `path`: the clipping path of the clip group holding just it changes, +/// else it goes into a new clip group. +fn clip_image_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> { + if set_clip_path(d, id, path.clone()) { return Ok(()); } + new_clip_group(d, id, path) +} + +/// Put object `id` of `d` into a new clip group of `path`, where it is. +fn new_clip_group(d: &mut Document, id: NodeId, path: PathData) -> Result<()> { let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; let image = d.remove(id)?; let none = vectorcraft_color::Paint::None; @@ -839,7 +967,7 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_ // the app connect to another computer. let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?; let bytes = read_file(&path)?; - let read = (link_info(&path, &bytes), linked_image(&bytes)?); + let read = (link_info(&path, &bytes), linked_image(&bytes, Some(&path))?); install(d, &[id], read, Some(Preserve::Bounds), None) } @@ -853,15 +981,17 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio // Every image picked shows content `also.1`, so one resolution serves them all. let old_pt = file_pt(d, also.1); let pick = |n: &Node| shows(n, also, false); - let mut make = |n: &Node| { + // The clipping path the old content's DCS file drew it through. + let old_clip = d.images.get(also.1).and_then(ImageBlob::separated_clip); + let mut make = |n: &Node, held: Option<(&PathData, FillRule)>| { let mut n = n.clone(); - let mut clip = None; + let mut clip = ClipTo::Keep; if let NodeKind::Image(im) = &mut n.kind { let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; let size = |w: u32, h: u32| (w as f64, h as f64); let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o); + clip = clip_to(im.xf, old_clip.as_ref(), c, &img, xf, held); (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); - clip = c; } (n, clip) }; @@ -872,15 +1002,43 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; let size = |w: u32, h: u32| (w as f64, h as f64); let (xf, clip) = placed_xf(im.xf, size(im.width, im.height), file_pt(d, &im.key), size(img.width, img.height), new_pt, o); + // Placement Options' clip, else a DCS file's clipping path, read again with its plates. + let old_clip = d.images.get(&im.key).and_then(ImageBlob::separated_clip); + clips.push((*id, clip_to(im.xf, old_clip.as_ref(), clip, &img, xf, clip_path_of(d, *id)))); if let Some(n) = d.node_mut(*id) && let NodeKind::Image(im) = &mut n.kind { (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); } - clips.extend(clip.map(|c| (*id, c))); } + // A DCS file's spot plates print on spot swatches of their inks. + super::place::dcs::add_spot_swatches(d, &img.blob.spot_plates()); store_image(d, &img.key, img.blob, true); - clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip)) + clips.into_iter().try_for_each(|(id, clip)| match clip { + ClipTo::Keep => Ok(()), + ClipTo::Path(path) => clip_image_to(d, id, path), + ClipTo::Nest(path) => new_clip_group(d, id, path), + ClipTo::Off => unclip(d, id), + }) +} + +/// Take the clipping path of the clip group holding just object `id` of `d` away: the group stays +/// as a plain group of the object, with its id, name, visibility and transparency as they were. +fn unclip(d: &mut Document, id: NodeId) -> Result<()> { + let Some((g, clip)) = d.parent_of(id).and_then(|g| match &d.node(g)?.kind { + NodeKind::Group { children, clip: true } => match &children[..] { + [c, o] if o.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some((g, c.id)), + _ => None, + }, + _ => None, + }) else { + return Ok(()); + }; + d.remove(clip)?; + if let Some(Node { kind: NodeKind::Group { clip, .. }, .. }) = d.node_mut(g) { + *clip = false; + } + Ok(()) } /// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in @@ -1013,7 +1171,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps // Read when the images show the preview, or when the file's details changed. let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None }; if preview && !eps_preview { - let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, linked_image(b).ok()?))) else { + let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, linked_image(b, Some(&f.path)).ok()?))) else { out.missing.extend(row); continue; }; @@ -1051,12 +1209,12 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps m.iter().find(|(k, doc, _)| k == key && *doc == document.is_some()).map(|(_, _, new)| new) }; let pick = |n: &Node| new(n).is_some(); - let mut make = |n: &Node| { + let mut make = |n: &Node, _: Option<(&PathData, FillRule)>| { let mut out = n.clone(); if let (Some(link), Some(l)) = (new(n), link_mut(&mut out)) { *l = link.clone(); } - (out, None) + (out, ClipTo::Keep) }; Nested { pick: &pick, make: &mut make, next: 0 }.run(d); } @@ -1328,10 +1486,44 @@ fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) - } } let updated: Vec = reads.iter().flat_map(|(g, _)| ids_json(&g.ids)).collect(); + let warnings = spot_swatch_notes(&st.doc, reads.iter().map(|(_, read)| read)); if !reads.is_empty() { s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))?; } - Ok(json!({ "updated": updated, "missing": missing })) + let mut out = json!({ "updated": updated, "missing": missing }); + if !warnings.is_empty() { + out["warnings"] = json!(warnings); + } + Ok(out) +} + +/// The warnings for the spot plates of the DCS files in `reads` whose ink a swatch of `d` already +/// names in another color, or as a process color ([`super::place::dcs::swatch_notes`]), or that an +/// earlier file of `reads` adds as a spot swatch in another color, each once. +fn spot_swatch_notes<'a>(d: &Document, reads: impl Iterator) -> Vec { + let mut out: Vec = vec![]; + // The spot swatches the reads add, in the order they are installed. + let mut added: Vec<(String, [f32; 4])> = vec![]; + for read in reads { + let Read::Image((_, img)) = read else { continue }; + let spots = img.blob.spot_plates(); + let mut notes = super::place::dcs::swatch_notes(d, &spots); + for (name, cmyk) in spots.into_iter().filter(|(name, _)| d.swatch(name).is_none()) { + match added.iter().find(|(n, _)| *n == name) { + Some((_, first)) if first.iter().zip(cmyk).any(|(a, b)| (a - b).abs() >= 0.005) => { + notes.push(format!("the document's swatch `{name}` is kept: it is a different color")); + } + Some(_) => {} + None => added.push((name, cmyk)), + } + } + for note in notes { + if !out.contains(¬e) { + out.push(note); + } + } + } + out } /// Which links `links.relink` points at the new file (the Replace choice of Relink). @@ -1513,7 +1705,11 @@ fn relink(s: &mut Session, p: &Value) -> Result { } } // Read before it is copied: a file that can't be shown isn't copied. - let img = if images.is_empty() { None } else { Some(linked_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) }; + let img = if images.is_empty() { None } else { Some(linked_image(&bytes, Some(&path)).map_err(|e| bad(C, format!("{path}: {e}")))?) }; + // A copy beside the document would point at plate files it can't find there. + if copy_to.is_some() && img.as_ref().is_some_and(|img| !img.blob.plate_files().is_empty()) { + return Err(bad(C, format!("{path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are"))); + } let mut sources = vec![]; for ((page, bounding, page_box), ids) in documents { sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?)); @@ -1548,12 +1744,16 @@ fn relink(s: &mut Session, p: &Value) -> Result { } } let relinked: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); + let warnings = spot_swatch_notes(&s.doc()?.doc, reads.iter().map(|(_, read)| read)); if !reads.is_empty() { s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None)))?; } not_found.sort(); not_found.dedup(); let mut out = json!({ "relinked": relinked, "notFound": not_found }); + if !warnings.is_empty() { + out["warnings"] = json!(warnings); + } if copy_to.is_some() { // The copies are in the document's folder, which the client did not give. for pair in copied.iter_mut().chain(kept.iter_mut()) { @@ -1712,6 +1912,9 @@ pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap &'static str { + if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_separated) { + return "DCS"; + } im.link .as_ref() .and_then(|l| fileio::format_for_name(l.name()).or_else(|| fileio::readable_format_for_name(l.name()))) @@ -1732,6 +1935,45 @@ fn status_rank(status: &str) -> usize { ["missing", "modified", "ok", "embedded"].iter().position(|s| *s == status).unwrap_or(4) } +/// The DCS images in the layers of `d` that are transparent (themselves, or a group or layer that +/// holds them) or overlap an object that is, as Show DCS Transparency Interactions marks them. +fn dcs_transparency(d: &Document) -> BTreeSet { + /// Each visible DCS image's id, bounds and whether it is transparent, and the bounds of each + /// other visible object with its own transparency. + fn walk(d: &Document, n: &Node, held: bool, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec) { + if !n.visible { + return; + } + let own = n.shows_own_transparency(); + match &n.kind { + NodeKind::Image(im) if d.images.get(&im.key).is_some_and(ImageBlob::is_separated) => { + if let Some(b) = n.geometric_bounds() { + images.push((n.id, b, held || own)); + // A transparent DCS image is an object with its own transparency for the + // others it overlaps. + if own { + see_through.push(b); + } + } + } + _ if own => see_through.extend(n.geometric_bounds()), + _ => {} + } + for c in n.children().into_iter().flatten() { + walk(d, c, held || own, images, see_through); + } + } + let (mut images, mut see_through) = (vec![], vec![]); + for l in &d.layers { + walk(d, l, false, &mut images, &mut see_through); + } + images + .into_iter() + .filter(|(_, b, transparent)| *transparent || see_through.iter().any(|t| t.intersect(*b).area() > 0.0)) + .map(|(id, ..)| id) + .collect() +} + fn list(s: &mut Session, p: &Value) -> Result { const C: &str = "links.list"; let st = s.doc()?; @@ -1739,6 +1981,7 @@ fn list(s: &mut Session, p: &Value) -> Result { if !matches!(show, "all" | "missing" | "modified" | "embedded") { return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded"))); } + let interactions = if switch_or(p, "dcsTransparency", false, C)? { dcs_transparency(&st.doc) } else { BTreeSet::new() }; let statuses = statuses(st); let mut objects = vec![]; st.doc.walk(|n| { @@ -1793,6 +2036,9 @@ fn list(s: &mut Session, p: &Value) -> Result { if preview { row["preview"] = json!(true); } + if interactions.contains(&n.id) { + row["dcsTransparency"] = json!(true); + } rows.push(row); } let text = |r: &Value, k: &str| r[k].as_str().unwrap_or_default().to_lowercase(); @@ -1831,6 +2077,14 @@ fn embed(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select the linked images or placed documents, or pass ids")); } + // A DCS file's plates print from its file. + let separated = |id: &NodeId| match kind(id) { + Some(NodeKind::Image(im)) => st.doc.images.get(&im.key).is_some_and(ImageBlob::is_separated), + _ => false, + }; + if let Some(id) = ids.iter().find(|id| separated(id)) { + return Err(bad(C, format!("object {}: {}", id.0, super::place::dcs::LINK_ONLY))); + } // Placed documents become editable copies of their art: those whose file can be read. let (mut documents, mut missing) = (vec![], vec![]); for id in ids.iter().copied() { @@ -1849,6 +2103,10 @@ fn embed(s: &mut Session, p: &Value) -> Result { } match probe(&g, LinkBase::of(st)) { (Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link, &mut image_reads) { + // One showing its preview is a DCS file only its file tells. + Ok((_, img)) if img.blob.is_separated() => { + return Err(bad(C, format!("object {}: {}", g.ids.first().map_or(0, |id| id.0), super::place::dcs::LINK_ONLY))); + } Ok(read) => { done.extend(&g.ids); reads.push((g.ids, read)); @@ -1915,7 +2173,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result { }; let path = absolute_path(&if fileio::extension(path).is_empty() { format!("{path}.{ext}") } else { path.to_string() }); // Read before writing: pixels that can't be read leave no file behind. - let img = linked_image(&bytes)?; + let img = linked_image(&bytes, Some(&path))?; write_file(&path, &bytes)?; let read = (link_info(&path, &bytes), img); s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds), None))?; diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index df388b81ab..eaf1597423 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -690,7 +690,9 @@ fn placed_across(d: &Document, ids: &[NodeId], paint: Paint, p: &Value, fill: bo /// The images among `ids` that a fill of `paint` colorizes: grayscale and 1-bit images, for a solid /// color (or none on a colorized image, which takes the color off). The color may be black, white, /// a process color or a spot color; on an image with an alpha channel, only a spot color, black or -/// white, and other colors are an error. +/// white, and other colors are an error. A colorized image that shows a preview that isn't +/// grayscale, such as a missing link's placeholder, takes a solid color as its new colorize paint; +/// while its paint is one an alpha channel allows, a process color is an error there too. fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result::Result, String> { let mut out = vec![]; for id in ids { @@ -701,6 +703,19 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result: out.push(*id); continue; } + // The Fill proxy shows such an image's colorize paint. Its pixels are not known until the + // file is relinked: a process color replaces only a process color, which the image's + // pixels already accepted, so a relink can't bring back an alpha channel under one. + if matches!(paint, Paint::Solid { .. }) + && let Some(old) = n.extras().colorize.as_deref() + && d.image_blob(im).is_some_and(|b| !b.takes_colorize()) + { + if !colorizes_transparency(d, paint) && colorizes_transparency(d, old) { + return Err(PROCESS_ON_TRANSPARENCY.into()); + } + out.push(*id); + continue; + } // A color-masked image is colorized as it shows: its masked pixels, which have an alpha // channel. let Some(gray) = d.image_blob(&d.shown_image(n, im)).and_then(|b| b.gray_source()) else { continue }; diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index c05493e964..b4639f0de5 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Close Path", [], None, - "{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor, placed as path.setHandle places it: a smooth first anchor keeps its outgoing handle in line at its own length, a corner keeps it where it is), out?: [x,y] (the first anchor's outgoing handle, placed after `in`; the anchor becomes a corner), independent?: bool (`in` makes the first anchor a corner and leaves its outgoing handle)}", + "{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), at?: [x,y] (move the first anchor there, its handles with it, before `in` and `out` are placed), in?: [x,y] (handle into the first anchor, placed as path.setHandle places it: a smooth first anchor keeps its outgoing handle in line at its own length, a corner keeps it where it is), out?: [x,y] (the first anchor's outgoing handle, placed after `in`; the anchor becomes a corner), independent?: bool (`in` makes the first anchor a corner and leaves its outgoing handle)}", has_doc, close ), @@ -205,6 +205,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result { /// Close the active open subpath of `id`, optionally setting the handles of the first anchor. The /// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only /// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape. +/// `at` first moves the anchor, its handles with it (the Pen's closing drag with Space held). fn close(s: &mut Session, p: &Value) -> Result { let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); @@ -212,6 +213,7 @@ fn close(s: &mut Session, p: &Value) -> Result { None => Ok(None), Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))), }; + let at = handle("at")?; let h_in = handle("in")?; let h_out = handle("out")?; let independent = switch_or(p, "independent", false, "path.close")?; @@ -223,6 +225,9 @@ fn close(s: &mut Session, p: &Value) -> Result { }; sp.closed = true; if let Some(first) = sp.anchors.first_mut() { + if let Some(to) = at { + first.translate(to - first.p); + } if let Some(h) = h_in { first.set_handle(false, h, independent); } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 5d56a2b646..f6c048a1d0 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -11,6 +11,8 @@ //! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by //! its art: the document an EPS file of ours carries, else what its PostScript draws, else its //! preview ([`fileio::load`]). +//! - A DCS file, an EPS file with one program per printing plate, becomes one linked image of its +//! plates ([`dcs`]); it is never embedded. //! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page //! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art //! (`crop: "bounding"`). With Link, a PDF/.ai/.ait page placed from a file becomes a placed @@ -32,6 +34,7 @@ //! `place` tool) with several files, which emits one `file.place` per click or drag. pub(crate) mod adopt; +pub(crate) mod dcs; mod doc; pub(crate) mod document; pub(crate) mod docx; @@ -69,7 +72,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; 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)} → {ids, name, format, linked, width, height, warnings}. 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 (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. 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; 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)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. 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 (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 ), @@ -78,7 +81,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), 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?: {…} (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}", always, info ), @@ -114,6 +117,8 @@ enum Art { /// A VectorCraft document or a PDF/.ai page placed linked: the file read, the link, and /// whether the art's bounds (not the artboard or page) are shown. Document(document::Source, LinkInfo, bool), + /// A DCS file: one linked image of its plates, clipped by its clipping path ([`dcs`]). + Dcs(Box), Vector { /// The loaded document: the source of the art's resources. src: Box, @@ -205,6 +210,15 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result) -> Node { }; Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) } + Art::Dcs(a) => { + let a = *a; + super::links::store_image(d, &a.img.key, a.img.blob, link.is_some()); + dcs::add_spot_swatches(d, &a.spots); + let im = + ImageObject { key: a.img.key, width: a.img.width, height: a.img.height, xf: a.xf, link, placement: Default::default(), pixels: None }; + let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); + match a.clip { + Some(c) => { + let children = vec![Arc::new(d.reid(&c)), Arc::new(image)]; + Node::new(d.alloc_id(), NodeKind::Group { children, clip: true }) + } + None => image, + } + } Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural, &d.setup)))), Art::Document(s, link, bounding) => document::node(d, &s, link, bounding), Art::Vector { src, mut nodes, clip } => { @@ -506,9 +535,13 @@ impl PlaceJob { pub fn read(self) -> Result { let mut loaded = load(&self.params, PLACE, self.target, true)?; let linked = loaded.link.is_some() && bool_or(&self.params, "link", true); - if let Art::Image(img) = &mut loaded.art { - let unread = super::links::kept_unread(&img.blob.bytes); - img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?; + match &mut loaded.art { + Art::Image(img) => { + let unread = super::links::kept_unread(&img.blob.bytes); + img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?; + } + Art::Dcs(a) => a.img.blob = super::links::readable(a.img.blob.clone(), false, true)?, + _ => {} } Ok(PlaceRead { job: self, loaded }) } @@ -628,7 +661,11 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { let below = st.current_layer().or_else(|| parent.and_then(|p| st.doc.layer_of(p))); let link = loaded.link.take().filter(|_| bool_or(p, "link", true)); let linked = link.is_some(); - let (name, format, warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings)); + let (name, format, mut warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings)); + let dcs_clipped = matches!(&loaded.art, Art::Dcs(a) if a.clip.is_some()); + if let Art::Dcs(a) = &loaded.art { + warnings.extend(dcs::swatch_notes(&st.doc, &a.spots)); + } let (id, size) = 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(); @@ -650,9 +687,18 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result { } Ok((id, size)) })?; - Ok( - json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings }), - ) + let mut out = json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings }); + // A DCS file clipped by its clipping path: its image, which the Links commands take. + if dcs_clipped { + let image = s.doc()?.doc.node(id).and_then(|n| match &n.kind { + NodeKind::Group { children, clip: true } => children.get(1).filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.id), + _ => None, + }); + if let Some(image) = image { + out["image"] = json!(image.0); + } + } + Ok(out) } /// The artboard under `at` (its edges included, [`Document::artboard_holding`]), else the first. @@ -750,10 +796,14 @@ fn read_queue(p: Value, queued: Vec) -> Result { // so that drawing it does not decode the file again. let checked = load(&q, QUEUE, None, false).and_then(|mut l| { let linked = l.link.is_some() && bool_or(&q, "link", true); - if let Art::Image(img) = &mut l.art { - let unread = super::links::kept_unread(&img.blob.bytes); - let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable }; - img.blob = check(img.blob.clone(), unread, linked)?; + match &mut l.art { + Art::Image(img) => { + let unread = super::links::kept_unread(&img.blob.bytes); + let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable }; + img.blob = check(img.blob.clone(), unread, linked)?; + } + Art::Dcs(a) => a.img.blob = super::links::readable(a.img.blob.clone(), false, true)?, + _ => {} } Ok(l) }); @@ -871,6 +921,15 @@ pub fn read_info(p: &Value) -> Result { out["ppi"] = json!([x, y]); out["colorMode"] = json!(color_mode(&img.blob.bytes)); } + if let Art::Dcs(a) = &l.art { + let (x, y) = a.img.ppi.unwrap_or((72.0, 72.0)); + out["pixelWidth"] = json!(a.img.width); + out["pixelHeight"] = json!(a.img.height); + out["ppi"] = json!([x, y]); + out["colorMode"] = json!("CMYK"); + let plates: Vec = a.img.blob.separated().map(|s| s.plates.iter().map(|p| p.name.clone()).collect()).unwrap_or_default(); + out["plates"] = json!(plates); + } if let Some(px) = p.get("thumbnail").and_then(Value::as_f64) { out["thumbnailBase64"] = json!(thumbnail_png(l, px)?); } diff --git a/crates/engine/src/cmd/place/dcs.rs b/crates/engine/src/cmd/place/dcs.rs new file mode 100644 index 0000000000..b540221a23 --- /dev/null +++ b/crates/engine/src/cmd/place/dcs.rs @@ -0,0 +1,132 @@ +//! DCS files (`file.place` with Link): an EPS file with one program per printing plate +//! ([`vectorcraft_eps::dcs`]). It is placed as one linked image whose plates are kept with its +//! pixels ([`vectorcraft_doc::separated`]): the screen and composite output show the plates mixed +//! into CMYK, and print separations put each plate on its own ink. Its spot plates become spot +//! swatches, and the clipping path its composite is drawn through clips it. A DCS file can't be +//! opened or embedded (Embed, Place without Link): the image stays linked to its file, which the +//! Links commands read again ([`raster`]). + +use vectorcraft_color::swatch::Swatch; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::separated::PlateFile; +use vectorcraft_doc::{Document, ImageBlob, Node, NodeKind}; +use vectorcraft_geom::{Affine, Rect}; + +use super::super::fileio::{self, RasterImage}; +use super::super::{EngineError, Result}; + +/// The largest plate file read (bytes). +const MAX_PLATE_FILE: u64 = 1 << 30; + +/// What refuses to open or embed a DCS file. +pub(crate) const LINK_ONLY: &str = "DCS files can only be placed linked: use File › Place with Link on"; + +/// A DCS file read for placing. +pub(crate) struct DcsArt { + pub img: RasterImage, + /// The image's pixels onto the file's page (its bounding box, top-left at the origin). + pub xf: Affine, + pub page: Rect, + /// The clipping path the composite is drawn through, on the page. + pub clip: Option, + /// The spot plates' inks and their process equivalents. + pub spots: Vec<(String, [f32; 4])>, + pub warnings: Vec, +} + +/// Is `bytes` a DCS file (an EPS file naming its plates)? +pub(crate) fn is_dcs(bytes: &[u8]) -> bool { + vectorcraft_eps::dcs::read(bytes).is_some() +} + +/// DCS file `bytes`, read from `path` (when there is one: plate files of their own are found in its +/// folder), with every plate. +pub(crate) fn read(bytes: &[u8], path: Option<&str>) -> Result { + let dcs = vectorcraft_eps::dcs::read(bytes).ok_or_else(|| EngineError::Other("not a DCS file".into()))?; + let mut files = vec![]; + let mut file = |name: &str| plate_file(path, name, &mut files); + let p = vectorcraft_eps::dcs::place(bytes, &dcs, &mut file).map_err(EngineError::Other)?; + // The clipping path in the image's pixel space, where the Links commands put it again; the + // image keeps it, so they know a clip group of that path as the file's. + let to_pixels = (p.xf.determinant().abs() > 1e-12).then(|| p.xf.inverse()); + let clip = match (&p.clip, to_pixels) { + (Some(Node { kind: NodeKind::Path { path, .. }, .. }), Some(inv)) => Some(path.transformed(inv)), + _ => None, + }; + let blob = ImageBlob::separated_tiff_clipped(&p.separated, p.ppi, &files, clip.as_ref()) + .ok_or_else(|| EngineError::Other("the plates can't be kept".into()))?; + let img = RasterImage { key: blob.content_key(), blob, width: p.separated.width, height: p.separated.height, ppi: p.ppi, clip }; + let spots = p.separated.spots().map(|s| (s.name.clone(), s.cmyk)).collect(); + Ok(DcsArt { img, xf: p.xf, page: p.page, clip: p.clip, spots, warnings: p.warnings }) +} + +/// A linked file's image, as the Links commands read it again: a DCS file's plates +/// ([`read`]), else the image file ([`fileio::raster_image`]). +pub(crate) fn raster(bytes: &[u8], path: Option<&str>) -> Result { + if is_dcs(bytes) { read(bytes, path).map(|a| a.img) } else { fileio::raster_image(bytes) } +} + +/// The path of the plate file a DCS file at `main` names `name`: the file of that name (its last +/// component, of any system's path) in `main`'s folder, with that name. +fn plate_path(main: Option<&str>, name: &str) -> std::result::Result<(String, String), String> { + let base = name.rsplit(['/', '\\', ':']).next().unwrap_or(name); + let folder = main.map(std::path::Path::new).and_then(std::path::Path::parent); + let Some(folder) = folder.filter(|_| !base.is_empty()) else { + return Err(format!("`{name}` is a file of its own, which can only be found next to the DCS file's path")); + }; + Ok((folder.join(base).to_string_lossy().into_owned(), base.to_string())) +} + +/// The path of the plate file named `name` that the DCS file at `main` (a path as a link keeps +/// it) was read with ([`plate_path`]); `None` when `main` has no folder. +pub(crate) fn plate_file_path(main: &str, name: &str) -> Option { + plate_path(Some(main), name).ok().map(|(path, _)| path) +} + +/// The plate file a DCS file at `main` names `name` ([`plate_path`]), noted in `files` as it is now. +fn plate_file(main: Option<&str>, name: &str, files: &mut Vec) -> std::result::Result, String> { + let (path, base) = plate_path(main, name)?; + match fileio::file_stamp(&path) { + None => Err(format!("`{base}` is not in the DCS file's folder")), + Some((size, _)) if size > MAX_PLATE_FILE => Err(format!("`{base}` is larger than {} MB", MAX_PLATE_FILE >> 20)), + Some((size, modified)) => { + let bytes = fileio::read_file(&path).map_err(|e| e.to_string())?; + files.push(PlateFile { name: base, size, modified: modified.unwrap_or(0) }); + Ok(bytes) + } + } +} + +/// Whether one of `files`, the plate files of their own a separated image was read from +/// ([`ImageBlob::plate_files`]), next to the DCS file at `main`, is gone or changed since. +pub(crate) fn plate_files_changed(files: &[PlateFile], main: &str) -> bool { + files.iter().any(|f| { + let stamp = plate_path(Some(main), &f.name).ok().and_then(|(path, _)| fileio::file_stamp(&path)); + stamp.is_none_or(|(size, modified)| size != f.size || modified.unwrap_or(0) != f.modified) + }) +} + +/// Add `spots` to `d` as spot swatches, each whose name no swatch has, in its process equivalent. +pub(crate) fn add_spot_swatches(d: &mut Document, spots: &[(String, [f32; 4])]) { + for (name, [c, m, y, k]) in spots { + if d.swatch(name).is_none() { + d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(Color::cmyk(*c, *m, *y, *k)), global: true, spot: true }); + } + } +} + +/// A warning for each of `spots` whose name a swatch of `d` already has that isn't a spot color in +/// the same process equivalent: the document's swatch stays, and the plate prints by it. +pub(crate) fn swatch_notes(d: &Document, spots: &[(String, [f32; 4])]) -> Vec { + let mut out = vec![]; + for (name, cmyk) in spots { + let Some(sw) = d.swatch(name) else { continue }; + let same = sw.spot + && matches!(sw.paint.color(), Some(Color::Cmyk { c, m, y, k }) if [c, m, y, k].iter().zip(cmyk).all(|(a, b)| (a - b).abs() < 0.005)); + if !same { + let how = if sw.spot { "a different color" } else { "a process color, so the plate prints as process inks" }; + out.push(format!("the document's swatch `{name}` is kept: it is {how}")); + } + } + out +} diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index c4b21b1cb4..5958a84b53 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -528,6 +528,7 @@ pub(super) const ALLOWED: &[&str] = &[ "select.same.textStrokeColor", "select.segments", "select.set", + "select.storedPath", "select.toggle", "select.update", "shape.arc", diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 9fe4665a5c..628facc1c3 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -112,6 +112,9 @@ fn preview(s: &mut Session, p: &Value) -> Result { super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?; let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?; pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv))); + // What the job loses comes first, then the notes on the options. + let settings = std::mem::take(&mut pv.setting_notes); + pv.warnings.extend(settings); let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?; v["settings"] = to_json(&set)?; Ok(v) @@ -199,7 +202,9 @@ fn print(s: &mut Session, p: &Value) -> Result { let mut r = vectorcraft_pdf::print(printed, &PrintOptions { settings: set, for_printer: bool_or(p, "forPrinter", false), ..Default::default() }) .map_err(|e| pdf_error(PRINT, e))?; + // What the job loses comes first, then the notes on the options. r.warnings.extend(typed); + r.warnings.extend(r.setting_notes); return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings })); } let level = match p.get("level").filter(|v| !v.is_null()) { @@ -209,12 +214,13 @@ fn print(s: &mut Session, p: &Value) -> Result { } None => Level::default(), }; - let mut warnings = vec![]; + // What the job loses, then the notes on the options: a reply lists them in that order. + let (mut warnings, mut setting_notes) = (vec![], vec![]); if !bitmap && (tile_images || doc.layers.iter().any(|l| l.shows_transparency())) { warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string()); } if !set.color.profile.trim().is_empty() && set.output.mode == vectorcraft_pdf::OutputMode::Composite { - warnings.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string()); + setting_notes.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string()); } let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness); let plan = vectorcraft_pdf::plan(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() }) @@ -244,5 +250,11 @@ fn print(s: &mut Session, p: &Value) -> Result { } let areas: Vec = plan.sheets.iter().filter_map(|sh| on_page(sh.area, sh.view * sh.place, sh.size)).collect(); warnings.extend(type_warnings(&full, which, &areas)); + for w in plan.setting_notes.iter().chain(&out.setting_notes) { + if !warnings.contains(w) && !setting_notes.contains(w) { + setting_notes.push(w.clone()); + } + } + warnings.extend(setting_notes); write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings })) } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index ea307c7575..ac9ca365f4 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -49,6 +49,15 @@ pub fn specs() -> Vec { has_doc, set ), + cmd!( + "select.storedPath", + "Select Path", + [], + None, + "{id: object id, fingerprint: text (\"v1:\" and 16 lowercase hex digits)} the Actions panel's Select Path step: select only the path or compound path `id` when its fingerprint (a hash of its kind, fill rule and anchors, its members' for a compound path) is still `fingerprint` and it can be selected: not hidden or locked, nor in a hidden or locked layer or group, a template layer or on a locked artboard, and inside the isolated object in isolation mode. A missing object, another kind of object, a path whose kind, fill rule or anchors changed, or one that cannot be selected is an error, and the selection stays as it was → {count, ids} (the resulting selection)", + has_doc, + stored_path + ), cmd!( "select.add", "Add to Selection", @@ -449,6 +458,150 @@ fn selection_result(s: &Session) -> Result { Ok(json!({ "count": selection.len(), "ids": selection.objects })) } +/// The prefix of every fingerprint [`path_fingerprint`] gives: the version of its hash. +const FINGERPRINT_PREFIX: &str = "v1:"; + +/// FNV-1a (64 bits) over the bytes written to it, starting from the offset basis. +pub(crate) struct Fnv(pub(crate) u64); + +impl Default for Fnv { + fn default() -> Self { + Self(0xcbf2_9ce4_8422_2325) + } +} + +impl std::io::Write for Fnv { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 = buf.iter().fold(self.0, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// The fingerprint of the path or compound path `n` that `select.storedPath` compares: FNV-1a (64 +/// bits) over a kind byte, the fill rule and the anchors of the path, or of each member of the +/// compound path in order, as JSON, written as [`FINGERPRINT_PREFIX`] and 16 lowercase hex digits. +/// None for any object [`is_storable_kind`] does not take. +pub fn path_fingerprint(n: &Node) -> Option { + fn feed(h: &mut Fnv, n: &Node) -> Option<()> { + use std::io::Write; + match &n.kind { + NodeKind::Path { path, rule, .. } => { + h.write_all(b"P").ok()?; + serde_json::to_writer(&mut *h, rule).ok()?; + serde_json::to_writer(&mut *h, path).ok() + } + NodeKind::Compound { children, rule } => { + h.write_all(b"C").ok()?; + serde_json::to_writer(&mut *h, rule).ok()?; + h.write_all(&(children.len() as u64).to_le_bytes()).ok()?; + children.iter().try_for_each(|c| feed(h, c)) + } + _ => None, + } + } + if !is_storable_kind(n) { + return None; + } + let mut h = Fnv::default(); + feed(&mut h, n)?; + Some(format!("{FINGERPRINT_PREFIX}{:016x}", h.0)) +} + +/// Whether `n` is an object a Select Path step can store: a path that is not a guide, or a compound +/// path whose members all are. +fn is_storable_kind(n: &Node) -> bool { + let path = |n: &Node| matches!(n.kind, NodeKind::Path { guide: false, .. }); + match &n.kind { + NodeKind::Compound { children, .. } => children.iter().all(|c| path(c)), + _ => path(n), + } +} + +/// The `id` and `fingerprint` of `select.storedPath`'s params `p`, or why they are not ones: an +/// object with only a non-negative whole `id` and a `fingerprint` that [`path_fingerprint`] could give. +pub fn stored_path_params(p: &Value) -> std::result::Result<(NodeId, &str), String> { + let o = p.as_object().ok_or("`params` must be an object")?; + if let Some(key) = o.keys().find(|k| !matches!(k.as_str(), "id" | "fingerprint")) { + let keys = "id, fingerprint"; + return Err(format!("unknown parameter `{key}`; the command takes: {keys}")); + } + let value = o.get("id").unwrap_or(&Value::Null); + let id = value.as_u64().map(NodeId).ok_or_else(|| format!("invalid object id {value}: expected a non-negative integer"))?; + let fingerprint = o.get("fingerprint").and_then(Value::as_str).unwrap_or_default(); + let hex = fingerprint.strip_prefix(FINGERPRINT_PREFIX).unwrap_or_default(); + if hex.len() != 16 || !hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + return Err("`fingerprint` must be \"v1:\" and 16 lowercase hex digits".into()); + } + Ok((id, fingerprint)) +} + +/// Why the object `id` of `st` cannot be the path of a Select Path step: it is not there, is not a +/// path (not a guide) or compound path ([`is_storable_kind`]), is a member of a compound path, cannot +/// be selected ([`is_selectable`]), or is on a locked artboard, in isolation mode too. +fn storable_path(st: &DocState, id: NodeId) -> std::result::Result<(), String> { + let value = id.0; + let n = st.doc.node(id).ok_or_else(|| format!("no such object id {value}"))?; + let in_compound = st.doc.parent_of(id).and_then(|p| st.doc.node(p)).is_some_and(|p| matches!(p.kind, NodeKind::Compound { .. })); + if !is_storable_kind(n) || in_compound { + return Err(WHOLE_PATH.into()); + } + // In isolation mode `is_selectable` does not look at the locked artboards, so they are checked here too. + let locks = st.doc.locked_artboards(); + if !is_selectable(&st.doc, st.isolation, &locks, id) || locks.holds_id(id) { + return Err("the path is hidden or locked, or cannot be selected here".into()); + } + Ok(()) +} + +/// What [`storable_selection`] asks for when the selection is not a whole path or compound path. +const WHOLE_PATH: &str = "select one whole path or compound path"; + +/// The one object selected in `st` when it can be the path of a Select Path step: a whole path or +/// compound path that can be selected ([`storable_path`]). An error for no selection, several +/// objects, a path with ruler guides, anchors or segments of a path alone, a group, type or any other object, a guide, or a +/// member of a compound path. It does not hash the path, so menus can ask it every frame. +pub fn storable_selection(st: &DocState) -> std::result::Result { + let sel = &st.selection; + let id = match sel.objects.as_slice() { + [] => return Err("select a path".into()), + [id] if sel.guides.is_empty() => *id, + _ => return Err("select only one path".into()), + }; + if sel.anchors.get(&id).is_some_and(|a| !a.is_empty()) || sel.segments.get(&id).is_some_and(|s| !s.is_empty()) { + return Err(WHOLE_PATH.into()); + } + storable_path(st, id)?; + Ok(id) +} + +/// The params of a `select.storedPath` step for the path selected in `st` ([`storable_selection`]): +/// `{id, fingerprint}`. +pub fn stored_path_step(st: &DocState) -> std::result::Result { + let id = storable_selection(st)?; + let fingerprint = st.doc.node(id).and_then(path_fingerprint).ok_or(WHOLE_PATH)?; + Ok(json!({ "id": id.0, "fingerprint": fingerprint })) +} + +/// `select.storedPath {id, fingerprint}`: selects only the path `id` when it is still the path the +/// step stored ([`path_fingerprint`]) and can be selected ([`storable_path`]); else an error, and +/// the selection stays as it was. +fn stored_path(s: &mut Session, p: &Value) -> Result { + const C: &str = "select.storedPath"; + let (id, fingerprint) = stored_path_params(p).map_err(|e| bad(C, e))?; + let st = s.doc()?; + let value = id.0; + let n = st.doc.node(id).ok_or_else(|| bad(C, format!("no such object id {value}")))?; + if path_fingerprint(n).as_deref() != Some(fingerprint) { + return Err(bad(C, format!("object {value} is not the stored path: it changed, or the document is another one"))); + } + storable_path(st, id).map_err(|e| bad(C, e))?; + s.select(|_, sel| sel.set([id]))?; + selection_result(s) +} + fn key(s: &mut Session, p: &Value) -> Result { let id = p.get("id").map(|value| checked_id(s, value, "select.key")).transpose()?; let guide = match p.get("guide").filter(|v| !v.is_null()) { diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 751313957f..55245b63a5 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Select All Unused", ["Window", "Swatches"], None, - "{} → {names: [the swatches nothing uses, in panel order]}: a colour is used when a paint links to it or an unlinked paint, gradient stop or mesh point has its colour; a gradient when a gradient links to it or has its stops; a pattern when it fills or strokes anything (art, symbols, pattern tiles and graphic styles count). None is never listed", + "{} → {names: [the swatches nothing uses, in panel order]}: a colour is used when a paint links to it or an unlinked paint, gradient stop or mesh point has its colour; a gradient when a gradient links to it or has its stops; a pattern when it fills or strokes anything (art, symbols, pattern tiles and graphic styles count); a color, spot or process, when an image placed from a DCS file that the art shows has a spot plate of its name. None is never listed", has_doc, swatch_unused ), @@ -988,8 +988,28 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result { let colors: HashSet = colors.iter().filter_map(color_key).collect(); let patterns: HashSet<&String> = patterns.iter().collect(); let gradients: HashSet = gradients.iter().filter_map(gradient_key).collect(); + // The spot inks the images placed from DCS files have plates for: their swatches print them. + // Only images the art shows count: a deleted one's pixels stay for undo. + let mut shown: HashSet<&str> = HashSet::new(); + for root in d + .layers + .iter() + .chain(d.symbols.iter().map(|s| &s.art)) + .chain(d.graph_designs.iter().map(|g| &g.art)) + .chain(d.patterns.iter().flat_map(|p| &p.art)) + { + vectorcraft_doc::swatches::walk_with_masks(root, &mut |n| { + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + shown.insert(im.key.as_str()); + } + }); + } + let plate_inks: HashSet = + shown.iter().filter_map(|k| d.images.get(*k)).flat_map(vectorcraft_doc::ImageBlob::spot_plates).map(|(name, _)| name).collect(); let used = |w: &Swatch| { gradient_links.contains(&w.name) + // A spot swatch prints the plate; a process one gives the color it prints as process. + || (w.paint.color().is_some() && plate_inks.contains(&w.name)) || match w.tint_of() { Some((base, tint)) => float_key(tint).is_some_and(|t| link_tints.contains(&(base, t))), None => link_names.contains(w.name.as_str()), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a891fb9599..0a5eeaa65c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,6 +32,7 @@ pub use cmd::distortcmds::perspective_click; pub use cmd::params::{check_keys, check_params}; pub use cmd::pathops::auto_simplify_level; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; +pub use cmd::select::{path_fingerprint, storable_selection, stored_path_params, stored_path_step}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use cmd::{EyedropperOptions, PathfinderOptions}; pub use journal::Journal; diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 747ce91545..c524f056a5 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -184,7 +184,9 @@ impl Session { if changed.is_empty() { return Ok(none()); } - let ids: Vec = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).collect(); + // Each once: a DCS file and its plate files list the same objects. + let mut seen = HashSet::new(); + let ids: Vec = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).filter(|id| seen.insert(*id)).collect(); let mode = if update { "automatically" } else { self.prefs.update_links.as_str() }; match mode { "automatically" => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 18eadcdf80..2352776651 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -44,6 +44,7 @@ mod containers; mod css; mod cut; mod dashalign; +mod dcs; mod distort; mod docsetup; mod document_move; @@ -175,6 +176,7 @@ mod smartguides; mod smooth_path; mod spelling; mod store; +mod stored_path; mod strokegeom; mod strokegradient; mod strokereach; diff --git a/crates/engine/src/tests/colorize.rs b/crates/engine/src/tests/colorize.rs index c71014eb1c..e281b3ee04 100644 --- a/crates/engine/src/tests/colorize.rs +++ b/crates/engine/src/tests/colorize.rs @@ -462,6 +462,49 @@ fn a_colorized_missing_link_placeholder_is_not_flattened() { assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0))), "the image keeps its paint"); } +/// A fill color on a colorized image that shows the missing-link placeholder recolors the image, +/// as the Fill proxy shows its colorize paint, and adds no fill item. +#[test] +fn a_fill_color_recolors_a_colorized_missing_link_placeholder() { + let mut s = session(); + let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap()); + let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }); + s.edit("Colorize", |d, _| { + d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + Ok(()) + }) + .unwrap(); + let appearance = s.doc().unwrap().doc.node(id).unwrap().appearance.clone(); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id.0]})).unwrap(); + assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into())); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance, appearance, "no fill item"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#0000ff".into())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0)))); +} + +/// The pixels behind the placeholder may have an alpha channel, so a missing-link image colorized +/// with a spot color takes another spot color, black or white, and refuses a process color. +#[test] +fn a_colorized_missing_link_placeholder_in_a_spot_color_refuses_a_process_color() { + let mut s = session(); + let bytes = std::sync::Arc::new(vectorcraft_doc::links::missing_placeholder().unwrap()); + let id = image(&mut s, "pic", ImageBlob { mime: "image/png".into(), bytes: bytes.clone(), proxy: Some(bytes) }); + s.execute("swatch.new", &json!({"name": "Ink 2", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap(); + s.edit("Colorize", |d, _| { + let ink = Paint::Solid { color: Color::rgb8(255, 0, 0), swatch: Some("Ink".into()), tint: 1.0 }; + d.node_mut(id).unwrap().extras_mut().colorize = Some(Box::new(ink)); + Ok(()) + }) + .unwrap(); + let before = colorize(&s, id); + let e = s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!(colorize(&s, id), before); + s.execute("paint.setFill", &json!({"swatch": "Ink 2", "ids": [id.0]})).unwrap(); + assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(w), .. }) if w == "Ink 2"), "{:?}", colorize(&s, id)); +} + #[test] fn flattened_colorized_image_shows_the_art_under_white_pixels() { let mut s = session(); diff --git a/crates/engine/src/tests/containers.rs b/crates/engine/src/tests/containers.rs index dd32135c18..437f932724 100644 --- a/crates/engine/src/tests/containers.rs +++ b/crates/engine/src/tests/containers.rs @@ -256,6 +256,122 @@ fn an_images_stroke_with_outline_object_draws_exports_and_expands_along_its_fram assert!(kids[0].appearance.fill_paint().is_none()); } +/// A 100×100 PNG, transparent but for an opaque red disc of radius 30 in its middle. +fn disc_png() -> Vec { + let mut png = vec![]; + image::RgbaImage::from_fn(100, 100, |x, y| { + let r = (f64::from(x) + 0.5 - 50.0).hypot(f64::from(y) + 0.5 - 50.0); + image::Rgba([255, 0, 0, if r <= 30.0 { 255 } else { 0 }]) + }) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + png +} + +#[test] +fn an_images_stroke_with_outline_object_follows_its_opaque_area() { + let mut s = session(); + // The frame runs from 50 to 150, the disc from 70 to 130. + let r = run(&mut s, "file.place", json!({"name": "disc.png", "dataBase64": vectorcraft_format::base64_encode(&disc_png()), "at": [100, 100]})); + let im = NodeId(r["ids"][0].as_u64().unwrap()); + run(&mut s, "appearance.addStroke", json!({})); + run(&mut s, "appearance.setItem", json!({"index": 0, "weight": 8, "color": "#000000"})); + run(&mut s, "effect.apply", json!({"effect": "path.outlineObject", "item": 0})); + // Under the image, the stroke's outer 4 pt shows around the disc, from 66 to 70; nothing runs + // along the frame. + let shade = |s: &Session, x: u32, y: u32| match pixel(s, x, y) { + p if p == RED => "red", + [r, g, b, _] if r.max(g).max(b) < 40 => "black", + [r, g, b, _] if r.min(g).min(b) > 215 => "white", + p => panic!("({x}, {y}) is {p:?}"), + }; + assert_eq!([shade(&s, 64, 100), shade(&s, 67, 100), shade(&s, 100, 100), shade(&s, 100, 132)], ["white", "black", "red", "black"]); + assert_eq!([shade(&s, 49, 100), shade(&s, 52, 100)], ["white", "white"]); + // SVG and PDF hold the disc's outline stroked 8 pt. + let doc = s.doc().unwrap().doc.clone(); + let disc = |b: vectorcraft_geom::Rect| [b.x0 - 70.0, b.y0 - 70.0, b.x1 - 130.0, b.y1 - 130.0].iter().all(|d| d.abs() < 0.5); + let svg = vectorcraft_svg::export(&doc, &Default::default()); + assert!(svg.contains(" Vec { let v = ViewInfo::default(); diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs new file mode 100644 index 0000000000..2801432a0a --- /dev/null +++ b/crates/engine/src/tests/dcs.rs @@ -0,0 +1,697 @@ +//! DCS files placed with Link (`file.place`): one linked image of their plates, their spot plates +//! as spot swatches, clipped by their composite's clipping path, never opened or embedded; and +//! the Links panel's Show DCS Transparency Interactions (`links.list` with `dcsTransparency`). +//! The files are made from scratch as http://www.telegraphics.net/doc/DCS.txt describes them. + +use std::path::PathBuf; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Node, NodeKind}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +/// A fresh scratch folder for one test. +fn temp_dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("vc-dcs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A plate program: one 2 × 2 grayscale image (`gray`, row by row) on a 20 × 10 pt page. +fn plate_eps(gray: [u8; 4]) -> String { + let hex: String = gray.iter().map(|g| format!("{g:02x}")).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n20 10 scale\n2 2 8 [2 0 0 -2 0 2] <{hex}> image\nshowpage\n%%Trailer\n%%EOF\n" + ) +} + +/// A single-file DCS 2.0 file of `plates` (name, plate program), with the header lines `extra`; its +/// composite is drawn through a clipping path, the left half of the page. +fn single_file(plates: &[(&str, String)], extra: &str) -> Vec { + let header = |offsets: &[usize]| { + let mut s = String::from("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n"); + s.push_str(extra); + for ((name, eps), at) in plates.iter().zip(offsets) { + s.push_str(&format!("%%PlateFile: ({name}) EPS #{at:010} {:010}\n", eps.len())); + } + s.push_str("%%EndComments\ngsave newpath 0 0 moveto 10 0 lineto 10 10 lineto 0 10 lineto closepath clip newpath\n"); + s.push_str("20 10 scale 2 2 8 [2 0 0 -2 0 2] <808080808080808080808080> false 3 colorimage grestore showpage\n%%EOF\n"); + s + }; + let main = header(&vec![0; plates.len()]).len(); + let mut offsets = vec![]; + let mut at = main; + for (_, eps) in plates { + offsets.push(at); + at += eps.len(); + } + let mut out = header(&offsets).into_bytes(); + for (_, eps) in plates { + out.extend(eps.as_bytes()); + } + out +} + +/// Cyan solid on the top row, PANTONE 185 C at 50% on the left column. +fn two_plates() -> Vec { + single_file( + &[("Cyan", plate_eps([0, 0, 255, 255])), ("PANTONE 185 C", plate_eps([127, 255, 127, 255]))], + "%%DocumentCustomColors: (PANTONE 185 C)\n%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n", + ) +} + +fn write(dir: &std::path::Path, name: &str, bytes: &[u8]) -> String { + let p = dir.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().into_owned() +} + +/// The image inside placed object `id` (itself, or the image of its clipping group). +fn image_of(s: &Session, id: &Value) -> Node { + let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone(); + let mut found = None; + n.walk(&mut |c: &Node| { + if found.is_none() && matches!(c.kind, NodeKind::Image(_)) { + found = Some(c.clone()); + } + }); + found.expect("an image") +} + +fn error(s: &mut Session, cmd: &str, p: Value) -> String { + s.execute(cmd, &p).unwrap_err().to_string() +} + +#[test] +fn a_dcs_file_places_as_one_linked_image_of_its_plates() { + let dir = temp_dir("place"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + assert_eq!(r["linked"], true); + assert_eq!(r["warnings"], json!([])); + let id = &r["ids"][0]; + let placed = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone(); + let NodeKind::Group { children, clip: true } = &placed.kind else { panic!("a clipping group: {:?}", placed.kind) }; + assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }), "the composite's clipping path"); + assert_eq!(children[0].geometric_bounds().map(|b| b.width().round()), Some(10.0)); + assert_eq!(r["width"].as_f64().map(f64::round), Some(10.0), "clipped to the left half of its 20 pt page"); + let image = image_of(&s, id); + let NodeKind::Image(im) = &image.kind else { not_an_image() }; + assert!(im.link.as_ref().is_some_and(|l| l.path.ends_with("pic.eps"))); + let doc = &s.doc().unwrap().doc; + let plates = doc.images[&im.key].separated().expect("its plates are kept"); + assert_eq!(plates.plates.iter().map(|p| p.name.as_str()).collect::>(), ["Cyan", "PANTONE 185 C"]); + assert_eq!(plates.plates[1].data, [128, 0, 128, 0]); + let sw = doc.swatch("PANTONE 185 C").expect("a spot swatch for the spot plate"); + assert!(sw.spot); + assert_eq!(sw.paint, Paint::solid(Color::cmyk(0.0, 0.91, 0.76, 0.0))); + assert!(!doc.swatches_iter().any(|w| w.spot && w.name == "Cyan"), "process plates add no spot swatch"); + let list = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(list["links"][0]["format"], "DCS"); + assert_eq!(list["links"][0]["status"], "ok"); + let info = s.execute("file.place.info", &json!({ "path": path })).unwrap(); + assert_eq!(info["plates"], json!(["Cyan", "PANTONE 185 C"])); + assert_eq!(info["colorMode"], "CMYK"); + assert_eq!(info["pixelWidth"], 2); + // One undo step takes the swatch away too. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.swatch("PANTONE 185 C").is_none()); +} + +fn not_an_image() -> ! { + panic!("an image") +} + +#[test] +fn a_dcs_file_is_never_opened_or_embedded() { + let dir = temp_dir("refused"); + let bytes = two_plates(); + let path = write(&dir, "pic.eps", &bytes); + let mut s = session(); + let only = "DCS files can only be placed linked"; + assert!(error(&mut s, "file.place", json!({ "path": path, "link": false })).contains(only)); + let data = json!({ "name": "pic.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes) }); + assert!(error(&mut s, "file.place", data).contains(only), "bytes alone can't be linked"); + assert!(error(&mut s, "document.open", json!({ "path": path })).contains(only)); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let image = image_of(&s, &r["ids"][0]); + let e = error(&mut s, "links.embed", json!({ "ids": [image.id.0] })); + assert!(e.contains(only), "{e}"); + let NodeKind::Image(im) = &image_of(&s, &r["ids"][0]).kind else { not_an_image() }; + assert!(im.link.is_some(), "still linked"); +} + +#[test] +fn a_swatch_of_the_same_name_is_kept_with_a_warning() { + let dir = temp_dir("swatch"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + s.execute("swatch.new", &json!({"name": "PANTONE 185 C", "color": {"c": 0.0, "m": 1.0, "y": 1.0, "k": 0.0}, "spot": true})).unwrap(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + assert_eq!(r["warnings"], json!(["the document's swatch `PANTONE 185 C` is kept: it is a different color"])); + let sw = s.doc().unwrap().doc.swatch("PANTONE 185 C").unwrap().clone(); + assert_eq!(sw.paint.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0))); +} + +#[test] +fn plate_files_of_their_own_are_read_from_the_dcs_files_folder() { + let dir = temp_dir("files"); + write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes()); + write(&dir, "pic.K", plate_eps([255, 255, 255, 0]).as_bytes()); + let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: OldDisk:Scans:pic.C\n%%BlackPlate: pic.K\n%%EndComments\nshowpage\n"; + let path = write(&dir, "pic.eps", main.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let image = image_of(&s, &r["ids"][0]); + assert!(matches!(image.kind, NodeKind::Image(_)), "no clipping path without a composite"); + let NodeKind::Image(im) = &image.kind else { not_an_image() }; + let plates = s.doc().unwrap().doc.images[&im.key].separated().unwrap(); + assert_eq!(plates.plates[0].data, [255, 0, 0, 0], "the plate file found by its name in the folder"); + assert_eq!(plates.plates[1].data, [0, 0, 0, 255]); + std::fs::remove_file(dir.join("pic.K")).unwrap(); + let e = error(&mut s, "file.place", json!({ "path": path })); + assert!(e.contains("the Black plate can't be read") && e.contains("`pic.K` is not in the DCS file's folder"), "{e}"); +} + +#[test] +fn update_link_reads_the_plates_again() { + let dir = temp_dir("update"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + let changed = single_file(&[("Cyan", plate_eps([0, 0, 0, 0])), ("Varnish", plate_eps([255; 4]))], "%%CMYKCustomColor: 0 0 .2 0 (Varnish)\n"); + std::fs::write(&path, &changed).unwrap(); + let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(u["updated"], json!([id.0])); + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() }; + let plates = s.doc().unwrap().doc.images[&im.key].separated().unwrap(); + assert_eq!(plates.plates.len(), 2); + assert_eq!(plates.plates[0].data, [255; 4]); + let sw = s.doc().unwrap().doc.swatch("Varnish").expect("a spot swatch for the plate the file gained"); + assert!(sw.spot); + assert_eq!(sw.paint, Paint::solid(Color::cmyk(0.0, 0.0, 0.2, 0.0))); +} + +#[test] +fn update_link_takes_the_files_new_clipping_path() { + let dir = temp_dir("update-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let id = image_of(&s, &r["ids"][0]).id; + let clip_width = |s: &Session| { + let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(group).unwrap().kind else { panic!("a clipping group") }; + children[0].geometric_bounds().map(|b| b.width()) + }; + let before = clip_width(&s).unwrap(); + // The same file, its composite now drawn through the left quarter of the page (same length, + // so the plates stay where the header says). + let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1); + std::fs::write(&path, text.as_bytes()).unwrap(); + let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(u["updated"], json!([id.0])); + let after = clip_width(&s).unwrap(); + assert!((after - before / 2.0).abs() < 1e-6, "the clipping path follows the file: {before} → {after}"); +} + +#[test] +fn update_link_clips_an_image_whose_file_gained_a_clipping_path() { + let dir = temp_dir("update-gained-clip"); + // The composite drawn without its clipping path (same length). + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + let path = write(&dir, "pic.eps", unclipped.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::Image(_)), "no clipping path, no group"); + std::fs::write(&path, two_plates()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + let group = doc.node(doc.parent_of(id).expect("a parent")).unwrap(); + let NodeKind::Group { children, clip: true } = &group.kind else { panic!("a clipping group: {:?}", group.kind) }; + assert_eq!(children[0].geometric_bounds().map(|b| b.width().round()), Some(10.0), "the left half of the page"); +} + +#[test] +fn a_changed_plate_file_marks_the_link_modified() { + let dir = temp_dir("plate-changed"); + write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes()); + let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n"; + let path = write(&dir, "pic.eps", main.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "ok"); + // Only the plate file changes (a size of its own, so the change shows whatever the clock). + write(&dir, "pic.C", format!("{}% more\n", plate_eps([255, 255, 255, 0])).as_bytes()); + assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "modified"); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u["updated"], json!([id.0])); + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() }; + assert_eq!(s.doc().unwrap().doc.images[&im.key].separated().unwrap().plates[0].data, [0, 0, 0, 255]); + assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "ok"); + std::fs::remove_file(dir.join("pic.C")).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "modified", "a plate file that is gone"); +} + +#[test] +fn the_link_watch_reads_the_link_again_when_only_a_plate_file_changes() { + let dir = temp_dir("plate-watch"); + write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes()); + let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n"; + let path = write(&dir, "pic.eps", main.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + let files: Vec = crate::link_watch::linked_files(&s.doc().unwrap().doc).into_iter().map(|(p, _)| p).collect(); + assert!(files.iter().any(|p| p.ends_with("pic.C")), "the plate file is watched too: {files:?}"); + // The first look only remembers the files. + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([])); + write(&dir, "pic.C", format!("{}% more\n", plate_eps([255, 255, 255, 0])).as_bytes()); + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([id.0])); + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() }; + assert_eq!(s.doc().unwrap().doc.images[&im.key].separated().unwrap().plates[0].data, [0, 0, 0, 255]); +} + +/// The width of the clipping path of the clip group holding the image in the art of symbol +/// `name`; `None` when no clip group holds it. +fn symbol_clip_width(s: &Session, name: &str) -> Option { + fn find(n: &Node) -> Option> { + if let NodeKind::Group { children, clip: true } = &n.kind + && let [c, im] = &children[..] + && matches!(im.kind, NodeKind::Image(_)) + { + return Some(c.geometric_bounds().map(|b| b.width())); + } + if matches!(n.kind, NodeKind::Image(_)) { + return Some(None); + } + n.children().and_then(|ch| ch.iter().find_map(|c| find(c))) + } + let doc = &s.doc().unwrap().doc; + find(&doc.symbols.iter().find(|w| w.name == name).expect("the symbol").art).expect("an image in the symbol") +} + +#[test] +fn update_link_takes_the_files_clipping_path_into_symbols_too() { + let dir = temp_dir("update-symbol-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + let other = s.execute("file.place", &json!({ "path": path, "at": [200, 100] })).unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [other["ids"][0]] })).unwrap(); + let before = symbol_clip_width(&s, "Pic").expect("the file's clipping path"); + // The composite now drawn through the left quarter of the page (same length). + let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1); + std::fs::write(&path, text.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let after = symbol_clip_width(&s, "Pic").expect("still clipped"); + assert!((after - before / 2.0).abs() < 1e-6, "the symbol's clipping path follows the file: {before} → {after}"); + // The file without a clipping path (same length): the symbol's image is no longer clipped. + let unclipped = text.replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(symbol_clip_width(&s, "Pic"), None); +} + +#[test] +fn a_file_that_lost_its_clipping_path_unclips_the_image() { + let dir = temp_dir("update-lost-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + let placed = NodeId(r["ids"][0].as_u64().unwrap()); + assert_eq!(doc.node(placed).and_then(plain_group_of_image), Some((false, true)), "the placed group stays, without the clip"); + let parent = doc.parent_of(id).expect("a parent"); + assert!(!matches!(doc.node(parent).unwrap().kind, NodeKind::Group { clip: true, .. }), "the image is not clipped"); + // Relinked to a plain image: no clip group either. + let png = dir.join("plain.png"); + image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap(); + std::fs::write(&path, two_plates()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert!( + s.doc().unwrap().doc.parent_of(id).is_some_and(|p| matches!(s.doc().unwrap().doc.node(p).unwrap().kind, NodeKind::Group { clip: true, .. })) + ); + s.execute("links.relink", &json!({ "ids": [id.0], "path": png.to_string_lossy() })).unwrap(); + let doc = &s.doc().unwrap().doc; + assert!( + !matches!(doc.node(doc.parent_of(id).unwrap()).unwrap().kind, NodeKind::Group { clip: true, .. }), + "a plain image isn't clipped by the DCS file's path" + ); +} + +/// The kind of group `g` is, as (clip, the kinds of its children are just one image). +fn plain_group_of_image(g: &Node) -> Option<(bool, bool)> { + match &g.kind { + NodeKind::Group { children, clip } => Some((*clip, matches!(&children[..], [c] if matches!(c.kind, NodeKind::Image(_))))), + _ => None, + } +} + +#[test] +fn a_hidden_or_transparent_clip_group_loses_the_files_old_clipping_path() { + let dir = temp_dir("lost-clip-kept-group"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let place = |s: &mut Session, x: f64| s.execute("file.place", &json!({ "path": path, "at": [x, 100] })).unwrap(); + let (faded, hidden, in_symbol) = (place(&mut s, 100.0), place(&mut s, 200.0), place(&mut s, 300.0)); + let (faded, hidden) = (NodeId(faded["ids"][0].as_u64().unwrap()), NodeId(hidden["ids"][0].as_u64().unwrap())); + let image = image_of(&s, &json!(faded.0)).id; + s.execute("transparency.set", &json!({"ids": [faded.0, in_symbol["ids"][0]], "opacity": 50})).unwrap(); + s.edit("hide", |d, _| { + if let Some(n) = d.node_mut(hidden) { + n.visible = false; + } + Ok(()) + }) + .unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [in_symbol["ids"][0]] })).unwrap(); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + let hidden_image = image_of(&s, &json!(hidden.0)).id; + s.execute("links.update", &json!({ "ids": [image.0, hidden_image.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + for (g, why) in [(faded, "the transparent group"), (hidden, "the hidden group")] { + let n = doc.node(g).unwrap_or_else(|| panic!("{why} stays")); + assert_eq!(plain_group_of_image(n), Some((false, true)), "{why} holds the image alone, unclipped"); + } + assert!((doc.node(faded).unwrap().opacity - 0.5).abs() < 1e-6, "its transparency kept"); + assert!(!doc.node(hidden).unwrap().visible, "still hidden"); + // In a symbol's art too. + let art = &doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art; + let mut found = None; + art.walk(&mut |n: &Node| { + if found.is_none() && n.children().is_some_and(|ch| ch.iter().any(|c| matches!(c.kind, NodeKind::Image(_)))) { + found = plain_group_of_image(n); + } + }); + assert_eq!(found, Some((false, true)), "the symbol's transparent group holds the image alone, unclipped"); +} + +#[test] +fn a_clipping_mask_of_ones_own_stays_when_the_link_is_updated() { + let dir = temp_dir("own-mask"); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + let path = write(&dir, "pic.eps", unclipped.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let id = NodeId(r["ids"][0].as_u64().unwrap()); + let rect = s.execute("shape.rectangle", &json!({"x": 95, "y": 95, "width": 4, "height": 4})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({ "ids": [id.0, rect] })).unwrap(); + let g = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(g), "the clipping mask made in the document stays"); + let mask_width = |s: &Session| { + let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(g).unwrap().kind else { panic!("the mask's group") }; + children[0].geometric_bounds().map(|b| b.width().round()) + }; + assert_eq!(mask_width(&s), Some(4.0)); + // The file gains a clipping path: its clip group goes inside the mask, which keeps its path. + std::fs::write(&path, two_plates()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let doc = &s.doc().unwrap().doc; + let inner = doc.parent_of(id).expect("a parent"); + assert_ne!(inner, g); + assert!(matches!(doc.node(inner).unwrap().kind, NodeKind::Group { clip: true, .. }), "the file's clip group"); + assert_eq!(doc.parent_of(inner), Some(g)); + assert_eq!(mask_width(&s), Some(4.0), "the mask's path is kept"); +} + +#[test] +fn a_dcs_file_takes_the_place_parameters_an_eps_file_takes() { + let dir = temp_dir("params"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + assert!(error(&mut s, "file.place", json!({ "path": path, "page": 0 })).contains("page")); + assert!(error(&mut s, "file.place", json!({ "path": path, "crop": "trim" })).contains("crop")); +} + +#[test] +fn a_clipped_placement_answers_its_image_for_the_links_commands() { + let dir = temp_dir("image-id"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + assert_eq!(r["image"], json!(image_of(&s, &r["ids"][0]).id.0)); + let info = s.execute("links.info", &json!({ "id": r["image"] })).unwrap(); + assert_eq!(info["format"], "DCS"); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + let plain = write(&dir, "plain.eps", unclipped.as_bytes()); + assert!(s.execute("file.place", &json!({ "path": plain })).unwrap().get("image").is_none(), "an image placed alone is its own id"); +} + +#[test] +fn update_and_relink_warn_of_a_swatch_kept_for_a_new_spot_plate() { + let dir = temp_dir("update-swatch"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let id = image_of(&s, &r["ids"][0]).id; + s.execute("swatch.new", &json!({"name": "Varnish", "color": {"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap(); + let changed = single_file(&[("Cyan", plate_eps([0, 0, 0, 0])), ("Varnish", plate_eps([255; 4]))], "%%CMYKCustomColor: 0 0 .2 0 (Varnish)\n"); + std::fs::write(&path, &changed).unwrap(); + let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + let kept = "the document's swatch `Varnish` is kept: it is a process color, so the plate prints as process inks"; + assert_eq!(u["warnings"], json!([kept])); + let other = write(&dir, "other.eps", &changed); + let r = s.execute("links.relink", &json!({ "ids": [id.0], "path": other })).unwrap(); + assert_eq!(r["warnings"], json!([kept])); + assert!(s.execute("links.update", &json!({ "ids": [id.0] })).unwrap().get("warnings").is_some(), "every read warns"); +} + +#[test] +fn a_deleted_dcs_image_leaves_its_spot_swatch_unused() { + let dir = temp_dir("unused"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C"); + assert!(!unused(&mut s), "in use while the image shows it"); + s.execute("select.set", &json!({ "ids": [r["ids"][0]] })).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert!(unused(&mut s), "unused once the image is deleted"); +} + +#[test] +fn a_process_swatch_giving_a_dcs_plate_its_color_is_used() { + let dir = temp_dir("unused-process"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + s.execute("swatch.new", &json!({"name": "PANTONE 185 C", "color": {"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap(); + let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C"); + assert!(unused(&mut s), "nothing uses it yet"); + s.execute("file.place", &json!({ "path": path })).unwrap(); + assert!(!unused(&mut s), "the plate of its name prints as its process inks"); +} + +#[test] +fn an_opened_clipping_path_is_not_the_files_and_stays() { + let dir = temp_dir("opened-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let id = image_of(&s, &r["ids"][0]).id; + // The clipping path opened, its anchors where they were. + s.edit("open", |d, _| { + let clip = d.node(group).and_then(|g| g.children()).map(|ch| ch[0].id).unwrap(); + if let Some(Node { kind: NodeKind::Path { path, .. }, .. }) = d.node_mut(clip) { + path.subpaths[0].closed = false; + } + Ok(()) + }) + .unwrap(); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(group), "a clipping path edited in the document stays"); +} + +#[test] +fn a_clipping_path_given_another_fill_rule_is_not_the_files_and_stays() { + let dir = temp_dir("rule-clip"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let id = image_of(&s, &r["ids"][0]).id; + // The clipping path's fill rule changed, its anchors where they were. + s.edit("rule", |d, _| { + let clip = d.node(group).and_then(|g| g.children()).map(|ch| ch[0].id).unwrap(); + if let Some(Node { kind: NodeKind::Path { rule, .. }, .. }) = d.node_mut(clip) { + *rule = vectorcraft_geom::FillRule::EvenOdd; + } + Ok(()) + }) + .unwrap(); + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(group), "a clipping path whose fill rule was changed stays"); +} + +#[test] +fn an_even_odd_clipping_path_is_placed_and_matched_under_the_nonzero_rule() { + let dir = temp_dir("eo-clip"); + // The same length, so the plates' offsets hold. + let eo = String::from_utf8(two_plates()).unwrap().replacen("gsave newpath 0 0 moveto", "gsave 0 0 moveto ", 1); + let eo = eo.replacen("closepath clip newpath", "closepath eoclip newpath", 1); + assert_eq!(eo.len(), two_plates().len()); + let path = write(&dir, "pic.eps", eo.as_bytes()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path })).unwrap(); + let group = NodeId(r["ids"][0].as_u64().unwrap()); + let id = image_of(&s, &r["ids"][0]).id; + let doc = &s.doc().unwrap().doc; + let clip = &doc.node(group).and_then(|g| g.children()).expect("the file's clip group")[0]; + assert!(matches!(clip.kind, NodeKind::Path { rule: vectorcraft_geom::FillRule::NonZero, clipping: true, .. }), "{:?}", clip.kind); + // The file without its clipping path: the group's clipping path is the file's, and goes. + let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1); + std::fs::write(&path, unclipped.as_bytes()).unwrap(); + s.execute("links.update", &json!({ "ids": [id.0] })).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(group).and_then(plain_group_of_image), Some((false, true)), "the file's clipping path is gone"); +} + +#[test] +fn files_updated_together_warn_when_they_add_one_ink_in_two_colors() { + let dir = temp_dir("update-two-inks"); + let (a, b) = (write(&dir, "a.eps", &two_plates()), write(&dir, "b.eps", &two_plates())); + let mut s = session(); + s.execute("file.place", &json!({ "path": a })).unwrap(); + s.execute("file.place", &json!({ "path": b })).unwrap(); + let varnish = + |c: &str| single_file(&[("Cyan", plate_eps([0; 4])), ("Varnish", plate_eps([255; 4]))], &format!("%%CMYKCustomColor: {c} (Varnish)\n")); + std::fs::write(&a, varnish("0 0 .2 0")).unwrap(); + std::fs::write(&b, varnish("1 0 0 0")).unwrap(); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u["updated"].as_array().map(Vec::len), Some(2), "{u}"); + assert_eq!(u["warnings"], json!(["the document's swatch `Varnish` is kept: it is a different color"])); +} + +#[test] +fn relink_with_copy_refuses_a_dcs_file_with_plate_files() { + let dir = temp_dir("copy"); + write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes()); + let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n"; + let path = write(&dir, "pic.eps", main.as_bytes()); + let docs = dir.join("docs"); + std::fs::create_dir_all(&docs).unwrap(); + let mut s = session(); + s.execute("document.save", &json!({ "path": docs.join("doc.vectorcraft").to_string_lossy() })).unwrap(); + let png = dir.join("plain.png"); + image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap(); + let r = s.execute("file.place", &json!({ "path": png.to_string_lossy() })).unwrap(); + let id = r["ids"][0].clone(); + let e = error(&mut s, "links.relink", json!({ "ids": [id], "path": path, "copy": true })); + assert!(e.contains("plate files"), "{e}"); + assert!(!docs.join("pic.eps").exists(), "nothing is copied"); + // A single-file DCS file has its plates inside it: it is copied. + let single = write(&dir, "single.eps", &two_plates()); + s.execute("links.relink", &json!({ "ids": [id], "path": single, "copy": true })).unwrap(); + assert!(docs.join("single.eps").exists()); +} + +#[test] +fn a_saved_document_reads_the_plates_again_and_prints_the_spot_ink() { + let dir = temp_dir("saved"); + let path = write(&dir, "pic.eps", &two_plates()); + let saved = dir.join("doc.vectorcraft").to_string_lossy().into_owned(); + let mut s = session(); + s.execute("file.place", &json!({ "path": path })).unwrap(); + let unused = s.execute("swatch.unused", &json!({})).unwrap(); + assert!(!unused["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C"), "the plate's swatch is in use: {unused}"); + s.execute("document.save", &json!({ "path": saved })).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({ "path": saved })).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(list["links"][0]["format"], "DCS"); + assert_eq!(list["links"][0]["status"], "ok"); + let id = NodeId(list["links"][0]["id"].as_u64().unwrap()); + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() }; + let plates = s.doc().unwrap().doc.images[&im.key].separated().expect("the plates, read from the file again"); + assert_eq!(plates.plates[1].data, [128, 0, 128, 0]); + let v = s.execute("print.preview", &json!({"settings": {"output": {"mode": "separations"}}})).unwrap(); + let inks: Vec<&str> = v["sheets"].as_array().unwrap().iter().filter_map(|p| p["ink"].as_str()).collect(); + assert_eq!(inks, ["Cyan", "Magenta", "Yellow", "Black", "PANTONE 185 C"]); +} + +#[test] +fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() { + let dir = temp_dir("transparency"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let image = image_of(&s, &r["ids"][0]).id; + let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true; + assert!(!marked(&mut s), "nothing transparent"); + assert!(s.execute("links.list", &json!({})).unwrap()["links"][0].get("dcsTransparency").is_none()); + // A half-transparent square away from it, then over it. + let far = s.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("transparency.set", &json!({"ids": [far], "opacity": 50})).unwrap(); + assert!(!marked(&mut s), "a transparent object elsewhere"); + s.execute("shape.rectangle", &json!({"x": 90, "y": 90, "width": 20, "height": 20})).unwrap(); + let over = s.execute("shape.rectangle", &json!({"x": 92, "y": 92, "width": 4, "height": 4})).unwrap()["id"].as_u64().unwrap(); + assert!(!marked(&mut s), "opaque objects over it"); + s.execute("transparency.set", &json!({"ids": [over], "blend": "multiply"})).unwrap(); + assert!(marked(&mut s), "a blend mode over it"); + s.execute("transparency.set", &json!({"ids": [over], "blend": "normal"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [image.0], "opacity": 80})).unwrap(); + assert!(marked(&mut s), "the image itself"); + assert!(error(&mut s, "links.list", json!({ "dcsTransparency": "yes" })).contains("dcsTransparency"), "a flag that isn't true or false"); +} + +#[test] +fn dcs_transparency_counts_a_transparent_dcs_image_over_another() { + let dir = temp_dir("transparency-two"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let a = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let b = s.execute("file.place", &json!({ "path": path, "at": [103, 100] })).unwrap(); + let (a, b) = (image_of(&s, &a["ids"][0]).id, image_of(&s, &b["ids"][0]).id); + s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 80})).unwrap(); + let list = s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap(); + let marked = |id: NodeId| list["links"].as_array().unwrap().iter().any(|r| r["id"] == id.0 && r["dcsTransparency"] == true); + assert!(marked(a), "the transparent image"); + assert!(marked(b), "the opaque image it overlaps"); +} + +#[test] +fn dcs_transparency_counts_characters_with_a_transparent_gradient() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let dir = temp_dir("transparency-text"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + let t = NodeId(s.execute("text.create", &json!({"x": 96, "y": 104, "text": "Hi", "size": 12})).unwrap()["id"].as_u64().unwrap()); + let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true; + assert!(!marked(&mut s), "opaque characters over it"); + let mut stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + stops[1].opacity = 0.5; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + s.edit("t", |d, _| { + if let Some(Node { kind: NodeKind::Text(text), .. }) = d.node_mut(t) { + text.runs[0].style.fill = gradient.clone(); + } + Ok(()) + }) + .unwrap(); + assert!(marked(&mut s), "characters filled with a gradient that has a transparent stop"); + assert!(s.doc().unwrap().doc.node(t).unwrap().shows_transparency()); +} diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index ea10df64b5..5986b618fd 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -3278,6 +3278,14 @@ fn close_path_places_the_first_anchors_handles() { s.execute("path.close", &json!({"id": id.0, "in": [60.0, 140.0], "out": [120.0, 60.0]})).unwrap(); let b = path(&s, id).subpaths[0].anchors[0]; assert_eq!((b.h_in, b.h_out, b.kind), (Point::new(60.0, 140.0), Point::new(120.0, 60.0), AnchorKind::Corner)); + // `at` moves the anchor, its handles with it, before `in` is placed. + s.execute("edit.undo", &json!({})).unwrap(); + refuses_with(&mut s, "path.close", &json!({"id": id.0, "at": "120, 90"}), "`at` must be [x, y]"); + s.execute("path.close", &json!({"id": id.0, "at": [120.0, 90.0], "in": [60.0, 140.0]})).unwrap(); + let b = path(&s, id).subpaths[0].anchors[0]; + assert_eq!((b.p, b.h_in, b.h_out), (Point::new(120.0, 90.0), Point::new(60.0, 140.0), Point::new(170.0, 90.0)), "{b:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors[0], a, "one undo step"); } #[test] diff --git a/crates/engine/src/tests/print.rs b/crates/engine/src/tests/print.rs index 8ccd76deba..f10bf65c94 100644 --- a/crates/engine/src/tests/print.rs +++ b/crates/engine/src/tests/print.rs @@ -254,6 +254,35 @@ fn type_in_a_missing_family_warns_as_pdf_export_does() { } } +/// The warnings of `file.print` (PDF and PostScript) and `print.preview` list what the job +/// leaves out of the document or prints differently first, then the notes on the options. +#[test] +fn what_a_print_loses_comes_before_the_notes_on_the_options() { + let (mut s, _) = session(); + s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let notes = |v: &Value| -> Vec { v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() }; + // A fixed flatness isn't written to a PDF: a note on the options, made before the type's. + let fixed = json!({"settings": {"graphics": {"autoFlatness": false}}}); + for v in [print(&mut s, fixed.clone()).1, s.execute("print.preview", &fixed).unwrap()] { + let n = notes(&v); + assert!(n.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{n:?}"); + assert!(n.last().is_some_and(|w| w.starts_with("a fixed flatness")), "{n:?}"); + } + // PostScript doesn't apply the printer profile. + let profile = json!({"format": "postscript", "settings": {"color": {"profile": vectorcraft_color::cms::SRGB}}}); + let n = notes(&s.execute("file.print", &profile).unwrap()); + assert!(n.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{n:?}"); + assert!(n.last().is_some_and(|w| w.starts_with("the printer profile")), "{n:?}"); + // An artboard larger than Letter: copies per sheet that don't fit come after the art that + // doesn't print. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 700, "height": 900})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 700, "height": 900})).unwrap(); + let n = notes(&print(&mut s, json!({"settings": {"copiesPerSheet": true}})).1); + assert!(n.iter().any(|w| w.contains("part of it doesn't print")), "{n:?}"); + assert!(n.last().is_some_and(|w| w.ends_with("it prints one copy per sheet")), "{n:?}"); +} + #[test] fn type_that_does_not_fit_its_frame_is_named() { let (mut s, _) = session(); diff --git a/crates/engine/src/tests/stored_path.rs b/crates/engine/src/tests/stored_path.rs new file mode 100644 index 0000000000..3e3bd4ef95 --- /dev/null +++ b/crates/engine/src/tests/stored_path.rs @@ -0,0 +1,202 @@ +//! `select.storedPath`, the Actions panel's Select Path step: the step stores a path's id and a +//! fingerprint of it, and playback selects that path only while it is still there unchanged and +//! can be selected. + +use std::io::Write; + +use serde_json::{Value, json}; + +use crate::*; + +/// A document with two rectangles and an ellipse, nothing selected → the session and their ids. +fn doc() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let mut add = |shape: &str, x: u32| s.execute(shape, &json!({"x": x, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap(); + let ids = [add("shape.rectangle", 10), add("shape.rectangle", 80), add("shape.ellipse", 150)]; + s.execute("select.none", &json!({})).unwrap(); + (s, ids) +} + +fn select(s: &mut Session, ids: &[u64]) { + s.execute("select.set", &json!({"ids": ids})).unwrap(); +} + +fn selected(s: &Session) -> Vec { + s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect() +} + +/// The Select Path step's params for `id`, selected alone. +fn step_for(s: &mut Session, id: u64) -> Value { + select(s, &[id]); + stored_path_step(s.doc().unwrap()).unwrap() +} + +#[test] +fn the_hash_is_fnv_1a_64() { + // The published FNV-1a 64-bit test values for "", "a" and "foobar". + for (bytes, want) in [(&b""[..], 0xcbf2_9ce4_8422_2325_u64), (b"a", 0xaf63_dc4c_8601_ec8c), (b"foobar", 0x8594_4171_f739_67e8)] { + let mut h = crate::cmd::select::Fnv::default(); + h.write_all(bytes).unwrap(); + assert_eq!(h.0, want, "{bytes:?}"); + } +} + +#[test] +fn the_step_stores_the_id_and_a_fingerprint_and_plays_by_selecting_the_path() { + let (mut s, [a, b, _]) = doc(); + let p = step_for(&mut s, a); + assert_eq!(p["id"], json!(a)); + let fingerprint = p["fingerprint"].as_str().unwrap(); + assert!(fingerprint.starts_with("v1:") && fingerprint.len() == 19, "{fingerprint}"); + assert_eq!(step_for(&mut s, a), p, "the same path gives the same step"); + assert_ne!(step_for(&mut s, b)["fingerprint"], p["fingerprint"], "another path gives another fingerprint"); + select(&mut s, &[b]); + let r = s.execute("select.storedPath", &p).unwrap(); + assert_eq!(r, json!({"count": 1, "ids": [a]})); + assert_eq!(selected(&s), [a], "only the stored path is selected"); +} + +#[test] +fn a_compound_path_is_one_step_and_a_path_in_a_group_is_one_too() { + let (mut s, [a, b, c]) = doc(); + select(&mut s, &[a, b]); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let p = step_for(&mut s, compound); + let member = s.doc().unwrap().doc.children(Some(vectorcraft_doc::NodeId(compound))).unwrap()[0].id.0; + select(&mut s, &[member]); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select one whole path or compound path", "a member alone is not stored"); + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.storedPath", &p).unwrap(); + assert_eq!(selected(&s), [compound]); + + select(&mut s, &[c, compound]); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select one whole path or compound path", "a group is not stored"); + let inner = step_for(&mut s, c); + select(&mut s, &[group]); + s.execute("select.storedPath", &inner).unwrap(); + assert_eq!(selected(&s), [c], "the path inside the group is selected alone"); +} + +#[test] +fn only_one_whole_path_that_can_be_selected_is_stored() { + let (mut s, [a, b, _]) = doc(); + let whole = "select one whole path or compound path"; + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select a path"); + select(&mut s, &[a, b]); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select only one path"); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + select(&mut s, &[a]); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + assert_eq!(s.doc().unwrap().selection.guides.len(), 1, "the guide is selected with the path"); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), "select only one path", "a path with a guide"); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap(), step_for(&mut s, a), "the path alone again"); + s.execute("select.anchors", &json!({"id": a, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), whole, "an anchor alone"); + let t = s.execute("text.create", &json!({"x": 10, "y": 200, "text": "Type"})).unwrap()["id"].as_u64().unwrap(); + select(&mut s, &[t]); + assert_eq!(stored_path_step(s.doc().unwrap()).unwrap_err(), whole, "type"); + select(&mut s, &[a]); + s.execute("object.lock", &json!({})).unwrap(); + let mut st = s.doc().unwrap().clone(); + st.selection.objects = vec![vectorcraft_doc::NodeId(a)]; + assert_eq!(stored_path_step(&st).unwrap_err(), "the path is hidden or locked, or cannot be selected here"); + let guide = { + let mut d = (*st.doc).clone(); + if let Some(vectorcraft_doc::NodeKind::Path { guide, .. }) = d.node_mut(vectorcraft_doc::NodeId(a)).map(|n| &mut n.kind) { + *guide = true; + } + d + }; + assert!(path_fingerprint(guide.node(vectorcraft_doc::NodeId(a)).unwrap()).is_none(), "a guide is not stored"); +} + +#[test] +fn a_changed_missing_hidden_or_other_path_is_an_error_and_keeps_the_selection() { + let (mut s, [a, b, c]) = doc(); + let p = step_for(&mut s, a); + let fails = |s: &mut Session, p: &Value, want: &str| { + select(s, &[c]); + let e = s.execute("select.storedPath", p).unwrap_err().to_string(); + assert!(e.contains(want), "{e}"); + assert_eq!(selected(s), [c], "the selection stays as it was"); + }; + select(&mut s, &[a]); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + fails(&mut s, &p, "is not the stored path"); + let moved = step_for(&mut s, a); + s.execute("object.hide", &json!({})).unwrap(); + fails(&mut s, &moved, "hidden or locked"); + s.execute("object.showAll", &json!({})).unwrap(); + let shown = step_for(&mut s, b); + s.execute("edit.clear", &json!({})).unwrap(); + fails(&mut s, &shown, "no such object id"); + // Another document: the id names another path there, or nothing. + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 7, "height": 9})).unwrap(); + let c2 = s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 9, "height": 7})).unwrap()["id"].as_u64().unwrap(); + select(&mut s, &[c2]); + assert!(s.execute("select.storedPath", &moved).is_err()); + assert_eq!(selected(&s), [c2]); +} + +#[test] +fn a_path_on_a_locked_artboard_is_refused_in_isolation_mode_too() { + let (mut s, [a, b, _]) = doc(); + select(&mut s, &[a, b]); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let p = step_for(&mut s, a); + s.execute("select.none", &json!({})).unwrap(); + s.execute("object.isolate", &json!({"id": group})).unwrap(); + s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + assert!(s.doc().unwrap().isolation.is_some(), "the group stays isolated"); + assert!(s.doc().unwrap().doc.on_locked_artboard(vectorcraft_doc::NodeId(a)), "the path is on the locked artboard"); + let mut st = s.doc().unwrap().clone(); + st.selection.objects = vec![vectorcraft_doc::NodeId(a)]; + assert_eq!(stored_path_step(&st).unwrap_err(), "the path is hidden or locked, or cannot be selected here", "capture"); + let before = selected(&s); + let e = s.execute("select.storedPath", &p).unwrap_err().to_string(); + assert!(e.contains("hidden or locked"), "{e}"); + assert_eq!(selected(&s), before, "the selection stays as it was"); +} + +#[test] +fn a_failing_step_in_a_batch_undoes_the_steps_before_it() { + let (mut s, [a, _, c]) = doc(); + let p = step_for(&mut s, a); + select(&mut s, &[a]); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + select(&mut s, &[c]); + let before = s.doc().unwrap().doc.clone(); + let steps = [json!({"command": "object.move", "params": {"dx": 1, "dy": 0}}), json!({"command": "select.storedPath", "params": p})]; + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert!(s.doc().unwrap().doc == before, "the move before the step is undone"); +} + +#[test] +fn params_are_an_id_and_a_v1_fingerprint() { + let (mut s, [a, ..]) = doc(); + let p = step_for(&mut s, a); + let f = p["fingerprint"].clone(); + for bad in [ + json!({"fingerprint": f}), + json!({"id": -1, "fingerprint": f}), + json!({"id": "1", "fingerprint": f}), + json!({"id": a}), + json!({"id": a, "fingerprint": 5}), + json!({"id": a, "fingerprint": "v2:0123456789abcdef"}), + json!({"id": a, "fingerprint": "v1:0123456789ABCDEF"}), + json!({"id": a, "fingerprint": "v1:0123456789abcde"}), + json!({"id": a, "fingerprint": f, "extra": 1}), + json!([a]), + ] { + assert!(stored_path_params(&bad).is_err(), "{bad}"); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("select.storedPath", &bad).is_err(), "{bad}"); + assert!(selected(&s).is_empty(), "{bad}"); + } + assert!(stored_path_params(&p).is_ok()); +} diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 43d93cec25..b7270f614e 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4423,7 +4423,7 @@ fn type_attributes_the_data_cant_hold_are_reported() { let back = read_back(&doc); assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Text(_))); // Any other attribute the text document doesn't hold is reported too: No Break, a paragraph's - // left-to-right direction set on it, Optical Margin Alignment, a font version, a font that isn't + // left-to-right direction set on it, Optical Margin Alignment, a font that isn't // installed whose name doesn't come back (a hyphen in its family), a Lab color, // small capitals at another size, right-to-left letters aligned to the start of a paragraph // without a direction of its own, tracking that isn't a whole number. @@ -4432,7 +4432,6 @@ fn type_attributes_the_data_cant_hold_are_reported() { |t| t.runs[0].style.tracking = 12.5, |t| t.para.direction = Some(vectorcraft_doc::ParaDirection::LeftToRight), |t| t.optical_margin = true, - |t| t.runs[0].style.font_version = Some("2.0".into()), |t| t.runs[0].style.font_family = "my-missing font".into(), |t| t.runs[0].style.fill = Paint::solid(MINT), |t| t.runs[0].style.small_caps = Some(80.0), @@ -4455,11 +4454,130 @@ fn type_attributes_the_data_cant_hold_are_reported() { missing.runs[0].style.font_family = "Missing Sans Probe".into(); missing.runs[0].style.font_style = "Bold Italic".into(); assert!(with(missing).is_empty()); + // A version that isn't installed draws in the face the font resolves to, and comes back in it. + let mut uninstalled = t.clone(); + uninstalled.runs[0].style.font_version = Some("2.0".into()); + assert!(with(uninstalled).is_empty()); edit(&mut t); assert_eq!(with(t), [writer::TYPE_ATTRIBUTES]); } } +/// Installs the copies of each font `copies` lists, the first of each loaded first. +fn install_copies(copies: &[(bool, vectorcraft_text::test_fonts::CopyNames<'_>)]) { + for (inter, names) in copies { + let font = vectorcraft_text::test_fonts::named_copy(*inter, *names).unwrap(); + assert_eq!(vectorcraft_text::FontDb::global().add_font(font), 1, "{names:?}"); + } +} + +/// A run's text, font family and font version, as [`fonts_saved`] reads them back. +type SavedRun = (String, String, Option); + +/// A 200 × 100 document of point type whose runs are `runs` (text, family, version). +fn runs_doc(runs: &[(&str, &str, Option<&str>)]) -> Document { + use vectorcraft_doc::{CharStyle, TextObject, TextRun}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "", style(18.0, Color::BLACK)); + t.runs = runs + .iter() + .map(|(text, family, version)| { + let st = CharStyle { font_family: (*family).into(), font_version: version.map(Into::into), ..style(18.0, Color::BLACK) }; + TextRun::new(*text, st) + }) + .collect(); + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + doc +} + +/// [`runs_doc`] saved and read back: the save's warnings, and the runs read back. +fn fonts_saved(runs: &[(&str, &str, Option<&str>)]) -> (Vec, Vec) { + let doc = runs_doc(runs); + let warnings = export_ai_editing(&doc, &opts()).unwrap().warnings; + let back = read_back(&doc); + let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type comes back as type") }; + (warnings, t.runs.iter().map(|r| (r.text.clone(), r.style.font_family.clone(), r.style.font_version.clone())).collect()) +} + +/// How many fonts the font set of [`runs_doc`] names, the hidden characters' font aside. +fn fonts_written(runs: &[(&str, &str, Option<&str>)]) -> usize { + text_document(&program(&runs_doc(runs))).matches("/CoolTypeFont").count() - 1 +} + +/// Type in a font installed beside a family spelled alike but for case and punctuation +/// (`Sample/Ink`, PostScript name `SampleInk`, beside `SAMPLE INK`), or beside a copy whose +/// PostScript name differs only in case (the type naming its version), is written under its +/// PostScript name and read back in its own family and version, so nothing about its font is +/// reported lost. +#[test] +fn type_in_a_font_beside_a_family_spelled_alike_keeps_its_font() { + install_copies(&[ + (true, ("SAVETEST INK", "Regular", "Version 1.000", "SAVETESTINK-Regular")), + (false, ("Savetest/Ink", "Regular", "Version 1.000", "SavetestInk")), + (true, ("CASESAVE INK", "Regular", "Version 1.000", "CASESAVEINK")), + (false, ("Casesave Ink", "Regular", "Version 2.000", "CasesaveInk")), + ]); + let (warnings, back) = fonts_saved(&[("Kept", "Savetest/Ink", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Kept".to_string(), "Savetest/Ink".to_string(), None)]); + let (warnings, back) = fonts_saved(&[("Kept", "Casesave Ink", Some("Version 2.000"))]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Kept".to_string(), "Casesave Ink".to_string(), Some("Version 2.000".to_string()))]); +} + +/// Type in a static font's version that the family and style don't resolve to (two installed +/// copies of one PostScript name, family and style) is written with that version (`/5`) and comes +/// back in it, without a note; two runs in two versions are two fonts of the set. +#[test] +fn type_in_another_version_of_a_static_font_keeps_it() { + install_copies(&[(true, ("Rttest Ink", "Regular", "1.0", "RttestInk-Regular")), (false, ("Rttest Ink", "Regular", "1.1", "RttestInk-Regular"))]); + let (warnings, back) = fonts_saved(&[("Kept", "Rttest Ink", Some("1.1"))]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Kept".to_string(), "Rttest Ink".to_string(), Some("1.1".to_string()))]); + let (warnings, back) = fonts_saved(&[("Old", "Rttest Ink", None), ("New", "Rttest Ink", Some("1.1"))]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back, [("Old".to_string(), "Rttest Ink".to_string(), None), ("New".to_string(), "Rttest Ink".to_string(), Some("1.1".to_string()))]); + // A run naming the version its family and style resolve to anyway, or one that isn't + // installed, is in the same face as a run naming none: one font of the set, and no note. + let (warnings, back) = fonts_saved(&[("Same", "Rttest Ink", Some("1.0")), ("Gone", "Rttest Ink", Some("9.9")), ("Plain", "Rttest Ink", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(back.iter().all(|(_, family, version)| family == "Rttest Ink" && version.is_none()), "{back:?}"); + assert_eq!(fonts_written(&[("Same", "Rttest Ink", Some("1.0")), ("Gone", "Rttest Ink", Some("9.9")), ("Plain", "Rttest Ink", None)]), 1); +} + +/// Type in the copy of a font that its family names, beside a copy of the same PostScript name in +/// another family that the name alone gives first, is written with its version (`/5`), so it comes +/// back in its own copy and family, without a note. +#[test] +fn type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it() { + install_copies(&[ + (true, ("RvdupsaveInk", "Regular", "2.0", "RvdupsaveInk-Regular")), + (false, ("Rvdupsave Ink", "Regular", "1.0", "RvdupsaveInk-Regular")), + ]); + let (warnings, back) = fonts_saved(&[("Kept", "Rvdupsave Ink", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.iter().map(|r| r.1.as_str()).collect::>(), ["Rvdupsave Ink"]); + let (warnings, back) = fonts_saved(&[("Kept", "RvdupsaveInk", None)]); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(back.iter().map(|r| r.1.as_str()).collect::>(), ["RvdupsaveInk"]); +} + +/// A version string longer than the reader takes (256 bytes) isn't one it can pick a copy by: type +/// in such a copy, which its name alone doesn't give back, is reported as losing its font. +#[test] +fn a_version_longer_than_the_reader_takes_is_reported() { + let long = "9".repeat(300); + install_copies(&[ + (true, ("Longtest Ink", "Regular", "1.0", "LongtestInk-Regular")), + (false, ("Longtest Ink", "Regular", &long, "LongtestInk-Regular")), + ]); + let (warnings, back) = fonts_saved(&[("Long", "Longtest Ink", Some(&long))]); + assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]); + assert_eq!(back, [("Long".to_string(), "Longtest Ink".to_string(), None)]); +} + /// On a large canvas, area type, threaded frames and type on a path stay type: the frames and /// lengths are written at the canvas's scale. Frames of a thread at two scales, and area type /// scaled by two factors, are written as outlines. diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs index 1e47ca1564..b161ec636f 100644 --- a/crates/eps/src/ai_editing/writer/texts.rs +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -338,6 +338,8 @@ pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { CharStyle { font_family: s.font_family.clone(), font_style: s.font_style.clone(), + // The font record's version (`/5`) names the face; whether it comes back is `font_kept`'s. + font_version: s.font_version.clone(), size: s.size, leading: s.leading, tracking: s.tracking.round(), @@ -452,15 +454,17 @@ struct FontRecord { /// the font's order, in the axis's own units. Empty for a static font, which has none: without /// it, an app that opens the file takes a variable font's default instance (#6388). design: Vec, - /// For a variable font, its version string (`/5`, name ID 5); empty for a static font. + /// Its version string (`/5`, name ID 5): for a variable font, and for a static font whose + /// PostScript name alone gives the reader another installed copy (two copies of one font, see + /// the reader's `document_font`); else empty. version: String, } -/// The font the font set names for style `st`: an installed face's PostScript name, and for a -/// variable font the setting of each of its axes (the named instance's, or the style's own -/// Variable Font setting, clamped to the axis as layout clamps it) and its version; else its family -/// and style as `family-style` (the family alone for Regular), the form the reader's -/// `family_style` splits. +/// The font the font set names for style `st`: an installed face's PostScript name, its version +/// for a variable font or when the name alone gives the reader another installed copy, and for a +/// variable font the setting of each of its axes (the named instance's, or the style's own Variable +/// Font setting, clamped to the axis as layout clamps it); else its family and style as +/// `family-style` (the family alone for Regular), the form the reader's `family_style` splits. fn font_name(st: &CharStyle) -> FontRecord { let db = vectorcraft_text::FontDb::global(); // The face of that family and style (the font database gives a fallback face for others). @@ -479,8 +483,11 @@ fn font_name(st: &CharStyle) -> FontRecord { set.map_or(own, |v| (v as f32).clamp(a.min.min(a.max), a.max.max(a.min))) }) .collect(); - let version = if design.is_empty() { String::new() } else { face.version.clone() }; - FontRecord { name: face.postscript_name(), truetype: face.is_truetype(), design, version } + let name = face.postscript_name(); + // The reader takes the face of a name alone as `postscript_face` finds it. + let by_name = crate::import::postscript_face(&name, None).is_some_and(|f| f.id() == face.id()); + let version = if design.is_empty() && by_name { String::new() } else { face.version.clone() }; + FontRecord { name, truetype: face.is_truetype(), design, version } } None if st.font_style.is_empty() || st.font_style == "Regular" => record(st.font_family.clone()), None => record(format!("{}-{}", st.font_family, st.font_style.replace(' ', ""))), @@ -518,12 +525,19 @@ fn fixed(v: f32) -> i64 { if v.is_finite() { (f64::from(v) * 65536.0).round() as i64 } else { 0 } } -/// Does the font of style `st` come back as itself: the family and style the reader makes of the -/// name it is written under (as its `char_style` does)? +/// Does the font of style `st` come back as itself: are the family and style the reader makes of +/// the record it is written as (its name, and its version as the reader takes it, as its +/// `char_style` does) the style's own, and the face they and the version read back resolve to the +/// face of the style? A version that isn't installed draws in the face the font resolves to anyway. fn font_kept(st: &CharStyle) -> bool { - let (family, style) = crate::family_style(&font_name(st).name); - let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); - family == st.font_family && style == st.font_style + let record = font_name(st); + let version = crate::import::record_version(&record.version); + let (family, style, read) = crate::import::document_font(&record.name, version); + let db = vectorcraft_text::FontDb::global(); + let face = |family: &str, style: &str, version: Option<&str>| db.face_version(family, style, version).map(|f| f.id()); + family == st.font_family + && style == st.font_style + && face(&family, &style, read.as_deref()) == face(&st.font_family, &st.font_style, st.font_version.as_deref()) } /// Whether `c` is a strong right-to-left letter (`Some(true)`: Hebrew, Arabic, Syriac, Thaana, NKo @@ -1022,13 +1036,13 @@ impl TextDoc { .iter() .map(|f| { let mut font = vec![(0, V::Str(f.name.clone())), (2, V::Int(i64::from(f.truetype)))]; - // A variable font's place in its design space and its version (#6388); a static - // font has neither. + // A variable font's place in its design space (#6388), and the version of a + // variable font or of a static one its name alone doesn't give back. if !f.design.is_empty() { font.push((4, V::List(f.design.iter().map(|v| V::Int(fixed(*v))).collect()))); - if !f.version.is_empty() { - font.push((5, V::Str(f.version.clone()))); - } + } + if !f.version.is_empty() { + font.push((5, V::Str(f.version.clone()))); } dict(vec![(99, V::Name("CoolTypeFont")), (0, dict(font))]) }) diff --git a/crates/eps/src/dcs.rs b/crates/eps/src/dcs.rs new file mode 100644 index 0000000000..1de21a4afe --- /dev/null +++ b/crates/eps/src/dcs.rs @@ -0,0 +1,491 @@ +//! DCS files (Desktop Color Separation): an EPS file whose header comments point at one EPS +//! program per printing plate. A DCS 2.0 file names each plate with `%%PlateFile: (name) EPS +//! #offset size`, the plate inside the file `offset` bytes from its start, or `%%PlateFile: (name) +//! EPS Local file`, the plate in a file of its own; a DCS 1.0 file names the four process plates' +//! files with `%%CyanPlate:`, `%%MagentaPlate:`, `%%YellowPlate:` and `%%BlackPlate:`. Spot inks +//! take their process equivalents from `%%CMYKCustomColor:` (else `%%RGBCustomColor:`). The comments +//! are described at http://www.telegraphics.net/doc/DCS.txt. +//! +//! [`read`] finds those comments; [`place`] reads every plate's image (its ink amounts: a dark +//! plate pixel is ink) into one [`Separated`] image, with the clipping path the main file's +//! composite is drawn through. + +use vectorcraft_color::cms::PROCESS_PLATES; +use vectorcraft_doc::separated::{InkPlate, MAX_NAME, MAX_PLATES, MAX_SAMPLES, Separated}; +use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_geom::{Affine, Rect}; + +/// Where a plate's program is. +#[derive(Clone, Debug, PartialEq)] +pub enum PlateAt { + /// In the DCS file itself: `len` bytes from byte `offset` of the file. + Here { offset: usize, len: usize }, + /// In a file of its own, as the DCS file names it (a path or a file name). + File(String), +} + +/// One plate a DCS file names. +#[derive(Clone, Debug, PartialEq)] +pub struct PlateRef { + /// "Cyan", "Magenta", "Yellow", "Black" or a spot ink's name. + pub name: String, + pub at: PlateAt, +} + +/// The DCS comments of a file. +#[derive(Clone, Debug, PartialEq)] +pub struct Dcs { + /// 1 or 2. + pub version: u8, + pub plates: Vec, + /// Spot inks' process equivalents (C, M, Y, K, 0..1), by name. + pub equivalents: Vec<(String, [f32; 4])>, +} + +impl Dcs { + /// The names of the plate files kept apart from the DCS file. + pub fn plate_files(&self) -> impl Iterator { + self.plates.iter().filter_map(|p| match &p.at { + PlateAt::File(f) => Some(f.as_str()), + PlateAt::Here { .. } => None, + }) + } +} + +/// The DCS comments of EPS file `bytes` (behind its binary header, if it has one); `None` when it +/// names no plate. +pub fn read(bytes: &[u8]) -> Option { + let (ps, _) = crate::sections(bytes)?; + if !ps.starts_with(b"%!") { + return None; + } + // The plates of the DCS 2.0 comments and of the DCS 1.0 ones. + let mut plates: Vec = vec![]; + let mut old: Vec = vec![]; + let mut equivalents: Vec<(String, [f32; 4])> = vec![]; + let mut rgb: Vec<(String, [f32; 4])> = vec![]; + // The comment a `%%+` line continues. + let mut last = String::new(); + for raw in ps.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty()).take(MAX_HEADER_LINES) { + let line = header_line(raw); + let line = line.trim_end(); + if line.starts_with("%%EndComments") || !line.starts_with('%') { + break; + } + let (key, value) = match line.strip_prefix("%%+") { + Some(v) => (last.clone(), v.trim()), + None => match line.split_once(':') { + Some((k, v)) => (k.to_string(), v.trim()), + None => (line.to_string(), ""), + }, + }; + match key.as_str() { + "%%PlateFile" => { + if let Some(p) = plate_file(value) + && !plates.iter().any(|q| q.name == p.name) + { + plates.push(p); + } + } + "%%CyanPlate" | "%%MagentaPlate" | "%%YellowPlate" | "%%BlackPlate" => { + let name = key.trim_start_matches("%%").trim_end_matches("Plate").to_string(); + if !value.is_empty() && !old.iter().any(|q| q.name == name) { + old.push(PlateRef { name, at: PlateAt::File(value.to_string()) }); + } + } + "%%CMYKCustomColor" => equivalents.extend(custom_colors(value, false)), + "%%RGBCustomColor" => rgb.extend(custom_colors(value, true)), + _ => {} + } + last = key; + } + // A file is one version or the other; the plate comments of DCS 2.0 win. + let version = if plates.is_empty() { + plates = old; + 1 + } else { + 2 + }; + if plates.is_empty() { + return None; + } + for (name, c) in rgb { + if !equivalents.iter().any(|(n, _)| *n == name) { + equivalents.push((name, c)); + } + } + Some(Dcs { version, plates, equivalents }) +} + +/// Most header lines looked at for the DCS comments. +const MAX_HEADER_LINES: usize = 10_000; + +/// Header line `raw` as text: UTF-8 when it is, else Latin-1 (each byte one character), as the +/// octal escapes of names are read. +fn header_line(raw: &[u8]) -> std::borrow::Cow<'_, str> { + match std::str::from_utf8(raw) { + Ok(s) => std::borrow::Cow::Borrowed(s), + Err(_) => std::borrow::Cow::Owned(raw.iter().copied().map(char::from).collect()), + } +} + +/// `s` without its first word and the whitespace around that word. +fn after_word(s: &str) -> &str { + s.trim_start().split_once(char::is_whitespace).map_or("", |(_, rest)| rest.trim_start()) +} + +/// A `%%PlateFile:` comment's plate: `(name) EPS #offset size` or `(name) EPS Local file`; `None` +/// for other locations, which only a print server reads. +fn plate_file(v: &str) -> Option { + let (name, rest) = ps_string(v)?; + let mut words = rest.split_whitespace(); + let _kind = words.next()?; + let location = words.next()?; + let name = canonical(name)?; + if let Some(offset) = location.strip_prefix('#') { + let len = words.next()?.parse().ok()?; + return Some(PlateRef { name, at: PlateAt::Here { offset: offset.parse().ok()?, len } }); + } + if !location.eq_ignore_ascii_case("Local") { + return None; + } + // The file name as it is written, its spaces kept. + let file = after_word(after_word(rest)); + (!file.is_empty()).then(|| PlateRef { name, at: PlateAt::File(file.to_string()) }) +} + +/// A plate's name as it is kept: a process ink's in its usual case ("cyan" is "Cyan"); `None` when +/// it is empty or longer than [`MAX_NAME`]. +fn canonical(name: String) -> Option { + if name.is_empty() || name.len() > MAX_NAME { + return None; + } + Some(PROCESS_PLATES.iter().find(|p| p.eq_ignore_ascii_case(&name)).map_or(name, |p| p.to_string())) +} + +/// The custom colours of a `%%CMYKCustomColor:` (four numbers then a name, repeated) or, with +/// `rgb`, a `%%RGBCustomColor:` comment (three numbers), the RGB ones as the process inks that +/// take their light away (`1 − r`, `1 − g`, `1 − b`, no black). Names may be bare words. +fn custom_colors(v: &str, rgb: bool) -> Vec<(String, [f32; 4])> { + let mut out = vec![]; + let mut rest = v.trim(); + let n = if rgb { 3 } else { 4 }; + while !rest.is_empty() && out.len() < MAX_PLATES { + let mut nums = [0.0f32; 4]; + for v in nums.iter_mut().take(n) { + let Some((word, after)) = rest.split_once(char::is_whitespace) else { return out }; + let Some(x) = word.parse::().ok().filter(|x| (0.0..=1.0).contains(x)) else { return out }; + *v = x; + rest = after.trim_start(); + } + let (name, after) = match ps_string(rest) { + Some((s, a)) => (s, a), + None => { + let (w, a) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + (w.to_string(), a) + } + }; + rest = after.trim_start(); + let cmyk = if rgb { [1.0 - nums[0], 1.0 - nums[1], 1.0 - nums[2], 0.0] } else { nums }; + if let Some(name) = canonical(name) { + out.push((name, cmyk)); + } + } + out +} + +/// The PostScript string at the start of `v` (`(…)`, with nested parentheses and backslash +/// escapes) and what follows it. +fn ps_string(v: &str) -> Option<(String, &str)> { + let body = v.trim_start().strip_prefix('(')?; + let mut out = String::new(); + let mut depth = 0usize; + let mut chars = body.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + match c { + '\\' => match chars.next()?.1 { + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + d @ '0'..='7' => { + let mut code = d.to_digit(8)?; + for _ in 0..2 { + match chars.peek().and_then(|(_, e)| e.to_digit(8)) { + Some(e) => { + chars.next(); + code = code * 8 + e; + } + None => break, + } + } + // Octal escapes name bytes; names are read as Latin-1. + out.push(char::from_u32(code & 0xff)?); + } + e => out.push(e), + }, + '(' => { + depth += 1; + out.push(c); + } + ')' if depth == 0 => return Some((out, body.get(i + 1..)?)), + ')' => { + depth -= 1; + out.push(c); + } + _ => out.push(c), + } + } + None +} + +/// The main part of DCS file `bytes`: its PostScript (behind its binary header, if it has one), up +/// to the first plate it holds. +pub fn main_program<'a>(bytes: &'a [u8], dcs: &Dcs) -> Option<&'a [u8]> { + let (ps, _) = crate::sections(bytes)?; + let start = ps.as_ptr() as usize - bytes.as_ptr() as usize; + let first = dcs.plates.iter().filter_map(|p| match p.at { + PlateAt::Here { offset, .. } if offset > start => Some(offset - start), + _ => None, + }); + Some(&ps[..first.min().unwrap_or(ps.len()).min(ps.len())]) +} + +/// A DCS file read for placing. +#[derive(Debug)] +pub struct Placed { + pub separated: Separated, + /// The file's page (its bounding box) with its top-left corner at the origin. + pub page: Rect, + /// The image's pixels onto the page. + pub xf: Affine, + /// The clipping path the composite is drawn through, on the page. + pub clip: Option, + /// The resolution of the plates (pixels per inch), from how large they print. + pub ppi: Option<(f64, f64)>, + pub warnings: Vec, +} + +/// One plate's image: its ink amounts, one byte a pixel, and where its pixels print on its page. +struct PlateImage { + width: u32, + height: u32, + data: Vec, + xf: Affine, +} + +/// Read DCS file `bytes` (its comments `dcs`) for placing: every plate's image, the plates kept +/// apart read with `file` (the name the file gives → the plate file's bytes). The plates are put on +/// the pixel grid of the first plate's image, grown to hold every plate's image where it prints; +/// another plate is resampled onto it. An error when the file names more than [`MAX_PLATES`] +/// plates or a plate can't be read. +pub fn place(bytes: &[u8], dcs: &Dcs, file: &mut dyn FnMut(&str) -> Result, String>) -> Result { + if dcs.plates.len() > MAX_PLATES { + return Err(format!("it names {} plates, and at most {MAX_PLATES} are read", dcs.plates.len())); + } + let main = main_program(bytes, dcs).ok_or("the file's preview header points outside the file")?; + let page = crate::page(main)?; + let mut warnings = vec![]; + let (mut images, mut samples) = (vec![], 0u64); + for p in &dcs.plates { + let read = match &p.at { + PlateAt::Here { offset, len } => { + let eps = offset.checked_add(*len).and_then(|end| bytes.get(*offset..end)); + let eps = eps.ok_or_else(|| format!("the {} plate lies outside the file", p.name))?; + plate_image(eps) + } + PlateAt::File(f) => file(f).and_then(|eps| plate_image(&eps)), + }; + let img = read.map_err(|e| format!("the {} plate can't be read: {e}", p.name))?; + // Checked before the other plates are read. + samples += u64::from(img.width) * u64::from(img.height); + if samples > MAX_SAMPLES { + return Err(format!("its plates are too large: more than {MAX_SAMPLES} pixels together")); + } + images.push((p, img)); + } + let Some((w, h, xf)) = grid(images.iter().map(|(_, img)| img)) else { return Err("the file names no plate".into()) }; + if !Separated::fits(w, h, images.len()) { + return Err(format!("its plates are too large: {} plates of {w} × {h} pixels", images.len())); + } + let mut plates = vec![]; + for (p, img) in images { + let name = p.name.clone(); + let aligned = img.xf.as_coeffs().iter().zip(xf.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9); + let data = if (img.width, img.height) == (w, h) && aligned { img.data } else { resample(&img, w, h, xf) }; + let cmyk = match dcs.equivalents.iter().find(|(n, _)| *n == name) { + Some((_, c)) => *c, + None if PROCESS_PLATES.contains(&name.as_str()) => [0.0; 4], + None => { + warnings.push(format!("the file gives no process equivalent for the {name} ink: it shows as black")); + [0.0, 0.0, 0.0, 1.0] + } + }; + plates.push(InkPlate { name, cmyk, data }); + } + let separated = Separated::new(w, h, plates).ok_or("its plates can't be kept together")?; + let clip = clip_path(main); + // Points one pixel spans along each axis. + let [a, b, c, d, ..] = xf.as_coeffs(); + let ppi = [a.hypot(b), c.hypot(d)].map(|l| (l > 1e-12).then(|| 72.0 / l)); + let ppi = match ppi { + [Some(x), Some(y)] => Some((x, y)), + _ => None, + }; + Ok(Placed { separated, page, xf, clip, ppi, warnings }) +} + +/// The pixel grid the plates `images` go on: the first image's pixels, grown by whole pixels to +/// hold where every image prints, as its width, height and where its pixels print; `None` without +/// an image. It is the first image's own when its transform can't be inverted. +fn grid<'a>(mut images: impl Iterator) -> Option<(u32, u32, Affine)> { + let first = images.next()?; + let own = Some((first.width, first.height, first.xf)); + if first.xf.determinant().abs() <= 1e-12 { + return own; + } + let to_first = first.xf.inverse(); + let mut r = Rect::new(0.0, 0.0, f64::from(first.width), f64::from(first.height)); + for img in images { + r = r.union((to_first * img.xf).transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(img.width), f64::from(img.height)))); + } + // Edges within a millionth of a pixel of a whole one are on it. + let snap = |v: f64, up: bool| { + let n = v.round(); + if (v - n).abs() < 1e-6 { + n + } else if up { + v.ceil() + } else { + v.floor() + } + }; + let (x0, y0, x1, y1) = (snap(r.x0, false), snap(r.y0, false), snap(r.x1, true), snap(r.y1, true)); + let size = |a: f64, b: f64| Some(b - a).filter(|n| n.is_finite() && (1.0..=f64::from(u32::MAX)).contains(n)).map(|n| n as u32); + match (size(x0, x1), size(y0, y1)) { + (Some(w), Some(h)) => Some((w, h, first.xf * Affine::translate((x0, y0)))), + // Larger than any separated image may be ([`Separated::fits`]). + _ => Some((u32::MAX, u32::MAX, first.xf)), + } +} + +/// The ink amounts of `img` on the `w` × `h` pixels that `onto` puts on the page (each the +/// nearest pixel of `img` where it prints there, none outside it). When either transform can't be +/// inverted, the pixels are stretched over one another instead. +fn resample(img: &PlateImage, w: u32, h: u32, onto: Affine) -> Vec { + let (sw, sh) = (img.width as usize, img.height as usize); + let mut out = Vec::with_capacity(w as usize * h as usize); + let invertible = |m: Affine| m.determinant().abs() > 1e-12; + // From a pixel of the image made to a pixel of `img`. + let to_plate = (invertible(img.xf) && invertible(onto)).then(|| img.xf.inverse() * onto); + for y in 0..h as usize { + for x in 0..w as usize { + let at = match to_plate { + Some(m) => { + let p = m * vectorcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5); + let inside = p.x >= 0.0 && p.y >= 0.0 && p.x < sw as f64 && p.y < sh as f64; + inside.then(|| (p.y as usize).min(sh - 1) * sw + (p.x as usize).min(sw - 1)) + } + None => Some((y * sh / h as usize).min(sh - 1) * sw + (x * sw / w as usize).min(sw - 1)), + }; + out.push(at.and_then(|i| img.data.get(i).copied()).unwrap_or(0)); + } + } + out +} + +/// Is an image under `n` (itself included) drawn through a clipping path: inside a clipping group +/// or layer, or `inside` one already? +fn clips_an_image(n: &Node, inside: bool) -> bool { + let inside = inside || matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + match &n.kind { + NodeKind::Image(_) => inside, + _ => n.children().is_some_and(|ch| ch.iter().any(|c| clips_an_image(c, inside))), + } +} + +/// The image plate program `eps` draws, as ink amounts (how dark each pixel is: 255 less its +/// luma), and where it prints on its page; an error when it draws more than one image, or art +/// besides it. +fn plate_image(eps: &[u8]) -> Result { + let r = crate::import_with(eps, false)?; + if r.preview { + return Err(r.warnings.first().cloned().unwrap_or_else(|| "its PostScript can't be read".into())); + } + // A plate is one image: one that draws more (several images, or art) would lose ink. + let (mut found, mut images, mut art) = (None, 0usize, false); + r.document.walk(|n| match &n.kind { + NodeKind::Image(im) => { + images += 1; + found.get_or_insert((im.key.clone(), im.xf, im.width, im.height)); + } + NodeKind::Path { clipping: true, .. } | NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::RulerGuide { .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + art |= n.visible && n.appearance.items.iter().any(|i| i.visible() && !matches!(i.paint(), vectorcraft_color::Paint::None)) + } + _ => art |= n.visible, + }); + if images > 1 || (art && images == 1) { + return Err(if images > 1 { "it draws more than one image".into() } else { "it draws art besides its image".into() }); + } + // Ink outside a clipping path would print: a plate whose image is clipped is refused. + if images == 1 && r.document.layers.iter().any(|l| clips_an_image(l, false)) { + return Err("it draws its image through a clipping path".into()); + } + let (key, xf, iw, ih) = found.ok_or("it draws no image")?; + let blob = r.document.images.get(&key).ok_or("it draws no image")?; + let (width, height, data) = blob.coverage().ok_or("its image is too large to read")?; + // The object's pixel space may differ from the pixels it keeps. + let xf = xf * Affine::scale_non_uniform(f64::from(iw) / f64::from(width.max(1)), f64::from(ih) / f64::from(height.max(1))); + Ok(PlateImage { width, height, data, xf }) +} + +/// The clipping path the first image of `main` is drawn through, when it is drawn through one. +fn clip_path(main: &[u8]) -> Option { + let r = crate::import_with(main, false).ok().filter(|r| !r.preview)?; + first_clip(&r.document) +} + +/// The clipping path the first image of `d` is drawn through: the clipping path of the innermost +/// clipping group that holds an image, cut to the clipping paths of the clipping groups around it. +fn first_clip(d: &Document) -> Option { + /// The clipping paths around the innermost clipping group under `n` that holds an image, + /// outermost first. + fn find(n: &Node) -> Option> { + let NodeKind::Group { children, clip } = &n.kind else { + return n.children().and_then(|ch| ch.iter().find_map(|c| find(c))); + }; + let first = children.first().filter(|f| *clip && matches!(f.kind, NodeKind::Path { clipping: true, .. })); + if let Some(mut inner) = children.iter().find_map(|c| find(c)) { + if let Some(f) = first { + inner.insert(0, (**f).clone()); + } + return Some(inner); + } + let holds_image = || { + let mut any = false; + n.walk(&mut |c: &Node| any |= matches!(c.kind, NodeKind::Image(_))); + any + }; + first.filter(|_| holds_image()).map(|f| vec![(**f).clone()]) + } + let mut clips = d.layers.iter().find_map(|l| find(l))?; + let mut innermost = clips.pop()?; + // The clipping paths together clip to where they overlap. + for outer in clips.iter().rev() { + if let (NodeKind::Path { path: o, rule: or, .. }, NodeKind::Path { path, rule, .. }) = (&outer.kind, &mut innermost.kind) { + *path = vectorcraft_pathops::boolean(path, *rule, o, *or, vectorcraft_pathops::BoolOp::Intersect); + *rule = vectorcraft_geom::FillRule::NonZero; + } + } + // An even-odd clipping path is kept as the same area under the nonzero rule, the rule of the + // clip groups the Links commands make and match again. + if let NodeKind::Path { path, rule: rule @ vectorcraft_geom::FillRule::EvenOdd, .. } = &mut innermost.kind + && let Ok(nonzero) = vectorcraft_pathops::try_boolean_n(&[(&*path, vectorcraft_geom::FillRule::EvenOdd)], |inside| !inside.is_empty()) + { + (*path, *rule) = (nonzero, vectorcraft_geom::FillRule::NonZero); + } + Some(innermost) +} + +#[cfg(test)] +mod tests; diff --git a/crates/eps/src/dcs/tests.rs b/crates/eps/src/dcs/tests.rs new file mode 100644 index 0000000000..b55fea0b84 --- /dev/null +++ b/crates/eps/src/dcs/tests.rs @@ -0,0 +1,283 @@ +//! DCS files made from scratch as http://www.telegraphics.net/doc/DCS.txt describes them: a main +//! EPS file whose header names its plates, each plate an EPS program drawing one grayscale image. + +use std::collections::HashMap; + +use vectorcraft_doc::NodeKind; + +use super::*; + +/// A plate program: one `w` × `h` grayscale image (`gray`, row by row from the top) on a 20 × 10 +/// pt page. +pub(crate) fn plate_eps(w: u32, h: u32, gray: &[u8]) -> String { + let hex: String = gray.iter().map(|g| format!("{g:02x}")).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n20 10 scale\n{w} {h} 8 [{w} 0 0 -{h} 0 {h}] <{hex}> image\nshowpage\n%%Trailer\n%%EOF\n" + ) +} + +/// The composite: the image `w` × `h` drawn through a clipping path, the left half of the page. +fn composite(w: u32, h: u32) -> String { + let hex = "80".repeat((w * h * 3) as usize); + format!( + "gsave newpath 0 0 moveto 10 0 lineto 10 10 lineto 0 10 lineto closepath clip newpath\n20 10 scale\n{w} {h} 8 [{w} 0 0 -{h} 0 {h}] <{hex}> false 3 colorimage\ngrestore\nshowpage\n%%EOF\n" + ) +} + +/// A single-file DCS 2.0 file of `plates` (name, plate program), with the header lines `extra`. +pub(crate) fn single_file(plates: &[(&str, String)], extra: &str) -> Vec { + // Offsets and sizes are written ten digits wide, so the header's length is known first. + let header = |offsets: &[usize]| { + let mut s = String::from("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n"); + s.push_str(extra); + for ((name, eps), at) in plates.iter().zip(offsets) { + s.push_str(&format!("%%PlateFile: ({name}) EPS #{at:010} {:010}\n", eps.len())); + } + s.push_str("%%EndComments\n"); + s.push_str(&composite(2, 2)); + s + }; + let main = header(&vec![0; plates.len()]).len(); + let mut offsets = vec![]; + let mut at = main; + for (_, eps) in plates { + offsets.push(at); + at += eps.len(); + } + let mut out = header(&offsets).into_bytes(); + assert_eq!(out.len(), main); + for (_, eps) in plates { + out.extend(eps.as_bytes()); + } + out +} + +#[test] +fn single_file_plates_and_equivalents_are_read() { + let plate = plate_eps(1, 1, &[0]); + let extra = "%%DocumentCustomColors: (PANTONE 185 C)\n%%+ (Gr\\374n \\(A\\))\n%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n%%+ .5 0 1 0 (Gr\\374n \\(A\\))\n%%RGBCustomColor: 1 1 0 (Lemon)\n"; + let bytes = single_file(&[("cyan", plate.clone()), ("PANTONE 185 C", plate.clone()), ("Grün (A)", plate.clone())], extra); + let dcs = read(&bytes).unwrap(); + assert_eq!(dcs.version, 2); + let names: Vec<&str> = dcs.plates.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Cyan", "PANTONE 185 C", "Grün (A)"], "a process ink in its usual case; escapes read"); + let PlateAt::Here { offset, len } = dcs.plates[1].at else { panic!("in the file: {:?}", dcs.plates[1]) }; + assert_eq!(&bytes[offset..offset + len], plate.as_bytes()); + assert_eq!( + dcs.equivalents, + [ + ("PANTONE 185 C".to_string(), [0.0, 0.91, 0.76, 0.0]), + ("Grün (A)".to_string(), [0.5, 0.0, 1.0, 0.0]), + ("Lemon".to_string(), [0.0, 0.0, 1.0, 0.0]) + ] + ); + assert_eq!(dcs.plate_files().count(), 0); + let main = main_program(&bytes, &dcs).unwrap(); + assert!(main.ends_with(b"%%EOF\n"), "up to the first plate"); + let PlateAt::Here { offset: first, .. } = dcs.plates[0].at else { panic!("in the file") }; + assert_eq!(main.len(), first); +} + +#[test] +fn plate_files_of_both_versions_are_read() { + let two = "%!PS-Adobe-3.0 EPSF-3.0\n%%PlateFile: (Black) EPS Local pic.K\n%%PlateFile: (Black) TIFF NewServe c:\\scans\\a0K.scn\n%%PlateFile: (TRUMATCH 1-a5) EPS Local my pic.9\n%%EndComments\n"; + let dcs = read(two.as_bytes()).unwrap(); + assert_eq!(dcs.version, 2); + assert_eq!( + dcs.plates, + [ + PlateRef { name: "Black".into(), at: PlateAt::File("pic.K".into()) }, + PlateRef { name: "TRUMATCH 1-a5".into(), at: PlateAt::File("my pic.9".into()) }, + ] + ); + let one = "%!PS-Adobe-2.0 EPSF-1.2\n%%CyanPlate: pic.C\n%%MagentaPlate: pic.M\n%%YellowPlate: pic.Y\n%%BlackPlate: pic.K\n%%EndComments\n"; + let dcs = read(one.as_bytes()).unwrap(); + assert_eq!(dcs.version, 1); + assert_eq!(dcs.plate_files().collect::>(), ["pic.C", "pic.M", "pic.Y", "pic.K"]); + let both = "%!PS-Adobe-2.0 EPSF-1.2\n%%CyanPlate: old.C\n%%PlateFile: (Cyan) EPS Local new.C\n%%EndComments\n"; + assert_eq!(read(both.as_bytes()).unwrap().plate_files().collect::>(), ["new.C"], "the DCS 2.0 comments win"); + // Comments after the header are not the file's. + assert!(read(b"%!PS-Adobe-3.0 EPSF-3.0\n%%EndComments\n%%PlateFile: (Cyan) EPS Local a.C\n").is_none()); + assert!(read(plate_eps(1, 1, &[0]).as_bytes()).is_none(), "a plain EPS file"); + assert!(read(b"\x89PNG").is_none()); +} + +#[test] +fn a_plate_program_drawing_more_than_its_image_is_an_error() { + let plate = plate_eps(1, 1, &[0]); + // Two image tiles, and an image with a filled square beside it. + let tiles = plate.replacen("showpage", "1 1 8 [1 0 0 -1 0 1] <00> image\nshowpage", 1); + let art = plate.replacen("20 10 scale", "0 0 moveto 5 0 lineto 5 5 lineto closepath fill\n20 10 scale", 1); + for (eps, why) in [(tiles, "more than one image"), (art, "art besides its image")] { + let bytes = single_file(&[("Cyan", eps)], ""); + let err = place(&bytes, &read(&bytes).unwrap(), &mut |_| Err("no files".into())).unwrap_err(); + assert!(err.contains("the Cyan plate can't be read") && err.contains(why), "{err}"); + } + let bytes = single_file(&[("Cyan", plate)], ""); + assert!(place(&bytes, &read(&bytes).unwrap(), &mut |_| Err("no files".into())).is_ok(), "one image alone"); +} + +#[test] +fn a_plate_program_clipping_its_image_is_an_error() { + let clipped = plate_eps(1, 1, &[0]).replacen("20 10 scale", "newpath 0 0 moveto 5 0 lineto 5 5 lineto closepath clip newpath\n20 10 scale", 1); + let bytes = single_file(&[("Cyan", clipped)], ""); + let err = place(&bytes, &read(&bytes).unwrap(), &mut |_| Err("no files".into())).unwrap_err(); + assert!(err.contains("the Cyan plate can't be read") && err.contains("clipping path"), "{err}"); +} + +#[test] +fn only_an_image_inside_a_clipping_group_or_layer_is_clipped() { + use std::sync::Arc; + use vectorcraft_color::Paint; + use vectorcraft_doc::NodeId; + let clip_path = || { + let rect = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)); + let mut p = Node::path(NodeId(1), rect, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut p.kind { + *clipping = true; + } + Arc::new(p) + }; + let image = || { + let im = vectorcraft_doc::ImageObject { + key: "k".into(), + width: 1, + height: 1, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + Arc::new(Node::new(NodeId(2), NodeKind::Image(Box::new(im)))) + }; + let group = |children: Vec>, clip| Node::new(NodeId(3), NodeKind::Group { children, clip }); + let beside = group(vec![Arc::new(group(vec![clip_path()], true)), image()], false); + assert!(!clips_an_image(&beside, false), "a clipping group beside the image"); + assert!(clips_an_image(&group(vec![clip_path(), image()], true), false), "the image inside the clipping group"); + assert!(clips_an_image(&group(vec![image()], false), true), "inside a clipping layer"); +} + +#[test] +fn nested_clips_around_the_composite_clip_it_together() { + // The left two thirds of the page, then the right three quarters inside it: 5 to 13 pt. + let main = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n\ + gsave newpath 0 0 moveto 13 0 lineto 13 10 lineto 0 10 lineto closepath clip newpath\n\ + gsave newpath 5 0 moveto 20 0 lineto 20 10 lineto 5 10 lineto closepath clip newpath\n\ + 20 10 scale 2 2 8 [2 0 0 -2 0 2] <808080808080808080808080> false 3 colorimage grestore grestore showpage\n%%EOF\n"; + let clip = clip_path(main.as_bytes()).expect("a clipping path"); + let b = clip.geometric_bounds().expect("bounds"); + assert!((b.x0 - 5.0).abs() < 1e-6 && (b.x1 - 13.0).abs() < 1e-6, "where both clips overlap: {b:?}"); +} + +#[test] +fn plate_file_names_keep_their_spaces() { + let two = "%!PS-Adobe-3.0 EPSF-3.0\n%%PlateFile: (Cyan) EPS Local my pic .C\n%%EndComments\n"; + assert_eq!(read(two.as_bytes()).unwrap().plate_files().collect::>(), ["my pic .C"]); +} + +#[test] +fn header_bytes_that_are_not_utf8_are_latin1() { + // `Grün` with a raw Latin-1 ü (0xFC), in a plate name and in a custom color's name. + let mut two = b"%!PS-Adobe-3.0 EPSF-3.0\n%%CMYKCustomColor: .5 0 1 0 (Gr\xfcn)\n%%PlateFile: (Gr\xfcn) EPS Local a.9\n".to_vec(); + two.extend(b"%%PlateFile: (Grau \xc3\xa9) EPS Local b.9\n%%EndComments\n"); + let dcs = read(&two).unwrap(); + let names: Vec<&str> = dcs.plates.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Grün", "Grau é"], "a line that is UTF-8 is read as UTF-8"); + assert_eq!(dcs.equivalents, [("Grün".to_string(), [0.5, 0.0, 1.0, 0.0])]); +} + +#[test] +fn every_plate_is_read_with_the_clipping_path() { + // 2 × 2 pixels: cyan solid on the top row, the spot ink at 50% on the first column. + let bytes = single_file( + &[("Cyan", plate_eps(2, 2, &[0, 0, 255, 255])), ("PANTONE 185 C", plate_eps(2, 2, &[127, 255, 127, 255]))], + "%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n", + ); + let dcs = read(&bytes).unwrap(); + let p = place(&bytes, &dcs, &mut |_| Err("no files".into())).unwrap(); + assert!(p.warnings.is_empty(), "{:?}", p.warnings); + assert_eq!(p.page, Rect::new(0.0, 0.0, 20.0, 10.0)); + let s = &p.separated; + assert_eq!((s.width, s.height), (2, 2)); + assert_eq!(s.plates[0].data, [255, 255, 0, 0], "a dark plate pixel is ink"); + assert_eq!(s.plates[1].data, [128, 0, 128, 0]); + assert_eq!(s.plates[1].cmyk, [0.0, 0.91, 0.76, 0.0]); + assert_eq!(p.xf * vectorcraft_geom::Point::new(2.0, 2.0), vectorcraft_geom::Point::new(20.0, 10.0)); + assert_eq!(p.ppi, Some((7.2, 14.4))); + let clip = p.clip.expect("the composite's clipping path"); + assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. })); + assert_eq!(clip.geometric_bounds().map(|b| b.width().round()), Some(10.0), "the left half of the page"); +} + +#[test] +fn plate_files_are_read_with_the_callback() { + let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%BlackPlate: pic.K\n%%EndComments\nshowpage\n"; + let files: HashMap<&str, String> = [("pic.C", plate_eps(1, 1, &[0])), ("pic.K", plate_eps(2, 1, &[255, 0]))].into(); + let dcs = read(main.as_bytes()).unwrap(); + let mut asked = vec![]; + let p = place(main.as_bytes(), &dcs, &mut |f| { + asked.push(f.to_string()); + files.get(f).map(|e| e.as_bytes().to_vec()).ok_or_else(|| "missing".into()) + }) + .unwrap(); + assert_eq!(asked, ["pic.C", "pic.K"]); + assert!(p.clip.is_none(), "no composite, no clipping path"); + assert_eq!((p.separated.width, p.separated.height), (1, 1), "the first plate's size"); + assert_eq!(p.separated.plates[1].data, [255], "resampled to it: the pixel at its centre"); + let err = place(main.as_bytes(), &dcs, &mut |_| Err("not found".into())).unwrap_err(); + assert_eq!(err, "the Cyan plate can't be read: not found"); +} + +#[test] +fn plates_printing_elsewhere_on_the_page_stay_in_register() { + // The Black plate's one solid pixel prints on the right half of the page only. + let right = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n10 0 translate 10 10 scale\n1 1 8 [1 0 0 -1 0 1] <00> image\nshowpage\n%%EOF\n"; + let bytes = single_file(&[("Cyan", plate_eps(2, 1, &[255, 255])), ("Black", right.to_string())], ""); + let p = place(&bytes, &read(&bytes).unwrap(), &mut |_| Err("no files".into())).unwrap(); + assert_eq!((p.separated.width, p.separated.height), (2, 1)); + assert_eq!(p.separated.plates[1].data, [0, 255], "the ink lands on the right pixel only"); +} + +/// A plate program whose one solid pixel prints on a 10 × 10 pt square `x` pt from the page's left. +fn square_at(x: u32) -> String { + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n{x} 0 translate 10 10 scale\n1 1 8 [1 0 0 -1 0 1] <00> image\nshowpage\n%%EOF\n" + ) +} + +#[test] +fn the_plates_grid_grows_to_hold_every_plate() { + let bytes = single_file(&[("Cyan", square_at(0)), ("Black", square_at(10))], ""); + let p = place(&bytes, &read(&bytes).unwrap(), &mut |_| Err("no files".into())).unwrap(); + assert_eq!((p.separated.width, p.separated.height), (2, 1), "the first plate's pixels, grown over the second"); + assert_eq!(p.separated.plates[0].data, [255, 0]); + assert_eq!(p.separated.plates[1].data, [0, 255], "nothing of the second plate is cut off"); + assert_eq!(p.xf * vectorcraft_geom::Point::new(2.0, 1.0), vectorcraft_geom::Point::new(20.0, 10.0)); +} + +#[test] +fn a_file_naming_more_plates_than_are_read_is_an_error() { + let plate = plate_eps(1, 1, &[0]); + let names: Vec = (0..=MAX_PLATES).map(|i| format!("Ink {i}")).collect(); + let plates: Vec<(&str, String)> = names.iter().map(|n| (n.as_str(), plate.clone())).collect(); + let bytes = single_file(&plates, ""); + let dcs = read(&bytes).unwrap(); + assert_eq!(dcs.plates.len(), MAX_PLATES + 1, "every plate named"); + let err = place(&bytes, &dcs, &mut |_| Err("no files".into())).unwrap_err(); + assert_eq!(err, "it names 65 plates, and at most 64 are read"); +} + +#[test] +fn bad_plates_are_errors_and_missing_equivalents_warnings() { + let mut bytes = single_file(&[("Cyan", plate_eps(1, 1, &[0]))], ""); + let dcs = read(&bytes).unwrap(); + bytes.truncate(bytes.len() - 5); + assert_eq!(place(&bytes, &dcs, &mut |_| Err(String::new())).unwrap_err(), "the Cyan plate lies outside the file"); + let text = single_file(&[("Cyan", "%!PS-Adobe-3.0\n0 0 moveto 1 1 lineto stroke\n".to_string())], ""); + let err = place(&text, &read(&text).unwrap(), &mut |_| Err(String::new())).unwrap_err(); + assert_eq!(err, "the Cyan plate can't be read: it draws no image"); + let spot = single_file(&[("Mystery", plate_eps(1, 1, &[0]))], ""); + let p = place(&spot, &read(&spot).unwrap(), &mut |_| Err(String::new())).unwrap(); + assert_eq!(p.separated.plates[0].cmyk, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(p.warnings, ["the file gives no process equivalent for the Mystery ink: it shows as black"]); +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index b750efbc20..2cc4bed521 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -89,10 +89,11 @@ pub(crate) use ai::{ BRUSH_MANAGER, BrushesRead, MAX_EDITING_OBJECTS, MAX_GRADIENT_STOPS, MAX_LISTED, MAX_LISTED_NAME, MAX_SYMBOLS, brush_plugin_name, brushes_kept, read_art_style, read_brush_object, read_style_list, style_moves_with, symbol_art_member, warp_number, }; -pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; +pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES, record_version}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_alone_with, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; pub use text::family_style; +pub(crate) use text::{document_font, postscript_face}; /// The page of a PostScript file without a bounding box (US Letter). const LETTER: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 6cdbb96747..043d6f2e4d 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -508,10 +508,16 @@ impl Reader<'_> { None => (full.as_str(), None), }; st.cmap = cmap; - let (family, style) = super::super::super::text::family_style(name); + // The installed face of that PostScript name, as type from a text document + // takes it; else the name split into a family and style. + let text = super::super::super::text::named_font(name, None); + let (family, style, version) = text.unwrap_or_else(|| { + let (family, style) = super::super::super::text::family_style(name); + (family, style, None) + }); st.style.font_family = family; st.style.font_style = style; - st.style.font_version = None; + st.style.font_version = version; } if let Some(size) = vals.get(at + 1).and_then(V::num).filter(|s| s.is_finite() && *s > 0.0) { st.style.size = size.min(MAX_SIZE); diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c6a6041212..3c0ac39a05 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3930,6 +3930,28 @@ fn point_type(attrs: &str, text: &str) -> String { format!("0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n{attrs}\n({text}) Tx\nTO\n") } +/// Legacy type is set in the installed face of exactly the PostScript name its `Tf` gives, as type +/// from a text document is: not a face whose name differs only in case and loads first, nor the +/// copy the family and style resolve to when the name's own is another (it names its version). +#[test] +fn legacy_type_takes_the_face_of_its_exact_postscript_name() { + use vectorcraft_text::test_fonts::named_copy; + let db = vectorcraft_text::FontDb::global(); + for (inter, names) in [ + (true, ("TWINLEGACY INK", "Regular", "Version 1.000", "TWINLEGACYINK-Regular")), + (false, ("Twinlegacy Ink", "Regular", "Version 2.000", "TwinlegacyInk-Regular")), + ] { + assert_eq!(db.add_font(named_copy(inter, names).unwrap()), 1, "{names:?}"); + } + let face = |font: &str| { + let texts = legacy_texts(&point_type("", "Hi").replace("/_Helvetica 12 Tf", &format!("/_{font} 12 Tf"))); + let f = db.style_face(&texts[0].runs[0].style).unwrap(); + (f.postscript_name(), f.version.clone()) + }; + assert_eq!(face("TwinlegacyInk-Regular"), ("TwinlegacyInk-Regular".to_string(), "Version 2.000".to_string())); + assert_eq!(face("TWINLEGACYINK-Regular"), ("TWINLEGACYINK-Regular".to_string(), "Version 1.000".to_string())); +} + /// `TC` and `TW` (§15.4.7) set the paragraph's letter and word spacing, `Tq` its hanging /// punctuation and `Th` (§22) its hyphenation; each paragraph keeps the values in force at its /// first character, and they last into the next text object. diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index f0a26d91f1..8d01b141d0 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -459,7 +459,8 @@ fn spend_tabs(tabs: &mut Vec, budget: &mut usize) -> bool { #[derive(Clone, Debug)] struct Style { - font: Option, + /// The font its record names, as the fonts the app has set it ([`DocFont::font`]). + font: Option, size: f64, /// `None`: auto. leading: Option, @@ -490,10 +491,44 @@ struct Style { standard_vertical_roman: bool, } +/// The longest font version string read from a font record: name ID 5 strings are short (`Version +/// 7.200`, or with the tools that built the font, some 80 characters). +const MAX_FONT_VERSION: usize = 256; + +/// The version a font record's key 5 gives `v`, as the reader takes it: `None` when it is empty or +/// longer than [`MAX_FONT_VERSION`] bytes. The `.ai` save checks a record it writes against this. +pub(crate) fn record_version(v: &str) -> Option<&str> { + (!v.is_empty() && v.len() <= MAX_FONT_VERSION).then_some(v) +} + +/// A font as type is set in it: family, style and the version it names +/// ([`super::text::document_font`]). +type Font = (String, String, Option); + +/// A font of a text document's font list, as its record (a `CoolTypeFont`) names it. +#[derive(Debug, Default)] +struct DocFont { + /// Key 0: the PostScript name. + name: String, + /// Key 5, when the record keeps it: the font's version string (name ID 5), which tells installed + /// copies of one font apart. + version: Option, + /// [`Self::font`]'s answer, found once for every run that names the record. + font: std::sync::OnceLock, +} + +impl DocFont { + /// The font type set in this record's font takes: the installed copy it names + /// ([`super::text::document_font`]). + fn font(&self) -> &Font { + self.font.get_or_init(|| super::text::document_font(&self.name, self.version.as_deref())) + } +} + /// The text document of an editing copy. pub(super) struct Texts { stories: Vec, - fonts: Vec, + fonts: Vec, /// The character and paragraph styles a story starts from. defaults: Val, para_defaults: Val, @@ -552,7 +587,15 @@ impl Texts { .get("0")? .list() .iter() - .map(|f| f.get("0").and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::str).unwrap_or_default().to_string()) + .map(|f| { + let record = f.get("0").and_then(|f| f.get("0")); + let text = |k: &str| record.and_then(|r| r.get(k)).and_then(Val::str); + DocFont { + name: text("0").unwrap_or_default().to_string(), + version: text("5").and_then(record_version).map(str::to_string), + font: std::sync::OnceLock::new(), + } + }) .collect(); let main = doc.get("1")?; let sheets = |key: &str| -> Vec { @@ -774,12 +817,12 @@ impl Texts { let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.defaults.get(k)); let num = |k: &str| pick(k).and_then(Val::num); let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false))); - let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned(); + let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.name.is_empty()); let size = num("1").filter(|v| *v > 0.0 && *v < 1e5).unwrap_or(12.0); // Key 5 of a paint names the art style that paints it ([`Self::with_patterns`]). let pattern = |k: &str| pick(k).and_then(|p| p.get("5")).and_then(Val::str).and_then(|name| self.patterns.get(name)).cloned(); Style { - font, + font: font.map(|f| f.font().clone()), size, leading: if on("4") { None } else { num("5").filter(|v| *v > 0.0 && *v < 1e5) }, tracking: num("8").filter(|v| v.abs() < 1e5).unwrap_or(0.0), @@ -1386,13 +1429,12 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle { } } -/// The character style of character attributes `s`: its font from the fonts the app has, its -/// lengths (size, leading, baseline shift, stroke width) scaled by `scale`, and its patterns placed -/// in the type's text space by `to_text` (the document → its text space), where they stay as the -/// document has them. +/// The character style of character attributes `s`: its font from the fonts the app has (the +/// installed copy its font record names, [`DocFont::font`]), its lengths (size, leading, baseline +/// shift, stroke width) scaled by `scale`, and its patterns placed in the type's text space by +/// `to_text` (the document → its text space), where they stay as the document has them. fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { - let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style); - let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + let (family, style, version) = s.font.clone().unwrap_or_else(|| (CharStyle::default().font_family, "Regular".to_string(), None)); // A fill or stroke that is on takes its pattern in place of its colour. let paint = |color: Option, pattern: &Option| match (color, pattern) { // A pattern that can't be placed (the type's text space out of range) keeps the colour. @@ -1407,6 +1449,7 @@ fn char_style(s: &Style, scale: f64, to_text: Affine) -> CharStyle { CharStyle { font_family: family, font_style: style, + font_version: version, size: s.size * scale, // Auto leading stays auto: the layout gives each line the paragraph's share of the largest // type on it, as the file's own layout does ("The largest leading value in a line of type diff --git a/crates/eps/src/import/tests_fontnames.rs b/crates/eps/src/import/tests_fontnames.rs index d925905ee7..a2a8d45641 100644 --- a/crates/eps/src/import/tests_fontnames.rs +++ b/crates/eps/src/import/tests_fontnames.rs @@ -63,3 +63,37 @@ fn abbreviated_styles_in_postscript_names_are_spelled_out() { fn an_installed_face_of_the_postscript_name_names_the_font() { assert_eq!(crate::family_style("ABCDEF+SourceSans3-Semibold"), ("Source Sans 3".to_string(), "Semibold".to_string())); } + +/// The PostScript name and version of the face each `show` of a program that sets `fonts` in turn +/// draws its type in. +fn faces_shown(fonts: &[&str]) -> Vec<(String, String)> { + let body: String = fonts.iter().map(|f| format!("/{f} findfont 12 scalefont setfont 10 20 moveto (Hi) show\n")).collect(); + let eps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}showpage\n%%EOF\n"); + let r = import(eps.as_bytes()).unwrap(); + let mut faces = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + let face = FontDb::global().style_face(&t.runs[0].style).unwrap(); + faces.push((face.postscript_name(), face.version.clone())); + } + }); + faces +} + +/// Type a PostScript program sets is drawn in the installed face of exactly the font's PostScript +/// name, as type from a text document is: not another family spelled alike but for case and +/// punctuation, nor a face whose name differs only in case and loads first. +#[test] +fn shown_type_takes_the_face_of_its_exact_postscript_name() { + use vectorcraft_text::test_fonts::named_copy; + for (inter, names) in [ + (true, ("FUZZSHOW INK", "Regular", "Version 1.000", "FUZZSHOWINK-Regular")), + (false, ("Fuzzshow/Ink", "Regular", "Version 1.000", "FuzzshowInk")), + (true, ("CASESHOW INK", "Regular", "Version 1.000", "CASESHOWINK")), + (false, ("Caseshow Ink", "Regular", "Version 2.000", "CaseshowInk")), + ] { + assert_eq!(FontDb::global().add_font(named_copy(inter, names).unwrap()), 1, "{names:?}"); + } + let names: Vec = faces_shown(&["FuzzshowInk", "CaseshowInk", "CASESHOWINK"]).into_iter().map(|f| f.0).collect(); + assert_eq!(names, ["FuzzshowInk", "CaseshowInk", "CASESHOWINK"]); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index f9e99f26d7..c7cdf20e11 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3128,6 +3128,73 @@ fn type_from_the_text_document_takes_its_character_styles_attributes() { assert_eq!(style(2), (solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Some(18.0)), "the run's own colour"); } +/// The PostScript name and version of the face that type from a text document whose font record is +/// `record` (in place of `/0 (Helvetica)`) is set in. +fn face_set_from(record: &str) -> (String, String) { + let doc = text_document(&[("Hamburg\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]).replacen("/0 (Helvetica)", record, 1); + let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + let texts = text_of(&r.document.layers[1]); + assert_eq!(texts.len(), 1, "{record}: {:?}", r.warnings); + let face = vectorcraft_text::FontDb::global().style_face(&texts[0].runs[0].style).unwrap(); + (face.postscript_name(), face.version.clone()) +} + +/// Installs the copies of each font `copies` lists, the first of each loaded first. +fn install(copies: &[(bool, vectorcraft_text::test_fonts::CopyNames<'_>)]) { + for (inter, names) in copies { + let font = vectorcraft_text::test_fonts::named_copy(*inter, *names).unwrap(); + assert_eq!(vectorcraft_text::FontDb::global().add_font(font), 1, "{names:?}"); + } +} + +/// Type from the text document is set in the installed face of exactly the PostScript name its +/// font record gives, whatever other installed copies answer to that name too: one whose family +/// is spelled alike but for case and punctuation (`SAMPLE INK` beside `Sample/Ink`, PostScript name +/// `SampleInk`), one whose PostScript name differs only in case or punctuation (in its own family or +/// another), or another copy of the same family and style under another PostScript name (an older +/// build of the font: the type names its version). Those copies set the type at other widths. +#[test] +fn type_from_the_text_document_takes_the_face_of_its_exact_postscript_name() { + install(&[ + (true, ("FUZZTEST INK", "Regular", "Version 1.000", "FUZZTESTINK-Regular")), + (false, ("Fuzztest/Ink", "Regular", "Version 1.000", "FuzztestInk")), + (true, ("CASETEST INK", "Regular", "Version 1.000", "CASETESTINK")), + (false, ("Casetest Ink", "Regular", "Version 2.000", "CasetestInk")), + (true, ("Copytest Ink", "Regular", "Version 1.000", "CopytestInk-Light")), + (false, ("Copytest Ink", "Regular", "Version 2.000", "CopytestInk-Regular")), + (true, ("NeuetestInk", "Bold", "Version 1.000", "NeuetestInkBold")), + (false, ("Neuetest Ink", "Bold", "Version 1.000", "NeuetestInk-Bold")), + ]); + assert_eq!(face_set_from("/0 (FuzztestInk)").0, "FuzztestInk", "not the family spelled alike"); + assert_eq!(face_set_from("/0 (FUZZTESTINK-Regular)").0, "FUZZTESTINK-Regular"); + assert_eq!(face_set_from("/0 (CasetestInk)").0, "CasetestInk", "not the name in other case, loaded first"); + assert_eq!(face_set_from("/0 (CASETESTINK)").0, "CASETESTINK"); + assert_eq!(face_set_from("/0 (CopytestInk-Regular)"), ("CopytestInk-Regular".to_string(), "Version 2.000".to_string())); + assert_eq!(face_set_from("/0 (CopytestInk-Light)"), ("CopytestInk-Light".to_string(), "Version 1.000".to_string())); + assert_eq!(face_set_from("/0 (NeuetestInk-Bold)").0, "NeuetestInk-Bold", "not the name without its hyphen, in another family"); + // A name in a case no installed face has still finds the font. + assert_eq!(face_set_from("/0 (casetestink)").0, "CASETESTINK"); +} + +/// A font record may keep the font's version string (key 5, name ID 5): of two installed copies +/// with one PostScript name, family and style, the type is set in that version, also when the +/// record spells the name with other punctuation. A version that isn't installed, or a record +/// without one, gives the copy the family and style resolve to. +#[test] +fn type_from_the_text_document_takes_the_version_its_font_record_keeps() { + install(&[(true, ("Vertest Ink", "Regular", "1.0", "VertestInk-Regular")), (false, ("Vertest Ink", "Regular", "1.1", "VertestInk-Regular"))]); + let version = |record: &str| face_set_from(record).1; + assert_eq!(version("/0 (VertestInk-Regular) /5 (1.1)"), "1.1"); + assert_eq!(version("/0 (VertestInk-Regular) /5 (1.0)"), "1.0"); + assert_eq!(version("/0 (VertestInk-Regular)"), "1.0"); + assert_eq!(version("/0 (VertestInk-Regular) /5 (9.9)"), "1.0"); + assert_eq!(version("/0 (VertestInk-Regular) /5 ()"), "1.0"); + // A record whose name differs from the copies' in punctuation alone still picks its version. + assert_eq!(version("/0 (Vertest-Ink-Regular) /5 (1.1)"), "1.1"); + assert_eq!(version("/0 (Vertest-Ink-Regular)"), "1.0"); +} + /// A story with no runs, and a run or paragraph that names no style, is set in `[Normal Character /// Style]` and `[Normal Paragraph Style]` before the text document's base attributes. A Large /// Canvas copy (1:10) writes the styles at the art's scale (1.2 for 12 points, 0.6 for a 6-point diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 989450f440..515e0b12ec 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -42,20 +42,75 @@ fn style_word(w: &str) -> String { .to_string() } +/// A font's PostScript name without a subset prefix (`ABCDEF+`) or the `*1` of a re-encoded copy +/// (`ABCDEF+RollerBabyBV*1` is `RollerBabyBV`). +fn bare_name(name: &str) -> &str { + let name = match name.split_once('+') { + Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest, + _ => name, + }; + match name.rsplit_once('*') { + Some((base, n)) if !base.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => base, + _ => name, + } +} + +/// The font of type that a text document's font record names: by `name`, its PostScript name, and +/// `version`, the version string (name ID 5) the record keeps, if any. An installed face of that +/// name gives its font ([`named_font`]). Without one, `name` is split ([`family_style`]) and the +/// family spelled as an available family is ([`FontDb::find_family`]: `MicrosoftYaHei` is +/// Microsoft YaHei). → (family, style, version). +/// +/// [`FontDb::find_family`]: vectorcraft_text::FontDb::find_family +pub(crate) fn document_font(name: &str, version: Option<&str>) -> (String, String, Option) { + named_font(name, version).unwrap_or_else(|| { + let (family, style) = family_style(name); + let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + (family, style, None) + }) +} + +/// The font of type set in the installed face of PostScript name `name` (a subset prefix and a +/// re-encoded copy's `*1` aside) in `version` ([`postscript_face`]): that face's own family and +/// style, and its version when the family and style alone resolve to another installed copy of +/// the font. A library can keep two copies under one family and style that differ in their +/// PostScript names or their versions, and draw the same text at different widths. → (family, +/// style, version); `None` when no installed face has the name. +pub(crate) fn named_font(name: &str, version: Option<&str>) -> Option<(String, String, Option)> { + let face = postscript_face(bare_name(name), version)?; + // As the PDF import names a version: only one that the family and style don't resolve to. + let default = vectorcraft_text::FontDb::global().face(&face.family, &face.style); + let version = default.filter(|d| d.version != face.version).map(|_| face.version.clone()); + Some((face.family.clone(), face.style.clone(), version)) +} + +/// The installed face of PostScript name `name` (without a subset prefix), among the installed +/// faces that answer to it ([`FontDb::postscript_faces`]): those of exactly that name when there +/// are any (PostScript names are case-sensitive, so `SampleSans` isn't the face named +/// `SAMPLESANS`, and `SampleSerif-Bold` isn't `SampleSerifBold`), and of those the one in +/// `version` when it is installed, else the first. `None` when no installed face has the name. +/// +/// [`FontDb::postscript_faces`]: vectorcraft_text::FontDb::postscript_faces +pub(crate) fn postscript_face(name: &str, version: Option<&str>) -> Option> { + let db = vectorcraft_text::FontDb::global(); + let found = db.find_postscript(name)?; + if found.postscript_name() == name && version.is_none_or(|v| v == found.version) { + return Some(found); + } + let faces = db.postscript_faces(name); + let named: Vec<_> = faces.iter().filter(|f| f.postscript_name() == name).cloned().collect(); + let pool = if named.is_empty() { faces } else { named }; + let in_version = version.and_then(|v| pool.iter().find(|f| f.version == v).cloned()); + Some(in_version.or_else(|| pool.into_iter().next()).unwrap_or(found)) +} + /// A font's family and style from its PostScript name, without a subset prefix (`ABCDEF+`) or the /// `*1` of a re-encoded copy (`ABCDEF+RollerBabyBV*1`): an installed or bundled face of that /// exact name gives its own (a family can hold hyphens: `rounded-x-mplus-1c-black` is Rounded-X /// M+ 1c, black); else the name is split, `Helvetica-BoldOblique` → (`Helvetica`, `Bold /// Oblique`), `TimesNewRomanPS-BoldMT` → (`Times New Roman`, `Bold`). pub fn family_style(name: &str) -> (String, String) { - let name = match name.split_once('+') { - Some((prefix, rest)) if prefix.len() == 6 && prefix.bytes().all(|b| b.is_ascii_uppercase()) => rest, - _ => name, - }; - let name = match name.rsplit_once('*') { - Some((base, n)) if !base.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => base, - _ => name, - }; + let name = bare_name(name); if let Some(face) = vectorcraft_text::FontDb::global().find_postscript(name) { return (face.family.clone(), face.style.clone()); } @@ -551,11 +606,11 @@ impl Interp<'_> { if !(size.is_finite() && size > 1e-3 && size < 1e5) { return ps_err("undefinedresult", "a font size"); } - let (family, font_style) = family_style(&name); - // The available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei". - let font_family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + // The installed face of that PostScript name, as type from a text document takes it; else + // the available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei". + let (font_family, font_style, font_version) = document_font(&name, None); let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() }; - let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() }; + let style = CharStyle { font_family, font_style, font_version, size, fill, stroke: Paint::None, ..CharStyle::default() }; let mut t = TextObject::point(Point::ZERO, text, style); t.vertical = vertical; let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index f6b7fba609..44ec49c190 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -31,9 +31,11 @@ //! - `ai_editing`: the editing copy of the art a PDF-compatible `.ai` file carries //! ([`export_ai_editing`], [`ai_private_data`]). //! - `ai_ops`: the path and line style operators those two write. +//! - [`dcs`]: DCS files, an EPS file with one program per printing plate, read for placing. mod ai_editing; mod ai_ops; +pub mod dcs; mod import; mod latin; mod legacy_ai; diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index d66ea18445..9d4066d524 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1439,6 +1439,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", ), + ( + "ui.action.insertSelectPath", + "Insert Select Path", + "", + "{set?: index, action?: index, step?: index} insert a Select Path step for the path selected in the active document, without changing the selection: the step `select.storedPath {id, fingerprint}` stores the path's id and a fingerprint of its kind, fill rule and anchors, and playback selects only that path while it is still there unchanged and can be selected, else the step is an error. The selection must be one whole path or compound path that can be selected: not anchors or segments alone, several objects, a group, type or another kind of object, a guide, a member of a compound path, or a hidden or locked object. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. No document, a selection that is not such a path, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Select Path runs this, for the selected step or action or the recording in progress → {set, action, step}, or {recording: true}", + ), ( "ui.action.insertSelectObject", "Select Object", diff --git a/crates/pdf/src/print.rs b/crates/pdf/src/print.rs index 0bad9d9f60..12c71756c8 100644 --- a/crates/pdf/src/print.rs +++ b/crates/pdf/src/print.rs @@ -81,7 +81,10 @@ pub struct PrintOptions { pub struct PrintReport { pub bytes: Vec, pub pages: usize, + /// What the job leaves out of the document or prints differently. pub warnings: Vec, + /// Notes on the options: one the job didn't apply or applied another way. + pub setting_notes: Vec, } /// One page of a job, as [`preview`] lists it (one copy). @@ -129,7 +132,12 @@ pub struct PrintPreview { pub tiles: Vec, /// The inks of a separation (empty for composite output). pub inks: Vec, + /// What the job leaves out of the document or prints differently. pub warnings: Vec, + /// Notes on the options: one the job doesn't apply or applies another way. They aren't + /// serialized: a reply lists them in `warnings`, after what the job leaves out. + #[serde(skip)] + pub setting_notes: Vec, } /// A laid-out job. @@ -144,7 +152,10 @@ struct Job<'a> { plates: Vec, /// One copy's pages: (layout, plate). sheets: Vec<(usize, Option)>, + /// What the job leaves out of the document or prints differently. warnings: Vec, + /// Notes on the options: one the job doesn't apply or applies another way. + setting_notes: Vec, } /// Keep the layers of `nodes` that print as `which` says, shown (hidden ones print with All), @@ -223,11 +234,11 @@ impl<'a> Job<'a> { fn new(doc: &'a Document, set: &PrintSettings, postscript: bool) -> Result { set.check()?; let doc = printed_document(doc, set); - let mut warnings = vec![]; - let (layouts, tiles) = layout::layout(&doc, set, &mut warnings)?; + let (mut warnings, mut setting_notes) = (vec![], vec![]); + let (layouts, tiles) = layout::layout(&doc, set, &mut warnings, &mut setting_notes)?; let separations = set.output.mode == OutputMode::Separations; let (inks, more) = if separations { print_inks(&doc, set) } else { Default::default() }; - warnings.extend(more); + setting_notes.extend(more); let plates: Vec = (0..inks.len()).filter(|i| inks[*i].print).collect(); let printed: Vec> = if separations { (0..plates.len()).map(Some).collect() } else { vec![None] }; if printed.is_empty() { @@ -245,18 +256,18 @@ impl<'a> Job<'a> { warnings.push("the document is RGB: separations convert its colours to CMYK with the colour settings".into()); } if !postscript && separations && set.output.inks.iter().any(|i| i.frequency.is_some() || i.angle.is_some()) { - warnings.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into()); + setting_notes.push("ink frequencies and angles are not written as halftone screens yet: the output device's screens apply".into()); } if separations && set.advanced.print_as_bitmap { - warnings.push("Print as Bitmap is for composite output: separations print the art as it is".into()); + setting_notes.push("Print as Bitmap is for composite output: separations print the art as it is".into()); } if separations && cms::profile(set.color.profile.trim()).is_some_and(|p| p.kind != ProfileKind::Cmyk) { - warnings.push("separations separate with a CMYK profile: the RGB printer profile is left out".into()); + setting_notes.push("separations separate with a CMYK profile: the RGB printer profile is left out".into()); } if !postscript && !set.graphics.auto_flatness { - warnings.push("a fixed flatness is not written yet: the output device flattens curves".into()); + setting_notes.push("a fixed flatness is not written yet: the output device flattens curves".into()); } - Ok(Self { doc, layouts, tiles, inks, plates, sheets, warnings }) + Ok(Self { doc, layouts, tiles, inks, plates, sheets, warnings, setting_notes }) } /// The ink of plate `plate` (`None`: composite). @@ -303,7 +314,14 @@ pub fn preview(doc: &Document, set: &PrintSettings) -> Result { pub title: String, /// When the job ran (Unix seconds, UTC). pub created: Option, + /// What the job leaves out of the document or prints differently. pub warnings: Vec, + /// Notes on the options: one the job doesn't apply or applies another way. + pub setting_notes: Vec, } /// Lay out printing `doc` as `opts` say, for a writer to draw. @@ -415,7 +436,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, }) .collect(); let order = job.order(set); - Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, doc: job.doc }) + Ok(PrintPlan { plates, sheets, order, title, created, warnings: job.warnings, setting_notes: job.setting_notes, doc: job.doc }) } /// Landscape sheet `s` upright on the paper ([`PrintOptions::for_printer`]): a page wider than @@ -473,12 +494,18 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet = vec![]; + for m in plan.setting_notes.into_iter().chain(settings) { + if !warnings.contains(&m) && !setting_notes.contains(&m) { + setting_notes.push(m); + } + } + Ok(PrintReport { bytes, pages: plan.order.len(), warnings, setting_notes }) } /// The printer's marks of page `l` (`None` without marks), with its page information: the diff --git a/crates/pdf/src/print/layout.rs b/crates/pdf/src/print/layout.rs index 3d404abab9..a88dd89950 100644 --- a/crates/pdf/src/print/layout.rs +++ b/crates/pdf/src/print/layout.rs @@ -184,8 +184,14 @@ fn too_many_pages() -> PdfError { } /// The pages of `doc` (whose layers are those that print) as `set` lays them out, the tiles of -/// each artboard when tiling, and warnings (art that doesn't fit the imageable area). -pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec) -> Result<(Vec, Vec), PdfError> { +/// each artboard when tiling. `warnings` gets what doesn't print (art outside the imageable area, +/// an artboard with no area), and `setting_notes` the copies per sheet that don't fit. +pub(crate) fn layout( + doc: &Document, + set: &PrintSettings, + warnings: &mut Vec, + setting_notes: &mut Vec, +) -> Result<(Vec, Vec), PdfError> { let marks = set.marks.printer_marks(); let bleed = set.bleed.of(doc); let (pw, ph) = set.paper(); @@ -278,7 +284,7 @@ pub(crate) fn layout(doc: &Document, set: &PrintSettings, warnings: &mut Vec { - warnings.push(format!("{what} with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet")); + setting_notes.push(format!("{what} with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet")); None } Some((cols, rows)) if cols * rows > MAX_COPIES_PER_SHEET as f64 => { diff --git a/crates/pdf/src/print/plates.rs b/crates/pdf/src/print/plates.rs index 42ca9e5a1d..ce6c5b336e 100644 --- a/crates/pdf/src/print/plates.rs +++ b/crates/pdf/src/print/plates.rs @@ -1,5 +1,6 @@ //! Separations: the inks of a job ([`print_inks`]) and the document as one ink's plate, every -//! colour (and image pixel) replaced by the grey of that ink's coverage. +//! colour (and image pixel) replaced by the grey of that ink's coverage. An image placed from a +//! DCS file prints its own plates ([`vectorcraft_doc::separated`]). use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -130,7 +131,30 @@ impl<'a> Separator<'a> { kept.insert(colorized_key(&key), blob.clone()); } } + // A DCS file's image prints its own plates: a spot plate on its ink when the job prints + // that ink, else as its process equivalent. + let spots: BTreeSet = if self.set.output.spots_to_process { + BTreeSet::new() + } else { + plates(self.doc).into_iter().filter(|p| p.spot).map(|p| p.name).collect() + }; + let own = |name: &str| spots.contains(name); + // A spot plate printing as process prints as the document's swatch of its ink does, when + // there is one; else as the process equivalent its file gives. + let equivalent = |p: &vectorcraft_doc::separated::InkPlate| match self.doc.swatch(&p.name).and_then(|w| Some((w.paint.color()?, w.spot))) { + Some((c, spot)) => { + let c = self.doc.linked_color(c, spot); + cms::PROCESS_PLATES.map(|q| self.ink(q, &c, None)) + } + None => p.cmyk, + }; for blob in d.images.values_mut() { + if let Some(s) = blob.separated() { + if let Some(gray) = s.plate_png(plate, &own, &equivalent) { + *blob = gray; + } + continue; + } let gray = blob.map_rgb(|[r, g, b]| { let rgb = |v: u8| v as f32 / 255.0; let v = (255.0 * (1.0 - self.ink(plate, &Color::rgb(rgb(r), rgb(g), rgb(b)), None))).round() as u8; diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 2ad5ab8bba..5b86f2b895 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -322,6 +322,76 @@ fn image_doc(blob: ImageBlob, w: u32, h: u32) -> Document { d } +/// An image placed from a DCS file prints each of its plates on its own ink: a spot plate on its +/// spot ink's plate, or as its process equivalent when spots print as process. Its composite, the +/// CMYK samples every other reader sees, never prints on a separation. +#[test] +fn a_dcs_image_prints_each_plate_on_its_own_ink() { + use vectorcraft_doc::separated::{InkPlate, Separated}; + let plate = |name: &str, cmyk: [f32; 4], data: [u8; 2]| InkPlate { name: name.into(), cmyk, data: data.to_vec() }; + let s = Separated::new(2, 1, vec![plate("Cyan", [0.0; 4], [255, 0]), plate("Spot Orange", [0.0, 0.6, 1.0, 0.0], [128, 0])]).unwrap(); + let mut d = image_doc(ImageBlob::separated_tiff(&s, None, &[]).unwrap(), 2, 1); + d.swatches.push(Swatch { name: "Spot Orange".into(), paint: Paint::solid(Color::cmyk(0.0, 0.6, 1.0, 0.0)), global: true, spot: true }); + let plates = |spots_to_process: bool| { + let settings: PrintSettings = serde_json::from_value(json!({"output": {"mode": "separations", "spotsToProcess": spots_to_process}})).unwrap(); + let names: Vec = print_inks(&d, &settings).0.into_iter().map(|i| i.name).collect(); + let job = plan(&d, &PrintOptions { settings, ..Default::default() }).unwrap(); + let coverage: Vec> = job + .plates + .iter() + .map(|p| { + let NodeKind::Image(im) = &p.layers[0].children().unwrap()[0].kind else { panic!("an image") }; + p.image_blob(im).and_then(|b| b.coverage()).unwrap().2 + }) + .collect(); + names.into_iter().zip(coverage).collect::>() + }; + let own = plates(false); + let get = |all: &[(String, Vec)], name: &str| all.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()); + assert_eq!(get(&own, "Cyan"), Some(vec![255, 0])); + assert_eq!(get(&own, "Magenta"), Some(vec![0, 0]), "the spot ink stays off the process plates"); + assert_eq!(get(&own, "Yellow"), Some(vec![0, 0])); + assert_eq!(get(&own, "Spot Orange"), Some(vec![128, 0])); + let process = plates(true); + assert_eq!(get(&process, "Spot Orange"), None); + assert_eq!(get(&process, "Magenta"), Some(vec![77, 0]), "60% of the spot plate's 50%"); + assert_eq!(get(&process, "Yellow"), Some(vec![128, 0])); +} + +/// A DCS spot plate that prints as process prints as the document's swatch of its ink does (the +/// swatch kept in place of the plate's), not as the equivalent its file gives. +#[test] +fn a_dcs_spot_plate_printing_as_process_takes_the_documents_swatch() { + use vectorcraft_doc::separated::{InkPlate, Separated}; + let plate = |name: &str, cmyk: [f32; 4], data: [u8; 2]| InkPlate { name: name.into(), cmyk, data: data.to_vec() }; + let s = Separated::new(2, 1, vec![plate("Cyan", [0.0; 4], [255, 0]), plate("Spot Orange", [0.0, 0.6, 1.0, 0.0], [0, 128])]).unwrap(); + let mut d = image_doc(ImageBlob::separated_tiff(&s, None, &[]).unwrap(), 2, 1); + let plates = |d: &Document, spots_to_process: bool| { + let settings: PrintSettings = serde_json::from_value(json!({"output": {"mode": "separations", "spotsToProcess": spots_to_process}})).unwrap(); + let names: Vec = print_inks(d, &settings).0.into_iter().map(|i| i.name).collect(); + let job = plan(d, &PrintOptions { settings, ..Default::default() }).unwrap(); + let coverage = job.plates.iter().map(|p| { + let NodeKind::Image(im) = &p.layers[0].children().unwrap()[0].kind else { panic!("an image") }; + p.image_blob(im).and_then(|b| b.coverage()).unwrap().2 + }); + names.into_iter().zip(coverage).collect::>() + }; + let get = |all: &[(String, Vec)], name: &str| all.iter().find(|(n, _)| n == name).map(|(_, c)| c.clone()); + // A spot swatch of the ink in another color. + d.swatches.push(Swatch { name: "Spot Orange".into(), paint: Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), global: true, spot: true }); + let process = plates(&d, true); + assert_eq!(get(&process, "Cyan"), Some(vec![255, 128]), "the swatch's cyan"); + assert_eq!(get(&process, "Magenta"), Some(vec![0, 0]), "not the file's magenta"); + assert_eq!(get(&process, "Yellow"), Some(vec![0, 0])); + // A process swatch of the ink's name: the job prints no plate for it. + d.swatches.retain(|w| w.name != "Spot Orange"); + d.swatches.push(Swatch { name: "Spot Orange".into(), paint: Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0)), global: true, spot: false }); + let own = plates(&d, false); + assert_eq!(get(&own, "Spot Orange"), None); + assert_eq!(get(&own, "Black"), Some(vec![0, 128]), "the swatch's black"); + assert_eq!(get(&own, "Yellow"), Some(vec![0, 0])); +} + #[test] fn the_output_settings_convert_cmyk_images_and_compression_recompresses_them_in_cmyk() { let ink = [0u8, 102, 255, 0]; diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index 897e7241b4..5c5e1eb2c0 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -275,11 +275,8 @@ fn an_artboard_that_does_not_fit_prints_one_copy_per_sheet() { let d = doc(700.0, 900.0, &[(Rect::new(0.0, 0.0, 700.0, 900.0), rgb(0.0, 0.0, 1.0))]); let r = job(&d, json!({"copiesPerSheet": true})); assert_eq!(r.pages, 1); - assert!( - r.warnings.iter().any(|w| w == "Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"), - "{:?}", - r.warnings - ); + // A note on the options; the art that doesn't print is what the job loses. + assert_eq!(r.setting_notes, ["Artboard 1 with its bleed and marks doesn't fit the imageable area: it prints one copy per sheet"]); assert!(r.warnings.iter().any(|w| w.contains("larger than the imageable area")), "{:?}", r.warnings); assert_eq!(r.bytes, job(&d, json!({})).bytes, "printed as without copies per sheet"); assert!(preview(&d, &settings(json!({"copiesPerSheet": true}))).unwrap().sheets[0].copies.is_empty()); diff --git a/crates/pdf/src/tests/printadvanced.rs b/crates/pdf/src/tests/printadvanced.rs index f9dacb7597..8ff14f2fd1 100644 --- a/crates/pdf/src/tests/printadvanced.rs +++ b/crates/pdf/src/tests/printadvanced.rs @@ -136,5 +136,5 @@ fn separations_separate_with_a_cmyk_printer_profile() { assert_ne!(device, press, "the profile separates"); // An RGB printer profile doesn't separate. let r = job(&d, json!({"output": {"mode": "separations"}, "color": {"profile": cms::SRGB}})); - assert!(r.warnings.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.warnings); + assert!(r.setting_notes.iter().any(|w| w.contains("RGB printer profile")), "{:?}", r.setting_notes); } diff --git a/crates/pdf/src/tests/realtext.rs b/crates/pdf/src/tests/realtext.rs index 3db63a4b2a..a36cea3ee7 100644 --- a/crates/pdf/src/tests/realtext.rs +++ b/crates/pdf/src/tests/realtext.rs @@ -976,7 +976,10 @@ fn dithered_type_keeps_its_resolution_when_scaled_and_stays_searchable() { /// Exact craft-fonts face: a substituted Arabic font would miss Noto's dot decomposition. fn mark_text(text: &str) -> Option { - let face = FontDb::global().face("Noto Sans Arabic", "Regular").filter(|f| f.family == "Noto Sans Arabic"); + // The craft-fonts build of the face: a system font of the same family (Linux distributions + // install one) can lack the Latin and digit glyphs these fixtures shape with it. + let bundled = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic" && f.style == "Regular"); + let face = FontDb::global().face("Noto Sans Arabic", "Regular").filter(|f| bundled && f.family == "Noto Sans Arabic"); let Some(face) = face else { assert!(std::env::var("CRAFT_FONTS_REQUIRED").as_deref() != Ok("1"), "Noto Sans Arabic is required"); eprintln!("SKIPPED: PDF mark-text regression needs craft-fonts Noto Sans Arabic"); diff --git a/crates/render/src/fx/predicates.rs b/crates/render/src/fx/predicates.rs index 2592d2b64e..4f14cadfd1 100644 --- a/crates/render/src/fx/predicates.rs +++ b/crates/render/src/fx/predicates.rs @@ -54,13 +54,15 @@ pub(super) fn evaluates_live(n: &Node) -> bool { /// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed), /// a symbol instance (made from its symbol's art), an object with a 3D effect or a color /// adjustment (shaded or recolored through the color settings), one with Vector Halftone (its -/// dots sampled from everything it draws), or one with a live-effect plug-in that paints its pieces -/// (drawn only while the swatches and patterns they name are in the document), in it or in the -/// opacity masks of the objects in it. +/// dots sampled from everything it draws), one with a live-effect plug-in that paints its pieces +/// (drawn only while the swatches and patterns they name are in the document), or an image with a +/// fill or stroke that follows the outline of its pixels ([`effects::follows_image_pixels`]: the +/// document's pixels for it can change), in it or in the opacity masks of the objects in it. pub(super) fn reads_outside(n: &Node) -> bool { let mut reads = false; n.walk(&mut |c| { reads |= matches!(c.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) + || effects::follows_image_pixels(c) || effects::has_revolve(c) || effects::has_solid(c) || effects::has_adjustment(c) diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 2b6610beb1..4d41e16af8 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -9,7 +9,8 @@ //! envelope around an instance of it). What the walk behind that key found is kept with the node //! and with the document's symbols and patterns as the walk read them ([`Walk`]). A later frame //! makes the key from what was kept while the node and the document's symbols and patterns stay -//! the same. A walk that found a placed file is not kept. +//! the same. A walk that found a placed file, or an image whose own fills or strokes follow its +//! pixels, is not kept. //! Gradient meshes are tessellated into many small solid-colour quads (vello //! has no mesh shading), with the density chosen from the on-screen patch size; each quad is //! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams @@ -44,7 +45,8 @@ type Walk = (Weak, Walked, Arc); /// Per-renderer cache of evaluated live objects: (live node, its evaluation, [`Found`]), a /// [`MeshEntry`] per mesh node and subdivision level, and a [`Walk`] per node whose key -/// [`Renderer::live_found`] found without finding a placed file. +/// [`Renderer::live_found`] found without finding a placed file or an image whose own fills or +/// strokes follow its pixels. pub(crate) struct LiveCache { expanded: FrameCache, Expanded, Found)>, meshes: FrameCache<(usize, usize), MeshEntry>, @@ -97,7 +99,8 @@ pub fn expand_live(n: &Node) -> Vec { /// What the evaluation of `n` (a live node, or an object whose effect art the renderer keeps with /// its inputs, see [`Renderer::live_found`]) reads from the document, placed files, /// the fonts, the plug-ins and the color settings, hashed: the art of the symbols, the tilings of -/// the patterns and the reads of the placed files it shows ([`hash_resources`]), the font +/// the patterns, the reads of the placed files it shows and the document's pixels for the images +/// whose own fills or strokes follow them ([`hash_resources`]), the font /// generation ([`vectorcraft_text::FontDb::generation`]) when it shows type or a placed file, the /// plug-in revision ([`crate::effects::plugin_revision`]) and `names` (the hash of the document's /// swatch and pattern names, [`DocInputs`]) when it shows an object with a plug-in effect, since a @@ -105,13 +108,13 @@ pub fn expand_live(n: &Node) -> Vec { /// while the swatches and patterns they name are in the document, and the color settings (`cms`, /// their address). Art is hashed by the addresses of its `Arc`s, which copies of the document /// share: a copy of the document has the same key. It also returns weak references to the reads -/// found, and what the walk found ([`Walked`]) when it found no placed file. +/// found, and what the walk found ([`Walked`]) when it found no placed file and no such image. fn resources_key(doc: &Document, cms: usize, names: u64, n: &Node) -> (u64, Vec>, Option) { let mut h = DefaultHasher::new(); let mut shown = Shown::default(); hash_resources(doc, n, &mut shown, &mut h); let walked = Walked { hash: h.finish(), text: shown.text, plugins: shown.plugins }; - (walked.key(cms, names), shown.reads, (!shown.placed).then_some(walked)) + (walked.key(cms, names), shown.reads, (!shown.placed && !shown.pixels).then_some(walked)) } /// What [`hash_resources`] found in a node: the hash of what it walked, whether it found type or @@ -153,6 +156,10 @@ struct Shown { text: bool, /// Whether a placed file was found. placed: bool, + /// Whether an image whose own fills or strokes follow its pixels was found + /// ([`crate::effects::follows_image_pixels`]): the document's pixels for it can change while + /// the walk's symbols and patterns stay the same. + pixels: bool, /// Whether an object with a visible plug-in effect was found. plugins: bool, /// Weak references to the reads of the placed files found. @@ -163,9 +170,11 @@ struct Shown { /// tilings ([`crate::pattern::hash_tiling`]) of the patterns in the fills and strokes of its /// objects and of type's characters, the address of each symbol instance's art, and the address of /// the read of each placed file that [`vectorcraft_doc::PlacedDocument::art`] shows (none before -/// the file is read). It walks `n` and then, one at a time from a list, the opacity-mask, pattern -/// and symbol art it finds, each pattern and each symbol's art once ([`first_seen`]). `shown` -/// records the type, placed files, reads and plug-in effects found. +/// the file is read), and the address of the document's pixels for each image whose +/// own fills or strokes follow them ([`crate::effects::follows_image_pixels`]). It walks `n` and +/// then, one at a time from a list, the opacity-mask, pattern and symbol art it finds, each pattern +/// and each symbol's art once ([`first_seen`]). `shown` records the type, placed files, reads, +/// such images and plug-in effects found. fn hash_resources<'a>(doc: &'a Document, n: &'a Node, shown: &mut Shown, h: &mut DefaultHasher) { let mut todo = vec![]; let mut next = Some(n); @@ -203,6 +212,12 @@ fn hash_resources<'a>(doc: &'a Document, n: &'a Node, shown: &mut Shown, h: &mut shown.placed = true; } NodeKind::Text(_) => shown.text = true, + // The address of the pixels the document holds for the image, which Update Link + // replaces under the same key ([`DocInputs`] holds them weakly). + NodeKind::Image(im) if crate::effects::follows_image_pixels(c) => { + doc.images.get(&im.key).map(|b| Arc::as_ptr(&b.bytes) as usize).hash(h); + shown.pixels = true; + } _ => {} } if let Some(m) = c.extras().mask.as_deref() { @@ -442,7 +457,7 @@ impl Renderer { /// evaluated live objects, type, symbol art or colors shaded through the color settings. The /// key is made from the [`Walk`] kept for `n` while `doc`'s symbols and patterns are those it /// read; `n` is walked again when they changed, and in every frame when the walk found a - /// placed file. + /// placed file or an image whose own fills or strokes follow its pixels. pub(crate) fn live_found(&mut self, doc: &Document, n: &Arc) -> Found { let inputs = self.doc_inputs(doc); let at = Arc::as_ptr(n) as usize; diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 15d5b4644a..a6736b022d 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -10,7 +10,8 @@ //! Registration swatch print their tint on every plate, spot plates included. //! Placed raster images show their pixels' process inks the same way ([`plate_image`]): a CMYK //! image its own inks, an RGB one its pixels separated through the color settings. They carry -//! no spot ink; a colorized image takes its color's plates, as a fill of it does. +//! no spot ink; a colorized image takes its color's plates, as a fill of it does. An image +//! placed from a DCS file shows its own plates, spot plates included ([`plates_shown`]). //! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that //! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with //! Darken, which approximates their inks printing over the inks below: a zero ink lets the @@ -82,6 +83,10 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], /// profiles. Each table is made when an image first needs it. pub(crate) struct PlateLut { proof: ProofSetup, + /// The document's solid swatches: name, whether it is a spot color, and its color. An image + /// placed from a DCS file shows a spot plate on its own ink only when it is one of the spot + /// ones, in that swatch's color, and else as the swatch of its name ([`plates_shown`]). + swatches: Vec<(String, bool, Color)>, cms: Arc, /// [`cms::profiles_generation`]. profiles: u64, @@ -107,20 +112,29 @@ static PLATE_LUTS: Mutex>> = Mutex::new(Vec::new()); /// The last [`PlateLut::generation`] handed out. static PLATE_GENERATION: AtomicU64 = AtomicU64::new(0); -/// The tables placed images show Separations Preview's visible plates through with `opts`, or -/// `None` when it is off. -pub(crate) fn plate_lut(opts: &RenderOptions) -> Option> { +/// The tables the placed images of `doc` show Separations Preview's visible plates through with +/// `opts`, or `None` when it is off. +pub(crate) fn plate_lut(opts: &RenderOptions, doc: &Document) -> Option> { let proof = opts.proof.as_ref().filter(|p| p.separations.is_some())?; let (c, profiles) = (cms::active(), cms::profiles_generation()); + // The spot ones are the spot plates of `vectorcraft_doc::inks::plates`; their colors are part + // of the tables' identity, so a swatch edit shows. + let swatches: Vec<(String, bool, Color)> = doc + .swatches_iter() + .filter_map(|s| match &s.paint { + vectorcraft_color::Paint::Solid { color, .. } => Some((s.name.clone(), s.spot, doc.linked_color(*color, s.spot))), + _ => None, + }) + .collect(); let mut luts = PLATE_LUTS.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(l) = luts.iter().find(|l| Arc::ptr_eq(&l.cms, &c) && l.profiles == profiles && l.proof == *proof) { + if let Some(l) = luts.iter().find(|l| Arc::ptr_eq(&l.cms, &c) && l.profiles == profiles && l.proof == *proof && l.swatches == swatches) { return Some(l.clone()); } if luts.len() >= 4 { luts.remove(0); } let generation = PLATE_GENERATION.fetch_add(1, Ordering::Relaxed) + 1; - let l = Arc::new(PlateLut { proof: proof.clone(), cms: c, profiles, generation, rgb: OnceLock::new(), cmyk: OnceLock::new() }); + let l = Arc::new(PlateLut { proof: proof.clone(), swatches, cms: c, profiles, generation, rgb: OnceLock::new(), cmyk: OnceLock::new() }); luts.push(l.clone()); Some(l) } @@ -134,6 +148,11 @@ pub(crate) fn plate_image(lut: &PlateLut, key: &ImageKey, pm: &Pixmap, blob: Opt if let Some(p) = lookup(&want, touch) { return (want, p); } + // An image placed from a DCS file shows its own plates, spot plates included. + if let Some(s) = blob.filter(|b| crate::ink::shows_own_inks(key) && b.is_separated()).and_then(ImageBlob::separated) { + let out = images::store().insert(want.clone(), Arc::new(plates_shown(lut, &s, pm))); + return (want, out); + } let size = (u32::from(pm.width()), u32::from(pm.height())); let raw = crate::ink::key_inks(key, blob).filter(|i| (i.width, i.height) == size); let mut out = pm.clone(); @@ -165,6 +184,117 @@ pub(crate) fn plate_image(lut: &PlateLut, key: &ImageKey, pm: &Pixmap, blob: Opt (want, out) } +/// The visible plates of separated image `s` drawn at the size of `pm` (each pixel the mean of the +/// plate pixels it covers, [`Fit`]): with one spot plate visible its ink as a grey, else the +/// visible process inks through `lut` with the visible spot inks multiplied on top, each in the +/// color of the document's spot swatch of its ink. A spot plate whose ink the document has no spot +/// swatch for shows on the process plates as it prints +/// ([`vectorcraft_doc::separated::Separated::coverage_as`]): as the document's swatch of its name +/// when there is one, else as the process equivalent its file gives. +fn plates_shown(lut: &PlateLut, s: &vectorcraft_doc::separated::Separated, pm: &Pixmap) -> Pixmap { + let visible = lut.proof.separations.as_deref().unwrap_or_default(); + let shown = |name: &str| visible.iter().any(|v| v == name); + let swatch = |name: &str| lut.swatches.iter().find(|(n, ..)| n == name); + let own = |name: &str| swatch(name).is_some_and(|(_, spot, _)| *spot); + let (w, h) = (usize::from(pm.width()).max(1), usize::from(pm.height()).max(1)); + let fit = Fit::new(((s.width as usize).max(1), (s.height as usize).max(1)), (w, h)); + let alone = match visible { + [one] if !PROCESS_PLATES.contains(&one.as_str()) => Some(s.plates.iter().find(|p| p.name == *one && own(&p.name))), + _ => None, + }; + let spots: Vec<(&[u8], [f32; 3])> = s + .spots() + .filter(|p| own(&p.name) && shown(&p.name)) + .map(|p| (p.data.as_slice(), swatch(&p.name).map_or_else(|| lut.cms.cmyk_to_srgb(p.cmyk, false), |(.., c)| lut.cms.display_rgb(c)))) + .collect(); + // The plates that print on the process plates, each with the process inks it stands for. + let process: Vec<(&[u8], [f32; 4])> = s + .plates + .iter() + .filter(|p| !p.spot() || !own(&p.name)) + .map(|p| { + let inks = match swatch(&p.name) { + Some((.., c)) if p.spot() => lut.cms.to_cmyk(c, lut.proof.intent), + _ => p.cmyk, + }; + (p.data.as_slice(), inks) + }) + .collect(); + let t = lut.cmyk.get_or_init(|| { + CmykLut::build(|inks| { + let [x, y, z] = lab::srgb_to_xyz(lut.shown(inks)); + [x, y, z, 0.0] + }) + }); + let mut out = pm.clone(); + for (at, o) in out.data_mut().iter_mut().enumerate() { + let ink = |data: &[u8]| fit.ink(data, at); + let rgb = match alone { + Some(p) => [1.0 - p.map_or(0.0, |p| ink(&p.data)); 3], + None => { + let mut clear = [1.0f32; 4]; + for (data, inks) in &process { + let a = ink(data); + for (c, e) in clear.iter_mut().zip(inks) { + *c *= 1.0 - a * e; + } + } + let [x, y, z, _] = t.apply(clear.map(|c| 1.0 - c)); + let mut rgb = lab::xyz_to_srgb([x, y, z]); + for (data, c) in &spots { + let a = ink(data); + for (v, c) in rgb.iter_mut().zip(c) { + *v *= 1.0 - a * (1.0 - c); + } + } + rgb + } + }; + let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + *o = PremulRgba8 { r, g, b, a: 255 }; + } + out +} + +/// Plates of `from` (width, height) pixels drawn at `to` pixels: each pixel the mean of the plate +/// pixels it covers, so a reduced image (a smaller mip level) keeps the coverage the plate has; the +/// nearest plate pixel when it covers less than one. It keeps only the spans of each column and +/// row, not a copy of any plate. +struct Fit { + /// The plate's width. + sw: usize, + /// The shown width. + w: usize, + /// The plate columns each shown column covers, `start..end`. + columns: Vec<(usize, usize)>, + /// The plate rows each shown row covers, `start..end`. + rows: Vec<(usize, usize)>, +} + +impl Fit { + fn new((sw, sh): (usize, usize), (w, h): (usize, usize)) -> Self { + // The plate pixels each shown column or row covers: at least one. + // In 64 bits: a plate's width times the shown width may not fit 32 (the web build). + let span = |i: usize, n: usize, of: usize| { + let at = |i: usize| usize::try_from(i as u64 * of as u64 / n as u64).unwrap_or(of); + let start = at(i).min(of - 1); + (start, at(i + 1).clamp(start + 1, of)) + }; + Self { sw, w, columns: (0..w).map(|x| span(x, w, sw)).collect(), rows: (0..h).map(|y| span(y, h, sh)).collect() } + } + + /// The ink of plate `data` at shown pixel `at` (row by row), from 0 to 1. + fn ink(&self, data: &[u8], at: usize) -> f32 { + let (Some(&(x0, x1)), Some(&(y0, y1))) = (self.columns.get(at % self.w), self.rows.get(at / self.w)) else { return 0.0 }; + let mut sum = 0u64; + for row in y0..y1 { + sum += data.get(row * self.sw + x0..row * self.sw + x1).map_or(0, |r| r.iter().map(|&v| u64::from(v)).sum()); + } + let n = ((y1 - y0) * (x1 - x0)) as u64; + sum as f32 / n as f32 / 255.0 + } +} + /// The art a renderer drew with Overprint Preview or Separations Preview, kept for its next frame /// ([`prepare`]): the document's layers and symbol art as they were, each beside the art drawn for /// it, and what that art was made with. The kept nodes keep their addresses: no other node gets diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index fc729c1cc3..500d62d5b4 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -150,7 +150,7 @@ impl Renderer { let screen = if opts.rich_black { Ink::RichBlack } else { Ink::Display }; let symbols = doc.symbol_boxes().quick(); let output = Affine::scale(1.0 / f64::from(cell.max(1))) * view; - let plates = proof::plate_lut(opts); + let plates = proof::plate_lut(opts, doc); let frame = Frame { mt: threads > 0, doc, symbols: &symbols, view, visible, px, opts, ink: screen, output, plates: plates.as_deref() }; self.begin_frame(); // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. diff --git a/crates/render/src/tests/colorize.rs b/crates/render/src/tests/colorize.rs index 49e14e5e68..721707502d 100644 --- a/crates/render/src/tests/colorize.rs +++ b/crates/render/src/tests/colorize.rs @@ -182,6 +182,113 @@ fn separations_preview_shows_the_image_on_its_spot_plate() { assert!(off[..3].iter().all(|v| *v >= 250), "the process plates show paper, got {off:?}"); } +/// Separations Preview shows an image placed from a DCS file through its own plates: its spot +/// plate on that ink's plate only, its cyan plate on the cyan plate only. Without the preview it +/// shows its composite, the spot ink mixed in. +#[test] +fn separations_preview_shows_a_dcs_image_through_its_own_plates() { + use vectorcraft_doc::separated::{InkPlate, Separated}; + // Two pixels: the spot ink solid on the left, cyan solid on the right. + let plates = vec![ + InkPlate { name: "Cyan".into(), cmyk: [0.0; 4], data: vec![0, 255] }, + InkPlate { name: "Ink".into(), cmyk: [0.0, 1.0, 1.0, 0.0], data: vec![255, 0] }, + ]; + let s = Separated::new(2, 1, plates).unwrap(); + let mut d = Document::new_with_mode(40.0, 20.0, vectorcraft_doc::ColorMode::Cmyk); + d.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), global: true, spot: true }); + d.images.insert("pic".into(), ImageBlob::separated_tiff(&s, None, &[]).unwrap()); + let im = ImageObject { key: "pic".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None }; + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + let shown = |plates: &[&str]| { + let out = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &separations(plates)); + (out.pixel(10, 10), out.pixel(30, 10)) + }; + let (left, right) = shown(&["Ink"]); + assert!(left[..3].iter().all(|v| *v < 20), "the Ink plate shows the spot plate as solid ink, got {left:?}"); + assert!(right[..3].iter().all(|v| *v >= 250), "and nothing of cyan, got {right:?}"); + let (left, right) = shown(&["Cyan"]); + assert!(left[..3].iter().all(|v| *v >= 250), "the Cyan plate shows nothing of the spot ink, got {left:?}"); + assert!(right[..3].iter().all(|v| *v < 20), "and the cyan plate as solid ink, got {right:?}"); + let (left, _) = shown(&["Cyan", "Magenta", "Yellow", "Black"]); + assert!(left[..3].iter().all(|v| *v >= 250), "the process plates show paper where only the spot ink prints, got {left:?}"); + let (left, _) = shown(&["Cyan", "Magenta", "Yellow", "Black", "Ink"]); + assert!(left[0] > 150 && left[1] < 100 && left[2] < 100, "with every plate the spot ink shows red, got {left:?}"); + let composite = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() }).pixel(10, 10); + assert!(composite[0] > 150 && composite[1] < 100, "the composite mixes the spot ink in, got {composite:?}"); + // Without a spot swatch of the ink, the job prints no plate for it: it shows on the process + // plates as its process equivalent, as it prints. + d.swatches.retain(|s| s.name != "Ink"); + let process = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &separations(&["Cyan", "Magenta", "Yellow", "Black"])).pixel(10, 10); + assert!(process[0] > 150 && process[1] < 100 && process[2] < 100, "the ink's magenta and yellow show, got {process:?}"); + let magenta = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &separations(&["Magenta"])).pixel(10, 10); + assert!(magenta[..3].iter().all(|v| *v < 20), "the Magenta plate alone shows it as solid ink, got {magenta:?}"); +} + +/// A document of 40 × 20 pt holding separated image `s` (2 × 1 pixels) at 20 pt a pixel. +fn separated_doc(s: &vectorcraft_doc::separated::Separated) -> Document { + let mut d = Document::new_with_mode(40.0, 20.0, vectorcraft_doc::ColorMode::Cmyk); + d.images.insert("pic".into(), ImageBlob::separated_tiff(s, None, &[]).unwrap()); + let im = ImageObject { + key: "pic".into(), + width: s.width, + height: s.height, + xf: Affine::scale(40.0 / f64::from(s.width)), + link: None, + placement: Default::default(), + pixels: None, + }; + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + d +} + +/// A DCS spot plate shows in the color of the document's swatch of its ink, as the art on that ink +/// does, and follows an edit of the swatch. +#[test] +fn separations_preview_colors_a_dcs_spot_plate_by_the_documents_swatch() { + use vectorcraft_doc::separated::{InkPlate, Separated}; + // The file says the ink is red; the document's swatch of it is cyan. + let s = Separated::new(2, 1, vec![InkPlate { name: "Ink".into(), cmyk: [0.0, 1.0, 1.0, 0.0], data: vec![255, 0] }]).unwrap(); + let mut d = separated_doc(&s); + d.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), global: true, spot: true }); + let all = ["Cyan", "Magenta", "Yellow", "Black", "Ink"]; + let left = |d: &Document, plates: &[&str]| Renderer::new().render(d, 40, 20, Affine::IDENTITY, &separations(plates)).pixel(10, 10); + let p = left(&d, &all); + assert!(p[0] < 100 && p[2] > 150, "the swatch's cyan, not the file's red, got {p:?}"); + // The swatch edited to yellow: the image follows. + if let Some(w) = d.swatches.iter_mut().find(|w| w.name == "Ink") { + w.paint = Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0)); + } + let p = left(&d, &all); + assert!(p[0] > 150 && p[2] < 100, "the edited swatch's yellow, got {p:?}"); + // A process swatch of the ink's name: the plate shows on the process plates as that swatch. + if let Some(w) = d.swatches.iter_mut().find(|w| w.name == "Ink") { + (w.paint, w.spot) = (Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), false); + } + let p = left(&d, &all[..4]); + assert!(p[0] < 100 && p[2] > 150, "the process swatch's cyan, got {p:?}"); +} + +/// A DCS image drawn reduced (from a smaller mip level) shows the coverage its plate has there, +/// the mean of the plate pixels each reduced pixel covers. +#[test] +fn separations_preview_keeps_a_reduced_dcs_plates_coverage() { + use vectorcraft_doc::separated::{InkPlate, Separated}; + // 64 × 64 pixels, the spot ink solid on every other column: 50% coverage. + let data: Vec = (0..64 * 64).map(|i| if i % 2 == 0 { 255 } else { 0 }).collect(); + let s = Separated::new(64, 64, vec![InkPlate { name: "Ink".into(), cmyk: [0.0, 1.0, 1.0, 0.0], data }]).unwrap(); + let mut d = Document::new_with_mode(64.0, 64.0, vectorcraft_doc::ColorMode::Cmyk); + d.swatches.push(vectorcraft_color::Swatch { name: "Ink".into(), paint: Paint::solid(Color::cmyk(0.0, 1.0, 1.0, 0.0)), global: true, spot: true }); + d.images.insert("pic".into(), ImageBlob::separated_tiff(&s, None, &[]).unwrap()); + let im = ImageObject { key: "pic".into(), width: 64, height: 64, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let (layer, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); + let opts = RenderOptions { image_mips: true, ..separations(&["Ink"]) }; + let p = Renderer::new().render(&d, 16, 16, Affine::scale(0.25), &opts).pixel(8, 8); + assert!(p[..3].iter().all(|v| (100..=160).contains(v)), "half the ink, got {p:?}"); +} + /// Separations Preview showing `plates`, as the canvas draws it. fn separations(plates: &[&str]) -> RenderOptions { let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() }; diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index 7e4007c6ac..e8cf7a1745 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -771,6 +771,27 @@ fn rasterize_on_an_image_takes_in_its_strokes_shape() { assert_eq!((lum(img.pixel(8, 35)), lum(img.pixel(12, 35)), img.pixel(35, 35)), (WHITE, 0, [255, 0, 0, 255])); } +#[test] +fn an_images_outline_object_stroke_follows_new_pixels_under_the_same_key() { + // The opaque image's stroke runs along its frame, from 18 to 22. + let mut d = stroked_image_doc(vec![fx("path.outlineObject", json!({}))], false); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!((lum(img.pixel(19, 35)), img.pixel(26, 35)), (0, [255, 0, 0, 255])); + // New pixels under the same key, as Update Link stores them, with the node unchanged: an + // opaque disc of radius 8 pixels in the middle. The stroke follows its edge at 26.5. + let disc = image::RgbaImage::from_fn(30, 30, |x, y| { + let r = (f64::from(x) + 0.5 - 15.0).hypot(f64::from(y) + 0.5 - 15.0); + image::Rgba([255, 0, 0, if r <= 8.0 { 255 } else { 0 }]) + }); + let mut png = vec![]; + disc.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("red".into(), ImageBlob::new("image/png", png)); + let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!((lum(img.pixel(19, 35)), lum(img.pixel(26, 35))), (WHITE, 0)); +} + /// A 40 pt square filled with `fill` carrying effect `id`, in a 100×100 document. fn glowing_square(fill: Color, id: &str, params: Value) -> Document { let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)), Appearance::basic(Paint::solid(fill), Paint::None, 0.0)); diff --git a/crates/text/src/fontdb/lookup.rs b/crates/text/src/fontdb/lookup.rs index fa91e97875..23e00afa0c 100644 --- a/crates/text/src/fontdb/lookup.rs +++ b/crates/text/src/fontdb/lookup.rs @@ -15,6 +15,9 @@ use super::{ Ordering, Size, }; +/// The most faces [`FontDb::postscript_faces`] gives, and the most families it loads. +const MAX_POSTSCRIPT_FACES: usize = 16; + impl FontDb { /// The family's own name and the style for a name given in a document: any of a family's /// names (`ヒラギノ角ゴシック` is Hiragino Sans), a legacy family name (`Hiragino Sans W6` with its @@ -141,6 +144,33 @@ impl FontDb { loaded(self) } + /// Every installed face that [`find_postscript`](Self::find_postscript) could give for `name` + /// (its PostScript name ignoring case and anything but letters and digits), its answer first, + /// then the others in load order, at most 16 in all: copies of one font in + /// other families (`SampleSerif` beside `Sample Serif`) or versions, and names that differ + /// only in case. Each family that has one is loaded from the system fonts whole, as + /// [`versions`](Self::versions) loads one. Empty when no installed font has the name. + pub fn postscript_faces(&self, name: &str) -> Vec> { + let Some(first) = self.find_postscript(name) else { return vec![] }; + let key = norm(name); + let mut families: Vec = vec![first.family.clone()]; + if let Some(v) = self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&key) { + for a in v.iter().filter(|a| a.paired.is_none() && a.style.is_some()) { + if families.len() < MAX_POSTSCRIPT_FACES && !families.iter().any(|f| f.eq_ignore_ascii_case(&a.family)) { + families.push(a.family.clone()); + } + } + } + for family in &families { + self.load_family_files(family); + } + let mut out = vec![first.clone()]; + let faces = self.read_faces(); + let others = faces.iter().filter(|f| f.id != first.id && f.keys.postscript.contains(&key)); + out.extend(others.take(MAX_POSTSCRIPT_FACES - 1).cloned()); + out + } + /// The (family, style) of the installed face whose PostScript name is `name` (ignoring ASCII /// case). Documents name fonts this way, and a family can hold hyphens /// ("Rounded-X-Mplus-1c-black" is Rounded-X M+ 1c, black), so the name can't be split. diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 2d766ae571..a75ee8d3af 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -1594,6 +1594,30 @@ pub fn twin_font(newer: bool) -> Option> { Some(builder.build()) } +/// The names of a [`named_copy`]: family, style, version string and PostScript name. +pub type CopyNames<'a> = (&'a str, &'a str, &'a str, &'a str); + +/// The bundled Source Sans 3 Regular or, `inter`, the bundled Inter Regular (glyphs of other +/// widths) under `names`, as an installed copy of a font: a font library can keep copies whose +/// names differ only in case or punctuation, or whose family and style are one but whose PostScript +/// names or versions differ. `None` when it can't be built. +pub fn named_copy(inter: bool, (family, style, version, postscript): CopyNames<'_>) -> Option> { + const SOURCE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let font = skrifa::FontRef::new(if inter { INTER } else { SOURCE }).ok()?; + let full = format!("{family} {style}"); + let name = name_table(&[(1, family), (2, style), (4, &full), (5, version), (6, postscript)])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`numbered_font`]: styles numbered W3 and W6, and no Regular. pub const NUMBERED_FAMILY: &str = "Numtest Mincho"; diff --git a/crates/text/src/tests/versions.rs b/crates/text/src/tests/versions.rs index a721e435b1..ca28d7e4ef 100644 --- a/crates/text/src/tests/versions.rs +++ b/crates/text/src/tests/versions.rs @@ -41,3 +41,26 @@ fn type_naming_a_version_is_set_in_it() { assert!((newer - older).abs() > 50.0, "the two versions set the line differently: {newer} vs {older}"); assert_eq!(width(&db, Some(VERSIONS[1])), newer); } + +/// Every installed face a PostScript name answers to, ignoring case and punctuation: the face +/// `find_postscript` gives first, then the copies in other families and the versions, in load +/// order; none for a name no face has. +#[test] +fn every_face_of_a_postscript_name_is_found() { + use crate::test_fonts::named_copy; + let db = FontDb::with_font_dirs(vec![]); + for (inter, names) in [ + (true, ("Facetest Ink", "Bold", "Version 1.000", "FacetestInkBold")), + (false, ("Facetest-Ink", "Bold", "Version 1.000", "FacetestInk-Bold")), + (true, ("Facetest-Ink", "Bold", "Version 2.000", "FACETESTINK-BOLD")), + (false, ("Other Ink", "Bold", "Version 1.000", "OtherInk-Bold")), + ] { + assert_eq!(db.add_font(named_copy(inter, names).expect("the test font builds")), 1, "{names:?}"); + } + let names = |name: &str| db.postscript_faces(name).iter().map(|f| f.postscript_name()).collect::>(); + let first = db.find_postscript("FacetestInk-Bold").expect("a face").postscript_name(); + assert_eq!(first, "FacetestInkBold"); + assert_eq!(names("FacetestInk-Bold"), ["FacetestInkBold", "FacetestInk-Bold", "FACETESTINK-BOLD"]); + assert_eq!(names("OtherInk-Bold"), ["OtherInk-Bold"]); + assert!(names("NoSuchInk-Bold").is_empty()); +} diff --git a/crates/tools/src/addons/smart_pen.rs b/crates/tools/src/addons/smart_pen.rs index ee7440dde6..6a287b9b54 100644 --- a/crates/tools/src/addons/smart_pen.rs +++ b/crates/tools/src/addons/smart_pen.rs @@ -368,7 +368,7 @@ impl SmartPenTool { } Target::Close { id, si, first, mut anchor } => { let out = Self::handle_to(cx, first, ev.pos, first, shift, false); - let act = close_drag(id, Some(si), first - (out - first), alt, anchor.as_mut()); + let act = close_drag(id, Some(si), None, first - (out - first), alt, anchor.as_mut()); pr.what = Target::Close { id, si, first, anchor }; ("", act) } diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 31aabfb38f..0b625f2813 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -7,7 +7,8 @@ //! incoming handle mirrors the pointer (a click leaves it retracted and the anchor a corner, so the //! closing segment comes in straight). Its outgoing handle stays where it is on a corner, so the first segment keeps its //! shape, and on a smooth anchor turns to stay in line at its own length; Alt makes the anchor a -//! corner, leaving the outgoing handle where it was when Alt went down. Clicking the +//! corner, leaving the outgoing handle where it was when Alt went down; Space held moves the +//! anchor, handles and all. Clicking the //! last one retracts its outgoing handle, so the next segment leaves it as a corner; dragging from //! it pulls a new one out on its own. //! Alt held over a handle end, an anchor or a segment of a selected path (the one being drawn too) @@ -76,13 +77,15 @@ const DRAG_THRESHOLD: f64 = 3.0; /// An anchor being placed, as the press decided: where it is, the path it goes on (none: the /// first anchor of a new path) and whether it closes that path (the press was on its first /// anchor). The drag only shapes its handles, whatever the path looks like meanwhile: a closing -/// preview leaves the path closed, so no longer one the pen draws on. +/// preview leaves the path closed, so no longer one the pen draws on. `moved`: Space held has +/// moved the anchor from where the press found it. #[derive(Clone, Copy)] struct Place { at: Point, path: Option, subpath: Option, closing: bool, + moved: bool, } /// The open path the pen is extending: the single selected open path, or the single open path of a @@ -329,7 +332,7 @@ impl Tool for PenTool { params["subpaths"] = json!([si, other_si]); return vec![Action::Exec("path.join".into(), params)]; } - self.drag = Some(Place { at: p, path: Some(id), subpath: Some(si), closing: false }); + self.drag = Some(Place { at: p, path: Some(id), subpath: Some(si), closing: false, moved: false }); self.in_h = p; let params = json!({"id": id.0, "x": p.x, "y": p.y, "subpath": si}); return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), params)]; @@ -366,7 +369,7 @@ impl Tool for PenTool { return vec![act]; } (self.drawing, self.path, self.subpath) = (true, None, None); - self.drag = Some(Place { at: p, path: None, subpath: None, closing: false }); + self.drag = Some(Place { at: p, path: None, subpath: None, closing: false, moved: false }); vec![Action::Begin("Pen".into()), Action::Preview("path.create".into(), json!({"anchors": [{"x": p.x, "y": p.y}]}))] } PointerKind::Drag => { @@ -390,13 +393,19 @@ impl Tool for PenTool { } return vec![set_out_handle(id, si, ai, h)]; } - let Some(Place { at: mut a, path, subpath, closing }) = self.drag else { return vec![] }; - // Space held moves the anchor being placed, handles and all. - if ev.mods.space && !closing { + let Some(Place { at: mut a, path, subpath, closing, mut moved }) = self.drag else { return vec![] }; + // Space held moves the anchor being placed, handles and all; while closing, the + // first anchor, with the outgoing handle a closing drag keeps. + if ev.mods.space { let d = ev.pos - self.last; a += d; self.in_h += d; - self.drag = Some(Place { at: a, path, subpath, closing }); + if closing && let Some((anchor, out)) = &mut self.first { + anchor.translate(d); + *out += d; + } + moved |= d != Vec2::ZERO; + self.drag = Some(Place { at: a, path, subpath, closing, moved }); } self.last = ev.pos; let mut out_h = ev.pos; @@ -413,7 +422,7 @@ impl Tool for PenTool { )]; }; if closing { - return vec![close_drag(id, subpath, a - (out_h - a), alt, self.first.as_mut())]; + return vec![close_drag(id, subpath, moved.then_some(a), a - (out_h - a), alt, self.first.as_mut())]; } // Alt pressed mid-drag keeps the curve already shaped into the anchor: only the // outgoing handle follows. Held from the start, the incoming handle stays retracted. @@ -583,7 +592,7 @@ impl PenTool { /// anchor a corner with its incoming handle retracted until a drag pulls it out, as a click on /// the last anchor retracts its outgoing one ([`set_out_handle`]). fn begin_close(&mut self, cx: &ToolContext, id: NodeId, si: usize, at: Point) -> Vec { - self.drag = Some(Place { at, path: Some(id), subpath: Some(si), closing: true }); + self.drag = Some(Place { at, path: Some(id), subpath: Some(si), closing: true, moved: false }); self.first = first_anchor(cx, id, si).map(|a| (a, a.h_out)); let params = json!({"id": id.0, "subpath": si, "in": [at.x, at.y], "independent": true}); vec![Action::Begin("Close Path".into()), Action::Preview("path.close".into(), params)] @@ -609,12 +618,23 @@ pub(crate) fn first_anchor(cx: &ToolContext, id: NodeId, si: usize) -> Option, in_h: Point, alt: bool, first: Option<&mut (Anchor, Point)>) -> Action { +/// The preview of a closing drag that puts the first anchor's incoming handle at `in_h`, the anchor +/// first moved to `at` when given (Space held). `first` holds that anchor as it was when the press +/// began closing the path (moved with it), and its outgoing handle as the drag last left it: a +/// smooth anchor turns that handle to stay in line, and Alt (`alt`), which makes the anchor a +/// corner, keeps it there. +pub(crate) fn close_drag( + id: NodeId, + subpath: Option, + at: Option, + in_h: Point, + alt: bool, + first: Option<&mut (Anchor, Point)>, +) -> Action { let mut params = json!({"id": id.0, "in": [in_h.x, in_h.y], "independent": alt}); + if let Some(at) = at { + params["at"] = json!([at.x, at.y]); + } if let Some((anchor, out)) = first { if alt { params["out"] = json!([out.x, out.y]); diff --git a/crates/tools/src/pen/tests.rs b/crates/tools/src/pen/tests.rs index 984f34646a..478589fe9e 100644 --- a/crates/tools/src/pen/tests.rs +++ b/crates/tools/src/pen/tests.rs @@ -628,6 +628,43 @@ fn alt_mid_close_keeps_the_turned_outgoing_handle() { assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 200.0)), vec![Action::Commit]); } +/// Space held during a closing drag moves the first anchor, handles and all, by as far as the +/// pointer moves; released, the drag shapes the handles round the anchor's new place. +#[test] +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 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))); + d.node_mut(id).unwrap().path_data_mut().unwrap().subpaths[0].closed = true; + let c = cx(&d, &s, &p); + let close = |v: serde_json::Value| vec![Action::Preview("path.close".into(), v)]; + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 100.0, 200.0)), + close(json!({"id": id.0, "in": [100.0, 400.0], "independent": false, "subpath": 0})) + ); + let space = Mods { space: true, ..Mods::default() }; + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 180.0).with_mods(space)), + close(json!({"id": id.0, "at": [130.0, 280.0], "in": [130.0, 380.0], "independent": false, "subpath": 0})), + "moved, handles and all" + ); + // Space released: the drag shapes the handles round the anchor's new place. + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 230.0)), + close(json!({"id": id.0, "at": [130.0, 280.0], "in": [130.0, 330.0], "independent": false, "subpath": 0})) + ); + // Alt keeps the outgoing handle where the drag had turned it, moved with the anchor. + let alt = Mods { alt: true, ..Mods::default() }; + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 230.0).with_mods(alt)), + close(json!({"id": id.0, "at": [130.0, 280.0], "in": [100.0, 330.0], "out": [130.0, 230.0], "independent": true, "subpath": 0})) + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 230.0)), vec![Action::Commit]); +} + #[test] fn a_one_anchor_path_does_not_close_on_itself() { let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0)], false)); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 17bf7eeaef..d0d624bbeb 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -239,7 +239,8 @@ pub(crate) struct MoveSnap { copy_targets: Option, copy_points: Option, /// With Smart Guides on, the ruler guides View › Snap to Point still pulls the grabbed point - /// onto (#1534), and lines the bounds up with (#5746); `points` holds them with Smart Guides off. + /// onto (#1534), and lines the bounds up with (#5746), within the Snapping Tolerance as drawing + /// does; `points` holds them with Smart Guides off. rulers: Option, } @@ -303,7 +304,7 @@ impl MoveSnap { // axis without one that Smart Guides left free, the bounds line up with the ruler guides // (#5746), alongside the Smart Guides. if let Some(t) = &self.rulers { - let (adj, ov, held) = t.snap_moved_to_rulers(start + d, self.bounds.map(|b| b + d), cx.tol(cx.snap_tolerance), taken); + let (adj, ov, held) = t.snap_moved_to_rulers(start + d, self.bounds.map(|b| b + d), cx.snap_tol(), taken); if adj != Vec2::ZERO { d += adj; if held { @@ -373,7 +374,7 @@ impl MoveSnap { } } if let Some(t) = &self.rulers { - let (q, ov) = t.snap_point_along(start + d, u, cx.tol(cx.snap_tolerance)); + let (q, ov) = t.snap_point_along(start + d, u, cx.snap_tol()); if q != start + d { d = q - start; guides = ov; @@ -382,7 +383,7 @@ impl MoveSnap { { // Nothing else lined up: the bounds slide along the line onto a ruler guide, as // they do without Shift (#4312). - let (adj, ov) = t.snap_moved_along_to_rulers(start + d, b + d, u, cx.tol(cx.snap_tolerance)); + let (adj, ov) = t.snap_moved_along_to_rulers(start + d, b + d, u, cx.snap_tol()); d += adj; guides = ov; } diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 659e50a6a5..c41056b7aa 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -751,7 +751,7 @@ fn escape_cancels_a_move_or_an_alt_drag_copy() { } /// #1534: with Smart Guides on (the default), the point a selection is dragged by still snaps -/// to a ruler guide within the Snap to Point distance; not with Snap to Point off. +/// to a ruler guide within the Snapping Tolerance; not with Snap to Point off. #[test] fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { let (mut d, id) = doc_with_rect(); @@ -797,6 +797,39 @@ fn a_shift_move_lands_the_edges_on_a_ruler_guide() { assert!(moved_by(m, (231.0, 0.0)), "{m:?}"); } +/// With Smart Guides on, a moved selection reaches a ruler guide within the Snapping Tolerance +/// (4 px), as drawing and handle drags do; with Smart Guides off, Snap to Point reaches it within +/// its own distance (2 px). +#[test] +fn a_move_reaches_ruler_guides_within_the_snapping_tolerance() { + let (mut d, id) = doc_with_rect(); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 340.0)); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let dx = |c: &ToolContext, to: f64| { + let mut t = SelectionTool::default(); + t.pointer(c, &ev(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(c, &ev(PointerKind::Drag, to, 150.0)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + v["matrix"][4].as_f64().unwrap() + }; + let c = cx(&d, &s, &p); + assert!(c.smart_guides && c.snap_to_point && c.guides); + // The right edge, 3 px short of the guide, lands on it, with or without Shift. + assert_eq!(dx(&c, 150.0 + 137.0), 140.0); + let (m, _) = shift_moved(&c, (150.0 + 137.0, 150.0)); + assert!(moved_by(m, (140.0, 0.0)), "{m:?}"); + // The grabbed point, 3 px from the guide, lands on it. + assert_eq!(dx(&c, 337.0), 190.0); + // 5 px away is out of reach. + assert_eq!(dx(&c, 150.0 + 135.0), 135.0); + // Smart Guides off: the grabbed point lands on the guide from 1.5 px, not from 3 px. + let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; + assert_eq!(dx(&off, 338.5), 190.0); + assert_eq!(dx(&off, 337.0), 187.0); +} + /// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as /// gathered before the previews moved it); released, the move doesn't. #[test] diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index 46c29f4e05..48baeccf83 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -51,6 +51,7 @@ mod gradient; mod group; mod logo; mod mosaic; +mod opaque; mod quantize; mod shapes; mod smooth; @@ -64,6 +65,7 @@ use vectorcraft_geom::PathData; pub use contour::{Component, Loop, trace_mask}; 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 shapes::TracedShape; use subpixel::Crack; diff --git a/crates/trace/src/opaque.rs b/crates/trace/src/opaque.rs new file mode 100644 index 0000000000..52e80a368d --- /dev/null +++ b/crates/trace/src/opaque.rs @@ -0,0 +1,292 @@ +//! The outline of an image's opaque area: what Outline Object on an image's own fill or stroke +//! follows. + +use vectorcraft_geom::{Affine, PathData}; + +use crate::raster::{MAX_PIXELS, fit_size, gif_first_frame_pixels}; +use crate::{Mode, Raster, TraceError, TraceParams, trace_within}; + +/// The most pixels [`opaque_outline`] traces: a larger image is reduced to this many first, +/// aspect kept ([`reduced`]), and its outline is scaled back up to the image's size. +pub const OUTLINE_MAX_PIXELS: u64 = 4 << 20; + +/// The most anchor points an outline may have; an image whose outline would have more gives +/// [`TraceError::TooComplex`]. +pub const OUTLINE_MAX_ANCHORS: usize = 100_000; + +/// The most bytes [`decode_alpha`] decodes an image's samples into (256 MB: 64 megapixels at 8 +/// bits a channel, 32 megapixels at 16). +pub const ALPHA_MAX_DECODE_BYTES: u64 = 256 << 20; + +/// The alpha of the encoded image `bytes` (PNG, WebP, GIF, TIFF and the other formats the image +/// reader takes), one byte a pixel, row by row, with the image's width and height. `None` when the +/// image's format or color type has no alpha; the image is then not decoded. An image of more +/// than [`MAX_PIXELS`] (64 megapixels), or whose samples take more than [`ALPHA_MAX_DECODE_BYTES`], +/// gives [`TraceError::TooLarge`] before it is decoded. +pub fn decode_alpha(bytes: &[u8]) -> Result)>, TraceError> { + use image::ImageDecoder; + let decode_err = |e: image::ImageError| TraceError::Decode(e.to_string()); + let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().map_err(|e| TraceError::Decode(e.to_string()))?; + let decoder = reader.into_decoder().map_err(decode_err)?; + let (w, h) = decoder.dimensions(); + if w == 0 || h == 0 { + return Err(TraceError::Empty); + } + if !decoder.color_type().has_alpha() { + return Ok(None); + } + let pixels = u64::from(w) * u64::from(h); + let frame = gif_first_frame_pixels(bytes).unwrap_or(0); + if pixels.max(frame) > MAX_PIXELS || decoder.total_bytes() > ALPHA_MAX_DECODE_BYTES { + return Err(TraceError::TooLarge { width: w, height: h, max: MAX_PIXELS >> 20 }); + } + let alpha: Vec = match image::DynamicImage::from_decoder(decoder).map_err(decode_err)? { + image::DynamicImage::ImageRgba8(img) => img.as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + image::DynamicImage::ImageLumaA8(img) => img.as_raw().as_chunks::<2>().0.iter().map(|p| p[1]).collect(), + img => img.to_rgba8().as_raw().as_chunks::<4>().0.iter().map(|p| p[3]).collect(), + }; + Ok(Some((w, h, alpha))) +} + +/// The outline of the pixels of a `width × height` image whose `alpha` (one byte a pixel, row by +/// row) is at least half opaque (128 or more), in its pixel space (x right, y down; the image +/// spans `0..width × 0..height`). `None` when every pixel is: the outline is then the image's +/// frame. An image with no such pixel gives an empty outline. +/// +/// The alpha is traced as Image Trace's Black and White mode traces a gray at threshold 128, with +/// its Paths and Corners at their defaults and no area left out as noise: the edge runs where the +/// alpha crosses one half, to a fraction of a pixel along anti-aliased edges, and the outline is +/// fitted with curves. Outer outlines and holes run in opposite directions, and an island in a +/// hole runs as outer outlines do, so the outline fills the same with either fill rule. An image +/// of more than [`OUTLINE_MAX_PIXELS`] is traced at that size ([`reduced`]). +pub fn opaque_outline(width: u32, height: u32, alpha: Vec) -> Result, TraceError> { + let (w, h) = (width, height); + if (w as usize).checked_mul(h as usize).is_none_or(|n| n == 0 || n != alpha.len()) { + return Err(TraceError::Empty); + } + if alpha.iter().all(|&a| a >= 128) { + return Ok(None); + } + if alpha.iter().all(|&a| a < 128) { + return Ok(Some(PathData::default())); + } + let (nw, nh) = fit_size(w, h, OUTLINE_MAX_PIXELS); + let alpha = if (nw, nh) == (w, h) { alpha } else { reduced(&alpha, w, h, nw, nh) }; + // Opaque pixels are black, transparent ones white. + let mut rgba = Vec::with_capacity(alpha.len() * 4); + for a in alpha { + let g = 255 - a; + rgba.extend_from_slice(&[g, g, g, 255]); + } + let gray = Raster::new(nw, nh, rgba)?; + let params = TraceParams { mode: Mode::BlackAndWhite, threshold: 128, ignore_white: true, noise: 0, ..TraceParams::default() }; + let traced = trace_within(&gray, ¶ms, OUTLINE_MAX_ANCHORS)?; + let mut out = PathData::default(); + for p in traced.paths { + out.subpaths.extend(p.path.subpaths); + } + if (nw, nh) != (w, h) { + out = out.transformed(Affine::scale_non_uniform(f64::from(w) / f64::from(nw), f64::from(h) / f64::from(nh))); + } + Ok(Some(out)) +} + +/// The `w × h` `alpha` reduced to `nw × nh` (at most `w × h`): each pixel holds the mean of the +/// block of pixels it covers, and at least 128 when that block holds a pixel of 128 or more, so an +/// opaque line thinner than a block stays inside the outline. Along an edge the outline can then +/// run up to half a block further out than the full-size image's would. +fn reduced(alpha: &[u8], w: u32, h: u32, nw: u32, nh: u32) -> Vec { + let span = |i: u32, n: u32, of: u32| { + let at = |i: u32| usize::try_from(u64::from(i) * u64::from(of) / u64::from(n.max(1))).unwrap_or(usize::MAX); + let start = at(i); + start..at(i + 1).max(start + 1) + }; + let cols: Vec<_> = (0..nw).map(|x| span(x, nw, w)).collect(); + let mut out = Vec::with_capacity(nw as usize * nh as usize); + let (mut sums, mut maxes) = (vec![0u64; cols.len()], vec![0u8; cols.len()]); + for ty in 0..nh { + let rows = span(ty, nh, h); + sums.fill(0); + maxes.fill(0); + for y in rows.clone() { + let Some(row) = alpha.get(y * w as usize..(y + 1) * w as usize) else { break }; + for (x, c) in cols.iter().enumerate() { + for &a in row.get(c.clone()).unwrap_or_default() { + sums[x] += u64::from(a); + maxes[x] = maxes[x].max(a); + } + } + } + for (x, c) in cols.iter().enumerate() { + let count = (rows.len() * c.len()).max(1) as u64; + let mean = u8::try_from((sums[x] + count / 2) / count).unwrap_or(u8::MAX); + out.push(if maxes[x] >= 128 { mean.max(128) } else { mean }); + } + } + out +} + +#[cfg(test)] +mod tests { + use kurbo::Shape; + + use super::*; + + /// The width, height and alpha of `img`, as [`opaque_outline`] takes them. + fn alpha(img: &Raster) -> (u32, u32, Vec) { + (img.width, img.height, img.rgba.as_chunks::<4>().0.iter().map(|p| p[3]).collect()) + } + + fn outline(img: &Raster) -> Result, TraceError> { + let (w, h, a) = alpha(img); + opaque_outline(w, h, a) + } + + /// A `w × h` raster, opaque red inside the disc of radius `r` around the middle and + /// transparent outside it. + fn disc(w: u32, h: u32, r: f64) -> Raster { + let (cx, cy) = (f64::from(w) / 2.0, f64::from(h) / 2.0); + Raster::from_fn(w, h, |x, y| { + let (dx, dy) = (f64::from(x) + 0.5 - cx, f64::from(y) + 0.5 - cy); + if dx.hypot(dy) <= r { [255, 0, 0, 255] } else { [0, 0, 0, 0] } + }) + } + + fn area(p: &PathData) -> f64 { + p.to_bezpath().area().abs() + } + + #[test] + fn the_outline_follows_the_opaque_disc_not_the_frame() { + let outline = outline(&disc(100, 100, 30.0)).unwrap().unwrap(); + let b = outline.bounds().unwrap(); + for (got, want) in [(b.x0, 20.0), (b.y0, 20.0), (b.x1, 80.0), (b.y1, 80.0)] { + assert!((got - want).abs() < 1.0, "{b:?}"); + } + let disc_area = std::f64::consts::PI * 30.0 * 30.0; + assert!((area(&outline) - disc_area).abs() < disc_area * 0.02, "{}", area(&outline)); + // Fitted with curves, not one line per pixel step. + assert!(outline.anchor_count() < 40, "{} anchors", outline.anchor_count()); + } + + #[test] + fn an_opaque_image_keeps_its_frame_and_a_transparent_one_has_no_outline() { + let opaque = Raster::from_fn(10, 10, |_, _| [0, 0, 255, 128]); + assert_eq!(outline(&opaque).unwrap(), None); + let clear = Raster::from_fn(10, 10, |_, _| [0, 0, 255, 127]); + assert!(outline(&clear).unwrap().unwrap().is_empty()); + // One pixel either way. + assert_eq!(outline(&Raster::from_fn(1, 1, |_, _| [9, 9, 9, 255])).unwrap(), None); + assert!(outline(&Raster::from_fn(1, 1, |_, _| [9, 9, 9, 0])).unwrap().unwrap().is_empty()); + // No pixels at all, or an alpha that doesn't match the size. + assert!(opaque_outline(0, 0, vec![]).is_err()); + assert!(opaque_outline(2, 2, vec![0]).is_err()); + } + + #[test] + fn pixels_less_than_half_opaque_are_outside() { + // A 20 px square, opaque, inside a 6 px band of alpha `band`, on a transparent 40 px image. + let framed = |band: u8| { + Raster::from_fn(40, 40, |x, y| { + let d = x.min(y).min(39 - x).min(39 - y); + let alpha = match d { + 10.. => 255, + 4.. => band, + _ => 0, + }; + [0, 0, 0, alpha] + }) + }; + let inner = outline(&framed(127)).unwrap().unwrap().bounds().unwrap(); + assert!((inner.x0 - 10.0).abs() < 0.5 && (inner.x1 - 30.0).abs() < 0.5, "{inner:?}"); + // A half-opaque band is inside; the edge runs through the middle of its outer pixels, + // which are half covered. + let outer = outline(&framed(128)).unwrap().unwrap().bounds().unwrap(); + assert!((outer.x0 - 4.5).abs() < 0.25 && (outer.x1 - 35.5).abs() < 0.25, "{outer:?}"); + let outer = outline(&framed(255)).unwrap().unwrap().bounds().unwrap(); + assert!((outer.x0 - 4.0).abs() < 0.25 && (outer.x1 - 36.0).abs() < 0.25, "{outer:?}"); + } + + #[test] + fn a_hole_in_the_opaque_area_is_a_hole_in_the_outline() { + let ring = Raster::from_fn(60, 60, |x, y| { + let d = (f64::from(x) + 0.5 - 30.0).hypot(f64::from(y) + 0.5 - 30.0); + [0, 0, 0, if (10.0..=25.0).contains(&d) { 255 } else { 0 }] + }); + let outline = outline(&ring).unwrap().unwrap(); + let want = std::f64::consts::PI * (25.0 * 25.0 - 10.0 * 10.0); + assert!((outline.to_bezpath().area().abs() - want).abs() < want * 0.03, "{}", outline.to_bezpath().area()); + assert!(!outline.to_bezpath().contains(kurbo::Point::new(30.0, 30.0))); + assert!(outline.to_bezpath().contains(kurbo::Point::new(30.0, 12.0))); + } + + #[test] + fn the_outline_fills_the_same_with_either_fill_rule() { + // A ring with an opaque island in its hole. + let target = Raster::from_fn(80, 80, |x, y| { + let d = (f64::from(x) + 0.5 - 40.0).hypot(f64::from(y) + 0.5 - 40.0); + [0, 0, 0, if (25.0..=35.0).contains(&d) || d <= 10.0 { 255 } else { 0 }] + }); + let path = outline(&target).unwrap().unwrap().to_bezpath(); + let winding = |x: f64, y: f64| path.winding(kurbo::Point::new(x, y)); + let (ring, hole, island, outside) = (winding(40.0, 10.0), winding(40.0, 20.0), winding(40.0, 40.0), winding(2.0, 2.0)); + // Non-zero and even-odd agree when every winding number is -1, 0 or 1. + assert!(ring.abs() == 1 && island == ring && hole == 0 && outside == 0, "{ring} {hole} {island} {outside}"); + } + + #[test] + fn a_large_image_is_traced_smaller_and_scaled_back() { + let big = disc(2400, 2000, 800.0); + assert!(u64::from(big.width) * u64::from(big.height) > OUTLINE_MAX_PIXELS); + let b = outline(&big).unwrap().unwrap().bounds().unwrap(); + for (got, want) in [(b.x0, 400.0), (b.y0, 200.0), (b.x1, 2000.0), (b.y1, 1800.0)] { + assert!((got - want).abs() < 3.0, "{b:?}"); + } + } + + #[test] + fn a_line_thinner_than_the_reduction_stays_in_a_large_images_outline() { + // 39 megapixels, reduced to 4: each traced pixel covers about 3 × 3. The only opaque + // pixels are a line 1 pixel wide, which averages to about a third opaque. + let (w, h) = (8192u32, 4800u32); + let mut a = vec![0u8; (w * h) as usize]; + for y in 1000..3000 { + a[(y * w + 2001) as usize] = 255; + } + let b = opaque_outline(w, h, a).unwrap().unwrap().bounds().unwrap(); + assert!(b.x0 > 1990.0 && b.x1 < 2012.0 && (b.y0 - 1000.0).abs() < 6.0 && (b.y1 - 3000.0).abs() < 6.0, "{b:?}"); + } + + #[test] + fn an_outline_of_too_many_anchors_is_refused() { + // A checkerboard of 2 px squares: more than 50,000 opaque squares. + let checker = Raster::from_fn(640, 640, |x, y| [0, 0, 0, if (x / 2 + y / 2) % 2 == 0 { 255 } else { 0 }]); + assert!(matches!(outline(&checker), Err(TraceError::TooComplex { max: OUTLINE_MAX_ANCHORS }))); + } + + /// `img` encoded as `format`. + fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec { + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap(); + out + } + + #[test] + fn decode_alpha_reads_the_alpha_of_formats_that_have_one() { + let px = |x: u32, y: u32| image::Rgba([10, 20, 30, u8::try_from((x + 4 * y) * 16).unwrap()]); + let rgba = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(4, 4, px)); + let want: Vec = (0..16).map(|i| u8::try_from(i * 16).unwrap()).collect(); + for (name, img) in [ + ("rgba", rgba.clone()), + ("gray and alpha", image::DynamicImage::ImageLumaA8(rgba.to_luma_alpha8())), + ("16 bits", image::DynamicImage::ImageRgba16(rgba.to_rgba16())), + ] { + let got = decode_alpha(&encoded(img, image::ImageFormat::Png)).unwrap(); + assert_eq!(got, Some((4, 4, want.clone())), "{name}"); + } + let rgb = image::DynamicImage::ImageRgb8(rgba.to_rgb8()); + assert_eq!(decode_alpha(&encoded(rgb.clone(), image::ImageFormat::Png)).unwrap(), None); + assert_eq!(decode_alpha(&encoded(rgb, image::ImageFormat::Jpeg)).unwrap(), None); + assert!(decode_alpha(b"not an image").is_err()); + } +} diff --git a/crates/trace/src/raster.rs b/crates/trace/src/raster.rs index 3e61c58953..5e198e6fb7 100644 --- a/crates/trace/src/raster.rs +++ b/crates/trace/src/raster.rs @@ -59,7 +59,7 @@ pub struct Raster { /// The pixels the first frame of GIF `bytes` declares in its image descriptor, which the decoder /// allocates for before it crops the frame to the logical screen the dimension check reads. `None` /// when the bytes aren't a GIF or hold no frame header. -fn gif_first_frame_pixels(bytes: &[u8]) -> Option { +pub(crate) fn gif_first_frame_pixels(bytes: &[u8]) -> Option { if !bytes.starts_with(b"GIF8") { return None; } diff --git a/crates/ui-egui/src/background.rs b/crates/ui-egui/src/background.rs index 16b87a6576..806434fa1f 100644 --- a/crates/ui-egui/src/background.rs +++ b/crates/ui-egui/src/background.rs @@ -190,6 +190,19 @@ pub fn run_with_progress( then(app, r) } +/// Run `f` on the UI thread once job `id` has finished, right after the job's own result is +/// applied, with that applied result (an error when the work failed). Nothing runs when no job +/// `id` is in progress. +pub fn after(app: &mut VectorcraftApp, id: u64, f: impl FnOnce(&mut VectorcraftApp, &Result) + 'static) { + let Some(job) = app.background.jobs.iter_mut().find(|j| j.id == id) else { return }; + let then = std::mem::replace(&mut job.then, Box::new(|_, r| r)); + job.then = Box::new(move |app, r| { + let r = then(app, r); + f(app, &r); + r + }); +} + /// Run `f` with every save and export it starts written at once, as with the preferences off, so /// its result is the written file or the error (a control client's `wait: true`). The jobs already /// running finish first, so none of them writes over its files afterwards. diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs index 40a8db9cff..2ff2097975 100644 --- a/crates/ui-egui/src/dialogs/package.rs +++ b/crates/ui-egui/src/dialogs/package.rs @@ -1,14 +1,14 @@ //! File → Package…: where the package folder goes, its name, and what it collects (the linked //! files, in a folder of their own or not, relinked or not; the fonts; the report). A document -//! never saved is saved first (Save As); unsaved changes are saved before packaging. The web -//! downloads the package as a zip. +//! never saved is saved first (Save As); unsaved changes are saved before packaging, and when that +//! save fails no package is written. The web downloads the package as a zip. //! //! Fields: `folder` (the location), `name` (the package folder), `copyLinks`, `linksFolder`, //! `relink`, `copyFonts`, `report`. OK runs `file.package` with them, then offers to show the //! package folder (`file.showPackage`). On the desktop with Background Export on, the package is //! collected and written on the background thread ([`crate::background`]) after the dialog closes. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use serde_json::{Value, json}; @@ -108,21 +108,35 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } else if d.str("folder").trim().is_empty() { return Err("choose a location for the package".into()); } - // The package holds the document as saved. + // The package holds the document as saved: when the save fails, or asks a question and writes + // nothing yet, no package is written. + let mut saving = None; if app.session.active().is_some_and(|st| st.is_dirty()) { let r = io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?; // The save asks first (it would replace a file that loses what opening it left out). if r.get("pending").is_some() { return Ok(r); } + // Background Save: the save is written on the worker, and its result decides the package. + if r["background"] == true { + saving = app.background.jobs.last().map(background::Job::id); + } } // Background Export (Preferences › File Handling): the linked files and fonts are collected // and the package is written from the document as it is now, while editing goes on. The jobs - // started before it (a save started above, an earlier package to the same folder) are written - // first: the worker runs its jobs in order, and a package made at once waits for them. + // started before it (an earlier package to the same folder) are written first: the worker + // runs its jobs in order, and a package made at once waits for them. let enabled = app.session.prefs.background_export; if !enabled { + let outcome: Rc>>> = Rc::default(); + if let Some(id) = saving { + let applied = outcome.clone(); + background::after(app, id, move |_, r| *applied.borrow_mut() = Some(r.clone())); + } background::wait_all(app); + if let Some(r) = outcome.take().filter(|r| !saved(r)) { + return r; + } } let package = app.session.prepare_package(¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; let label = format!("Packaging {}", d.str("name")); @@ -134,7 +148,23 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { |path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(if e.contains(path) { e } else { format!("{path}: {e}") })); package.write(&mut write).map_err(|e| e.to_string()) }; - let r = background::run(app, enabled, label, None, work, move |app, r| packaged(app, r, web, in_background.get()))?; + let start = + move |app: &mut VectorcraftApp| background::run(app, enabled, label, None, work, move |app, r| packaged(app, r, web, in_background.get())); + // Both in the background: the package, collected from the document as it is now, starts once + // the save is written. + if let Some(id) = saving.filter(|_| enabled) { + later.set(true); + app.ui.dialog = None; + background::after(app, id, move |app, r| { + if saved(r) + && let Err(e) = start(app) + { + app.status(e); + } + }); + return Ok(json!({ "background": true })); + } + let r = start(app)?; if r["background"] == true { later.set(true); app.ui.dialog = None; @@ -142,6 +172,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { Ok(r) } +/// Did a save whose applied result is `r` write the document (no error, and no question asked +/// before writing)? +fn saved(r: &Result) -> bool { + r.as_ref().is_ok_and(|r| r.get("pending").is_none()) +} + /// Report the package `r` (`file.package`'s reply): the status bar, the web's download, and the /// offer to show the package folder. `later`: it was written in the background, after the dialog /// closed, and a dialog open now is another one, which stays (the status bar names the folder). diff --git a/crates/ui-egui/src/dialogs/tests_package.rs b/crates/ui-egui/src/dialogs/tests_package.rs index 41253029a2..5c9e899354 100644 --- a/crates/ui-egui/src/dialogs/tests_package.rs +++ b/crates/ui-egui/src/dialogs/tests_package.rs @@ -181,7 +181,7 @@ fn a_dirty_documents_save_is_written_before_its_package() { let (mut app, files) = saved_with_worker(&dir, &gate); let (doc, out) = (dir.path("card.vectorcraft"), dir.path("out")); // With Background Export on, the save and the package both run in the background, and the - // worker writes the save first. + // package starts once the save is written. app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); package_dialog(&mut app, &out); // A thread opens the gate after 10 s at the latest, so a confirm that waits for the save ends. @@ -191,7 +191,7 @@ fn a_dirty_documents_save_is_written_before_its_package() { opener.open(); }); assert_eq!(confirm(&mut app).unwrap()["background"], true); - assert_eq!(app.background.jobs.len(), 2, "the save and the package"); + assert_eq!(app.background.jobs.len(), 1, "the save; the package waits for it"); gate.open(); background::wait_all(&mut app); let paths = written(&files); @@ -216,6 +216,38 @@ fn a_dirty_documents_save_is_written_before_its_package() { assert!(app.ui.status.starts_with("Packaged 3 files in "), "the package's status came last: {}", app.ui.status); } +/// A save before packaging that fails writes no package, with Background Export on or off. +#[test] +fn a_failed_save_before_packaging_writes_no_package() { + let dir = Folder::new("failed-save"); + let (doc, out) = (dir.path("card.vectorcraft"), dir.path("out")); + let root = std::path::Path::new(&out).join("card Folder"); + for background_export in [true, false] { + let mut app = saved(&dir); + let (files, refused) = (Files::default(), doc.clone()); + let recorded = files.clone(); + app.services.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| { + if p == refused { + return Err("Access is denied. (os error 5)".into()); + } + recorded.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + app.session.execute("prefs.set", &json!({"values": {"backgroundExport": background_export}})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + package_dialog(&mut app, &out); + let r = confirm(&mut app); + background::wait_all(&mut app); + assert!(written(&files).is_empty(), "Background Export {background_export}: {:?}", written(&files)); + assert!(!root.exists(), "Background Export {background_export}: no package folder"); + assert!(app.session.active().unwrap().is_dirty(), "the save failed"); + match r { + Ok(r) => assert!(background_export && r["background"] == true, "{r}"), + Err(e) => assert!(!background_export && e.contains("Access is denied"), "{e}"), + } + } +} + #[test] fn a_package_made_at_once_waits_for_one_in_the_background() { let dir = Folder::new("twice"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e6f3cde533..44b28d812e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -659,7 +659,8 @@ 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": [...]}`), stay as they are. A part that starts with `{` or `[` is translated only +/// `{"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)`. pub fn message(lang: Lang, s: &str) -> String { message_at(lang, s, MESSAGE_DEPTH) @@ -670,8 +671,9 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> 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() }; + let quoted = |v: &str| [('"', '"'), ('“', '”'), ('`', '`')].iter().any(|(a, b)| s.contains(&format!("{a}{v}{b}"))); let nested = |v: &str| { - if depth <= 1 { + if depth <= 1 || quoted(v) { v.to_string() } else if v.starts_with(['{', '[']) { lang.catalog().exact_message(v).unwrap_or(v).to_string() diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 562c881809..f75c0ef8fb 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2204,6 +2204,8 @@ font-technology Variable Променливи Normal Screen Mode Нормален режим на екран Note: Забележка: Nothing matches the Show filter. Нищо не отговаря на филтъра за показване. + Show DCS Transparency Interactions Показване на взаимодействията на DCS с прозрачност + Transparent, or overlaps transparent objects: check its separations Прозрачно е или се припокрива с прозрачни обекти: проверете цветоотделянето му Nothing to print Няма нищо за отпечатване Nudge Преместване Nudge in Perspective Преместване в перспектива @@ -5987,6 +5989,13 @@ axis Y Axis Y ос @msg Batch cannot play an action with a stop Пакет не може да изпълни действие със спиране Insert Menu Item… Вмъкване на елемент от меню… Insert Menu Item Вмъкване на елемент от меню + Insert Select Path Вмъкване на избор на път + Select Path Избор на път +@msg select one whole path or compound path изберете един цял път или съставен път +@msg select only one path изберете само един път +@msg the path is hidden or locked, or cannot be selected here пътят е скрит или заключен, или не може да бъде избран тук +@msg object {value} is not the stored path: it changed, or the document is another one обект {value} не е запазеният път: той е променен или документът е друг +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` трябва да е "v1:" и 16 шестнадесетични цифри с малки букви Select Object… Избиране на обект… Select Object Избиране на обект Set Selection Задаване на селекция diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index c8a06e4d91..60f2ca7b7d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1929,6 +1929,8 @@ digits Malayalam Malajalam Modified since it was read: {path} Izmijenjeno nakon čitanja: {path} No Thumbnails Bez sličica Nothing matches the Show filter. Ništa ne odgovara filteru Prikaži. + Show DCS Transparency Interactions Prikaži interakcije DCS-a s prozirnošću + Transparent, or overlaps transparent objects: check its separations Prozirno je ili preklapa prozirne objekte: provjerite njegove separacije PPI PPI Relink… Ponovo poveži… Find Linked Files Pronađi povezane datoteke @@ -6029,6 +6031,13 @@ axis Y Axis Y osa @msg Batch cannot play an action with a stop Paketna obrada ne može reprodukovati akciju sa zaustavljanjem Insert Menu Item… Umetni stavku menija… Insert Menu Item Umetni stavku menija + Insert Select Path Umetni odabir putanje + Select Path Odabir putanje +@msg select one whole path or compound path odaberite jednu cijelu putanju ili složenu putanju +@msg select only one path odaberite samo jednu putanju +@msg the path is hidden or locked, or cannot be selected here putanja je skrivena ili zaključana, ili se ovdje ne može odabrati +@msg object {value} is not the stored path: it changed, or the document is another one objekt {value} nije sačuvana putanja: promijenjena je ili je dokument drugi +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` mora biti "v1:" i 16 malih heksadecimalnih cifara Select Object… Odaberi objekt… Select Object Odaberi objekt Set Selection Postavi odabir diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0dda77c19d..e3ca9b906d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2812,6 +2812,8 @@ digits Malayalam Malaiàlam Note: Nota: Nothing is selected. Select the art to export, then open this tab again. No hi ha res seleccionat. Selecciona la il·lustració que vols exportar i torna a obrir aquesta pestanya. Nothing matches the Show filter. Res no coincideix amb el filtre Mostra. + Show DCS Transparency Interactions Mostra les interaccions de transparència DCS + Transparent, or overlaps transparent objects: check its separations És transparent o se superposa a objectes transparents: comproveu-ne les separacions Nothing to print No hi ha res per imprimir NTSC Colors Colors NTSC Nudge Desplaça @@ -4041,6 +4043,13 @@ digits Malayalam Malaiàlam @msg Batch cannot play an action with a stop Lot no pot reproduir una acció amb una aturada Insert Menu Item… Insereix un element de menú… Insert Menu Item Insereix un element de menú + Insert Select Path Insereix una selecció de traçat + Select Path Selecciona el traçat +@msg select one whole path or compound path selecciona un traçat sencer o un traçat compost +@msg select only one path selecciona només un traçat +@msg the path is hidden or locked, or cannot be selected here el traçat està amagat o bloquejat, o no es pot seleccionar aquí +@msg object {value} is not the stored path: it changed, or the document is another one l’objecte {value} no és el traçat desat: ha canviat o el document és un altre +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` ha de ser "v1:" i 16 dígits hexadecimals en minúscula Select Object… Selecciona un objecte… Select Object Selecciona un objecte Set Selection Defineix la selecció @@ -6026,6 +6035,65 @@ digits Malayalam Malaiàlam @msg Targa files are RGB: colorModel rgb Els fitxers Targa són RGB: colorModel rgb @msg Text Wrap: select a wrap object Ajustament de text: selecciona un objecte d’ajustament @msg that document is no longer open aquest document ja no està obert +@msg Extrude & Bevel Extrudeix i bisella +@msg Revolve Revoluciona +@msg Inflate Infla +@msg Rotate Gira +@msg Extrude & Bevel (Classic) Extrudeix i bisella (clàssic) +@msg Revolve (Classic) Revoluciona (clàssic) +@msg Rotate (Classic) Gira (clàssic) +@msg This effect Aquest efecte +@msg clipping paths traçats de retall +@msg other objects altres objectes +@msg {label} requires a group holding a shape with at least two anchors {label} requereix un grup que contingui una forma amb com a mínim dos punts d’ancoratge +@msg Revolve requires a group holding a shape with at least two anchors Revoluciona requereix un grup que contingui una forma amb com a mínim dos punts d’ancoratge +@msg Revolve requires a shape with at least two anchors Revoluciona requereix una forma amb com a mínim dos punts d’ancoratge +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} en un grup necessita traçats, traçats compostos, text, grups, grups de retall, fusions i instàncies de símbol: primer allibera o expandeix aquests elements: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} en un grup admet com a màxim {MAX_MEMBERS} objectes +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Un efecte 3D en un grup o una capa s’aplica al mateix grup, no al seu farciment ni al seu traç +@msg a 3D effect on a group's fill or stroke draws nothing un efecte 3D al farciment o al traç d’un grup no dibuixa res +@msg a 3D effect on a group's fill or stroke draws nothing to export un efecte 3D al farciment o al traç d’un grup no dibuixa res per exportar +@msg The 3D object is too complex to export L’objecte 3D és massa complex per exportar-lo +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) l’objecte no té cap efecte 3D (Extrudeix i bisella, Revoluciona, Infla o Gira) +@msg its 3D effect builds no solid el seu efecte 3D no construeix cap sòlid +@msg bevel must be none or one of {_1} bevel ha de ser none o un de {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} no pot construir sòlids a partir d’aquesta il·lustració: les seves instàncies de símbol, fusions i grups de retall contenen massa objectes +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} no pot construir un sòlid a partir de la instància del símbol «{symbol}»: la il·lustració del símbol no està disponible +@msg {_1}: the clipping path is too complex to cut the art with {_1}: el traçat de retall és massa complex per retallar-hi la il·lustració +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} admet com a màxim {MAX_MEMBERS} objectes +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} no pot construir sòlids a partir d’una il·lustració imbricada en més de {MAX_DEPTH} nivells +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient La pintura de Revoluciona és massa complexa per a aquesta superfície; redueix Segments o simplifica el degradat +@msg Revolve edge must be left or right Revoluciona: edge ha de ser left o right +@msg Revolve cap must be a boolean Revoluciona: cap ha de ser un booleà +@msg the revolved object has no fill or stroke l’objecte de revolució no té farciment ni traç +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor ha de ser un color RGB opac en format #rrggbb +@msg lights must contain one to ten lights lights ha de contenir d’un a deu llums +@msg each light must be an object cada llum ha de ser un objecte +@msg unknown light setting: use color, intensity, rotation, height, softness or behind paràmetre de llum desconegut: fes servir color, intensity, rotation, height, softness o behind +@msg light {key} must be a number from {min} to {max} llum: {key} ha de ser un nombre de {min} a {max} +@msg light color must use opaque #rrggbb color d’un llum ha de fer servir #rrggbb opac +@msg light behind must be true or false behind d’un llum ha de ser true o false +@msg shadow must be true or false shadow ha de ser true o false +@msg shadowPosition must be behind or below shadowPosition ha de ser behind o below +@msg {key} must be a number from {min} to {max} {key} ha de ser un nombre de {min} a {max} +@msg each of `{GRAPHICS}` must be an object cada element de `{GRAPHICS}` ha de ser un objecte +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` les entrades de `{GRAPHICS}` admeten symbol, face, x, y, scale i rotation, no `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol cada element de `{GRAPHICS}` necessita `symbol`, el nom d’un símbol +@msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` ha de ser front o back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` ha de ser un nombre de {lo} a {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` ha de ser una matriu +@msg Graphics map onto planar faces: use the Extrude or Plane object type Els gràfics s’apliquen a cares planes: fes servir el tipus d’objecte Extrusió o Pla +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` admet com a màxim {MAX_GRAPHICS} gràfics +@msg A plane has one face: map its graphics onto the front Un pla té una sola cara: aplica’n els gràfics a la cara davantera +@msg The graphics are too complex: simplify the symbols' art Els gràfics són massa complexos: simplifica la il·lustració dels símbols +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art La il·lustració d’un gràfic es pot imbricar com a màxim en {MAX_LEVELS} nivells: simplifica la il·lustració del símbol +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art La il·lustració d’un gràfic pot imbricar símbols com a màxim en {MAX_NESTING} nivells: simplifica la il·lustració del símbol +@msg A graphic's art holds too many objects: simplify the symbol's art La il·lustració d’un gràfic conté massa objectes: simplifica la il·lustració del símbol +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Un gràfic dibuixa com a màxim {MAX_PIECES} farciments i traços: simplifica la il·lustració del símbol +@msg `surface` must be one of {_1} `surface` ha de ser un de {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} dibuixa la il·lustració d’origen d’aquest objecte en lloc de la seva superfície 3D +@msg {label} draws no faces for this object {label} no dibuixa cap cara per a aquest objecte +@msg the wireframe has more than {MAX_WIRE_EDGES} edges el filferro té més de {MAX_WIRE_EDGES} arestes @msg The 3D surface is too complex; simplify the shape La superfície 3D és massa complexa; simplifica la forma @msg the action has no steps l’acció no té cap pas @msg the action has no included steps l’acció no té cap pas inclòs diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 40e12bf6f4..525c876cf5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1960,6 +1960,8 @@ digits Malayalam Malajálamské Modified since it was read: {path} Upraveno od načtení: {path} No Thumbnails Bez miniatur Nothing matches the Show filter. Filtru Zobrazit nic neodpovídá. + Show DCS Transparency Interactions Zobrazit interakce DCS s průhledností + Transparent, or overlaps transparent objects: check its separations Je průhledný nebo překrývá průhledné objekty: zkontrolujte jeho separace PPI PPI Relink… Znovu propojit… Find Linked Files Najít propojené soubory @@ -5876,6 +5878,65 @@ mark-style Japanese Japonské @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} nedokáže vytvořit těleso z obrazu: nejprve jej vektorizujte příkazem Objekt › Vektorizace obrazu › Vytvořit. @msg {label} requires a shape with at least two anchors {label} vyžaduje tvar alespoň se dvěma kotevními body @msg The shape is too complex for a 3D effect Tvar je pro 3D efekt příliš složitý +@msg Extrude & Bevel Vysunutí a zkosení +@msg Revolve Rotační těleso +@msg Inflate Nafouknutí +@msg Rotate Otočit +@msg Extrude & Bevel (Classic) Vysunutí a zkosení (klasické) +@msg Revolve (Classic) Rotační těleso (klasické) +@msg Rotate (Classic) Otočit (klasické) +@msg This effect Tento efekt +@msg clipping paths ořezové cesty +@msg other objects jiné objekty +@msg {label} requires a group holding a shape with at least two anchors {label} vyžaduje skupinu obsahující tvar alespoň se dvěma kotevními body +@msg Revolve requires a group holding a shape with at least two anchors Rotační těleso vyžaduje skupinu obsahující tvar alespoň se dvěma kotevními body +@msg Revolve requires a shape with at least two anchors Rotační těleso vyžaduje tvar alespoň se dvěma kotevními body +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} na skupině potřebuje cesty, složené cesty, text, skupiny, ořezové skupiny, prolnutí a instance symbolů: nejprve uvolněte nebo rozdělte tyto položky: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} na skupině přijímá nejvýše {MAX_MEMBERS} objektů +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 3D efekt na skupině nebo vrstvě patří na samotnou skupinu, ne na její výplň nebo tah +@msg a 3D effect on a group's fill or stroke draws nothing 3D efekt na výplni nebo tahu skupiny nic nekreslí +@msg a 3D effect on a group's fill or stroke draws nothing to export 3D efekt na výplni nebo tahu skupiny nekreslí nic k exportu +@msg The 3D object is too complex to export 3D objekt je pro export příliš složitý +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) objekt nemá žádný 3D efekt (Vysunutí a zkosení, Rotační těleso, Nafouknutí ani Otočit) +@msg its 3D effect builds no solid jeho 3D efekt nevytváří žádné těleso +@msg bevel must be none or one of {_1} bevel musí být none nebo jedno z {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} nemůže z této kresby vytvořit tělesa: její instance symbolů, prolnutí a ořezové skupiny obsahují příliš mnoho objektů +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} nemůže vytvořit těleso z instance symbolu „{symbol}“: kresba symbolu není k dispozici +@msg {_1}: the clipping path is too complex to cut the art with {_1}: ořezová cesta je příliš složitá na oříznutí kresby +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} přijímá nejvýše {MAX_MEMBERS} objektů +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} nemůže vytvořit tělesa z kresby vnořené hlouběji než do {MAX_DEPTH} úrovní +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Barva rotačního tělesa je pro tento povrch příliš složitá; snižte Segmenty nebo zjednodušte přechod +@msg Revolve edge must be left or right Rotační těleso: edge musí být left nebo right +@msg Revolve cap must be a boolean Rotační těleso: cap musí být logická hodnota +@msg the revolved object has no fill or stroke rotovaný objekt nemá výplň ani tah +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor musí být neprůhledná barva RGB ve tvaru #rrggbb +@msg lights must contain one to ten lights lights musí obsahovat jedno až deset světel +@msg each light must be an object každé světlo musí být objekt +@msg unknown light setting: use color, intensity, rotation, height, softness or behind neznámé nastavení světla: použijte color, intensity, rotation, height, softness nebo behind +@msg light {key} must be a number from {min} to {max} světlo: {key} musí být číslo od {min} do {max} +@msg light color must use opaque #rrggbb color světla musí být neprůhledná barva #rrggbb +@msg light behind must be true or false behind světla musí být true nebo false +@msg shadow must be true or false shadow musí být true nebo false +@msg shadowPosition must be behind or below shadowPosition musí být behind nebo below +@msg {key} must be a number from {min} to {max} {key} musí být číslo od {min} do {max} +@msg each of `{GRAPHICS}` must be an object každá položka `{GRAPHICS}` musí být objekt +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` položky `{GRAPHICS}` přijímají symbol, face, x, y, scale a rotation, ne `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol každá položka `{GRAPHICS}` potřebuje `symbol`, název symbolu +@msg `{GRAPHICS}` face must be front or back face v `{GRAPHICS}` musí být front nebo back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} v `{GRAPHICS}` musí být číslo od {lo} do {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` musí být pole +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafika se mapuje na rovinné stěny: použijte typ objektu Vysunutí nebo Rovina +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` přijímá nejvýše {MAX_GRAPHICS} grafik +@msg A plane has one face: map its graphics onto the front Rovina má jednu stranu: umístěte její grafiku na přední stranu +@msg The graphics are too complex: simplify the symbols' art Grafika je příliš složitá: zjednodušte kresbu symbolů +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Kresba grafiky smí být vnořena nejvýše do {MAX_LEVELS} úrovní: zjednodušte kresbu symbolu +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Kresba grafiky smí vnořovat symboly nejvýše do {MAX_NESTING} úrovní: zjednodušte kresbu symbolu +@msg A graphic's art holds too many objects: simplify the symbol's art Kresba grafiky obsahuje příliš mnoho objektů: zjednodušte kresbu symbolu +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Grafika kreslí nejvýše {MAX_PIECES} výplní a tahů: zjednodušte kresbu symbolu +@msg `surface` must be one of {_1} `surface` musí být jedno z {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} pro tento objekt kreslí zdrojovou kresbu místo jeho 3D povrchu +@msg {label} draws no faces for this object {label} pro tento objekt nekreslí žádné stěny +@msg the wireframe has more than {MAX_WIRE_EDGES} edges drátový model má více než {MAX_WIRE_EDGES} hran @msg The 3D surface is too complex; simplify the shape 3D povrch je příliš složitý; zjednodušte tvar Transform Objects Transformovat objekty @msg unknown parameter `{key}`; did you mean `{near}`? neznámý parametr `{key}`; mysleli jste `{near}`? @@ -6461,6 +6522,13 @@ axis Y Axis osa Y @msg Batch cannot play an action with a stop Dávka nemůže přehrát akci se zastavením Insert Menu Item… Vložit položku nabídky… Insert Menu Item Vložit položku nabídky + Insert Select Path Vložit výběr cesty + Select Path Vybrat cestu +@msg select one whole path or compound path vyberte jednu celou cestu nebo složenou cestu +@msg select only one path vyberte jen jednu cestu +@msg the path is hidden or locked, or cannot be selected here cesta je skrytá nebo zamčená, nebo ji zde nelze vybrat +@msg object {value} is not the stored path: it changed, or the document is another one objekt {value} není uložená cesta: změnila se, nebo jde o jiný dokument +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` musí být "v1:" a 16 šestnáctkových číslic malými písmeny Select Object… Vybrat objekt… Select Object Vybrat objekt Set Selection Nastavit výběr diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 6b99525b1c..33b89053a9 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1775,6 +1775,8 @@ digits Malayalam Malayalam Modified since it was read: {path} Ændret siden den blev læst: {path} No Thumbnails Ingen miniaturer Nothing matches the Show filter. Intet matcher Vis-filteret. + Show DCS Transparency Interactions Vis DCS-gennemsigtighedsinteraktioner + Transparent, or overlaps transparent objects: check its separations Gennemsigtig eller overlapper gennemsigtige objekter: kontrollér dens separationer PPI PPI Relink… Sammenkæd igen… Find Linked Files Find sammenkædede filer @@ -6004,6 +6006,13 @@ axis Y Axis Y-akse @msg Batch cannot play an action with a stop Batch kan ikke afspille en handling med et stop Insert Menu Item… Indsæt menupunkt… Insert Menu Item Indsæt menupunkt + Insert Select Path Indsæt kurvemarkering + Select Path Markér kurve +@msg select one whole path or compound path markér én hel kurve eller sammensat kurve +@msg select only one path markér kun én kurve +@msg the path is hidden or locked, or cannot be selected here kurven er skjult eller låst, eller kan ikke markeres her +@msg object {value} is not the stored path: it changed, or the document is another one objekt {value} er ikke den gemte kurve: den er ændret, eller dokumentet er et andet +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` skal være "v1:" og 16 hexadecimale cifre med små bogstaver Select Object… Markér objekt… Select Object Markér objekt Set Selection Angiv markering diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0e732c8be0..655300a7f7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2238,6 +2238,8 @@ file-filter Templates Vorlagen Normal Screen Mode Normaler Bildschirmmodus Note: Notiz: Nothing matches the Show filter. Nichts entspricht dem Filter „Anzeigen“. + Show DCS Transparency Interactions DCS-Transparenzinteraktionen anzeigen + Transparent, or overlaps transparent objects: check its separations Transparent oder überlappt transparente Objekte: Separationen prüfen Nothing to print Nichts zu drucken Nudge Schrittweise verschieben Nudge in Perspective In Perspektive schrittweise verschieben @@ -5451,6 +5453,65 @@ lighting Light Licht @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kann aus einem Bild keinen Körper erstellen: Zeichnen Sie es zuerst mit Objekt › Bildnachzeichner › Erstellen nach. @msg {label} requires a shape with at least two anchors {label} erfordert eine Form mit mindestens zwei Ankerpunkten @msg The shape is too complex for a 3D effect Die Form ist für einen 3D-Effekt zu komplex +@msg Extrude & Bevel Extrudieren und abgeflachte Kante +@msg Revolve Kreiseln +@msg Inflate Aufblasen +@msg Rotate Drehen +@msg Extrude & Bevel (Classic) Extrudieren und abgeflachte Kante (klassisch) +@msg Revolve (Classic) Kreiseln (klassisch) +@msg Rotate (Classic) Drehen (klassisch) +@msg This effect Dieser Effekt +@msg clipping paths Schnittpfade +@msg other objects andere Objekte +@msg {label} requires a group holding a shape with at least two anchors {label} erfordert eine Gruppe mit einer Form mit mindestens zwei Ankerpunkten +@msg Revolve requires a group holding a shape with at least two anchors Kreiseln erfordert eine Gruppe mit einer Form mit mindestens zwei Ankerpunkten +@msg Revolve requires a shape with at least two anchors Kreiseln erfordert eine Form mit mindestens zwei Ankerpunkten +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} auf einer Gruppe braucht Pfade, zusammengesetzte Pfade, Text, Gruppen, Schnittgruppen, Angleichungen und Symbolinstanzen: Geben Sie zuerst Folgendes frei oder wandeln Sie es um: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} auf einer Gruppe nimmt höchstens {MAX_MEMBERS} Objekte auf +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Ein 3D-Effekt auf einer Gruppe oder Ebene gehört auf die Gruppe selbst, nicht auf ihre Fläche oder Kontur +@msg a 3D effect on a group's fill or stroke draws nothing ein 3D-Effekt auf der Fläche oder Kontur einer Gruppe zeichnet nichts +@msg a 3D effect on a group's fill or stroke draws nothing to export ein 3D-Effekt auf der Fläche oder Kontur einer Gruppe zeichnet nichts, das exportiert werden kann +@msg The 3D object is too complex to export Das 3D-Objekt ist zu komplex für den Export +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) das Objekt hat keinen 3D-Effekt (Extrudieren und abgeflachte Kante, Kreiseln, Aufblasen oder Drehen) +@msg its 3D effect builds no solid sein 3D-Effekt erzeugt keinen Körper +@msg bevel must be none or one of {_1} bevel muss none oder einer der folgenden Werte sein: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} kann aus diesem Bildmaterial keine Körper erstellen: Seine Symbolinstanzen, Angleichungen und Schnittgruppen enthalten zu viele Objekte +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} kann keinen Körper aus der Instanz des Symbols „{symbol}“ erstellen: Das Bildmaterial des Symbols ist nicht verfügbar +@msg {_1}: the clipping path is too complex to cut the art with {_1}: Der Schnittpfad ist zu komplex, um das Bildmaterial damit zu beschneiden +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} nimmt höchstens {MAX_MEMBERS} Objekte auf +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} kann keine Körper aus Bildmaterial erstellen, das mehr als {MAX_DEPTH} Ebenen tief verschachtelt ist +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Die Farbe von Kreiseln ist für diese Oberfläche zu komplex; verringern Sie Segmente oder vereinfachen Sie den Verlauf +@msg Revolve edge must be left or right Kreiseln: edge muss left oder right sein +@msg Revolve cap must be a boolean Kreiseln: cap muss ein boolescher Wert sein +@msg the revolved object has no fill or stroke das gekreiselte Objekt hat keine Fläche oder Kontur +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor muss eine deckende RGB-Farbe der Form #rrggbb sein +@msg lights must contain one to ten lights lights muss ein bis zehn Lichter enthalten +@msg each light must be an object jedes Licht muss ein Objekt sein +@msg unknown light setting: use color, intensity, rotation, height, softness or behind unbekannte Lichteinstellung: Verwenden Sie color, intensity, rotation, height, softness oder behind +@msg light {key} must be a number from {min} to {max} Licht: {key} muss eine Zahl von {min} bis {max} sein +@msg light color must use opaque #rrggbb color eines Lichts muss ein deckendes #rrggbb sein +@msg light behind must be true or false behind eines Lichts muss true oder false sein +@msg shadow must be true or false shadow muss true oder false sein +@msg shadowPosition must be behind or below shadowPosition muss behind oder below sein +@msg {key} must be a number from {min} to {max} {key} muss eine Zahl von {min} bis {max} sein +@msg each of `{GRAPHICS}` must be an object jeder Eintrag von `{GRAPHICS}` muss ein Objekt sein +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}`-Einträge nehmen symbol, face, x, y, scale und rotation an, nicht `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol jeder Eintrag von `{GRAPHICS}` braucht `symbol`, den Namen eines Symbols +@msg `{GRAPHICS}` face must be front or back face in `{GRAPHICS}` muss front oder back sein +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} in `{GRAPHICS}` muss eine Zahl von {lo} bis {hi} sein +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` muss ein Array sein +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiken werden auf ebene Seiten abgebildet: Verwenden Sie den Objekttyp Extrudieren oder Ebene +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` nimmt höchstens {MAX_GRAPHICS} Grafiken auf +@msg A plane has one face: map its graphics onto the front Eine Ebene hat nur eine Seite: Bilden Sie ihre Grafiken auf der Vorderseite ab +@msg The graphics are too complex: simplify the symbols' art Die Grafiken sind zu komplex: Vereinfachen Sie das Bildmaterial der Symbole +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Das Bildmaterial einer Grafik darf höchstens {MAX_LEVELS} Ebenen tief verschachtelt sein: Vereinfachen Sie das Bildmaterial des Symbols +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Das Bildmaterial einer Grafik darf Symbole höchstens {MAX_NESTING} Ebenen tief verschachteln: Vereinfachen Sie das Bildmaterial des Symbols +@msg A graphic's art holds too many objects: simplify the symbol's art Das Bildmaterial einer Grafik enthält zu viele Objekte: Vereinfachen Sie das Bildmaterial des Symbols +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Eine Grafik zeichnet höchstens {MAX_PIECES} Flächen und Konturen: Vereinfachen Sie das Bildmaterial des Symbols +@msg `surface` must be one of {_1} `surface` muss einer der folgenden Werte sein: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} zeichnet für dieses Objekt das Ausgangsbildmaterial statt seiner 3D-Oberfläche +@msg {label} draws no faces for this object {label} zeichnet für dieses Objekt keine Seiten +@msg the wireframe has more than {MAX_WIRE_EDGES} edges das Drahtmodell hat mehr als {MAX_WIRE_EDGES} Kanten @msg The 3D surface is too complex; simplify the shape Die 3D-Oberfläche ist zu komplex; vereinfachen Sie die Form Transform Objects Objekte transformieren @msg unknown parameter `{key}`; did you mean `{near}`? unbekannter Parameter `{key}`; meinten Sie `{near}`? @@ -6052,6 +6113,13 @@ axis Y Axis Y-Achse @msg Batch cannot play an action with a stop Die Stapelverarbeitung kann keine Aktion mit einem Stopp abspielen Insert Menu Item… Menübefehl einfügen… Insert Menu Item Menübefehl einfügen + Insert Select Path Pfadauswahl einfügen + Select Path Pfad auswählen +@msg select one whole path or compound path wählen Sie einen ganzen Pfad oder zusammengesetzten Pfad aus +@msg select only one path wählen Sie nur einen Pfad aus +@msg the path is hidden or locked, or cannot be selected here der Pfad ist ausgeblendet oder gesperrt oder kann hier nicht ausgewählt werden +@msg object {value} is not the stored path: it changed, or the document is another one Objekt {value} ist nicht der gespeicherte Pfad: Er wurde geändert, oder es ist ein anderes Dokument +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` muss aus "v1:" und 16 hexadezimalen Ziffern in Kleinbuchstaben bestehen Select Object… Objekt auswählen… Select Object Objekt auswählen Set Selection Auswahl festlegen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 59d243f120..e8a368cddb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3136,6 +3136,8 @@ theme Appearance Εμφάνιση Normal Screen Mode Κανονική λειτουργία οθόνης Note: Σημείωση: Nothing matches the Show filter. Κανένα στοιχείο δεν αντιστοιχεί στο φίλτρο Εμφάνιση. + Show DCS Transparency Interactions Εμφάνιση αλληλεπιδράσεων διαφάνειας DCS + Transparent, or overlaps transparent objects: check its separations Διαφανές ή επικαλύπτει διαφανή αντικείμενα: ελέγξτε τους διαχωρισμούς του Nothing to print Δεν υπάρχει τίποτα για εκτύπωση Nudge Μετακίνηση κατά βήμα Nudge in Perspective Μετακίνηση κατά βήμα σε προοπτική @@ -5235,6 +5237,65 @@ digits Malayalam Μαλαγιάλαμ @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. Το {label} δεν μπορεί να δημιουργήσει στερεό από εικόνα: ανιχνεύστε την πρώτα με Αντικείμενο › Ανίχνευση περιγραμμάτων εικόνας › Κατασκευαστής. @msg {label} requires a shape with at least two anchors το {label} απαιτεί σχήμα με τουλάχιστον δύο σημεία αγκύρωσης @msg The shape is too complex for a 3D effect Το σχήμα είναι πολύ σύνθετο για εφέ 3D +@msg Extrude & Bevel Εξώθηση και λοξότμηση +@msg Revolve Περιστροφή γύρω από άξονα +@msg Inflate Φούσκωμα +@msg Rotate Περιστροφή +@msg Extrude & Bevel (Classic) Εξώθηση και λοξότμηση (κλασικό) +@msg Revolve (Classic) Περιστροφή γύρω από άξονα (κλασικό) +@msg Rotate (Classic) Περιστροφή (κλασικό) +@msg This effect Αυτό το εφέ +@msg clipping paths διαδρομές αποκοπής +@msg other objects άλλα αντικείμενα +@msg {label} requires a group holding a shape with at least two anchors {label}: απαιτείται ομάδα με σχήμα με τουλάχιστον δύο σημεία αγκύρωσης +@msg Revolve requires a group holding a shape with at least two anchors Περιστροφή γύρω από άξονα: απαιτείται ομάδα με σχήμα με τουλάχιστον δύο σημεία αγκύρωσης +@msg Revolve requires a shape with at least two anchors Περιστροφή γύρω από άξονα: απαιτείται σχήμα με τουλάχιστον δύο σημεία αγκύρωσης +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} σε ομάδα: απαιτούνται διαδρομές, σύνθετες διαδρομές, κείμενο, ομάδες, ομάδες αποκοπής, αναμείξεις και παρουσίες συμβόλων· πρώτα αποδεσμεύστε ή αναπτύξτε τα εξής: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} σε ομάδα: το πολύ {MAX_MEMBERS} αντικείμενα +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Ένα εφέ 3D σε ομάδα ή επίπεδο εφαρμόζεται στην ίδια την ομάδα, όχι στο γέμισμα ή στην πινελιά της +@msg a 3D effect on a group's fill or stroke draws nothing ένα εφέ 3D στο γέμισμα ή στην πινελιά μιας ομάδας δεν σχεδιάζει τίποτα +@msg a 3D effect on a group's fill or stroke draws nothing to export ένα εφέ 3D στο γέμισμα ή στην πινελιά μιας ομάδας δεν σχεδιάζει τίποτα για εξαγωγή +@msg The 3D object is too complex to export Το αντικείμενο 3D είναι πολύ σύνθετο για εξαγωγή +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) το αντικείμενο δεν έχει εφέ 3D (Εξώθηση και λοξότμηση, Περιστροφή γύρω από άξονα, Φούσκωμα ή Περιστροφή) +@msg its 3D effect builds no solid το εφέ 3D του δεν δημιουργεί στερεό +@msg bevel must be none or one of {_1} το bevel πρέπει να είναι none ή ένα από τα {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: δεν μπορούν να δημιουργηθούν στερεά από αυτό το σχέδιο· οι παρουσίες συμβόλων, οι αναμείξεις και οι ομάδες αποκοπής του περιέχουν πάρα πολλά αντικείμενα +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}: δεν μπορεί να δημιουργηθεί στερεό από την παρουσία του συμβόλου «{symbol}»· το σχέδιο του συμβόλου δεν είναι διαθέσιμο +@msg {_1}: the clipping path is too complex to cut the art with {_1}: η διαδρομή αποκοπής είναι πολύ σύνθετη για να κόψει το σχέδιο +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}: το πολύ {MAX_MEMBERS} αντικείμενα +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}: δεν μπορούν να δημιουργηθούν στερεά από σχέδιο ένθετο σε περισσότερα από {MAX_DEPTH} επίπεδα +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Το χρώμα της Περιστροφής γύρω από άξονα είναι πολύ σύνθετο για αυτή την επιφάνεια· μειώστε τα Τμήματα ή απλοποιήστε τη διαβάθμιση +@msg Revolve edge must be left or right Περιστροφή γύρω από άξονα: το edge πρέπει να είναι left ή right +@msg Revolve cap must be a boolean Περιστροφή γύρω από άξονα: το cap πρέπει να είναι λογική τιμή +@msg the revolved object has no fill or stroke το περιστρεφόμενο αντικείμενο δεν έχει γέμισμα ούτε πινελιά +@msg lightColor must be an opaque RGB color in #rrggbb form το lightColor πρέπει να είναι αδιαφανές χρώμα RGB της μορφής #rrggbb +@msg lights must contain one to ten lights το lights πρέπει να περιέχει από ένα έως δέκα φώτα +@msg each light must be an object κάθε φως πρέπει να είναι αντικείμενο +@msg unknown light setting: use color, intensity, rotation, height, softness or behind άγνωστη ρύθμιση φωτός: χρησιμοποιήστε color, intensity, rotation, height, softness ή behind +@msg light {key} must be a number from {min} to {max} φως: το {key} πρέπει να είναι αριθμός από {min} έως {max} +@msg light color must use opaque #rrggbb το color ενός φωτός πρέπει να είναι αδιαφανές #rrggbb +@msg light behind must be true or false το behind ενός φωτός πρέπει να είναι true ή false +@msg shadow must be true or false το shadow πρέπει να είναι true ή false +@msg shadowPosition must be behind or below το shadowPosition πρέπει να είναι behind ή below +@msg {key} must be a number from {min} to {max} το {key} πρέπει να είναι αριθμός από {min} έως {max} +@msg each of `{GRAPHICS}` must be an object κάθε στοιχείο του `{GRAPHICS}` πρέπει να είναι αντικείμενο +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` οι καταχωρίσεις του `{GRAPHICS}` δέχονται symbol, face, x, y, scale και rotation, όχι `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol κάθε στοιχείο του `{GRAPHICS}` χρειάζεται `symbol`, το όνομα ενός συμβόλου +@msg `{GRAPHICS}` face must be front or back το face του `{GRAPHICS}` πρέπει να είναι front ή back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} το {key} του `{GRAPHICS}` πρέπει να είναι αριθμός από {lo} έως {hi} +@msg `{GRAPHICS}` must be an array το `{GRAPHICS}` πρέπει να είναι πίνακας +@msg Graphics map onto planar faces: use the Extrude or Plane object type Τα γραφικά αντιστοιχίζονται σε επίπεδες όψεις: χρησιμοποιήστε τον τύπο αντικειμένου Εξώθηση ή Επίπεδο +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics το `{GRAPHICS}` δέχεται το πολύ {MAX_GRAPHICS} γραφικά +@msg A plane has one face: map its graphics onto the front Ένα επίπεδο έχει μία όψη: αντιστοιχίστε τα γραφικά του στην μπροστινή όψη +@msg The graphics are too complex: simplify the symbols' art Τα γραφικά είναι πολύ σύνθετα: απλοποιήστε το σχέδιο των συμβόλων +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Το σχέδιο ενός γραφικού μπορεί να είναι ένθετο το πολύ σε {MAX_LEVELS} επίπεδα: απλοποιήστε το σχέδιο του συμβόλου +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Το σχέδιο ενός γραφικού μπορεί να περιέχει ένθετα σύμβολα το πολύ σε {MAX_NESTING} επίπεδα: απλοποιήστε το σχέδιο του συμβόλου +@msg A graphic's art holds too many objects: simplify the symbol's art Το σχέδιο ενός γραφικού περιέχει πάρα πολλά αντικείμενα: απλοποιήστε το σχέδιο του συμβόλου +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Ένα γραφικό σχεδιάζει το πολύ {MAX_PIECES} γεμίσματα και πινελιές: απλοποιήστε το σχέδιο του συμβόλου +@msg `surface` must be one of {_1} Το `surface` πρέπει να είναι ένα από τα {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label}: σχεδιάζεται το αρχικό σχέδιο αυτού του αντικειμένου αντί για την επιφάνεια 3D του +@msg {label} draws no faces for this object {label}: δεν σχεδιάζεται καμία όψη για αυτό το αντικείμενο +@msg the wireframe has more than {MAX_WIRE_EDGES} edges το συρμάτινο πλέγμα έχει περισσότερες από {MAX_WIRE_EDGES} ακμές @msg The 3D surface is too complex; simplify the shape Η επιφάνεια 3D είναι πολύ σύνθετη· απλοποιήστε το σχήμα Transform Objects Μετασχηματισμός αντικειμένων @msg unknown parameter `{key}`; did you mean `{near}`? άγνωστη παράμετρος `{key}`· μήπως εννοείτε `{near}`; @@ -6412,6 +6473,13 @@ axis Y Axis Άξονας Y @msg Batch cannot play an action with a stop Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με διακοπή Insert Menu Item… Εισαγωγή στοιχείου μενού… Insert Menu Item Εισαγωγή στοιχείου μενού + Insert Select Path Εισαγωγή επιλογής διαδρομής + Select Path Επιλογή διαδρομής +@msg select one whole path or compound path επιλέξτε μία ολόκληρη διαδρομή ή σύνθετη διαδρομή +@msg select only one path επιλέξτε μόνο μία διαδρομή +@msg the path is hidden or locked, or cannot be selected here η διαδρομή είναι κρυφή ή κλειδωμένη, ή δεν μπορεί να επιλεγεί εδώ +@msg object {value} is not the stored path: it changed, or the document is another one το αντικείμενο {value} δεν είναι η αποθηκευμένη διαδρομή: άλλαξε ή το έγγραφο είναι άλλο +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits το `fingerprint` πρέπει να είναι "v1:" και 16 πεζά δεκαεξαδικά ψηφία Select Object… Επιλογή αντικειμένου… Select Object Επιλογή αντικειμένου Set Selection Ορισμός επιλογής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c4180a77ec..111dc66303 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2276,6 +2276,8 @@ font-technology Variable Variables Normal Screen Mode Modo de pantalla normal Note: Nota: Nothing matches the Show filter. Nada coincide con el filtro Mostrar. + Show DCS Transparency Interactions Mostrar interacciones de transparencia DCS + Transparent, or overlaps transparent objects: check its separations Transparente o superpuesto a objetos transparentes: compruebe sus separaciones Nothing to print No hay nada para imprimir Nudge Desplazar Nudge in Perspective Desplazar en perspectiva @@ -5817,6 +5819,65 @@ mark-style Japanese Japonesas @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} no puede crear un sólido a partir de una imagen: cálquela primero con Objeto › Calco de imagen › Crear. @msg {label} requires a shape with at least two anchors {label} requiere una forma con al menos dos puntos de ancla @msg The shape is too complex for a 3D effect La forma es demasiado compleja para un efecto 3D +@msg Extrude & Bevel Extrusión y biselado +@msg Revolve Revolución +@msg Inflate Inflar +@msg Rotate Rotar +@msg Extrude & Bevel (Classic) Extrusión y biselado (clásico) +@msg Revolve (Classic) Revolución (clásico) +@msg Rotate (Classic) Rotar (clásico) +@msg This effect Este efecto +@msg clipping paths trazados de recorte +@msg other objects otros objetos +@msg {label} requires a group holding a shape with at least two anchors {label} requiere un grupo que contenga una forma con al menos dos puntos de ancla +@msg Revolve requires a group holding a shape with at least two anchors Revolución requiere un grupo que contenga una forma con al menos dos puntos de ancla +@msg Revolve requires a shape with at least two anchors Revolución requiere una forma con al menos dos puntos de ancla +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} en un grupo necesita trazados, trazados compuestos, texto, grupos, grupos de recorte, fusiones e instancias de símbolo: primero libere o expanda estos elementos: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} en un grupo admite como máximo {MAX_MEMBERS} objetos +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Un efecto 3D en un grupo o una capa se aplica al propio grupo, no a su relleno ni a su trazo +@msg a 3D effect on a group's fill or stroke draws nothing un efecto 3D en el relleno o el trazo de un grupo no dibuja nada +@msg a 3D effect on a group's fill or stroke draws nothing to export un efecto 3D en el relleno o el trazo de un grupo no dibuja nada que exportar +@msg The 3D object is too complex to export El objeto 3D es demasiado complejo para exportarlo +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) el objeto no tiene ningún efecto 3D (Extrusión y biselado, Revolución, Inflar o Rotar) +@msg its 3D effect builds no solid su efecto 3D no construye ningún sólido +@msg bevel must be none or one of {_1} bevel debe ser none o uno de estos: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} no puede construir sólidos a partir de esta ilustración: sus instancias de símbolo, fusiones y grupos de recorte contienen demasiados objetos +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} no puede construir un sólido a partir de la instancia del símbolo «{symbol}»: la ilustración del símbolo no está disponible +@msg {_1}: the clipping path is too complex to cut the art with {_1}: el trazado de recorte es demasiado complejo para recortar la ilustración +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} admite como máximo {MAX_MEMBERS} objetos +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} no puede construir sólidos a partir de una ilustración anidada en más de {MAX_DEPTH} niveles +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient La pintura de Revolución es demasiado compleja para esta superficie; reduzca Segmentos o simplifique el degradado +@msg Revolve edge must be left or right Revolución: edge debe ser left o right +@msg Revolve cap must be a boolean Revolución: cap debe ser un valor booleano +@msg the revolved object has no fill or stroke el objeto de revolución no tiene relleno ni trazo +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor debe ser un color RGB opaco con el formato #rrggbb +@msg lights must contain one to ten lights lights debe contener de una a diez luces +@msg each light must be an object cada luz debe ser un objeto +@msg unknown light setting: use color, intensity, rotation, height, softness or behind ajuste de luz desconocido: use color, intensity, rotation, height, softness o behind +@msg light {key} must be a number from {min} to {max} luz: {key} debe ser un número de {min} a {max} +@msg light color must use opaque #rrggbb color de una luz debe ser un #rrggbb opaco +@msg light behind must be true or false behind de una luz debe ser true o false +@msg shadow must be true or false shadow debe ser true o false +@msg shadowPosition must be behind or below shadowPosition debe ser behind o below +@msg {key} must be a number from {min} to {max} {key} debe ser un número de {min} a {max} +@msg each of `{GRAPHICS}` must be an object cada elemento de `{GRAPHICS}` debe ser un objeto +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` los elementos de `{GRAPHICS}` admiten symbol, face, x, y, scale y rotation, no `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol cada elemento de `{GRAPHICS}` necesita `symbol`, el nombre de un símbolo +@msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` debe ser front o back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` debe ser un número de {lo} a {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` debe ser una matriz +@msg Graphics map onto planar faces: use the Extrude or Plane object type Los gráficos se asignan a caras planas: use el tipo de objeto Extrusión o Plano +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` admite como máximo {MAX_GRAPHICS} gráficos +@msg A plane has one face: map its graphics onto the front Un plano tiene una sola cara: asigne sus gráficos a la cara frontal +@msg The graphics are too complex: simplify the symbols' art Los gráficos son demasiado complejos: simplifique la ilustración de los símbolos +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art La ilustración de un gráfico puede anidarse como máximo en {MAX_LEVELS} niveles: simplifique la ilustración del símbolo +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art La ilustración de un gráfico puede anidar símbolos como máximo en {MAX_NESTING} niveles: simplifique la ilustración del símbolo +@msg A graphic's art holds too many objects: simplify the symbol's art La ilustración de un gráfico contiene demasiados objetos: simplifique la ilustración del símbolo +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Un gráfico dibuja como máximo {MAX_PIECES} rellenos y trazos: simplifique la ilustración del símbolo +@msg `surface` must be one of {_1} `surface` debe ser uno de estos: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} dibuja la ilustración de origen de este objeto en lugar de su superficie 3D +@msg {label} draws no faces for this object {label} no dibuja ninguna cara para este objeto +@msg the wireframe has more than {MAX_WIRE_EDGES} edges la estructura alámbrica tiene más de {MAX_WIRE_EDGES} aristas @msg The 3D surface is too complex; simplify the shape La superficie 3D es demasiado compleja; simplifique la forma Transform Objects Transformar objetos @msg unknown parameter `{key}`; did you mean `{near}`? parámetro desconocido `{key}`; ¿quiso decir `{near}`? @@ -6403,6 +6464,13 @@ axis Y Axis Eje Y @msg Batch cannot play an action with a stop Lote no puede reproducir una acción con una parada Insert Menu Item… Insertar elemento de menú… Insert Menu Item Insertar elemento de menú + Insert Select Path Insertar selección de trazado + Select Path Seleccionar trazado +@msg select one whole path or compound path selecciona un trazado completo o un trazado compuesto +@msg select only one path selecciona solo un trazado +@msg the path is hidden or locked, or cannot be selected here el trazado está oculto o bloqueado, o no se puede seleccionar aquí +@msg object {value} is not the stored path: it changed, or the document is another one el objeto {value} no es el trazado guardado: ha cambiado o el documento es otro +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` debe ser "v1:" y 16 dígitos hexadecimales en minúscula Select Object… Seleccionar objeto… Select Object Seleccionar objeto Set Selection Definir selección diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 365003fc51..176eb8d58d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1921,6 +1921,8 @@ digits Malayalam Malajalam Modified since it was read: {path} Muokattu lukemisen jälkeen: {path} No Thumbnails Ei pikkukuvia Nothing matches the Show filter. Mikään ei vastaa Näytä-suodinta. + Show DCS Transparency Interactions Näytä DCS-läpinäkyvyyden vuorovaikutukset + Transparent, or overlaps transparent objects: check its separations Läpinäkyvä tai läpinäkyvien objektien päällä: tarkista sen erottelut PPI PPI Relink… Linkitä uudelleen… Find Linked Files Etsi linkitetyt tiedostot @@ -5758,6 +5760,65 @@ mark-style Japanese Japanilainen @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} ei voi luoda kappaletta kuvasta: jäljitä se ensin komennolla Objekti › Kuvan jäljitys › Luo. @msg {label} requires a shape with at least two anchors {label} vaatii muodon, jossa on vähintään kaksi ankkuria @msg The shape is too complex for a 3D effect Muoto on liian monimutkainen 3D-tehosteelle +@msg Extrude & Bevel Pursota ja viistä +@msg Revolve Pyöräytä +@msg Inflate Täytä ilmalla +@msg Rotate Kierrä +@msg Extrude & Bevel (Classic) Pursota ja viistä (perinteinen) +@msg Revolve (Classic) Pyöräytä (perinteinen) +@msg Rotate (Classic) Kierrä (perinteinen) +@msg This effect Tämä tehoste +@msg clipping paths leikkauspolut +@msg other objects muut objektit +@msg {label} requires a group holding a shape with at least two anchors {label} vaatii ryhmän, jonka muodossa on vähintään kaksi ankkuria +@msg Revolve requires a group holding a shape with at least two anchors Pyöräytä vaatii ryhmän, jonka muodossa on vähintään kaksi ankkuria +@msg Revolve requires a shape with at least two anchors Pyöräytä vaatii muodon, jossa on vähintään kaksi ankkuria +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} ryhmässä vaatii polkuja, yhdistelmäpolkuja, tekstiä, ryhmiä, leikkausryhmiä, sulautuksia ja symbolien esiintymiä: vapauta tai laajenna ensin nämä: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} ryhmässä hyväksyy enintään {MAX_MEMBERS} objektia +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Ryhmän tai tason 3D-tehoste kuuluu itse ryhmälle, ei sen täytölle tai viivalle +@msg a 3D effect on a group's fill or stroke draws nothing ryhmän täytön tai viivan 3D-tehoste ei piirrä mitään +@msg a 3D effect on a group's fill or stroke draws nothing to export ryhmän täytön tai viivan 3D-tehoste ei piirrä mitään vietävää +@msg The 3D object is too complex to export 3D-objekti on liian monimutkainen vietäväksi +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) objektilla ei ole 3D-tehostetta (Pursota ja viistä, Pyöräytä, Täytä ilmalla tai Kierrä) +@msg its 3D effect builds no solid sen 3D-tehoste ei muodosta kappaletta +@msg bevel must be none or one of {_1} bevel-arvon on oltava none tai jokin seuraavista: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} ei voi muodostaa kappaleita tästä kuvituksesta: sen symbolien esiintymät, sulautukset ja leikkausryhmät sisältävät liikaa objekteja +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} ei voi muodostaa kappaletta symbolin ”{symbol}” esiintymästä: symbolin kuvitus ei ole käytettävissä +@msg {_1}: the clipping path is too complex to cut the art with {_1}: leikkauspolku on liian monimutkainen kuvituksen leikkaamiseen +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} hyväksyy enintään {MAX_MEMBERS} objektia +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} ei voi muodostaa kappaleita kuvituksesta, joka on sisäkkäin yli {MAX_DEPTH} tason syvyydellä +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Pyöräytä-tehosteen maalaus on liian monimutkainen tälle pinnalle; pienennä Segmentit-arvoa tai yksinkertaista liukuväriä +@msg Revolve edge must be left or right Pyöräytä: edge-arvon on oltava left tai right +@msg Revolve cap must be a boolean Pyöräytä: cap-arvon on oltava totuusarvo +@msg the revolved object has no fill or stroke pyöräytetyllä objektilla ei ole täyttöä eikä viivaa +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor-arvon on oltava peittävä RGB-väri muodossa #rrggbb +@msg lights must contain one to ten lights lights-arvossa on oltava yhdestä kymmeneen valoa +@msg each light must be an object jokaisen valon on oltava objekti +@msg unknown light setting: use color, intensity, rotation, height, softness or behind tuntematon valoasetus: käytä arvoja color, intensity, rotation, height, softness tai behind +@msg light {key} must be a number from {min} to {max} valo: {key}-arvon on oltava luku, joka on vähintään {min} ja enintään {max} +@msg light color must use opaque #rrggbb valon color-arvon on oltava peittävä #rrggbb +@msg light behind must be true or false valon behind-arvon on oltava true tai false +@msg shadow must be true or false shadow-arvon on oltava true tai false +@msg shadowPosition must be behind or below shadowPosition-arvon on oltava behind tai below +@msg {key} must be a number from {min} to {max} {key}-arvon on oltava luku, joka on vähintään {min} ja enintään {max} +@msg each of `{GRAPHICS}` must be an object jokaisen `{GRAPHICS}`-kohteen on oltava objekti +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}`-kohteet hyväksyvät arvot symbol, face, x, y, scale ja rotation, eivät arvoa `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol jokainen `{GRAPHICS}`-kohde tarvitsee arvon `symbol`, symbolin nimen +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`-kohteen face-arvon on oltava front tai back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`-kohteen {key}-arvon on oltava luku, joka on vähintään {lo} ja enintään {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` on oltava taulukko +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiikat sijoitetaan tasaisille pinnoille: käytä objektityyppiä Pursota tai Taso +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` hyväksyy enintään {MAX_GRAPHICS} grafiikkaa +@msg A plane has one face: map its graphics onto the front Tasolla on yksi puoli: sijoita sen grafiikat etupuolelle +@msg The graphics are too complex: simplify the symbols' art Grafiikat ovat liian monimutkaisia: yksinkertaista symbolien kuvitusta +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Grafiikan kuvitus voi olla sisäkkäin enintään {MAX_LEVELS} tason syvyydellä: yksinkertaista symbolin kuvitusta +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Grafiikan kuvituksessa symbolit voivat olla sisäkkäin enintään {MAX_NESTING} tason syvyydellä: yksinkertaista symbolin kuvitusta +@msg A graphic's art holds too many objects: simplify the symbol's art Grafiikan kuvitus sisältää liikaa objekteja: yksinkertaista symbolin kuvitusta +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Grafiikka piirtää enintään {MAX_PIECES} täyttöä ja viivaa: yksinkertaista symbolin kuvitusta +@msg `surface` must be one of {_1} `surface` on oltava jokin seuraavista: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} piirtää tälle objektille lähdekuvituksen sen 3D-pinnan sijaan +@msg {label} draws no faces for this object {label} ei piirrä tälle objektille yhtään pintaa +@msg the wireframe has more than {MAX_WIRE_EDGES} edges rautalankamallissa on yli {MAX_WIRE_EDGES} reunaa @msg The 3D surface is too complex; simplify the shape 3D-pinta on liian monimutkainen; yksinkertaista muotoa Transform Objects Muunna objekteja @msg unknown parameter `{key}`; did you mean `{near}`? tuntematon parametri `{key}`; tarkoititko `{near}`? @@ -6491,6 +6552,13 @@ axis Y Axis Y-akseli @msg Batch cannot play an action with a stop Erä ei voi toistaa toimintoa, jossa on pysäytys Insert Menu Item… Lisää valikkokohta… Insert Menu Item Lisää valikkokohta + Insert Select Path Lisää polun valinta + Select Path Valitse polku +@msg select one whole path or compound path valitse yksi kokonainen polku tai yhdistelmäpolku +@msg select only one path valitse vain yksi polku +@msg the path is hidden or locked, or cannot be selected here polku on piilotettu tai lukittu, tai sitä ei voi valita tässä +@msg object {value} is not the stored path: it changed, or the document is another one objekti {value} ei ole tallennettu polku: se on muuttunut, tai asiakirja on toinen +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` on oltava "v1:" ja 16 pientä heksadesimaalinumeroa Select Object… Valitse objekti… Select Object Valitse objekti Set Selection Määritä valinta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8a3ee3e111..829d79a902 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2268,6 +2268,8 @@ font-technology Variable Variables Normal Screen Mode Mode Écran normal Note: Remarque : Nothing matches the Show filter. Aucun élément ne correspond au filtre Afficher. + Show DCS Transparency Interactions Afficher les interactions de transparence DCS + Transparent, or overlaps transparent objects: check its separations Transparent ou superposé à des objets transparents : vérifiez ses séparations Nothing to print Rien à imprimer Nudge Décaler Nudge in Perspective Décaler en perspective @@ -5446,6 +5448,65 @@ lighting Light Lumière @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} ne peut pas créer de solide à partir d'une image : vectorisez-la d'abord avec Objet › Vectorisation de l’image › Créer. @msg {label} requires a shape with at least two anchors {label} nécessite une forme comportant au moins deux points d'ancrage @msg The shape is too complex for a 3D effect La forme est trop complexe pour un effet 3D +@msg Extrude & Bevel Extrusion et biseautage +@msg Revolve Révolution +@msg Inflate Gonfler +@msg Rotate Rotation +@msg Extrude & Bevel (Classic) Extrusion et biseautage (classique) +@msg Revolve (Classic) Révolution (classique) +@msg Rotate (Classic) Rotation (classique) +@msg This effect Cet effet +@msg clipping paths tracés de découpe +@msg other objects autres objets +@msg {label} requires a group holding a shape with at least two anchors {label} nécessite un groupe contenant une forme comportant au moins deux points d'ancrage +@msg Revolve requires a group holding a shape with at least two anchors Révolution nécessite un groupe contenant une forme comportant au moins deux points d'ancrage +@msg Revolve requires a shape with at least two anchors Révolution nécessite une forme comportant au moins deux points d'ancrage +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} sur un groupe nécessite des tracés, tracés transparents, textes, groupes, groupes de découpe, dégradés de formes et occurrences de symbole : libérez ou développez d'abord ces éléments : {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} sur un groupe accepte au plus {MAX_MEMBERS} objets +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Un effet 3D sur un groupe ou un calque s'applique au groupe lui-même, pas à son fond ni à son contour +@msg a 3D effect on a group's fill or stroke draws nothing un effet 3D sur le fond ou le contour d'un groupe ne dessine rien +@msg a 3D effect on a group's fill or stroke draws nothing to export un effet 3D sur le fond ou le contour d'un groupe ne dessine rien à exporter +@msg The 3D object is too complex to export L'objet 3D est trop complexe pour être exporté +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) l'objet n'a aucun effet 3D (Extrusion et biseautage, Révolution, Gonfler ou Rotation) +@msg its 3D effect builds no solid son effet 3D ne construit aucun volume +@msg bevel must be none or one of {_1} bevel doit valoir none ou l’une de ces valeurs : {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} ne peut pas construire de volumes à partir de cette illustration : ses occurrences de symbole, dégradés de formes et groupes de découpe contiennent trop d'objets +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} ne peut pas construire de volume à partir de l'occurrence du symbole « {symbol} » : l'illustration du symbole n'est pas disponible +@msg {_1}: the clipping path is too complex to cut the art with {_1} : le tracé de découpe est trop complexe pour découper l'illustration +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} accepte au plus {MAX_MEMBERS} objets +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} ne peut pas construire de volumes à partir d'une illustration imbriquée sur plus de {MAX_DEPTH} niveaux +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient La peinture de Révolution est trop complexe pour cette surface ; réduisez Segments ou simplifiez le dégradé +@msg Revolve edge must be left or right Révolution : edge doit valoir left ou right +@msg Revolve cap must be a boolean Révolution : cap doit être un booléen +@msg the revolved object has no fill or stroke l'objet en révolution n'a ni fond ni contour +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor doit être une couleur RVB opaque au format #rrggbb +@msg lights must contain one to ten lights lights doit contenir de une à dix lumières +@msg each light must be an object chaque lumière doit être un objet +@msg unknown light setting: use color, intensity, rotation, height, softness or behind réglage de lumière inconnu : utilisez color, intensity, rotation, height, softness ou behind +@msg light {key} must be a number from {min} to {max} lumière : {key} doit être un nombre de {min} à {max} +@msg light color must use opaque #rrggbb color d'une lumière doit être une couleur #rrggbb opaque +@msg light behind must be true or false behind d'une lumière doit valoir true ou false +@msg shadow must be true or false shadow doit valoir true ou false +@msg shadowPosition must be behind or below shadowPosition doit valoir behind ou below +@msg {key} must be a number from {min} to {max} {key} doit être un nombre de {min} à {max} +@msg each of `{GRAPHICS}` must be an object chaque élément de `{GRAPHICS}` doit être un objet +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` les éléments de `{GRAPHICS}` acceptent symbol, face, x, y, scale et rotation, pas `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol chaque élément de `{GRAPHICS}` nécessite `symbol`, le nom d'un symbole +@msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` doit valoir front ou back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` doit être un nombre de {lo} à {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` doit être un tableau +@msg Graphics map onto planar faces: use the Extrude or Plane object type Les graphiques s'appliquent sur des faces planes : utilisez le type d'objet Extrusion ou Plan +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` accepte au plus {MAX_GRAPHICS} graphiques +@msg A plane has one face: map its graphics onto the front Un plan n'a qu'une face : appliquez ses graphiques sur la face avant +@msg The graphics are too complex: simplify the symbols' art Les graphiques sont trop complexes : simplifiez l'illustration des symboles +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art L'illustration d'un graphique peut être imbriquée sur {MAX_LEVELS} niveaux au plus : simplifiez l'illustration du symbole +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art L'illustration d'un graphique peut imbriquer des symboles sur {MAX_NESTING} niveaux au plus : simplifiez l'illustration du symbole +@msg A graphic's art holds too many objects: simplify the symbol's art L'illustration d'un graphique contient trop d'objets : simplifiez l'illustration du symbole +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Un graphique dessine au plus {MAX_PIECES} fonds et contours : simplifiez l'illustration du symbole +@msg `surface` must be one of {_1} `surface` doit valoir l’une de ces valeurs : {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} dessine l'illustration source de cet objet au lieu de sa surface 3D +@msg {label} draws no faces for this object {label} ne dessine aucune face pour cet objet +@msg the wireframe has more than {MAX_WIRE_EDGES} edges le fil de fer comporte plus de {MAX_WIRE_EDGES} arêtes @msg The 3D surface is too complex; simplify the shape La surface 3D est trop complexe ; simplifiez la forme Transform Objects Transformer les objets @msg unknown parameter `{key}`; did you mean `{near}`? paramètre inconnu `{key}` ; vouliez-vous dire `{near}` ? @@ -6456,6 +6517,13 @@ axis Y Axis Axe Y @msg Batch cannot play an action with a stop Le traitement par lots ne peut pas exécuter un script comportant un arrêt Insert Menu Item… Insérer une commande de menu… Insert Menu Item Insérer une commande de menu + Insert Select Path Insérer une sélection de tracé + Select Path Sélectionner le tracé +@msg select one whole path or compound path sélectionnez un tracé entier ou un tracé transparent +@msg select only one path sélectionnez un seul tracé +@msg the path is hidden or locked, or cannot be selected here le tracé est masqué ou verrouillé, ou ne peut pas être sélectionné ici +@msg object {value} is not the stored path: it changed, or the document is another one l’objet {value} n’est pas le tracé enregistré : il a été modifié, ou il s’agit d’un autre document +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` doit être "v1:" suivi de 16 chiffres hexadécimaux en minuscules Select Object… Sélectionner un objet… Select Object Sélectionner un objet Set Selection Définir la sélection diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index cae603977e..5c4bce1389 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3065,6 +3065,8 @@ antiAlias Sharp शार्प Note: ध्यान दें: Nothing is selected. Select the art to export, then open this tab again. कुछ भी नहीं चुना गया है। निर्यात करने के लिए कला सिलेक्ट करें, फिर इस टैब को फिर से ओपन करें। Nothing matches the Show filter. कुछ भी नहीं शो फिल्टर से मेल खाता है। + Show DCS Transparency Interactions DCS पारदर्शिता इंटरैक्शन दिखाएँ + Transparent, or overlaps transparent objects: check its separations पारदर्शी है या पारदर्शी ऑब्जेक्ट पर चढ़ता है: इसके सेपरेशन जाँचें Nothing to print प्रिंट करने के लिए कुछ भी नहीं NTSC Colors NTSC रंग Nudge न्याय @@ -3470,6 +3472,13 @@ antiAlias Sharp शार्प @msg Batch cannot play an action with a stop बैच स्टॉप वाला एक्शन नहीं चला सकता Insert Menu Item… मेनू आइटम जोड़ें… Insert Menu Item मेनू आइटम जोड़ें + Insert Select Path सिलेक्ट पाथ जोड़ें + Select Path पाथ सिलेक्ट करें +@msg select one whole path or compound path एक पूरा पाथ या कंपाउंड पाथ चुनें +@msg select only one path केवल एक पाथ चुनें +@msg the path is hidden or locked, or cannot be selected here पाथ छिपा हुआ है या लॉक है, या यहाँ चुना नहीं जा सकता +@msg object {value} is not the stored path: it changed, or the document is another one ऑब्जेक्ट {value} सहेजा गया पाथ नहीं है: वह बदल गया है, या डॉक्यूमेंट दूसरा है +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` "v1:" और छोटे अक्षरों वाले 16 हेक्स अंक होना चाहिए Select Object… ऑब्जेक्ट सिलेक्ट करें… Select Object ऑब्जेक्ट सिलेक्ट करें Set Selection सिलेक्शन सेट करें @@ -7853,6 +7862,65 @@ antiAlias Sharp शार्प @msg that makes too many pieces (at most 100000): make the dashes longer जो बहुत सारे टुकड़े बनाता है (अधिकांश 100000 में): डैश को लंबे समय तक बनाते हैं @msg that object is not in the overlap उस ऑब्जेक्ट को ओवरलैप में नहीं है @msg the {tool} tool has no options {tool} टूल में कोई ऑप्शन्स नहीं है +@msg Extrude & Bevel एक्सट्रूडेड और बेवेल +@msg Revolve रिवॉल्व +@msg Inflate फुलाएँ +@msg Rotate घुमाव +@msg Extrude & Bevel (Classic) एक्सट्रूडेड और बेवेल (क्लासिक) +@msg Revolve (Classic) रिवॉल्व (क्लासिक) +@msg Rotate (Classic) घुमाव (क्लासिक) +@msg This effect यह इफ़ेक्ट +@msg clipping paths क्लिपिंग पाथ +@msg other objects अन्य ऑब्जेक्ट +@msg {label} requires a group holding a shape with at least two anchors {label} को कम से कम दो एंकरों वाले आकार से युक्त ग्रुप की आवश्यकता होती है +@msg Revolve requires a group holding a shape with at least two anchors रिवॉल्व को कम से कम दो एंकरों वाले आकार से युक्त ग्रुप की आवश्यकता होती है +@msg Revolve requires a shape with at least two anchors रिवॉल्व को कम से कम दो एंकरों वाले आकार की आवश्यकता होती है +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. ग्रुप पर {label} के लिए पाथ, कंपाउंड पाथ, टाइप, ग्रुप, क्लिपिंग ग्रुप, ब्लेंड और सिंबल इंस्टेंस चाहिए: पहले इन्हें रिलीज़ या एक्सपैंड करें: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects ग्रुप पर {label} अधिकतम {MAX_MEMBERS} ऑब्जेक्ट लेता है +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke किसी ग्रुप या लेयर पर 3D इफ़ेक्ट ग्रुप पर ही लगता है, उसके फ़िल या स्ट्रोक पर नहीं +@msg a 3D effect on a group's fill or stroke draws nothing ग्रुप के फ़िल या स्ट्रोक पर 3D इफ़ेक्ट कुछ नहीं बनाता +@msg a 3D effect on a group's fill or stroke draws nothing to export ग्रुप के फ़िल या स्ट्रोक पर 3D इफ़ेक्ट एक्सपोर्ट करने लायक कुछ नहीं बनाता +@msg The 3D object is too complex to export 3D ऑब्जेक्ट एक्सपोर्ट करने के लिए बहुत जटिल है +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) ऑब्जेक्ट पर कोई 3D इफ़ेक्ट नहीं है (एक्सट्रूडेड और बेवेल, रिवॉल्व, फुलाएँ या घुमाव) +@msg its 3D effect builds no solid इसका 3D इफ़ेक्ट कोई ठोस नहीं बनाता +@msg bevel must be none or one of {_1} bevel को none या {_1} में से एक होना चाहिए +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} इस आर्ट से ठोस नहीं बना सकता: इसके सिंबल इंस्टेंस, ब्लेंड और क्लिपिंग ग्रुप में बहुत अधिक ऑब्जेक्ट हैं +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} सिंबल "{symbol}" के इंस्टेंस से ठोस नहीं बना सकता: सिंबल का आर्ट उपलब्ध नहीं है +@msg {_1}: the clipping path is too complex to cut the art with {_1}: क्लिपिंग पाथ आर्ट को काटने के लिए बहुत जटिल है +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} अधिकतम {MAX_MEMBERS} ऑब्जेक्ट लेता है +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} {MAX_DEPTH} स्तर से अधिक गहराई तक नेस्ट किए गए आर्ट से ठोस नहीं बना सकता +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient रिवॉल्व का पेंट इस सतह के लिए बहुत जटिल है; सेगमेंट कम करें या ग्रेडिएंट को सरल बनाएँ +@msg Revolve edge must be left or right रिवॉल्व: edge को left या right होना चाहिए +@msg Revolve cap must be a boolean रिवॉल्व: cap एक बूलियन होना चाहिए +@msg the revolved object has no fill or stroke रिवॉल्व किए गए ऑब्जेक्ट में कोई फ़िल या स्ट्रोक नहीं है +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor को #rrggbb रूप में अपारदर्शी RGB रंग होना चाहिए +@msg lights must contain one to ten lights lights में एक से दस लाइटें होनी चाहिए +@msg each light must be an object हर लाइट एक ऑब्जेक्ट होनी चाहिए +@msg unknown light setting: use color, intensity, rotation, height, softness or behind अज्ञात लाइट सेटिंग: color, intensity, rotation, height, softness या behind का उपयोग करें +@msg light {key} must be a number from {min} to {max} लाइट: {key} को {min} से {max} तक की संख्या होना चाहिए +@msg light color must use opaque #rrggbb लाइट का color अपारदर्शी #rrggbb होना चाहिए +@msg light behind must be true or false लाइट का behind, true या false होना चाहिए +@msg shadow must be true or false shadow को true या false होना चाहिए +@msg shadowPosition must be behind or below shadowPosition को behind या below होना चाहिए +@msg {key} must be a number from {min} to {max} {key} को {min} से {max} तक की संख्या होना चाहिए +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}` की हर प्रविष्टि एक ऑब्जेक्ट होनी चाहिए +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` की प्रविष्टियाँ symbol, face, x, y, scale और rotation लेती हैं, `{k}` नहीं +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}` की हर प्रविष्टि को `symbol` चाहिए, यानी किसी सिंबल का नाम +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` का face, front या back होना चाहिए +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` का {key}, {lo} से {hi} तक की संख्या होना चाहिए +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` एक ऐरे होना चाहिए +@msg Graphics map onto planar faces: use the Extrude or Plane object type ग्राफ़िक्स समतल फ़ेस पर लगते हैं: एक्सट्रूड या समतल ऑब्जेक्ट प्रकार का उपयोग करें +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` अधिकतम {MAX_GRAPHICS} ग्राफ़िक्स लेता है +@msg A plane has one face: map its graphics onto the front समतल का एक ही फ़ेस होता है: उसके ग्राफ़िक्स को सामने वाले फ़ेस पर लगाएँ +@msg The graphics are too complex: simplify the symbols' art ग्राफ़िक्स बहुत जटिल हैं: सिंबल्स के आर्ट को सरल बनाएँ +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art ग्राफ़िक का आर्ट अधिकतम {MAX_LEVELS} स्तर तक नेस्ट हो सकता है: सिंबल के आर्ट को सरल बनाएँ +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art ग्राफ़िक का आर्ट सिंबल को अधिकतम {MAX_NESTING} स्तर तक नेस्ट कर सकता है: सिंबल के आर्ट को सरल बनाएँ +@msg A graphic's art holds too many objects: simplify the symbol's art ग्राफ़िक के आर्ट में बहुत अधिक ऑब्जेक्ट हैं: सिंबल के आर्ट को सरल बनाएँ +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art एक ग्राफ़िक अधिकतम {MAX_PIECES} फ़िल और स्ट्रोक बनाता है: सिंबल के आर्ट को सरल बनाएँ +@msg `surface` must be one of {_1} `surface` को {_1} में से एक होना चाहिए +@msg {label} draws the source art for this object instead of its 3D surface {label} इस ऑब्जेक्ट के लिए उसकी 3D सतह के बजाय स्रोत आर्ट बनाता है +@msg {label} draws no faces for this object {label} इस ऑब्जेक्ट के लिए कोई फ़ेस नहीं बनाता +@msg the wireframe has more than {MAX_WIRE_EDGES} edges वायरफ़्रेम में {MAX_WIRE_EDGES} से अधिक किनारे हैं @msg The 3D surface is too complex; simplify the shape 3D सतह बहुत जटिल है; आकार को सरल बनाएं @msg the action has no included steps कार्रवाई में कोई कदम शामिल नहीं है @msg the action has no steps कार्रवाई में कोई कदम नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0155edf5b2..5d8ee77ab7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2753,6 +2753,8 @@ file-filter Templates Sablonok Not found Nem található Note: Megjegyzés: Nothing matches the Show filter. A Megjelenítés szűrőnek semmi sem felel meg. + Show DCS Transparency Interactions DCS-átlátszósági kölcsönhatások megjelenítése + Transparent, or overlaps transparent objects: check its separations Átlátszó, vagy átlátszó objektumokat fed át: ellenőrizze a színkivonatait Nothing to print Nincs mit nyomtatni NTSC Colors NTSC-színek Nudge Léptetés @@ -5859,6 +5861,65 @@ imageTrace Outline Körvonal @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} képből nem tud testet létrehozni: előbb vektorizálja az Objektum › Képvektorizálás › Létrehozás paranccsal. @msg {label} requires a shape with at least two anchors {label} legalább két szerkesztőpontos alakzatot igényel @msg The shape is too complex for a 3D effect Az alakzat túl összetett a 3D effektushoz +@msg Extrude & Bevel Kihúzás és élletörés +@msg Revolve Forgatás tengely körül +@msg Inflate Felfújás +@msg Rotate Elforgatás +@msg Extrude & Bevel (Classic) Kihúzás és élletörés (klasszikus) +@msg Revolve (Classic) Forgatás tengely körül (klasszikus) +@msg Rotate (Classic) Elforgatás (klasszikus) +@msg This effect Ez az effektus +@msg clipping paths vágóútvonalak +@msg other objects egyéb objektumok +@msg {label} requires a group holding a shape with at least two anchors {label}: legalább két szerkesztőpontos alakzatot tartalmazó csoport szükséges +@msg Revolve requires a group holding a shape with at least two anchors Forgatás tengely körül: legalább két szerkesztőpontos alakzatot tartalmazó csoport szükséges +@msg Revolve requires a shape with at least two anchors Forgatás tengely körül: legalább két szerkesztőpontos alakzat szükséges +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} csoporton: útvonalak, összetett útvonalak, szöveg, csoportok, vágócsoportok, összemosások és szimbólumpéldányok szükségesek; előbb bontsa fel vagy alakítsa át ezeket: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} csoporton: legfeljebb {MAX_MEMBERS} objektum lehet +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke A csoporton vagy rétegen lévő 3D effektus magára a csoportra kerül, nem a kitöltésére vagy körvonalára +@msg a 3D effect on a group's fill or stroke draws nothing a csoport kitöltésén vagy körvonalán lévő 3D effektus nem rajzol semmit +@msg a 3D effect on a group's fill or stroke draws nothing to export a csoport kitöltésén vagy körvonalán lévő 3D effektus nem rajzol semmi exportálhatót +@msg The 3D object is too complex to export A 3D objektum túl összetett az exportáláshoz +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) az objektumnak nincs 3D effektusa (Kihúzás és élletörés, Forgatás tengely körül, Felfújás vagy Elforgatás) +@msg its 3D effect builds no solid a 3D effektusa nem hoz létre testet +@msg bevel must be none or one of {_1} a bevel értéke none vagy ezek egyike lehet: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: ebből a rajzból nem hozható létre test: a szimbólumpéldányai, összemosásai és vágócsoportjai túl sok objektumot tartalmaznak +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}: nem hozható létre test a(z) „{symbol}” szimbólum példányából: a szimbólum rajza nem érhető el +@msg {_1}: the clipping path is too complex to cut the art with {_1}: a vágóútvonal túl összetett a rajz kivágásához +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}: legfeljebb {MAX_MEMBERS} objektum lehet +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}: nem hozható létre test {MAX_DEPTH} szintnél mélyebben egymásba ágyazott rajzból +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient A Forgatás tengely körül festése túl összetett ehhez a felülethez; csökkentse a Szakaszok értékét, vagy egyszerűsítse a színátmenetet +@msg Revolve edge must be left or right Forgatás tengely körül: az edge értéke left vagy right lehet +@msg Revolve cap must be a boolean Forgatás tengely körül: a cap értéke logikai érték lehet +@msg the revolved object has no fill or stroke a forgatott objektumnak nincs kitöltése vagy körvonala +@msg lightColor must be an opaque RGB color in #rrggbb form a lightColor értéke #rrggbb formájú átlátszatlan RGB szín lehet +@msg lights must contain one to ten lights a lights egy és tíz közötti számú fényt tartalmazhat +@msg each light must be an object minden fénynek objektumnak kell lennie +@msg unknown light setting: use color, intensity, rotation, height, softness or behind ismeretlen fénybeállítás: ezeket használja: color, intensity, rotation, height, softness vagy behind +@msg light {key} must be a number from {min} to {max} fény: a {key} értéke {min} és {max} közötti szám lehet +@msg light color must use opaque #rrggbb egy fény color értéke átlátszatlan #rrggbb lehet +@msg light behind must be true or false egy fény behind értéke true vagy false lehet +@msg shadow must be true or false a shadow értéke true vagy false lehet +@msg shadowPosition must be behind or below a shadowPosition értéke behind vagy below lehet +@msg {key} must be a number from {min} to {max} a {key} értéke {min} és {max} közötti szám lehet +@msg each of `{GRAPHICS}` must be an object a `{GRAPHICS}` minden elemének objektumnak kell lennie +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` a `{GRAPHICS}` elemei symbol, face, x, y, scale és rotation értéket fogadnak, nem `{k}` értéket +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol a `{GRAPHICS}` minden eleméhez kell `symbol`, egy szimbólum neve +@msg `{GRAPHICS}` face must be front or back a `{GRAPHICS}` face értéke front vagy back lehet +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} a `{GRAPHICS}` {key} értéke {lo} és {hi} közötti szám lehet +@msg `{GRAPHICS}` must be an array a `{GRAPHICS}` értéke tömb kell legyen +@msg Graphics map onto planar faces: use the Extrude or Plane object type A grafikák sík lapokra kerülnek: használja a Kihúzás vagy a Sík objektumtípust +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics a `{GRAPHICS}` legfeljebb {MAX_GRAPHICS} grafikát fogad +@msg A plane has one face: map its graphics onto the front A síknak egy oldala van: a grafikáit az elülső oldalra helyezze +@msg The graphics are too complex: simplify the symbols' art A grafikák túl összetettek: egyszerűsítse a szimbólumok rajzát +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Egy grafika rajza legfeljebb {MAX_LEVELS} szint mélyen lehet egymásba ágyazva: egyszerűsítse a szimbólum rajzát +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Egy grafika rajza legfeljebb {MAX_NESTING} szint mélyen ágyazhat egymásba szimbólumokat: egyszerűsítse a szimbólum rajzát +@msg A graphic's art holds too many objects: simplify the symbol's art Egy grafika rajza túl sok objektumot tartalmaz: egyszerűsítse a szimbólum rajzát +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Egy grafika legfeljebb {MAX_PIECES} kitöltést és körvonalat rajzol: egyszerűsítse a szimbólum rajzát +@msg `surface` must be one of {_1} `surface`: ezek egyike kell: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label}: ehhez az objektumhoz a forrásrajzot rajzolja a 3D felülete helyett +@msg {label} draws no faces for this object {label}: ehhez az objektumhoz nem rajzol lapot +@msg the wireframe has more than {MAX_WIRE_EDGES} edges a drótváznak több mint {MAX_WIRE_EDGES} éle van @msg The 3D surface is too complex; simplify the shape A 3D felület túl összetett; egyszerűsítse az alakzatot Transform Objects Objektumok transzformálása @msg unknown parameter `{key}`; did you mean `{near}`? ismeretlen paraméter: `{key}`; erre gondolt: `{near}`? @@ -6444,6 +6505,13 @@ axis Y Axis Y tengely @msg Batch cannot play an action with a stop A kötegelt feldolgozás nem játszhat le megállást tartalmazó műveletet Insert Menu Item… Menüelem beszúrása… Insert Menu Item Menüelem beszúrása + Insert Select Path Útvonal kijelölésének beszúrása + Select Path Útvonal kijelölése +@msg select one whole path or compound path jelölj ki egy teljes útvonalat vagy összetett útvonalat +@msg select only one path csak egy útvonalat jelölj ki +@msg the path is hidden or locked, or cannot be selected here az útvonal rejtett vagy zárolt, vagy itt nem jelölhető ki +@msg object {value} is not the stored path: it changed, or the document is another one a(z) {value} objektum nem a tárolt útvonal: megváltozott, vagy ez egy másik dokumentum +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits a `fingerprint` értéke "v1:" és 16 kisbetűs hexadecimális számjegy kell legyen Select Object… Objektum kijelölése… Select Object Objektum kijelölése Set Selection Kijelölés beállítása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9ef5051464..8659e06ef4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1775,6 +1775,8 @@ digits Malayalam Malayalam Modified since it was read: {path} Diubah sejak terakhir dibaca: {path} No Thumbnails Tanpa thumbnail Nothing matches the Show filter. Tidak ada yang cocok dengan filter Tampilkan. + Show DCS Transparency Interactions Tampilkan Interaksi Transparansi DCS + Transparent, or overlaps transparent objects: check its separations Transparan, atau menimpa objek transparan: periksa separasinya PPI PPI Relink… Tautkan ulang… Find Linked Files Temukan file tertaut @@ -6004,6 +6006,13 @@ axis Y Axis Sumbu Y @msg Batch cannot play an action with a stop Batch tidak dapat memutar tindakan dengan titik henti Insert Menu Item… Sisipkan item menu… Insert Menu Item Sisipkan item menu + Insert Select Path Sisipkan pemilihan jalur + Select Path Pilih jalur +@msg select one whole path or compound path pilih satu jalur utuh atau jalur majemuk +@msg select only one path pilih hanya satu jalur +@msg the path is hidden or locked, or cannot be selected here jalur tersembunyi atau terkunci, atau tidak dapat dipilih di sini +@msg object {value} is not the stored path: it changed, or the document is another one objek {value} bukan jalur yang disimpan: jalur itu berubah, atau dokumennya berbeda +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` harus berupa "v1:" dan 16 digit heksadesimal huruf kecil Select Object… Pilih objek… Select Object Pilih objek Set Selection Atur seleksi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 34d901ec90..04dc97c02d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2314,6 +2314,8 @@ file-filter Templates Modelli Normal Screen Mode Modalità schermo normale Note: Nota: Nothing matches the Show filter. Nessun elemento corrisponde al filtro Mostra. + Show DCS Transparency Interactions Mostra interazioni di trasparenza DCS + Transparent, or overlaps transparent objects: check its separations Trasparente o sovrapposto a oggetti trasparenti: controlla le sue separazioni Nothing to print Niente da stampare Nudge Sposta leggermente Nudge in Perspective Sposta leggermente in prospettiva @@ -5493,6 +5495,65 @@ lighting Light Luce @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} non può creare un solido da un'immagine: ricalcatela prima con Oggetto › Ricalco immagine › Crea. @msg {label} requires a shape with at least two anchors {label} richiede una forma con almeno due punti di ancoraggio @msg The shape is too complex for a 3D effect La forma è troppo complessa per un effetto 3D +@msg Extrude & Bevel Estrusione e smusso +@msg Revolve Rivoluzione +@msg Inflate Gonfia +@msg Rotate Ruota +@msg Extrude & Bevel (Classic) Estrusione e smusso (classico) +@msg Revolve (Classic) Rivoluzione (classico) +@msg Rotate (Classic) Ruota (classico) +@msg This effect Questo effetto +@msg clipping paths tracciati di ritaglio +@msg other objects altri oggetti +@msg {label} requires a group holding a shape with at least two anchors {label} richiede un gruppo che contenga una forma con almeno due punti di ancoraggio +@msg Revolve requires a group holding a shape with at least two anchors Rivoluzione richiede un gruppo che contenga una forma con almeno due punti di ancoraggio +@msg Revolve requires a shape with at least two anchors Rivoluzione richiede una forma con almeno due punti di ancoraggio +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} su un gruppo richiede tracciati, tracciati composti, testo, gruppi, gruppi di ritaglio, fusioni e istanze di simbolo: prima rilasciate o espandete questi elementi: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} su un gruppo accetta al massimo {MAX_MEMBERS} oggetti +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Un effetto 3D su un gruppo o un livello va applicato al gruppo stesso, non al suo riempimento o alla sua traccia +@msg a 3D effect on a group's fill or stroke draws nothing un effetto 3D sul riempimento o sulla traccia di un gruppo non disegna nulla +@msg a 3D effect on a group's fill or stroke draws nothing to export un effetto 3D sul riempimento o sulla traccia di un gruppo non disegna nulla da esportare +@msg The 3D object is too complex to export L'oggetto 3D è troppo complesso per essere esportato +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) l'oggetto non ha alcun effetto 3D (Estrusione e smusso, Rivoluzione, Gonfia o Ruota) +@msg its 3D effect builds no solid il suo effetto 3D non costruisce alcun solido +@msg bevel must be none or one of {_1} bevel deve essere none o uno dei seguenti: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} non può costruire solidi da questo disegno: le sue istanze di simbolo, fusioni e gruppi di ritaglio contengono troppi oggetti +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} non può costruire un solido dall'istanza del simbolo «{symbol}»: il disegno del simbolo non è disponibile +@msg {_1}: the clipping path is too complex to cut the art with {_1}: il tracciato di ritaglio è troppo complesso per ritagliare il disegno +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} accetta al massimo {MAX_MEMBERS} oggetti +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} non può costruire solidi da un disegno nidificato per più di {MAX_DEPTH} livelli +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient La pittura di Rivoluzione è troppo complessa per questa superficie; riducete Segmenti o semplificate la sfumatura +@msg Revolve edge must be left or right Rivoluzione: edge deve essere left o right +@msg Revolve cap must be a boolean Rivoluzione: cap deve essere un valore booleano +@msg the revolved object has no fill or stroke l'oggetto in rivoluzione non ha riempimento né traccia +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor deve essere un colore RGB opaco nel formato #rrggbb +@msg lights must contain one to ten lights lights deve contenere da una a dieci luci +@msg each light must be an object ogni luce deve essere un oggetto +@msg unknown light setting: use color, intensity, rotation, height, softness or behind impostazione della luce sconosciuta: usate color, intensity, rotation, height, softness o behind +@msg light {key} must be a number from {min} to {max} luce: {key} deve essere un numero da {min} a {max} +@msg light color must use opaque #rrggbb color di una luce deve essere un #rrggbb opaco +@msg light behind must be true or false behind di una luce deve essere true o false +@msg shadow must be true or false shadow deve essere true o false +@msg shadowPosition must be behind or below shadowPosition deve essere behind o below +@msg {key} must be a number from {min} to {max} {key} deve essere un numero da {min} a {max} +@msg each of `{GRAPHICS}` must be an object ogni voce di `{GRAPHICS}` deve essere un oggetto +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` le voci di `{GRAPHICS}` accettano symbol, face, x, y, scale e rotation, non `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol ogni voce di `{GRAPHICS}` richiede `symbol`, il nome di un simbolo +@msg `{GRAPHICS}` face must be front or back face di `{GRAPHICS}` deve essere front o back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} di `{GRAPHICS}` deve essere un numero da {lo} a {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` deve essere un array +@msg Graphics map onto planar faces: use the Extrude or Plane object type Le grafiche si applicano a facce piane: usate il tipo di oggetto Estrusione o Piano +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` accetta al massimo {MAX_GRAPHICS} grafiche +@msg A plane has one face: map its graphics onto the front Un piano ha una sola faccia: applicate le sue grafiche sulla faccia anteriore +@msg The graphics are too complex: simplify the symbols' art Le grafiche sono troppo complesse: semplificate il disegno dei simboli +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Il disegno di una grafica può essere nidificato al massimo per {MAX_LEVELS} livelli: semplificate il disegno del simbolo +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Il disegno di una grafica può nidificare simboli al massimo per {MAX_NESTING} livelli: semplificate il disegno del simbolo +@msg A graphic's art holds too many objects: simplify the symbol's art Il disegno di una grafica contiene troppi oggetti: semplificate il disegno del simbolo +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Una grafica disegna al massimo {MAX_PIECES} riempimenti e tracce: semplificate il disegno del simbolo +@msg `surface` must be one of {_1} `surface` deve essere uno dei seguenti: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} mostra il disegno di origine di questo oggetto al posto della sua superficie 3D +@msg {label} draws no faces for this object {label} non disegna alcuna faccia per questo oggetto +@msg the wireframe has more than {MAX_WIRE_EDGES} edges il wireframe ha più di {MAX_WIRE_EDGES} spigoli @msg The 3D surface is too complex; simplify the shape La superficie 3D è troppo complessa; semplificate la forma Transform Objects Trasforma oggetti @msg unknown parameter `{key}`; did you mean `{near}`? parametro sconosciuto `{key}`; forse intendevate `{near}`? @@ -6086,6 +6147,13 @@ axis Y Axis Asse Y @msg Batch cannot play an action with a stop L'elaborazione batch non può eseguire un'azione con un'interruzione Insert Menu Item… Inserisci voce di menu… Insert Menu Item Inserisci voce di menu + Insert Select Path Inserisci selezione tracciato + Select Path Seleziona tracciato +@msg select one whole path or compound path seleziona un tracciato intero o un tracciato composto +@msg select only one path seleziona un solo tracciato +@msg the path is hidden or locked, or cannot be selected here il tracciato è nascosto o bloccato, oppure non può essere selezionato qui +@msg object {value} is not the stored path: it changed, or the document is another one l’oggetto {value} non è il tracciato memorizzato: è cambiato, oppure il documento è un altro +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` deve essere "v1:" seguito da 16 cifre esadecimali minuscole Select Object… Seleziona oggetto… Select Object Seleziona oggetto Set Selection Imposta selezione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 027b262681..b8a3ce10ea 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2493,6 +2493,8 @@ antiAlias Sharp シャープ Normal 通常 Note: メモ: Nothing matches the Show filter. 表示フィルターに一致する項目がありません。 + Show DCS Transparency Interactions DCS の透明の相互作用を表示 + Transparent, or overlaps transparent objects: check its separations 透明、または透明なオブジェクトと重なっています:分版を確認してください Nothing to print プリントするものがありません Nudge 微調整 Nudge in Perspective 遠近で微調整 @@ -5535,6 +5537,65 @@ lighting Light ライト @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} は画像から立体を作成できません。先に「オブジェクト › 画像トレース › 作成」でトレースしてください。 @msg {label} requires a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプが必要です @msg The shape is too complex for a 3D effect シェイプが複雑すぎるため 3D 効果を適用できません +@msg Extrude & Bevel 押し出しとベベル +@msg Revolve 回転体 +@msg Inflate 膨張 +@msg Rotate 回転 +@msg Extrude & Bevel (Classic) 押し出しとベベル(クラシック) +@msg Revolve (Classic) 回転体(クラシック) +@msg Rotate (Classic) 回転(クラシック) +@msg This effect この効果 +@msg clipping paths クリッピングパス +@msg other objects その他のオブジェクト +@msg {label} requires a group holding a shape with at least two anchors {label} には 2 つ以上のアンカーポイントを持つシェイプを含むグループが必要です +@msg Revolve requires a group holding a shape with at least two anchors 回転体には 2 つ以上のアンカーポイントを持つシェイプを含むグループが必要です +@msg Revolve requires a shape with at least two anchors 回転体には 2 つ以上のアンカーポイントを持つシェイプが必要です +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. グループの {label} には、パス、複合パス、テキスト、グループ、クリッピンググループ、ブレンド、シンボルインスタンスが必要です。先に次を解除または分割してください: {what}。 +@msg {label} on a group takes at most {MAX_MEMBERS} objects グループの {label} で扱えるオブジェクトは {MAX_MEMBERS} 個までです +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke グループまたはレイヤーの 3D 効果は、塗りや線ではなくグループ自体に適用されます +@msg a 3D effect on a group's fill or stroke draws nothing グループの塗りまたは線にある 3D 効果は何も描画しません +@msg a 3D effect on a group's fill or stroke draws nothing to export グループの塗りまたは線にある 3D 効果には書き出せる描画がありません +@msg The 3D object is too complex to export 3D オブジェクトが複雑すぎるため書き出せません +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) オブジェクトに 3D 効果 (押し出しとベベル、回転体、膨張、回転) がありません +@msg its 3D effect builds no solid 3D 効果からソリッドが作成されません +@msg bevel must be none or one of {_1} bevel には none または {_1} のいずれかを指定してください +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} はこのアートからソリッドを作成できません。シンボルインスタンス、ブレンド、クリッピンググループに含まれるオブジェクトが多すぎます +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} はシンボル「{symbol}」のインスタンスからソリッドを作成できません。シンボルのアートを使用できません +@msg {_1}: the clipping path is too complex to cut the art with {_1}: クリッピングパスが複雑すぎるため、アートを切り抜けません +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} で扱えるオブジェクトは {MAX_MEMBERS} 個までです +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} は {MAX_DEPTH} 階層より深く入れ子になったアートからソリッドを作成できません +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient 回転体のペイントがこのサーフェスには複雑すぎます。分割数を減らすか、グラデーションを単純化してください +@msg Revolve edge must be left or right 回転体: edge には left または right を指定してください +@msg Revolve cap must be a boolean 回転体: cap はブール値にしてください +@msg the revolved object has no fill or stroke 回転体のオブジェクトに塗りも線もありません +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor は #rrggbb 形式の不透明な RGB カラーにしてください +@msg lights must contain one to ten lights lights には 1 から 10 個のライトを含めてください +@msg each light must be an object 各ライトはオブジェクトにしてください +@msg unknown light setting: use color, intensity, rotation, height, softness or behind 不明なライトの設定です。color、intensity、rotation、height、softness、behind のいずれかを使用してください +@msg light {key} must be a number from {min} to {max} ライト: {key} は {min} から {max} までの数値にしてください +@msg light color must use opaque #rrggbb ライトの color は不透明な #rrggbb にしてください +@msg light behind must be true or false ライトの behind は true または false にしてください +@msg shadow must be true or false shadow は true または false にしてください +@msg shadowPosition must be behind or below shadowPosition には behind または below を指定してください +@msg {key} must be a number from {min} to {max} {key} は {min} から {max} までの数値にしてください +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}` の各項目はオブジェクトにしてください +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` の項目に指定できるのは symbol、face、x、y、scale、rotation で、`{k}` は指定できません +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}` の各項目にはシンボルの名前 `symbol` が必要です +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` の face には front または back を指定してください +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` の {key} は {lo} から {hi} までの数値にしてください +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` は配列にしてください +@msg Graphics map onto planar faces: use the Extrude or Plane object type グラフィックは平らな面にマッピングされます。オブジェクトの種類に押し出しまたは平面を使用してください +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` に指定できるグラフィックは {MAX_GRAPHICS} 個までです +@msg A plane has one face: map its graphics onto the front 平面には面が 1 つしかありません。グラフィックは前面にマッピングしてください +@msg The graphics are too complex: simplify the symbols' art グラフィックが複雑すぎます。シンボルのアートを単純化してください +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art グラフィックのアートの入れ子は {MAX_LEVELS} 階層までです。シンボルのアートを単純化してください +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art グラフィックのアートでシンボルを入れ子にできるのは {MAX_NESTING} 階層までです。シンボルのアートを単純化してください +@msg A graphic's art holds too many objects: simplify the symbol's art グラフィックのアートに含まれるオブジェクトが多すぎます。シンボルのアートを単純化してください +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art グラフィックが描画できる塗りと線は {MAX_PIECES} 個までです。シンボルのアートを単純化してください +@msg `surface` must be one of {_1} `surface` は {_1} のいずれかにしてください +@msg {label} draws the source art for this object instead of its 3D surface {label} はこのオブジェクトの 3D サーフェスの代わりに元のアートを描画します +@msg {label} draws no faces for this object {label} はこのオブジェクトに面を描画しません +@msg the wireframe has more than {MAX_WIRE_EDGES} edges ワイヤーフレームの辺が {MAX_WIRE_EDGES} 本を超えています @msg The 3D surface is too complex; simplify the shape 3D サーフェスが複雑すぎます。シェイプを単純化してください Transform Objects オブジェクトの変形 @msg unknown parameter `{key}`; did you mean `{near}`? `{key}` は不明なパラメーターです。`{near}` のことですか? @@ -6151,6 +6212,13 @@ axis Y Axis Y 軸 @msg Batch cannot play an action with a stop バッチでは停止を含むアクションを再生できません Insert Menu Item… メニュー項目を挿入… Insert Menu Item メニュー項目を挿入 + Insert Select Path パスの選択を挿入 + Select Path パスを選択 +@msg select one whole path or compound path パス全体または複合パスを 1 つ選択してください +@msg select only one path パスを 1 つだけ選択してください +@msg the path is hidden or locked, or cannot be selected here パスが非表示またはロックされているか、ここでは選択できません +@msg object {value} is not the stored path: it changed, or the document is another one オブジェクト {value} は保存されたパスではありません: 変更されたか、別のドキュメントです +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` は "v1:" と小文字の 16 進数字 16 桁である必要があります Select Object… オブジェクトを選択… Select Object オブジェクトを選択 Set Selection 選択範囲を設定 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e49e6cd4b8..9e1a97291e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1713,6 +1713,8 @@ digits Malayalam მალაიალამური Modified since it was read: {path} შეიცვალა წაკითხვის შემდეგ: {path} No Thumbnails ესკიზების გარეშე Nothing matches the Show filter. „ჩვენების“ ფილტრს არაფერი შეესაბამება. + Show DCS Transparency Interactions DCS-ის გამჭვირვალობის ურთიერთქმედებების ჩვენება + Transparent, or overlaps transparent objects: check its separations გამჭვირვალეა ან გადაფარავს გამჭვირვალე ობიექტებს: შეამოწმეთ მისი სეპარაციები PPI PPI Relink… ხელახლა დაკავშირება… Select an image to see its Link Info. ბმულის ინფორმაციის სანახავად მონიშნეთ გამოსახულება. @@ -5164,6 +5166,65 @@ font-technology Variable ცვლადი @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: გამოსახულებიდან მყარი სხეულის აგება შეუძლებელია. ჯერ მოახდინეთ მისი ვექტორიზაცია ბრძანებით ობიექტი › გამოსახულების ვექტორიზაცია › შექმნა. @msg {label} requires a shape with at least two anchors {label}: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით @msg The shape is too complex for a 3D effect ფიგურა ზედმეტად რთულია 3D ეფექტისთვის +@msg Extrude & Bevel ამოწევა და წაკვეთა +@msg Revolve ბრუნვა +@msg Inflate გაბერვა +@msg Rotate შემობრუნება +@msg Extrude & Bevel (Classic) ამოწევა და წაკვეთა (კლასიკური) +@msg Revolve (Classic) ბრუნვა (კლასიკური) +@msg Rotate (Classic) შემობრუნება (კლასიკური) +@msg This effect ეს ეფექტი +@msg clipping paths მომჭრელი ტრაექტორიები +@msg other objects სხვა ობიექტები +@msg {label} requires a group holding a shape with at least two anchors {label}: საჭიროა ჯგუფი, რომელიც შეიცავს ფიგურას მინიმუმ ორი საყრდენი წერტილით +@msg Revolve requires a group holding a shape with at least two anchors ბრუნვა: საჭიროა ჯგუფი, რომელიც შეიცავს ფიგურას მინიმუმ ორი საყრდენი წერტილით +@msg Revolve requires a shape with at least two anchors ბრუნვა: საჭიროა ფიგურა მინიმუმ ორი საყრდენი წერტილით +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} ჯგუფზე: საჭიროა ტრაექტორიები, შედგენილი ტრაექტორიები, ტექსტი, ჯგუფები, მომჭრელი ჯგუფები, გადასვლები და სიმბოლოს ეგზემპლარები; ჯერ გაათავისუფლეთ ან დაშალეთ ეს ელემენტები: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} ჯგუფზე: მაქსიმუმ {MAX_MEMBERS} ობიექტი +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke ჯგუფის ან ფენის 3D ეფექტი თავად ჯგუფზე უნდა იყოს და არა მის შევსებაზე ან კონტურზე +@msg a 3D effect on a group's fill or stroke draws nothing ჯგუფის შევსებაზე ან კონტურზე 3D ეფექტი არაფერს ხატავს +@msg a 3D effect on a group's fill or stroke draws nothing to export ჯგუფის შევსებაზე ან კონტურზე 3D ეფექტი ექსპორტისთვის არაფერს ხატავს +@msg The 3D object is too complex to export 3D ობიექტი ზედმეტად რთულია ექსპორტისთვის +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) ობიექტს არ აქვს 3D ეფექტი (ამოწევა და წაკვეთა, ბრუნვა, გაბერვა ან შემობრუნება) +@msg its 3D effect builds no solid მისი 3D ეფექტი სხეულს არ ქმნის +@msg bevel must be none or one of {_1} bevel უნდა იყოს none ან ერთ-ერთი: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: ამ ნახატიდან სხეულების შექმნა შეუძლებელია: მისი სიმბოლოს ეგზემპლარები, გადასვლები და მომჭრელი ჯგუფები ზედმეტად ბევრ ობიექტს შეიცავს +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}: სიმბოლოს „{symbol}“ ეგზემპლარიდან სხეულის შექმნა შეუძლებელია: სიმბოლოს ნახატი მიუწვდომელია +@msg {_1}: the clipping path is too complex to cut the art with {_1}: მომჭრელი ტრაექტორია ზედმეტად რთულია ნახატის მოსაჭრელად +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}: მაქსიმუმ {MAX_MEMBERS} ობიექტი +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}: შეუძლებელია სხეულების შექმნა {MAX_DEPTH} დონეზე ღრმად ჩადგმული ნახატიდან +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient ბრუნვის საღებავი ზედმეტად რთულია ამ ზედაპირისთვის; შეამცირეთ სეგმენტები ან გაამარტივეთ გრადიენტი +@msg Revolve edge must be left or right ბრუნვა: edge უნდა იყოს left ან right +@msg Revolve cap must be a boolean ბრუნვა: cap უნდა იყოს ლოგიკური მნიშვნელობა +@msg the revolved object has no fill or stroke მობრუნებულ ობიექტს არ აქვს შევსება ან კონტური +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor უნდა იყოს გაუმჭვირვალე RGB ფერი #rrggbb ფორმით +@msg lights must contain one to ten lights lights უნდა შეიცავდეს ერთიდან ათამდე სინათლეს +@msg each light must be an object თითოეული სინათლე უნდა იყოს ობიექტი +@msg unknown light setting: use color, intensity, rotation, height, softness or behind სინათლის უცნობი პარამეტრი: გამოიყენეთ color, intensity, rotation, height, softness ან behind +@msg light {key} must be a number from {min} to {max} სინათლე: {key} უნდა იყოს რიცხვი {min}-დან {max}-მდე +@msg light color must use opaque #rrggbb სინათლის color უნდა იყოს გაუმჭვირვალე #rrggbb +@msg light behind must be true or false სინათლის behind უნდა იყოს true ან false +@msg shadow must be true or false shadow უნდა იყოს true ან false +@msg shadowPosition must be behind or below shadowPosition უნდა იყოს behind ან below +@msg {key} must be a number from {min} to {max} {key} უნდა იყოს რიცხვი {min}-დან {max}-მდე +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}`-ის თითოეული ჩანაწერი უნდა იყოს ობიექტი +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}`-ის ჩანაწერები იღებს symbol, face, x, y, scale და rotation მნიშვნელობებს და არა `{k}`-ს +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}`-ის თითოეულ ჩანაწერს სჭირდება `symbol`, სიმბოლოს სახელი +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`-ის face უნდა იყოს front ან back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`-ის {key} უნდა იყოს რიცხვი {lo}-დან {hi}-მდე +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` უნდა იყოს მასივი +@msg Graphics map onto planar faces: use the Extrude or Plane object type გრაფიკა ბრტყელ წახნაგებზე თავსდება: გამოიყენეთ ობიექტის ტიპი „ამოწევა“ ან „სიბრტყე“ +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` იღებს მაქსიმუმ {MAX_GRAPHICS} გრაფიკას +@msg A plane has one face: map its graphics onto the front სიბრტყეს ერთი მხარე აქვს: მისი გრაფიკა წინა მხარეზე განათავსეთ +@msg The graphics are too complex: simplify the symbols' art გრაფიკა ზედმეტად რთულია: გაამარტივეთ სიმბოლოების ნახატი +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art გრაფიკის ნახატი შეიძლება ჩადგმული იყოს მაქსიმუმ {MAX_LEVELS} დონეზე: გაამარტივეთ სიმბოლოს ნახატი +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art გრაფიკის ნახატში სიმბოლოები შეიძლება ჩადგმული იყოს მაქსიმუმ {MAX_NESTING} დონეზე: გაამარტივეთ სიმბოლოს ნახატი +@msg A graphic's art holds too many objects: simplify the symbol's art გრაფიკის ნახატი ზედმეტად ბევრ ობიექტს შეიცავს: გაამარტივეთ სიმბოლოს ნახატი +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art გრაფიკა ხატავს მაქსიმუმ {MAX_PIECES} შევსებას და კონტურს: გაამარტივეთ სიმბოლოს ნახატი +@msg `surface` must be one of {_1} `surface` უნდა იყოს ერთ-ერთი: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label}: ამ ობიექტისთვის 3D ზედაპირის ნაცვლად საწყისი ნახატი იხატება +@msg {label} draws no faces for this object {label}: ამ ობიექტისთვის არცერთი წახნაგი არ იხატება +@msg the wireframe has more than {MAX_WIRE_EDGES} edges კარკასს {MAX_WIRE_EDGES}-ზე მეტი წიბო აქვს @msg The 3D surface is too complex; simplify the shape 3D ზედაპირი ზედმეტად რთულია; გაამარტივეთ ფიგურა Transform Objects ობიექტების გარდაქმნა @msg unknown parameter `{key}`; did you mean `{near}`? უცნობი პარამეტრი `{key}`; იქნებ `{near}` იგულისხმეთ? @@ -6412,6 +6473,13 @@ axis Y Axis Y ღერძი @msg Batch cannot play an action with a stop პაკეტურ დამუშავებას არ შეუძლია გაჩერების მქონე მოქმედების შესრულება Insert Menu Item… მენიუს ელემენტის ჩასმა… Insert Menu Item მენიუს ელემენტის ჩასმა + Insert Select Path ტრაექტორიის მონიშვნის ჩასმა + Select Path ტრაექტორიის მონიშვნა +@msg select one whole path or compound path მონიშნეთ ერთი მთლიანი ტრაექტორია ან შედგენილი ტრაექტორია +@msg select only one path მონიშნეთ მხოლოდ ერთი ტრაექტორია +@msg the path is hidden or locked, or cannot be selected here ტრაექტორია დამალული ან ჩაკეტილია, ან აქ ვერ მოინიშნება +@msg object {value} is not the stored path: it changed, or the document is another one ობიექტი {value} არ არის შენახული ტრაექტორია: ის შეიცვალა, ან დოკუმენტი სხვაა +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` უნდა იყოს "v1:" და 16 პატარა თექვსმეტობითი ციფრი Select Object… ობიექტის მონიშვნა… Select Object ობიექტის მონიშვნა Set Selection მონიშვნის დაყენება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 206d70f302..88a8444b5a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2483,6 +2483,8 @@ antiAlias Sharp 날카롭게 Normal 표준 Note: 참고: Nothing matches the Show filter. 표시 필터와 일치하는 항목이 없습니다. + Show DCS Transparency Interactions DCS 투명도 상호 작용 표시 + Transparent, or overlaps transparent objects: check its separations 투명하거나 투명한 개체와 겹칩니다: 분판을 확인하세요 Nothing to print 인쇄할 항목 없음 Nudge 미세 이동 Nudge in Perspective 원근 미세 이동 @@ -5454,6 +5456,65 @@ lighting Light 광원 @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}은(는) 이미지로 입체를 만들 수 없습니다. 먼저 개체 › 이미지 추적 › 만들기로 추적하십시오. @msg {label} requires a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양이 필요합니다 @msg The shape is too complex for a 3D effect 모양이 너무 복잡하여 3D 효과를 적용할 수 없습니다 +@msg Extrude & Bevel 돌출 및 경사 +@msg Revolve 회전체 +@msg Inflate 부풀리기 +@msg Rotate 회전 +@msg Extrude & Bevel (Classic) 돌출 및 경사(클래식) +@msg Revolve (Classic) 회전체(클래식) +@msg Rotate (Classic) 회전(클래식) +@msg This effect 이 효과 +@msg clipping paths 클리핑 패스 +@msg other objects 기타 개체 +@msg {label} requires a group holding a shape with at least two anchors {label}에는 고정점이 두 개 이상인 모양을 포함한 그룹이 필요합니다 +@msg Revolve requires a group holding a shape with at least two anchors 회전체에는 고정점이 두 개 이상인 모양을 포함한 그룹이 필요합니다 +@msg Revolve requires a shape with at least two anchors 회전체에는 고정점이 두 개 이상인 모양이 필요합니다 +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. 그룹의 {label}에는 패스, 컴파운드 패스, 텍스트, 그룹, 클리핑 그룹, 블렌드, 심볼 인스턴스가 필요합니다. 먼저 다음을 놓거나 확장하십시오: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects 그룹의 {label}은(는) 개체를 최대 {MAX_MEMBERS}개까지 받습니다 +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 그룹이나 레이어의 3D 효과는 칠이나 획이 아니라 그룹 자체에 적용됩니다 +@msg a 3D effect on a group's fill or stroke draws nothing 그룹의 칠이나 획에 있는 3D 효과는 아무것도 그리지 않습니다 +@msg a 3D effect on a group's fill or stroke draws nothing to export 그룹의 칠이나 획에 있는 3D 효과는 내보낼 것을 그리지 않습니다 +@msg The 3D object is too complex to export 3D 개체가 너무 복잡하여 내보낼 수 없습니다 +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) 개체에 3D 효과(돌출 및 경사, 회전체, 부풀리기 또는 회전)가 없습니다 +@msg its 3D effect builds no solid 3D 효과가 입체를 만들지 않습니다 +@msg bevel must be none or one of {_1} bevel은 none 또는 {_1} 중 하나여야 합니다 +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}은(는) 이 아트로 입체를 만들 수 없습니다. 심볼 인스턴스, 블렌드, 클리핑 그룹에 개체가 너무 많습니다 +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}은(는) 심볼 “{symbol}”의 인스턴스로 입체를 만들 수 없습니다. 심볼의 아트를 사용할 수 없습니다 +@msg {_1}: the clipping path is too complex to cut the art with {_1}: 클리핑 패스가 너무 복잡하여 아트를 자를 수 없습니다 +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}은(는) 개체를 최대 {MAX_MEMBERS}개까지 받습니다 +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}은(는) {MAX_DEPTH}단계보다 깊이 중첩된 아트로 입체를 만들 수 없습니다 +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient 회전체의 칠이 이 표면에 비해 너무 복잡합니다. 세그먼트를 줄이거나 그레이디언트를 단순화하십시오 +@msg Revolve edge must be left or right 회전체: edge는 left 또는 right여야 합니다 +@msg Revolve cap must be a boolean 회전체: cap은 부울 값이어야 합니다 +@msg the revolved object has no fill or stroke 회전체 개체에 칠이나 획이 없습니다 +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor는 #rrggbb 형식의 불투명한 RGB 색상이어야 합니다 +@msg lights must contain one to ten lights lights에는 조명이 1개에서 10개까지 있어야 합니다 +@msg each light must be an object 각 조명은 개체여야 합니다 +@msg unknown light setting: use color, intensity, rotation, height, softness or behind 알 수 없는 조명 설정입니다. color, intensity, rotation, height, softness 또는 behind를 사용하십시오 +@msg light {key} must be a number from {min} to {max} 조명: {key}은(는) {min}에서 {max} 사이의 숫자여야 합니다 +@msg light color must use opaque #rrggbb 조명의 color는 불투명한 #rrggbb여야 합니다 +@msg light behind must be true or false 조명의 behind는 true 또는 false여야 합니다 +@msg shadow must be true or false shadow는 true 또는 false여야 합니다 +@msg shadowPosition must be behind or below shadowPosition은 behind 또는 below여야 합니다 +@msg {key} must be a number from {min} to {max} {key}은(는) {min}에서 {max} 사이의 숫자여야 합니다 +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}`의 각 항목은 개체여야 합니다 +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` 항목에는 symbol, face, x, y, scale, rotation만 사용할 수 있으며 `{k}`은(는) 사용할 수 없습니다 +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}`의 각 항목에는 심볼 이름인 `symbol`이 필요합니다 +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}`의 face는 front 또는 back이어야 합니다 +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}`의 {key}은(는) {lo}에서 {hi} 사이의 숫자여야 합니다 +@msg `{GRAPHICS}` must be an array `{GRAPHICS}`은(는) 배열이어야 합니다 +@msg Graphics map onto planar faces: use the Extrude or Plane object type 그래픽은 평평한 면에 매핑됩니다. 돌출 또는 평면 개체 유형을 사용하십시오 +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}`에는 그래픽을 최대 {MAX_GRAPHICS}개까지 넣을 수 있습니다 +@msg A plane has one face: map its graphics onto the front 평면에는 면이 하나뿐입니다. 그래픽을 앞면에 매핑하십시오 +@msg The graphics are too complex: simplify the symbols' art 그래픽이 너무 복잡합니다. 심볼의 아트를 단순화하십시오 +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art 그래픽의 아트는 최대 {MAX_LEVELS}단계까지 중첩할 수 있습니다. 심볼의 아트를 단순화하십시오 +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art 그래픽의 아트는 심볼을 최대 {MAX_NESTING}단계까지 중첩할 수 있습니다. 심볼의 아트를 단순화하십시오 +@msg A graphic's art holds too many objects: simplify the symbol's art 그래픽의 아트에 개체가 너무 많습니다. 심볼의 아트를 단순화하십시오 +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art 그래픽은 칠과 획을 최대 {MAX_PIECES}개까지 그립니다. 심볼의 아트를 단순화하십시오 +@msg `surface` must be one of {_1} `surface`은(는) {_1} 중 하나여야 합니다 +@msg {label} draws the source art for this object instead of its 3D surface {label}은(는) 이 개체에 3D 표면 대신 원본 아트를 그립니다 +@msg {label} draws no faces for this object {label}은(는) 이 개체에 면을 그리지 않습니다 +@msg the wireframe has more than {MAX_WIRE_EDGES} edges 와이어프레임의 가장자리가 {MAX_WIRE_EDGES}개를 넘습니다 @msg The 3D surface is too complex; simplify the shape 3D 표면이 너무 복잡합니다. 모양을 단순화하십시오 Transform Objects 개체 변형 @msg unknown parameter `{key}`; did you mean `{near}`? 알 수 없는 매개변수 `{key}`: `{near}`을(를) 의미하셨습니까? @@ -6047,6 +6108,13 @@ axis Y Axis Y축 @msg Batch cannot play an action with a stop 일괄 처리에서는 중지가 있는 액션을 재생할 수 없습니다 Insert Menu Item… 메뉴 항목 삽입… Insert Menu Item 메뉴 항목 삽입 + Insert Select Path 패스 선택 삽입 + Select Path 패스 선택 +@msg select one whole path or compound path 전체 패스 또는 컴파운드 패스를 하나 선택하세요 +@msg select only one path 패스를 하나만 선택하세요 +@msg the path is hidden or locked, or cannot be selected here 패스가 숨겨져 있거나 잠겨 있거나 여기에서 선택할 수 없습니다 +@msg object {value} is not the stored path: it changed, or the document is another one 개체 {value}은(는) 저장된 패스가 아닙니다: 변경되었거나 다른 문서입니다 +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint`는 "v1:"과 소문자 16진수 16자리여야 합니다 Select Object… 개체 선택… Select Object 개체 선택 Set Selection 선택 영역 설정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 87961019c8..248387ec80 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1789,6 +1789,8 @@ digits Malayalam Malayalam Modified since it was read: {path} Gewijzigd sinds het is ingelezen: {path} No Thumbnails Geen miniaturen Nothing matches the Show filter. Niets komt overeen met het weergavefilter. + Show DCS Transparency Interactions DCS-transparantie-interacties weergeven + Transparent, or overlaps transparent objects: check its separations Transparant of overlapt transparante objecten: controleer de scheidingen PPI PPI Relink… Opnieuw koppelen… Find Linked Files Gekoppelde bestanden zoeken @@ -6055,6 +6057,13 @@ axis Y Axis Y-as @msg Batch cannot play an action with a stop Batch kan geen actie met een stop afspelen Insert Menu Item… Menu-item invoegen… Insert Menu Item Menu-item invoegen + Insert Select Path Pad selecteren invoegen + Select Path Pad selecteren +@msg select one whole path or compound path selecteer één volledig pad of samengesteld pad +@msg select only one path selecteer slechts één pad +@msg the path is hidden or locked, or cannot be selected here het pad is verborgen of vergrendeld, of kan hier niet worden geselecteerd +@msg object {value} is not the stored path: it changed, or the document is another one object {value} is niet het opgeslagen pad: het is gewijzigd, of het document is een ander +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` moet "v1:" zijn, gevolgd door 16 hexadecimale cijfers in kleine letters Select Object… Object selecteren… Select Object Object selecteren Set Selection Selectie instellen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3121bfbb61..b4ccfc24e9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1878,6 +1878,8 @@ digits Malayalam Malajalam Modified since it was read: {path} Zmodyfikowano od wczytania: {path} No Thumbnails Bez miniatur Nothing matches the Show filter. Nic nie pasuje do filtra Pokaż. + Show DCS Transparency Interactions Pokaż interakcje przezroczystości DCS + Transparent, or overlaps transparent objects: check its separations Przezroczysty lub nakłada się na przezroczyste obiekty: sprawdź jego separacje PPI PPI Relink… Połącz ponownie… Find Linked Files Znajdź połączone pliki @@ -5706,6 +5708,65 @@ mark-style Japanese Japońskie @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} nie może utworzyć bryły z obrazu: najpierw obrysuj go poleceniem Obiekt › Obrys obrazu › Utwórz. @msg {label} requires a shape with at least two anchors {label} wymaga kształtu z co najmniej dwoma punktami @msg The shape is too complex for a 3D effect Kształt jest zbyt złożony dla efektu 3D +@msg Extrude & Bevel Wyciągnięcie i fazowanie +@msg Revolve Obrót +@msg Inflate Nadmuchanie +@msg Rotate Obróć +@msg Extrude & Bevel (Classic) Wyciągnięcie i fazowanie (klasyczne) +@msg Revolve (Classic) Obrót (klasyczny) +@msg Rotate (Classic) Obróć (klasyczny) +@msg This effect Ten efekt +@msg clipping paths ścieżki przycinające +@msg other objects inne obiekty +@msg {label} requires a group holding a shape with at least two anchors {label} wymaga grupy zawierającej kształt z co najmniej dwoma punktami +@msg Revolve requires a group holding a shape with at least two anchors Obrót wymaga grupy zawierającej kształt z co najmniej dwoma punktami +@msg Revolve requires a shape with at least two anchors Obrót wymaga kształtu z co najmniej dwoma punktami +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} na grupie wymaga ścieżek, ścieżek złożonych, tekstu, grup, grup przycinających, przejść i wystąpień symboli: najpierw zwolnij lub rozszerz te elementy: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} na grupie przyjmuje najwyżej {MAX_MEMBERS} obiektów +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Efekt 3D na grupie lub warstwie należy nałożyć na samą grupę, a nie na jej wypełnienie lub obrys +@msg a 3D effect on a group's fill or stroke draws nothing efekt 3D na wypełnieniu lub obrysie grupy niczego nie rysuje +@msg a 3D effect on a group's fill or stroke draws nothing to export efekt 3D na wypełnieniu lub obrysie grupy nie rysuje niczego do eksportu +@msg The 3D object is too complex to export Obiekt 3D jest zbyt złożony, aby go wyeksportować +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) obiekt nie ma efektu 3D (Wyciągnięcie i fazowanie, Obrót, Nadmuchanie ani Obróć) +@msg its 3D effect builds no solid jego efekt 3D nie tworzy żadnej bryły +@msg bevel must be none or one of {_1} bevel musi mieć wartość none lub jedną z: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} nie może utworzyć brył z tego rysunku: jego wystąpienia symboli, przejścia i grupy przycinające zawierają zbyt wiele obiektów +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} nie może utworzyć bryły z wystąpienia symbolu „{symbol}”: rysunek symbolu jest niedostępny +@msg {_1}: the clipping path is too complex to cut the art with {_1}: ścieżka przycinająca jest zbyt złożona, aby przyciąć nią rysunek +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} przyjmuje najwyżej {MAX_MEMBERS} obiektów +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} nie może utworzyć brył z rysunku zagnieżdżonego na więcej niż {MAX_DEPTH} poziomach +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Farba efektu Obrót jest zbyt złożona dla tej powierzchni; zmniejsz Segmenty lub uprość gradient +@msg Revolve edge must be left or right Obrót: edge musi mieć wartość left lub right +@msg Revolve cap must be a boolean Obrót: cap musi być wartością logiczną +@msg the revolved object has no fill or stroke obracany obiekt nie ma wypełnienia ani obrysu +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor musi być nieprzezroczystym kolorem RGB w postaci #rrggbb +@msg lights must contain one to ten lights lights musi zawierać od jednego do dziesięciu świateł +@msg each light must be an object każde światło musi być obiektem +@msg unknown light setting: use color, intensity, rotation, height, softness or behind nieznane ustawienie światła: użyj color, intensity, rotation, height, softness lub behind +@msg light {key} must be a number from {min} to {max} światło: {key} musi być liczbą od {min} do {max} +@msg light color must use opaque #rrggbb color światła musi być nieprzezroczystym kolorem #rrggbb +@msg light behind must be true or false behind światła musi mieć wartość true lub false +@msg shadow must be true or false shadow musi mieć wartość true lub false +@msg shadowPosition must be behind or below shadowPosition musi mieć wartość behind lub below +@msg {key} must be a number from {min} to {max} {key} musi być liczbą od {min} do {max} +@msg each of `{GRAPHICS}` must be an object każdy element `{GRAPHICS}` musi być obiektem +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` elementy `{GRAPHICS}` przyjmują symbol, face, x, y, scale i rotation, a nie `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol każdy element `{GRAPHICS}` wymaga `symbol`, nazwy symbolu +@msg `{GRAPHICS}` face must be front or back face w `{GRAPHICS}` musi mieć wartość front lub back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} w `{GRAPHICS}` musi być liczbą od {lo} do {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` musi być tablicą +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafiki są nakładane na płaskie ściany: użyj typu obiektu Wyciągnięcie lub Płaszczyzna +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` przyjmuje najwyżej {MAX_GRAPHICS} grafik +@msg A plane has one face: map its graphics onto the front Płaszczyzna ma jedną stronę: umieść jej grafiki na przedniej stronie +@msg The graphics are too complex: simplify the symbols' art Grafiki są zbyt złożone: uprość rysunki symboli +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Rysunek grafiki może być zagnieżdżony najwyżej na {MAX_LEVELS} poziomach: uprość rysunek symbolu +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Rysunek grafiki może zagnieżdżać symbole najwyżej na {MAX_NESTING} poziomach: uprość rysunek symbolu +@msg A graphic's art holds too many objects: simplify the symbol's art Rysunek grafiki zawiera zbyt wiele obiektów: uprość rysunek symbolu +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Grafika rysuje najwyżej {MAX_PIECES} wypełnień i obrysów: uprość rysunek symbolu +@msg `surface` must be one of {_1} `surface` musi mieć jedną z wartości: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} rysuje dla tego obiektu rysunek źródłowy zamiast jego powierzchni 3D +@msg {label} draws no faces for this object {label} nie rysuje żadnych ścian dla tego obiektu +@msg the wireframe has more than {MAX_WIRE_EDGES} edges model szkieletowy ma więcej niż {MAX_WIRE_EDGES} krawędzi @msg The 3D surface is too complex; simplify the shape Powierzchnia 3D jest zbyt złożona; uprość kształt Add Guides Dodaj linie pomocnicze Total: Razem: @@ -6315,6 +6376,13 @@ axis Y Axis Oś Y @msg Batch cannot play an action with a stop Przetwarzanie wsadowe nie może odtworzyć operacji z zatrzymaniem Insert Menu Item… Wstaw polecenie menu… Insert Menu Item Wstaw polecenie menu + Insert Select Path Wstaw zaznaczenie ścieżki + Select Path Zaznacz ścieżkę +@msg select one whole path or compound path zaznacz jedną całą ścieżkę lub ścieżkę złożoną +@msg select only one path zaznacz tylko jedną ścieżkę +@msg the path is hidden or locked, or cannot be selected here ścieżka jest ukryta lub zablokowana albo nie można jej tu zaznaczyć +@msg object {value} is not the stored path: it changed, or the document is another one obiekt {value} nie jest zapisaną ścieżką: zmienił się albo dokument jest inny +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` musi mieć postać "v1:" i 16 cyfr szesnastkowych małymi literami Select Object… Zaznacz obiekt… Select Object Zaznacz obiekt Set Selection Ustaw zaznaczenie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index d6f98a96e4..540deb9fc9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1877,6 +1877,8 @@ antiAlias Sharp Nítido Normal Screen Mode Modo de Tela Normal Note: Nota: Nothing matches the Show filter. Nada corresponde ao filtro Mostrar. + Show DCS Transparency Interactions Mostrar interações de transparência DCS + Transparent, or overlaps transparent objects: check its separations Transparente ou sobrepõe objetos transparentes: verifique as separações Nothing to print Nada para imprimir Nudge Nudge Nudge in Perspective Nudge em Perspectiva @@ -5825,6 +5827,65 @@ mark-style Japanese Japonesas @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} não pode criar um sólido a partir de uma imagem: rastreie-a antes com Objeto › Rastreamento de Imagem › Criar. @msg {label} requires a shape with at least two anchors {label} exige uma forma com pelo menos dois pontos de ancoragem @msg The shape is too complex for a 3D effect A forma é complexa demais para um efeito 3D +@msg Extrude & Bevel Extrusão e Bisel +@msg Revolve Revolver +@msg Inflate Inflar +@msg Rotate Girar +@msg Extrude & Bevel (Classic) Extrusão e Bisel (Clássico) +@msg Revolve (Classic) Revolver (Clássico) +@msg Rotate (Classic) Girar (Clássico) +@msg This effect Este efeito +@msg clipping paths caminhos de recorte +@msg other objects outros objetos +@msg {label} requires a group holding a shape with at least two anchors {label} exige um grupo com uma forma com pelo menos dois pontos de ancoragem +@msg Revolve requires a group holding a shape with at least two anchors Revolver exige um grupo com uma forma com pelo menos dois pontos de ancoragem +@msg Revolve requires a shape with at least two anchors Revolver exige uma forma com pelo menos dois pontos de ancoragem +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} em um grupo exige caminhos, caminhos compostos, texto, grupos, grupos de recorte, mesclagens e instâncias de símbolo: primeiro libere ou expanda estes itens: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} em um grupo aceita no máximo {MAX_MEMBERS} objetos +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Um efeito 3D em um grupo ou camada se aplica ao próprio grupo, não ao seu preenchimento ou traçado +@msg a 3D effect on a group's fill or stroke draws nothing um efeito 3D no preenchimento ou traçado de um grupo não desenha nada +@msg a 3D effect on a group's fill or stroke draws nothing to export um efeito 3D no preenchimento ou traçado de um grupo não desenha nada para exportar +@msg The 3D object is too complex to export O objeto 3D é complexo demais para ser exportado +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) o objeto não tem efeito 3D (Extrusão e Bisel, Revolver, Inflar ou Girar) +@msg its 3D effect builds no solid seu efeito 3D não constrói nenhum sólido +@msg bevel must be none or one of {_1} bevel deve ser none ou um de {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} não pode construir sólidos a partir desta arte: suas instâncias de símbolo, mesclagens e grupos de recorte contêm objetos demais +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} não pode construir um sólido a partir da instância do símbolo “{symbol}”: a arte do símbolo não está disponível +@msg {_1}: the clipping path is too complex to cut the art with {_1}: o caminho de recorte é complexo demais para recortar a arte +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} aceita no máximo {MAX_MEMBERS} objetos +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} não pode construir sólidos a partir de arte aninhada em mais de {MAX_DEPTH} níveis +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient A pintura de Revolver é complexa demais para esta superfície; reduza Segmentos ou simplifique o degradê +@msg Revolve edge must be left or right Revolver: edge deve ser left ou right +@msg Revolve cap must be a boolean Revolver: cap deve ser um booleano +@msg the revolved object has no fill or stroke o objeto revolvido não tem preenchimento nem traçado +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor deve ser uma cor RGB opaca no formato #rrggbb +@msg lights must contain one to ten lights lights deve conter de uma a dez luzes +@msg each light must be an object cada luz deve ser um objeto +@msg unknown light setting: use color, intensity, rotation, height, softness or behind configuração de luz desconhecida: use color, intensity, rotation, height, softness ou behind +@msg light {key} must be a number from {min} to {max} luz: {key} deve ser um número de {min} a {max} +@msg light color must use opaque #rrggbb color de uma luz deve usar #rrggbb opaco +@msg light behind must be true or false behind de uma luz deve ser true ou false +@msg shadow must be true or false shadow deve ser true ou false +@msg shadowPosition must be behind or below shadowPosition deve ser behind ou below +@msg {key} must be a number from {min} to {max} {key} deve ser um número de {min} a {max} +@msg each of `{GRAPHICS}` must be an object cada item de `{GRAPHICS}` deve ser um objeto +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` as entradas de `{GRAPHICS}` aceitam symbol, face, x, y, scale e rotation, não `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol cada item de `{GRAPHICS}` precisa de `symbol`, o nome de um símbolo +@msg `{GRAPHICS}` face must be front or back face de `{GRAPHICS}` deve ser front ou back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} de `{GRAPHICS}` deve ser um número de {lo} a {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` deve ser uma matriz +@msg Graphics map onto planar faces: use the Extrude or Plane object type Os gráficos são mapeados em faces planas: use o tipo de objeto Extrusão ou Plano +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` aceita no máximo {MAX_GRAPHICS} gráficos +@msg A plane has one face: map its graphics onto the front Um plano tem uma única face: mapeie seus gráficos na face frontal +@msg The graphics are too complex: simplify the symbols' art Os gráficos são complexos demais: simplifique a arte dos símbolos +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art A arte de um gráfico pode ser aninhada em no máximo {MAX_LEVELS} níveis: simplifique a arte do símbolo +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art A arte de um gráfico pode aninhar símbolos em no máximo {MAX_NESTING} níveis: simplifique a arte do símbolo +@msg A graphic's art holds too many objects: simplify the symbol's art A arte de um gráfico contém objetos demais: simplifique a arte do símbolo +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Um gráfico desenha no máximo {MAX_PIECES} preenchimentos e traçados: simplifique a arte do símbolo +@msg `surface` must be one of {_1} `surface` deve ser um de {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} desenha a arte original deste objeto em vez da sua superfície 3D +@msg {label} draws no faces for this object {label} não desenha nenhuma face para este objeto +@msg the wireframe has more than {MAX_WIRE_EDGES} edges a estrutura de arame tem mais de {MAX_WIRE_EDGES} arestas @msg The 3D surface is too complex; simplify the shape A superfície 3D é complexa demais; simplifique a forma @msg an image of {w} × {h} pixels is too large to draw uma imagem de {w} × {h} pixels é grande demais para ser desenhada @msg {path}: not a PNG, JPEG, WebP, TIFF or BMP image {path}: não é uma imagem PNG, JPEG, WebP, TIFF ou BMP @@ -6376,6 +6437,13 @@ axis Y Axis Eixo Y @msg Batch cannot play an action with a stop Lote não pode reproduzir uma ação com uma parada Insert Menu Item… Inserir Item de Menu… Insert Menu Item Inserir Item de Menu + Insert Select Path Inserir Seleção de Caminho + Select Path Selecionar Caminho +@msg select one whole path or compound path selecione um caminho inteiro ou um caminho composto +@msg select only one path selecione apenas um caminho +@msg the path is hidden or locked, or cannot be selected here o caminho está oculto ou bloqueado, ou não pode ser selecionado aqui +@msg object {value} is not the stored path: it changed, or the document is another one o objeto {value} não é o caminho armazenado: ele mudou, ou o documento é outro +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` deve ser "v1:" e 16 dígitos hexadecimais minúsculos Select Object… Selecionar Objeto… Select Object Selecionar Objeto Set Selection Definir Seleção diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 13512a6070..f70877e5b1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2301,6 +2301,8 @@ file-filter Templates Шаблоны Normal Screen Mode Обычный режим экрана Note: Примечание: Nothing matches the Show filter. Ничего не соответствует фильтру «Показать». + Show DCS Transparency Interactions Показывать взаимодействия DCS с прозрачностью + Transparent, or overlaps transparent objects: check its separations Прозрачно или перекрывает прозрачные объекты: проверьте цветоделение Nothing to print Нечего печатать Nudge Сдвинуть Nudge in Perspective Сдвинуть в перспективе @@ -5479,6 +5481,65 @@ lighting Light Свет @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: из изображения нельзя построить тело. Сначала трассируйте его командой Объект › Трассировка изображения › Создать. @msg {label} requires a shape with at least two anchors {label}: требуется фигура не менее чем с двумя опорными точками @msg The shape is too complex for a 3D effect Фигура слишком сложна для 3D-эффекта +@msg Extrude & Bevel Выдавливание и фаска +@msg Revolve Вращение +@msg Inflate Надувание +@msg Rotate Повернуть +@msg Extrude & Bevel (Classic) Выдавливание и фаска (классическое) +@msg Revolve (Classic) Вращение (классическое) +@msg Rotate (Classic) Поворот (классический) +@msg This effect Этот эффект +@msg clipping paths обтравочные контуры +@msg other objects другие объекты +@msg {label} requires a group holding a shape with at least two anchors {label}: требуется группа с фигурой не менее чем с двумя опорными точками +@msg Revolve requires a group holding a shape with at least two anchors Вращение: требуется группа с фигурой не менее чем с двумя опорными точками +@msg Revolve requires a shape with at least two anchors Вращение: требуется фигура не менее чем с двумя опорными точками +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} для группы: нужны контуры, составные контуры, текст, группы, обтравочные группы, переходы и экземпляры символов; сначала отмените или разберите эти элементы: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} для группы: допускается не более {MAX_MEMBERS} объектов +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 3D-эффект для группы или слоя применяется к самой группе, а не к её заливке или обводке +@msg a 3D effect on a group's fill or stroke draws nothing 3D-эффект на заливке или обводке группы ничего не рисует +@msg a 3D effect on a group's fill or stroke draws nothing to export 3D-эффект на заливке или обводке группы не рисует ничего для экспорта +@msg The 3D object is too complex to export 3D-объект слишком сложен для экспорта +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) у объекта нет 3D-эффекта (Выдавливание и фаска, Вращение, Надувание или Повернуть) +@msg its 3D effect builds no solid его 3D-эффект не строит тела +@msg bevel must be none or one of {_1} bevel должен быть none или одним из: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: нельзя построить тела из этой иллюстрации: её экземпляры символов, переходы и обтравочные группы содержат слишком много объектов +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}: нельзя построить тело из экземпляра символа «{symbol}»: иллюстрация символа недоступна +@msg {_1}: the clipping path is too complex to cut the art with {_1}: обтравочный контур слишком сложен, чтобы обрезать им иллюстрацию +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}: допускается не более {MAX_MEMBERS} объектов +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}: нельзя построить тела из иллюстрации с вложенностью более {MAX_DEPTH} уровней +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Окраска «Вращения» слишком сложна для этой поверхности; уменьшите «Сегменты» или упростите градиент +@msg Revolve edge must be left or right Вращение: edge должен быть left или right +@msg Revolve cap must be a boolean Вращение: cap должен быть логическим значением +@msg the revolved object has no fill or stroke у объекта вращения нет заливки или обводки +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor должен быть непрозрачным цветом RGB в формате #rrggbb +@msg lights must contain one to ten lights lights должен содержать от одного до десяти источников света +@msg each light must be an object каждый источник света должен быть объектом +@msg unknown light setting: use color, intensity, rotation, height, softness or behind неизвестный параметр источника света: используйте color, intensity, rotation, height, softness или behind +@msg light {key} must be a number from {min} to {max} источник света: {key} должен быть числом от {min} до {max} +@msg light color must use opaque #rrggbb color источника света должен быть непрозрачным #rrggbb +@msg light behind must be true or false behind источника света должен быть true или false +@msg shadow must be true or false shadow должен быть true или false +@msg shadowPosition must be behind or below shadowPosition должен быть behind или below +@msg {key} must be a number from {min} to {max} {key} должно быть числом от {min} до {max} +@msg each of `{GRAPHICS}` must be an object каждый элемент `{GRAPHICS}` должен быть объектом +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` элементы `{GRAPHICS}` принимают symbol, face, x, y, scale и rotation, а не `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol каждому элементу `{GRAPHICS}` нужен `symbol`, имя символа +@msg `{GRAPHICS}` face must be front or back face в `{GRAPHICS}` должен быть front или back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} в `{GRAPHICS}` должен быть числом от {lo} до {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` должен быть массивом +@msg Graphics map onto planar faces: use the Extrude or Plane object type Графика наносится на плоские грани: используйте тип объекта «Выдавливание» или «Плоскость» +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` принимает не более {MAX_GRAPHICS} графических элементов +@msg A plane has one face: map its graphics onto the front У плоскости одна сторона: размещайте её графику на передней стороне +@msg The graphics are too complex: simplify the symbols' art Графика слишком сложна: упростите иллюстрации символов +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Иллюстрация графики может быть вложена не более чем на {MAX_LEVELS} уровней: упростите иллюстрацию символа +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Иллюстрация графики может вкладывать символы не более чем на {MAX_NESTING} уровней: упростите иллюстрацию символа +@msg A graphic's art holds too many objects: simplify the symbol's art Иллюстрация графики содержит слишком много объектов: упростите иллюстрацию символа +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Графика рисует не более {MAX_PIECES} заливок и обводок: упростите иллюстрацию символа +@msg `surface` must be one of {_1} `surface` должно быть одним из: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label}: для этого объекта рисуется исходная иллюстрация вместо его 3D-поверхности +@msg {label} draws no faces for this object {label}: для этого объекта не рисуется ни одной грани +@msg the wireframe has more than {MAX_WIRE_EDGES} edges каркас содержит более {MAX_WIRE_EDGES} рёбер @msg The 3D surface is too complex; simplify the shape 3D-поверхность слишком сложна; упростите фигуру Transform Objects Трансформировать объекты @msg unknown parameter `{key}`; did you mean `{near}`? неизвестный параметр `{key}`; возможно, имелся в виду `{near}`? @@ -6072,6 +6133,13 @@ axis Y Axis Ось Y @msg Batch cannot play an action with a stop Пакет не может воспроизвести действие с остановкой Insert Menu Item… Вставить пункт меню… Insert Menu Item Вставить пункт меню + Insert Select Path Вставить выделение контура + Select Path Выделить контур +@msg select one whole path or compound path выделите один целый контур или составной контур +@msg select only one path выделите только один контур +@msg the path is hidden or locked, or cannot be selected here контур скрыт или заблокирован либо не может быть выделен здесь +@msg object {value} is not the stored path: it changed, or the document is another one объект {value} не является сохранённым контуром: он изменился или это другой документ +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` должен состоять из "v1:" и 16 шестнадцатеричных цифр в нижнем регистре Select Object… Выделить объект… Select Object Выделить объект Set Selection Задать выделение diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5f82d23f68..810e92e181 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2215,6 +2215,8 @@ font-technology Variable Variabla Normal Screen Mode Normalt skärmläge Note: Obs: Nothing matches the Show filter. Inget matchar filtret Visa. + Show DCS Transparency Interactions Visa DCS-genomskinlighetsinteraktioner + Transparent, or overlaps transparent objects: check its separations Genomskinlig eller överlappar genomskinliga objekt: kontrollera dess separationer Nothing to print Inget att skriva ut Nudge Flytta stegvis Nudge in Perspective Flytta stegvis i perspektiv @@ -5347,6 +5349,65 @@ lighting Light Ljus @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} kan inte skapa en solid av en bild: kalkera den först med Objekt › Bildkalkering › Skapa. @msg {label} requires a shape with at least two anchors {label} kräver en form med minst två ankarpunkter @msg The shape is too complex for a 3D effect Formen är för komplex för en 3D-effekt +@msg Extrude & Bevel Extrudera och fasa +@msg Revolve Rotera runt axel +@msg Inflate Blås upp +@msg Rotate Rotera +@msg Extrude & Bevel (Classic) Extrudera och fasa (klassisk) +@msg Revolve (Classic) Rotera runt axel (klassisk) +@msg Rotate (Classic) Rotera (klassisk) +@msg This effect Den här effekten +@msg clipping paths urklippsbanor +@msg other objects andra objekt +@msg {label} requires a group holding a shape with at least two anchors {label} kräver en grupp med en form med minst två ankarpunkter +@msg Revolve requires a group holding a shape with at least two anchors Rotera runt axel kräver en grupp med en form med minst två ankarpunkter +@msg Revolve requires a shape with at least two anchors Rotera runt axel kräver en form med minst två ankarpunkter +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} på en grupp kräver banor, sammansatta banor, text, grupper, urklippsgrupper, blandningar och symbolinstanser: frigör eller expandera först följande: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} på en grupp tar högst {MAX_MEMBERS} objekt +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke En 3D-effekt på en grupp eller ett lager hör till själva gruppen, inte till dess fyllning eller linje +@msg a 3D effect on a group's fill or stroke draws nothing en 3D-effekt på en grupps fyllning eller linje ritar ingenting +@msg a 3D effect on a group's fill or stroke draws nothing to export en 3D-effekt på en grupps fyllning eller linje ritar ingenting att exportera +@msg The 3D object is too complex to export 3D-objektet är för komplext för att exporteras +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) objektet har ingen 3D-effekt (Extrudera och fasa, Rotera runt axel, Blås upp eller Rotera) +@msg its 3D effect builds no solid dess 3D-effekt bygger ingen solid +@msg bevel must be none or one of {_1} bevel måste vara none eller något av {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} kan inte bygga solider av den här teckningen: dess symbolinstanser, blandningar och urklippsgrupper innehåller för många objekt +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} kan inte bygga en solid av instansen av symbolen ”{symbol}”: symbolens teckning är inte tillgänglig +@msg {_1}: the clipping path is too complex to cut the art with {_1}: urklippsbanan är för komplex för att klippa teckningen med +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} tar högst {MAX_MEMBERS} objekt +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} kan inte bygga solider av teckning som är kapslad mer än {MAX_DEPTH} nivåer djupt +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Färgen för Rotera runt axel är för komplex för den här ytan; minska Segment eller förenkla övertoningen +@msg Revolve edge must be left or right Rotera runt axel: edge måste vara left eller right +@msg Revolve cap must be a boolean Rotera runt axel: cap måste vara ett booleskt värde +@msg the revolved object has no fill or stroke det roterade objektet har ingen fyllning eller linje +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor måste vara en ogenomskinlig RGB-färg i formen #rrggbb +@msg lights must contain one to ten lights lights måste innehålla ett till tio ljus +@msg each light must be an object varje ljus måste vara ett objekt +@msg unknown light setting: use color, intensity, rotation, height, softness or behind okänd ljusinställning: använd color, intensity, rotation, height, softness eller behind +@msg light {key} must be a number from {min} to {max} ljus: {key} måste vara ett tal från {min} till {max} +@msg light color must use opaque #rrggbb color för ett ljus måste vara en ogenomskinlig #rrggbb +@msg light behind must be true or false behind för ett ljus måste vara true eller false +@msg shadow must be true or false shadow måste vara true eller false +@msg shadowPosition must be behind or below shadowPosition måste vara behind eller below +@msg {key} must be a number from {min} to {max} {key} måste vara ett tal från {min} till {max} +@msg each of `{GRAPHICS}` must be an object varje post i `{GRAPHICS}` måste vara ett objekt +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` posterna i `{GRAPHICS}` tar symbol, face, x, y, scale och rotation, inte `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol varje post i `{GRAPHICS}` behöver `symbol`, namnet på en symbol +@msg `{GRAPHICS}` face must be front or back face i `{GRAPHICS}` måste vara front eller back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} i `{GRAPHICS}` måste vara ett tal från {lo} till {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` måste vara en matris +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafik placeras på plana sidor: använd objekttypen Extrudera eller Plan +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` tar högst {MAX_GRAPHICS} grafiker +@msg A plane has one face: map its graphics onto the front Ett plan har en sida: placera dess grafik på framsidan +@msg The graphics are too complex: simplify the symbols' art Grafiken är för komplex: förenkla symbolernas teckning +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art En grafiks teckning får vara kapslad högst {MAX_LEVELS} nivåer djupt: förenkla symbolens teckning +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art En grafiks teckning får kapsla symboler högst {MAX_NESTING} nivåer djupt: förenkla symbolens teckning +@msg A graphic's art holds too many objects: simplify the symbol's art En grafiks teckning innehåller för många objekt: förenkla symbolens teckning +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art En grafik ritar högst {MAX_PIECES} fyllningar och linjer: förenkla symbolens teckning +@msg `surface` must be one of {_1} `surface` måste vara något av {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} ritar källteckningen för det här objektet i stället för dess 3D-yta +@msg {label} draws no faces for this object {label} ritar inga sidor för det här objektet +@msg the wireframe has more than {MAX_WIRE_EDGES} edges trådramen har fler än {MAX_WIRE_EDGES} kanter @msg The 3D surface is too complex; simplify the shape 3D-ytan är för komplex; förenkla formen Transform Objects Transformera objekt @msg unknown parameter `{key}`; did you mean `{near}`? okänd parameter `{key}`; menade du `{near}`? @@ -6442,6 +6503,13 @@ axis Y Axis Y-axel @msg Batch cannot play an action with a stop Batch kan inte spela upp en åtgärd med ett stopp Insert Menu Item… Infoga menyalternativ… Insert Menu Item Infoga menyalternativ + Insert Select Path Infoga markering av bana + Select Path Markera bana +@msg select one whole path or compound path markera en hel bana eller sammansatt bana +@msg select only one path markera bara en bana +@msg the path is hidden or locked, or cannot be selected here banan är dold eller låst, eller kan inte markeras här +@msg object {value} is not the stored path: it changed, or the document is another one objekt {value} är inte den sparade banan: den har ändrats, eller så är dokumentet ett annat +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` måste vara "v1:" och 16 hexadecimala siffror med gemener Select Object… Markera objekt… Select Object Markera objekt Set Selection Ange markering diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 70dfe0ab90..b5d6eaf7ec 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1896,6 +1896,142 @@ fn plugin_error_reasons_are_translated_with_the_error() { assert!(message(de(), &errors[3].0).ends_with("objects[0]: `opacity` muss eine Zahl sein"), "{}", message(de(), &errors[3].0)); } +/// The 3D effects' source files under `crates/effects/src`. Their refusals reach the status bar +/// through the effect commands, and every `format!` text in them outside tests is a refusal. +const THREE_D_SOURCES: &[&str] = &[ + "solids.rs", + "solids/classic.rs", + "solids/graphics.rs", + "solids/shadow.rs", + "solids/source.rs", + "solids/wireframe.rs", + "revolve.rs", + "revolve/classic.rs", + "lighting.rs", +]; + +/// The values 3D refusals put in their templates that are messages of their own: the name of an +/// effect that has no 3D type of its own, and the objects a group's 3D effect can't build from. +const THREE_D_PARTS: &[&str] = &["This effect", "clipping paths", "other objects"]; + +/// The 3D effects' refusals as catalog templates: the messages of [`THREE_D_SOURCES`] that +/// [`source_messages`] finds, and the `format!` texts their helpers return. +fn three_d_refusals() -> std::collections::BTreeSet { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../effects/src"); + let texts: Vec = THREE_D_SOURCES.iter().map(|f| std::fs::read_to_string(dir.join(f)).expect("a 3D source file")).collect(); + let mut consts = std::collections::BTreeMap::new(); + for text in &texts { + for (name, value) in str_consts(without_test_module(text)) { + consts.entry(name).or_insert_with(Vec::new).push(value); + } + } + texts.iter().flat_map(|text| source_messages(without_test_module(text), &[("format!(", 0)], &consts)).collect() +} + +/// The names 3D refusals give their effect (`{label}`): each 3D effect's menu label without its +/// ellipsis, and [`THREE_D_PARTS`]. +fn three_d_parts() -> Vec { + let labels = vectorcraft_effects::effect_catalog().into_iter().filter(|e| vectorcraft_effects::is_three_d(e.id)); + labels.map(|e| e.label.trim_end_matches('…').to_string()).chain(THREE_D_PARTS.iter().map(|p| p.to_string())).collect() +} + +/// The 3D effects' refusals, and the effect names and objects they name, have an `@msg` row in +/// every complete-message catalog. +#[test] +fn three_d_refusals_are_translated_in_every_complete_catalog() { + let refusals = three_d_refusals(); + assert!(refusals.len() > 55, "scan found only {} 3D refusals", refusals.len()); + let helper_made = [ + "{label} can't build a solid from an image: trace it first with Object › Image Trace › Make.", + "{_1} can't build solids from art nested more than {MAX_DEPTH} levels deep", + "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)", + ]; + for m in helper_made { + assert!(refusals.contains(m), "the scan reads helpers' texts and constants: {m}"); + } + let parts = three_d_parts(); + for label in ["Extrude & Bevel", "Revolve", "Inflate", "Rotate", "Extrude & Bevel (Classic)", "Revolve (Classic)", "Rotate (Classic)"] { + assert!(parts.iter().any(|p| p == label), "{label} is a 3D effect's name"); + } + let mut missing = vec![]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + let rows: std::collections::BTreeSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect(); + missing.extend(refusals.iter().chain(&parts).filter(|m| !rows.contains(m.as_str())).map(|m| format!("{code}: {m}"))); + } + assert!(missing.is_empty(), "{} 3D messages have no @msg row:\n{}", missing.len(), missing.join("\n")); +} + +/// A 3D effect's refusal shows in the interface language whole, with the effect's translated +/// name, and with its numbers, the parameter keys it names and a symbol's name as given; the +/// error the command returns stays English. +#[test] +fn three_d_refusals_translate_with_their_values() { + use serde_json::json; + use std::sync::Arc; + use vectorcraft_doc::{Node, NodeId, NodeKind}; + use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE}; + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let id = s.execute("path.create", &json!({"d": "M10 10L60 10L60 60L10 60Z"})).unwrap()["id"].as_u64().unwrap(); + let rect = s.active().unwrap().doc.node(NodeId(id)).unwrap().clone(); + let group = Node::group(NodeId(id + 1), vec![Arc::new(rect.clone())]); + let instance = Node::new(NodeId(id + 2), NodeKind::SymbolInstance { symbol: "Mine 7".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let refuse = |r: Result<(), String>| { + let e = r.expect_err("refused"); + vectorcraft_engine::EngineError::BadParams { cmd: "effect.apply".into(), msg: e }.to_string() + }; + let errors = [ + (refuse(vectorcraft_effects::validate_three_d_at(&group, EXTRUDE, &json!({}), Some((0, 0)))), "goes on the group itself", vec![]), + ( + refuse(vectorcraft_effects::validate_three_d(&rect, EXTRUDE, &json!({"lights": [{"intensity": 500}]}))), + "must be a number from", + vec!["0", "100"], + ), + ( + refuse(vectorcraft_effects::validate_three_d(&rect, EXTRUDE, &json!({"graphics": [{"symbol": "S", "face": "side"}]}))), + "must be front or back", + vec!["`graphics`", "front", "back"], + ), + (refuse(vectorcraft_effects::validate_three_d(&rect, CLASSIC_EXTRUDE, &json!({"depth": "deep"}))), "must be a number", vec!["`depth`"]), + (refuse(vectorcraft_effects::validate_three_d(&instance, EXTRUDE, &json!({}))), "the symbol's art isn't available", vec!["Mine 7"]), + (refuse(vectorcraft_effects::three_d_wireframe_in(None, &rect).map(drop)), "has no 3D effect", vec![]), + ]; + for (e, english, _) in &errors { + assert!(e.starts_with("invalid parameters for `effect.apply`: ") && e.contains(english), "{e}"); + assert_eq!(message(Lang::EN, e), *e); + } + assert!(errors[1].0.contains("light intensity must be a number from 0 to 100"), "{}", errors[1].0); + assert!(errors[4].0.contains("Extrude & Bevel can't build a solid from the instance of symbol \"Mine 7\""), "{}", errors[4].0); + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + for (e, english, kept) in &errors { + let shown = message(lang, e); + assert!(!shown.contains(english) && shown.contains("`effect.apply`"), "{code}: {e} -> {shown}"); + assert!(kept.iter().all(|k| shown.contains(k)), "{code}: {e} -> {shown}"); + } + let symbol = message(lang, &errors[4].0); + let label = message(lang, "Extrude & Bevel"); + assert!(label != "Extrude & Bevel" && symbol.contains(&label), "{code}: the effect's name is translated: {symbol}"); + } + assert_eq!(message(de(), &errors[1].0), "ungültige Parameter für `effect.apply`: Licht: intensity muss eine Zahl von 0 bis 100 sein"); +} + +/// A value a message quotes is a name and is shown as written, even when a catalog row translates +/// the same text: a symbol called Rotate stays Rotate while the effect's name is translated. +#[test] +fn quoted_values_in_a_message_stay_as_written() { + let s = "Extrude & Bevel can't build a solid from the instance of symbol \"Rotate\": the symbol's art isn't available"; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let shown = message(lang, s); + let rotate = message(lang, "Rotate"); + assert!(rotate != "Rotate" && shown.contains("Rotate") && !shown.contains(&rotate), "{code}: {shown}"); + assert!(shown.contains(&message(lang, "Extrude & Bevel")), "{code}: {shown}"); + } +} + /// The Gamut Check status line names a count of 1 in the singular and other counts in the plural, /// and every complete catalog translates it, in the form its plural rule picks. #[test] @@ -3262,6 +3398,7 @@ const NAMED_KEYS: &[(&str, &[&str])] = &[ ("axis must be \"vertical\", \"horizontal\" or an angle in degrees, not `{v}`", &["axis"]), ("balance must be between 0 and 100", &["balance"]), ("before must name a panel", &["before"]), + ("bevel must be none or one of {_1}", &["bevel", "none"]), ("brightness must be dark|mediumDark|mediumLight|light", &["brightness", "dark", "mediumDark", "mediumLight", "light"]), ("clip must be true or false", &["clip"]), ("collapsed must be true or false", &["collapsed"]), @@ -3281,6 +3418,10 @@ const NAMED_KEYS: &[(&str, &[&str])] = &[ ("kerning must be a number, \"auto\", or \"optical\"", &["kerning"]), ("lang must be one of {_1}", &["lang"]), ("layers must be true or false", &["layers"]), + ("light behind must be true or false", &["behind"]), + ("light color must use opaque #rrggbb", &["color"]), + ("lightColor must be an opaque RGB color in #rrggbb form", &["lightColor"]), + ("lights must contain one to ten lights", &["lights"]), ("member is for order front, forward, backward or back", &["member", "order", "front", "forward", "backward", "back"]), ("mode is set, add or toggle, not `{other}`", &["mode", "set", "add", "toggle"]), ("names is empty", &["names"]), @@ -3295,18 +3436,28 @@ const NAMED_KEYS: &[(&str, &[&str])] = &[ ("plane must be left, right or ground", &["plane", "left", "right", "ground"]), ("precision must be from 0.001 to 10 pt", &["precision"]), ("rect is [x, y, width, height] in points", &["rect"]), + ("Revolve cap must be a boolean", &["cap"]), + ("Revolve edge must be left or right", &["edge", "left", "right"]), ("scale must be a number from {_1} to {_2}, not {_3}", &["scale"]), ("scale must be a number from {_1} to {_2}, not {s}", &["scale"]), + ("shadow must be true or false", &["shadow"]), + ("shadowPosition must be behind or below", &["shadowPosition", "behind", "below"]), ("show must be true or false", &["show"]), ("span: \"selection\" places a linear, radial, conic or diamond gradient, not a freeform one", &["span"]), ("thickness must be from 0.01 to 5000 pt", &["thickness"]), ("title can't be empty", &["title"]), + ( + "unknown light setting: use color, intensity, rotation, height, softness or behind", + &["color", "intensity", "rotation", "height", "softness", "behind"], + ), ("view is required", &["view"]), ("width must be positive", &["width"]), ("zone must be a string", &["zone"]), ("zone must be tab, center, left, right, top or bottom", &["zone", "tab", "center", "left", "right", "top", "bottom"]), ("`dither` applies to gradient fills (stroke: false)", &["stroke"]), ("`strokeMode` applies to strokes (stroke: true)", &["stroke"]), + ("`{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}`", &["symbol", "face", "x", "y", "scale", "rotation"]), + ("`{GRAPHICS}` face must be front or back", &["face", "front", "back"]), ]; /// Every catalog's translation of a [`NAMED_KEYS`] message keeps each name the table lists, as diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index c86ac108e2..5f33b1dc88 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2085,6 +2085,8 @@ font-technology Variable Değişken Normal Screen Mode Normal Ekran Modu Note: Not: Nothing matches the Show filter. Hiçbir şey Göster filtresiyle eşleşmiyor. + Show DCS Transparency Interactions DCS Saydamlık Etkileşimlerini Göster + Transparent, or overlaps transparent objects: check its separations Saydam veya saydam nesnelerle çakışıyor: ayrımlarını denetleyin Nothing to print Yazdırılacak bir şey yok Nudge Dürt Nudge in Perspective Perspektifte İtme @@ -5142,6 +5144,65 @@ lighting Light Işık @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label} bir görüntüden katı cisim oluşturamaz: önce Nesne › Görüntü İzleme › Yap ile izleyin. @msg {label} requires a shape with at least two anchors {label} en az iki bağlantı noktasına sahip bir şekil gerektirir @msg The shape is too complex for a 3D effect Şekli 3 boyutlu bir efekt için fazla karmaşık +@msg Extrude & Bevel Ekstrüzyon ve Eğim +@msg Revolve Döndür +@msg Inflate Şişir +@msg Rotate Döndür +@msg Extrude & Bevel (Classic) Ekstrüzyon ve Eğim (Klasik) +@msg Revolve (Classic) Döndür (Klasik) +@msg Rotate (Classic) Çevir (Klasik) +@msg This effect Bu efekt +@msg clipping paths kırpma yolları +@msg other objects diğer nesneler +@msg {label} requires a group holding a shape with at least two anchors {label}, en az iki bağlantı noktasına sahip bir şekil içeren bir grup gerektirir +@msg Revolve requires a group holding a shape with at least two anchors Döndür, en az iki bağlantı noktasına sahip bir şekil içeren bir grup gerektirir +@msg Revolve requires a shape with at least two anchors Döndür, en az iki bağlantı noktasına sahip bir şekil gerektirir +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. Bir gruptaki {label}; yollar, bileşik yollar, metin, gruplar, kırpma grupları, karışımlar ve sembol örnekleri gerektirir: önce şunları serbest bırak veya genişlet: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects Bir gruptaki {label} en fazla {MAX_MEMBERS} nesne alır +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Bir grup veya katmandaki 3 boyutlu efekt, dolgusuna veya konturuna değil, grubun kendisine uygulanır +@msg a 3D effect on a group's fill or stroke draws nothing bir grubun dolgusundaki veya konturundaki 3 boyutlu efekt hiçbir şey çizmez +@msg a 3D effect on a group's fill or stroke draws nothing to export bir grubun dolgusundaki veya konturundaki 3 boyutlu efekt dışa aktarılacak hiçbir şey çizmez +@msg The 3D object is too complex to export 3 boyutlu nesne dışa aktarmak için çok karmaşık +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) nesnenin 3 boyutlu efekti yok (Ekstrüzyon ve Eğim, Döndür, Şişir veya Döndür) +@msg its 3D effect builds no solid 3 boyutlu efekti hiçbir katı oluşturmuyor +@msg bevel must be none or one of {_1} bevel, none veya {_1} değerlerinden biri olmalıdır +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} bu resimden katı oluşturamaz: sembol örnekleri, karışımları ve kırpma grupları çok fazla nesne içeriyor +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}, “{symbol}” sembolünün örneğinden katı oluşturamaz: sembolün resmi kullanılamıyor +@msg {_1}: the clipping path is too complex to cut the art with {_1}: kırpma yolu resmi kesmek için çok karmaşık +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} en fazla {MAX_MEMBERS} nesne alır +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}, {MAX_DEPTH} düzeyden daha derin iç içe geçmiş resimden katı oluşturamaz +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Döndür boyası bu yüzey için çok karmaşık; Segmentler değerini azalt veya degradeyi basitleştir +@msg Revolve edge must be left or right Döndür: edge, left veya right olmalıdır +@msg Revolve cap must be a boolean Döndür: cap bir boole değeri olmalıdır +@msg the revolved object has no fill or stroke döndürülen nesnenin dolgusu veya konturu yok +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor, #rrggbb biçiminde opak bir RGB rengi olmalıdır +@msg lights must contain one to ten lights lights bir ile on arasında ışık içermelidir +@msg each light must be an object her ışık bir nesne olmalıdır +@msg unknown light setting: use color, intensity, rotation, height, softness or behind bilinmeyen ışık ayarı: color, intensity, rotation, height, softness veya behind kullan +@msg light {key} must be a number from {min} to {max} ışık: {key} değeri {min} ile {max} arasında bir sayı olmalıdır +@msg light color must use opaque #rrggbb ışığın color değeri opak #rrggbb olmalıdır +@msg light behind must be true or false ışığın behind değeri true veya false olmalıdır +@msg shadow must be true or false shadow, true veya false olmalıdır +@msg shadowPosition must be behind or below shadowPosition, behind veya below olmalıdır +@msg {key} must be a number from {min} to {max} {key} değeri {min} ile {max} arasında bir sayı olmalıdır +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}` içindeki her öğe bir nesne olmalıdır +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` girdileri symbol, face, x, y, scale ve rotation alır, `{k}` almaz +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}` içindeki her öğe, bir sembolün adı olan `symbol` gerektirir +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` face değeri front veya back olmalıdır +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` {key} değeri {lo} ile {hi} arasında bir sayı olmalıdır +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` bir dizi olmalıdır +@msg Graphics map onto planar faces: use the Extrude or Plane object type Grafikler düz yüzlere eşlenir: Ekstrüzyon veya Düzlem nesne türünü kullan +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` en fazla {MAX_GRAPHICS} grafik alır +@msg A plane has one face: map its graphics onto the front Bir düzlemin tek yüzü vardır: grafiklerini ön yüze eşle +@msg The graphics are too complex: simplify the symbols' art Grafikler çok karmaşık: sembollerin resmini basitleştir +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Bir grafiğin resmi en fazla {MAX_LEVELS} düzey iç içe olabilir: sembolün resmini basitleştir +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Bir grafiğin resmi sembolleri en fazla {MAX_NESTING} düzey iç içe yerleştirebilir: sembolün resmini basitleştir +@msg A graphic's art holds too many objects: simplify the symbol's art Bir grafiğin resmi çok fazla nesne içeriyor: sembolün resmini basitleştir +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Bir grafik en fazla {MAX_PIECES} dolgu ve kontur çizer: sembolün resmini basitleştir +@msg `surface` must be one of {_1} `surface`, {_1} değerlerinden biri olmalıdır +@msg {label} draws the source art for this object instead of its 3D surface {label} bu nesne için 3 boyutlu yüzeyi yerine kaynak resmi çizer +@msg {label} draws no faces for this object {label} bu nesne için hiçbir yüz çizmez +@msg the wireframe has more than {MAX_WIRE_EDGES} edges tel kafeste {MAX_WIRE_EDGES} kenardan fazla var @msg The 3D surface is too complex; simplify the shape 3 boyutlu yüzey çok karmaşık; şekli basitleştir Transform Objects Nesneleri Dönüştür @msg unknown parameter `{key}`; did you mean `{near}`? bilinmeyen parametre `{key}`; `{near}` mi demek istediniz? @@ -6417,6 +6478,13 @@ axis Y Axis Y ekseni @msg Batch cannot play an action with a stop Toplu işlem durak içeren bir eylemi oynatamaz Insert Menu Item… Menü Öğesi Ekle… Insert Menu Item Menü Öğesi Ekle + Insert Select Path Yol Seçimi Ekle + Select Path Yolu Seç +@msg select one whole path or compound path bir yolun tamamını veya bir bileşik yolu seçin +@msg select only one path yalnızca bir yol seçin +@msg the path is hidden or locked, or cannot be selected here yol gizli veya kilitli ya da burada seçilemiyor +@msg object {value} is not the stored path: it changed, or the document is another one {value} nesnesi kaydedilen yol değil: değişti ya da belge başka +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` "v1:" ve 16 küçük harfli onaltılık basamak olmalıdır Select Object… Nesne Seç… Select Object Nesne Seç Set Selection Seçimi Ayarla diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4601c6ca19..54cd1ab052 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2306,6 +2306,8 @@ file-filter Templates Шаблони Normal Screen Mode Звичайний режим екрану Note: Примітка: Nothing matches the Show filter. Нічого не відповідає фільтру "Показати". + Show DCS Transparency Interactions Показувати взаємодії DCS із прозорістю + Transparent, or overlaps transparent objects: check its separations Прозоре або перекриває прозорі об'єкти: перевірте кольороподіл Nothing to print Нема чого друкувати Nudge Зрушити Nudge in Perspective Зрушити у перспективі @@ -5473,6 +5475,65 @@ lighting Light Світло @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}: із зображення не можна побудувати тіло. Спершу трасуйте його командою Об’єкт › Трасування зображення › Створити. @msg {label} requires a shape with at least two anchors {label}: потрібна фігура щонайменше з двома опорними точками @msg The shape is too complex for a 3D effect Фігура надто складна для 3D-ефекту +@msg Extrude & Bevel Видавлювання та фаска +@msg Revolve Обертання +@msg Inflate Надування +@msg Rotate Повернути +@msg Extrude & Bevel (Classic) Видавлювання та фаска (класичне) +@msg Revolve (Classic) Обертання (класичне) +@msg Rotate (Classic) Поворот (класичний) +@msg This effect Цей ефект +@msg clipping paths відсічні контури +@msg other objects інші об’єкти +@msg {label} requires a group holding a shape with at least two anchors {label}: потрібна група з фігурою щонайменше з двома опорними точками +@msg Revolve requires a group holding a shape with at least two anchors Обертання: потрібна група з фігурою щонайменше з двома опорними точками +@msg Revolve requires a shape with at least two anchors Обертання: потрібна фігура щонайменше з двома опорними точками +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} для групи: потрібні контури, складені контури, текст, групи, відсічні групи, переходи та екземпляри символів; спершу звільніть або розберіть ці елементи: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} для групи: допускається не більше {MAX_MEMBERS} об’єктів +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 3D-ефект для групи або шару застосовується до самої групи, а не до її заливки чи обведення +@msg a 3D effect on a group's fill or stroke draws nothing 3D-ефект на заливці або обведенні групи нічого не малює +@msg a 3D effect on a group's fill or stroke draws nothing to export 3D-ефект на заливці або обведенні групи не малює нічого для експорту +@msg The 3D object is too complex to export 3D-об’єкт надто складний для експорту +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) об’єкт не має 3D-ефекту (Видавлювання та фаска, Обертання, Надування або Повернути) +@msg its 3D effect builds no solid його 3D-ефект не будує тіла +@msg bevel must be none or one of {_1} bevel має бути none або одним з: {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}: не вдається побудувати тіла з цієї ілюстрації: її екземпляри символів, переходи та відсічні групи містять забагато об’єктів +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}: не вдається побудувати тіло з екземпляра символу «{symbol}»: ілюстрація символу недоступна +@msg {_1}: the clipping path is too complex to cut the art with {_1}: відсічний контур надто складний, щоб обрізати ним ілюстрацію +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}: допускається не більше {MAX_MEMBERS} об’єктів +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}: не вдається побудувати тіла з ілюстрації з вкладеністю понад {MAX_DEPTH} рівнів +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Фарба «Обертання» надто складна для цієї поверхні; зменште «Сегменти» або спростіть градієнт +@msg Revolve edge must be left or right Обертання: edge має бути left або right +@msg Revolve cap must be a boolean Обертання: cap має бути логічним значенням +@msg the revolved object has no fill or stroke об’єкт обертання не має заливки чи обведення +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor має бути непрозорим кольором RGB у форматі #rrggbb +@msg lights must contain one to ten lights lights має містити від одного до десяти джерел світла +@msg each light must be an object кожне джерело світла має бути об’єктом +@msg unknown light setting: use color, intensity, rotation, height, softness or behind невідомий параметр джерела світла: використайте color, intensity, rotation, height, softness або behind +@msg light {key} must be a number from {min} to {max} джерело світла: {key} має бути числом від {min} до {max} +@msg light color must use opaque #rrggbb color джерела світла має бути непрозорим #rrggbb +@msg light behind must be true or false behind джерела світла має бути true або false +@msg shadow must be true or false shadow має бути true або false +@msg shadowPosition must be behind or below shadowPosition має бути behind або below +@msg {key} must be a number from {min} to {max} {key} має бути числом від {min} до {max} +@msg each of `{GRAPHICS}` must be an object кожен елемент `{GRAPHICS}` має бути об’єктом +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` елементи `{GRAPHICS}` приймають symbol, face, x, y, scale і rotation, а не `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol кожному елементу `{GRAPHICS}` потрібен `symbol`, назва символу +@msg `{GRAPHICS}` face must be front or back face у `{GRAPHICS}` має бути front або back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} у `{GRAPHICS}` має бути числом від {lo} до {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` має бути масивом +@msg Graphics map onto planar faces: use the Extrude or Plane object type Графіка наноситься на плоскі грані: використайте тип об’єкта «Видавлювання» або «Площина» +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` приймає не більше {MAX_GRAPHICS} графічних елементів +@msg A plane has one face: map its graphics onto the front Площина має одну сторону: розміщуйте її графіку на передній стороні +@msg The graphics are too complex: simplify the symbols' art Графіка надто складна: спростіть ілюстрації символів +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Ілюстрація графіки може бути вкладена не більше ніж на {MAX_LEVELS} рівнів: спростіть ілюстрацію символу +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Ілюстрація графіки може вкладати символи не більше ніж на {MAX_NESTING} рівнів: спростіть ілюстрацію символу +@msg A graphic's art holds too many objects: simplify the symbol's art Ілюстрація графіки містить забагато об’єктів: спростіть ілюстрацію символу +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Графіка малює не більше {MAX_PIECES} заливок і обведень: спростіть ілюстрацію символу +@msg `surface` must be one of {_1} `surface` має бути одним з: {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label}: для цього об’єкта малюється вихідна ілюстрація замість його 3D-поверхні +@msg {label} draws no faces for this object {label}: для цього об’єкта не малюється жодної грані +@msg the wireframe has more than {MAX_WIRE_EDGES} edges каркас має понад {MAX_WIRE_EDGES} ребер @msg The 3D surface is too complex; simplify the shape 3D-поверхня надто складна; спростіть фігуру Transform Objects Трансформувати об’єкти @msg unknown parameter `{key}`; did you mean `{near}`? невідомий параметр `{key}`; можливо, ви мали на увазі `{near}`? @@ -6066,6 +6127,13 @@ axis Y Axis Вісь Y @msg Batch cannot play an action with a stop Пакет не може відтворити дію із зупинкою Insert Menu Item… Вставити пункт меню… Insert Menu Item Вставити пункт меню + Insert Select Path Вставити виділення контуру + Select Path Виділити контур +@msg select one whole path or compound path виділіть один цілий контур або складений контур +@msg select only one path виділіть лише один контур +@msg the path is hidden or locked, or cannot be selected here контур прихований або заблокований, або його не можна виділити тут +@msg object {value} is not the stored path: it changed, or the document is another one об'єкт {value} не є збереженим контуром: його змінено або це інший документ +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` має складатися з "v1:" і 16 шістнадцяткових цифр у нижньому регістрі Select Object… Виділити об’єкт… Select Object Виділити об’єкт Set Selection Задати виділення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6c42f99c33..fba35da273 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2567,6 +2567,8 @@ digits Hindi Hindi Not found Không tìm thấy Note: Ghi chú: Nothing matches the Show filter. Không có gì khớp với bộ lọc Hiện. + Show DCS Transparency Interactions Hiện tương tác trong suốt DCS + Transparent, or overlaps transparent objects: check its separations Trong suốt hoặc chồng lên đối tượng trong suốt: hãy kiểm tra các bản tách màu Nothing to print Không có gì để in NTSC Colors Màu NTSC Nudge Đẩy nhẹ @@ -5260,6 +5262,65 @@ theme Appearance Giao diện @msg Targa files are RGB: colorModel rgb Tệp Targa là RGB: colorModel rgb @msg Text Wrap: select a wrap object Bọc chữ: hãy chọn đối tượng bọc @msg that document is no longer open tài liệu đó không còn mở +@msg Extrude & Bevel Kéo dài & vát +@msg Revolve Xoay tròn +@msg Inflate Phình +@msg Rotate Xoay +@msg Extrude & Bevel (Classic) Kéo dài & vát (cổ điển) +@msg Revolve (Classic) Xoay tròn (cổ điển) +@msg Rotate (Classic) Xoay (cổ điển) +@msg This effect Hiệu ứng này +@msg clipping paths đường dẫn cắt +@msg other objects các đối tượng khác +@msg {label} requires a group holding a shape with at least two anchors {label} yêu cầu một nhóm chứa hình dạng có ít nhất hai điểm neo +@msg Revolve requires a group holding a shape with at least two anchors Xoay tròn yêu cầu một nhóm chứa hình dạng có ít nhất hai điểm neo +@msg Revolve requires a shape with at least two anchors Xoay tròn yêu cầu một hình dạng có ít nhất hai điểm neo +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. {label} trên nhóm cần đường dẫn, đường dẫn phức, văn bản, nhóm, nhóm cắt, hòa trộn và phiên bản ký hiệu: hãy nhả hoặc mở rộng các mục sau trước: {what}. +@msg {label} on a group takes at most {MAX_MEMBERS} objects {label} trên nhóm nhận tối đa {MAX_MEMBERS} đối tượng +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke Hiệu ứng 3D trên nhóm hoặc lớp được đặt trên chính nhóm, không phải trên phần tô hoặc nét của nó +@msg a 3D effect on a group's fill or stroke draws nothing hiệu ứng 3D trên phần tô hoặc nét của nhóm không vẽ gì +@msg a 3D effect on a group's fill or stroke draws nothing to export hiệu ứng 3D trên phần tô hoặc nét của nhóm không vẽ gì để xuất +@msg The 3D object is too complex to export Đối tượng 3D quá phức tạp để xuất +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) đối tượng không có hiệu ứng 3D (Kéo dài & vát, Xoay tròn, Phình hoặc Xoay) +@msg its 3D effect builds no solid hiệu ứng 3D của nó không tạo ra khối nào +@msg bevel must be none or one of {_1} bevel phải là none hoặc một trong {_1} +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1} không thể tạo khối từ hình vẽ này: các phiên bản ký hiệu, hòa trộn và nhóm cắt của nó chứa quá nhiều đối tượng +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label} không thể tạo khối từ phiên bản của ký hiệu “{symbol}”: không có hình vẽ của ký hiệu +@msg {_1}: the clipping path is too complex to cut the art with {_1}: đường dẫn cắt quá phức tạp để cắt hình vẽ +@msg {_1} takes at most {MAX_MEMBERS} objects {_1} nhận tối đa {MAX_MEMBERS} đối tượng +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} không thể tạo khối từ hình vẽ lồng sâu hơn {MAX_DEPTH} cấp +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Màu tô của Xoay tròn quá phức tạp cho bề mặt này; hãy giảm Đoạn hoặc đơn giản hóa chuyển sắc +@msg Revolve edge must be left or right Xoay tròn: edge phải là left hoặc right +@msg Revolve cap must be a boolean Xoay tròn: cap phải là giá trị boolean +@msg the revolved object has no fill or stroke đối tượng xoay tròn không có phần tô hoặc nét +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor phải là màu RGB không trong suốt dạng #rrggbb +@msg lights must contain one to ten lights lights phải chứa từ một đến mười nguồn sáng +@msg each light must be an object mỗi nguồn sáng phải là một đối tượng +@msg unknown light setting: use color, intensity, rotation, height, softness or behind thiết lập nguồn sáng không xác định: hãy dùng color, intensity, rotation, height, softness hoặc behind +@msg light {key} must be a number from {min} to {max} nguồn sáng: {key} phải là số từ {min} đến {max} +@msg light color must use opaque #rrggbb color của nguồn sáng phải là #rrggbb không trong suốt +@msg light behind must be true or false behind của nguồn sáng phải là true hoặc false +@msg shadow must be true or false shadow phải là true hoặc false +@msg shadowPosition must be behind or below shadowPosition phải là behind hoặc below +@msg {key} must be a number from {min} to {max} {key} phải là số từ {min} đến {max} +@msg each of `{GRAPHICS}` must be an object mỗi mục của `{GRAPHICS}` phải là một đối tượng +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` các mục của `{GRAPHICS}` nhận symbol, face, x, y, scale và rotation, không nhận `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol mỗi mục của `{GRAPHICS}` cần `symbol`, tên của một ký hiệu +@msg `{GRAPHICS}` face must be front or back face của `{GRAPHICS}` phải là front hoặc back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} {key} của `{GRAPHICS}` phải là số từ {lo} đến {hi} +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` phải là một mảng +@msg Graphics map onto planar faces: use the Extrude or Plane object type Đồ họa được đặt lên các mặt phẳng: hãy dùng loại đối tượng Kéo dài hoặc Mặt phẳng +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` nhận tối đa {MAX_GRAPHICS} đồ họa +@msg A plane has one face: map its graphics onto the front Mặt phẳng chỉ có một mặt: hãy đặt đồ họa của nó lên mặt trước +@msg The graphics are too complex: simplify the symbols' art Đồ họa quá phức tạp: hãy đơn giản hóa hình vẽ của các ký hiệu +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art Hình vẽ của một đồ họa chỉ được lồng tối đa {MAX_LEVELS} cấp: hãy đơn giản hóa hình vẽ của ký hiệu +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art Hình vẽ của một đồ họa chỉ được lồng ký hiệu tối đa {MAX_NESTING} cấp: hãy đơn giản hóa hình vẽ của ký hiệu +@msg A graphic's art holds too many objects: simplify the symbol's art Hình vẽ của một đồ họa chứa quá nhiều đối tượng: hãy đơn giản hóa hình vẽ của ký hiệu +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art Một đồ họa vẽ tối đa {MAX_PIECES} phần tô và nét: hãy đơn giản hóa hình vẽ của ký hiệu +@msg `surface` must be one of {_1} `surface` phải là một trong {_1} +@msg {label} draws the source art for this object instead of its 3D surface {label} vẽ hình vẽ nguồn cho đối tượng này thay vì bề mặt 3D của nó +@msg {label} draws no faces for this object {label} không vẽ mặt nào cho đối tượng này +@msg the wireframe has more than {MAX_WIRE_EDGES} edges khung dây có hơn {MAX_WIRE_EDGES} cạnh @msg The 3D surface is too complex; simplify the shape Bề mặt 3D quá phức tạp; hãy đơn giản hóa hình dạng @msg the art could not be sampled không thể lấy mẫu tác phẩm @msg the art has no bounds tác phẩm không có giới hạn @@ -6067,6 +6128,13 @@ axis Y Axis Trục Y @msg Batch cannot play an action with a stop Hàng loạt không thể phát hành động có điểm dừng Insert Menu Item… Chèn mục menu… Insert Menu Item Chèn mục menu + Insert Select Path Chèn chọn đường dẫn + Select Path Chọn đường dẫn +@msg select one whole path or compound path hãy chọn một đường dẫn trọn vẹn hoặc đường dẫn phức +@msg select only one path chỉ chọn một đường dẫn +@msg the path is hidden or locked, or cannot be selected here đường dẫn bị ẩn hoặc bị khóa, hoặc không thể chọn ở đây +@msg object {value} is not the stored path: it changed, or the document is another one đối tượng {value} không phải là đường dẫn đã lưu: nó đã thay đổi, hoặc đây là tài liệu khác +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` phải là "v1:" và 16 chữ số thập lục phân viết thường Select Object… Chọn đối tượng… Select Object Chọn đối tượng Set Selection Đặt vùng chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index f5415f5d21..f9de2c84d6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2173,6 +2173,8 @@ file-filter Templates 模板 Normal Screen Mode 正常屏幕模式 Note: 注释: Nothing matches the Show filter. 没有项目符合“显示”筛选条件。 + Show DCS Transparency Interactions 显示 DCS 透明度交互 + Transparent, or overlaps transparent objects: check its separations 透明或与透明对象重叠:请检查其分色 Nothing to print 没有可打印的内容 Nudge 微移 Nudge in Perspective 在透视中微移 @@ -5749,6 +5751,65 @@ lighting Light 光源 @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}无法从图像生成立体:请先使用“对象 › 图像描摹 › 创建”进行描摹。 @msg {label} requires a shape with at least two anchors {label}需要至少有两个锚点的形状 @msg The shape is too complex for a 3D effect 形状过于复杂,无法应用 3D 效果 +@msg Extrude & Bevel 突出与斜角 +@msg Revolve 回转 +@msg Inflate 膨胀 +@msg Rotate 旋转 +@msg Extrude & Bevel (Classic) 突出与斜角 (经典) +@msg Revolve (Classic) 回转 (经典) +@msg Rotate (Classic) 旋转 (经典) +@msg This effect 此效果 +@msg clipping paths 剪切路径 +@msg other objects 其他对象 +@msg {label} requires a group holding a shape with at least two anchors {label}需要一个包含至少有两个锚点的形状的编组 +@msg Revolve requires a group holding a shape with at least two anchors 回转需要一个包含至少有两个锚点的形状的编组 +@msg Revolve requires a shape with at least two anchors 回转需要至少有两个锚点的形状 +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. 编组上的{label}需要路径、复合路径、文字、编组、剪切组、混合和符号实例:请先释放或扩展以下内容:{what}。 +@msg {label} on a group takes at most {MAX_MEMBERS} objects 编组上的{label}最多接受 {MAX_MEMBERS} 个对象 +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 编组或图层上的 3D 效果应作用于编组本身,而不是其填色或描边 +@msg a 3D effect on a group's fill or stroke draws nothing 编组填色或描边上的 3D 效果不绘制任何内容 +@msg a 3D effect on a group's fill or stroke draws nothing to export 编组填色或描边上的 3D 效果没有可导出的内容 +@msg The 3D object is too complex to export 3D 对象过于复杂,无法导出 +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) 该对象没有 3D 效果(突出与斜角、回转、膨胀或旋转) +@msg its 3D effect builds no solid 其 3D 效果未生成任何实体 +@msg bevel must be none or one of {_1} bevel 必须是 none 或 {_1} 之一 +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}无法从此图稿生成实体:其符号实例、混合和剪切组包含的对象过多 +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}无法从符号“{symbol}”的实例生成实体:该符号的图稿不可用 +@msg {_1}: the clipping path is too complex to cut the art with {_1}:剪切路径过于复杂,无法用来剪切图稿 +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}最多接受 {MAX_MEMBERS} 个对象 +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}无法从嵌套超过 {MAX_DEPTH} 层的图稿生成实体 +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient 回转的上色对此表面过于复杂;请减少分段数或简化渐变 +@msg Revolve edge must be left or right 回转:edge 必须是 left 或 right +@msg Revolve cap must be a boolean 回转:cap 必须是布尔值 +@msg the revolved object has no fill or stroke 回转对象没有填色或描边 +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor 必须是 #rrggbb 格式的不透明 RGB 颜色 +@msg lights must contain one to ten lights lights 必须包含一到十个光源 +@msg each light must be an object 每个光源必须是对象 +@msg unknown light setting: use color, intensity, rotation, height, softness or behind 未知的光源设置:请使用 color、intensity、rotation、height、softness 或 behind +@msg light {key} must be a number from {min} to {max} 光源:{key} 必须是 {min} 到 {max} 之间的数字 +@msg light color must use opaque #rrggbb 光源的 color 必须是不透明的 #rrggbb +@msg light behind must be true or false 光源的 behind 必须是 true 或 false +@msg shadow must be true or false shadow 必须是 true 或 false +@msg shadowPosition must be behind or below shadowPosition 必须是 behind 或 below +@msg {key} must be a number from {min} to {max} {key} 必须是 {min} 到 {max} 之间的数字 +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}` 的每个条目必须是对象 +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` 的条目接受 symbol、face、x、y、scale 和 rotation,不接受 `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}` 的每个条目都需要 `symbol`,即符号的名称 +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` 的 face 必须是 front 或 back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` 的 {key} 必须是 {lo} 到 {hi} 之间的数字 +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` 必须是数组 +@msg Graphics map onto planar faces: use the Extrude or Plane object type 图形映射到平坦的面上:请使用突出或平面对象类型 +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` 最多接受 {MAX_GRAPHICS} 个图形 +@msg A plane has one face: map its graphics onto the front 平面只有一个面:请将其图形映射到正面 +@msg The graphics are too complex: simplify the symbols' art 图形过于复杂:请简化符号的图稿 +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art 图形的图稿最多可嵌套 {MAX_LEVELS} 层:请简化符号的图稿 +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art 图形的图稿最多可嵌套 {MAX_NESTING} 层符号:请简化符号的图稿 +@msg A graphic's art holds too many objects: simplify the symbol's art 图形的图稿包含过多对象:请简化符号的图稿 +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art 一个图形最多绘制 {MAX_PIECES} 个填色和描边:请简化符号的图稿 +@msg `surface` must be one of {_1} `surface` 必须是 {_1} 之一 +@msg {label} draws the source art for this object instead of its 3D surface {label}为此对象绘制源图稿,而不是其 3D 表面 +@msg {label} draws no faces for this object {label}没有为此对象绘制任何面 +@msg the wireframe has more than {MAX_WIRE_EDGES} edges 线框的边超过 {MAX_WIRE_EDGES} 条 @msg The 3D surface is too complex; simplify the shape 3D 表面过于复杂;请简化形状 Transform Objects 变换对象 @msg unknown parameter `{key}`; did you mean `{near}`? 未知参数 `{key}`;您是否想输入 `{near}`? @@ -6299,6 +6360,13 @@ axis Y Axis Y 轴 @msg Batch cannot play an action with a stop 批处理无法播放含有停止的动作 Insert Menu Item… 插入菜单项… Insert Menu Item 插入菜单项 + Insert Select Path 插入选择路径 + Select Path 选择路径 +@msg select one whole path or compound path 请选择一条完整路径或复合路径 +@msg select only one path 请只选择一条路径 +@msg the path is hidden or locked, or cannot be selected here 该路径已隐藏或锁定,或无法在此处选择 +@msg object {value} is not the stored path: it changed, or the document is another one 对象 {value} 不是存储的路径:它已更改,或这是另一个文档 +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` 必须为 "v1:" 加 16 位小写十六进制数字 Select Object… 选择对象… Select Object 选择对象 Set Selection 设置选区 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e83c81ef30..e6eb3f8312 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3361,6 +3361,8 @@ file-filter Templates 範本 Normal Screen Mode 正常螢幕模式 Note: 註解: Nothing matches the Show filter. 沒有項目符合「顯示」篩選條件。 + Show DCS Transparency Interactions 顯示 DCS 透明度互動 + Transparent, or overlaps transparent objects: check its separations 透明或與透明物件重疊:請檢查其分色 Nothing to print 沒有可列印的內容 Nudge 微移 Nudge in Perspective 在透視中微移 @@ -5581,6 +5583,65 @@ lighting Light 光源 @msg {label} can't build a solid from an image: trace it first with Object › Image Trace › Make. {label}無法從影像建立立體:請先使用「物件 › 影像描圖 › 製作」描圖。 @msg {label} requires a shape with at least two anchors {label}需要至少有兩個錨點的形狀 @msg The shape is too complex for a 3D effect 形狀過於複雜,無法套用 3D 效果 +@msg Extrude & Bevel 突出與斜角 +@msg Revolve 迴轉 +@msg Inflate 膨脹 +@msg Rotate 旋轉 +@msg Extrude & Bevel (Classic) 突出與斜角 (經典) +@msg Revolve (Classic) 迴轉 (經典) +@msg Rotate (Classic) 旋轉 (經典) +@msg This effect 此效果 +@msg clipping paths 剪裁路徑 +@msg other objects 其他物件 +@msg {label} requires a group holding a shape with at least two anchors {label}需要一個包含至少有兩個錨點之形狀的群組 +@msg Revolve requires a group holding a shape with at least two anchors 迴轉需要一個包含至少有兩個錨點之形狀的群組 +@msg Revolve requires a shape with at least two anchors 迴轉需要至少有兩個錨點的形狀 +@msg {label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first. 群組上的{label}需要路徑、複合路徑、文字、群組、剪裁群組、漸變和符號實例:請先釋放或展開以下內容:{what}。 +@msg {label} on a group takes at most {MAX_MEMBERS} objects 群組上的{label}最多接受 {MAX_MEMBERS} 個物件 +@msg A 3D effect on a group or layer goes on the group itself, not on its fill or stroke 群組或圖層上的 3D 效果應套用在群組本身,而不是其填色或筆畫 +@msg a 3D effect on a group's fill or stroke draws nothing 群組填色或筆畫上的 3D 效果不會繪製任何內容 +@msg a 3D effect on a group's fill or stroke draws nothing to export 群組填色或筆畫上的 3D 效果沒有可匯出的內容 +@msg The 3D object is too complex to export 3D 物件過於複雜,無法匯出 +@msg the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate) 此物件沒有 3D 效果 (突出與斜角、迴轉、膨脹或旋轉) +@msg its 3D effect builds no solid 其 3D 效果沒有產生任何實體 +@msg bevel must be none or one of {_1} bevel 必須是 none 或 {_1} 其中之一 +@msg {_1} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects {_1}無法從此圖稿產生實體:其符號實例、漸變和剪裁群組包含的物件過多 +@msg {label} can't build a solid from the instance of symbol "{symbol}": the symbol's art isn't available {label}無法從符號「{symbol}」的實例產生實體:該符號的圖稿無法使用 +@msg {_1}: the clipping path is too complex to cut the art with {_1}:剪裁路徑過於複雜,無法用來剪裁圖稿 +@msg {_1} takes at most {MAX_MEMBERS} objects {_1}最多接受 {MAX_MEMBERS} 個物件 +@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1}無法從巢狀超過 {MAX_DEPTH} 層的圖稿產生實體 +@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient 迴轉的上色對此表面過於複雜;請減少分段數或簡化漸層 +@msg Revolve edge must be left or right 迴轉:edge 必須是 left 或 right +@msg Revolve cap must be a boolean 迴轉:cap 必須是布林值 +@msg the revolved object has no fill or stroke 迴轉物件沒有填色或筆畫 +@msg lightColor must be an opaque RGB color in #rrggbb form lightColor 必須是 #rrggbb 格式的不透明 RGB 顏色 +@msg lights must contain one to ten lights lights 必須包含一到十個光源 +@msg each light must be an object 每個光源都必須是物件 +@msg unknown light setting: use color, intensity, rotation, height, softness or behind 未知的光源設定:請使用 color、intensity、rotation、height、softness 或 behind +@msg light {key} must be a number from {min} to {max} 光源:{key} 必須是介於 {min} 到 {max} 之間的數字 +@msg light color must use opaque #rrggbb 光源的 color 必須是不透明的 #rrggbb +@msg light behind must be true or false 光源的 behind 必須是 true 或 false +@msg shadow must be true or false shadow 必須是 true 或 false +@msg shadowPosition must be behind or below shadowPosition 必須是 behind 或 below +@msg {key} must be a number from {min} to {max} {key} 必須是介於 {min} 到 {max} 之間的數字 +@msg each of `{GRAPHICS}` must be an object `{GRAPHICS}` 的每個項目都必須是物件 +@msg `{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}` `{GRAPHICS}` 的項目接受 symbol、face、x、y、scale 和 rotation,不接受 `{k}` +@msg each of `{GRAPHICS}` needs `symbol`, the name of a symbol `{GRAPHICS}` 的每個項目都需要 `symbol`,也就是符號的名稱 +@msg `{GRAPHICS}` face must be front or back `{GRAPHICS}` 的 face 必須是 front 或 back +@msg `{GRAPHICS}` {key} must be a number from {lo} to {hi} `{GRAPHICS}` 的 {key} 必須是介於 {lo} 到 {hi} 之間的數字 +@msg `{GRAPHICS}` must be an array `{GRAPHICS}` 必須是陣列 +@msg Graphics map onto planar faces: use the Extrude or Plane object type 圖形會對應到平坦的面:請使用「突出」或「平面」物件類型 +@msg `{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics `{GRAPHICS}` 最多接受 {MAX_GRAPHICS} 個圖形 +@msg A plane has one face: map its graphics onto the front 平面只有一個面:請將其圖形對應到正面 +@msg The graphics are too complex: simplify the symbols' art 圖形過於複雜:請簡化符號的圖稿 +@msg A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art 圖形的圖稿最多可巢狀 {MAX_LEVELS} 層:請簡化符號的圖稿 +@msg A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art 圖形的圖稿最多可巢狀 {MAX_NESTING} 層符號:請簡化符號的圖稿 +@msg A graphic's art holds too many objects: simplify the symbol's art 圖形的圖稿包含過多物件:請簡化符號的圖稿 +@msg A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art 一個圖形最多繪製 {MAX_PIECES} 個填色和筆畫:請簡化符號的圖稿 +@msg `surface` must be one of {_1} `surface` 必須是 {_1} 其中之一 +@msg {label} draws the source art for this object instead of its 3D surface {label}為此物件繪製原始圖稿,而不是其 3D 表面 +@msg {label} draws no faces for this object {label}沒有為此物件繪製任何面 +@msg the wireframe has more than {MAX_WIRE_EDGES} edges 線框的邊超過 {MAX_WIRE_EDGES} 條 @msg The 3D surface is too complex; simplify the shape 3D 表面過於複雜;請簡化形狀 Transform Objects 變形物件 @msg unknown parameter `{key}`; did you mean `{near}`? 未知的參數 `{key}`;您是指 `{near}` 嗎? @@ -6104,6 +6165,13 @@ axis Y Axis Y 軸 @msg Batch cannot play an action with a stop 批次無法播放含有停止的動作 Insert Menu Item… 插入選單項目… Insert Menu Item 插入選單項目 + Insert Select Path 插入選取路徑 + Select Path 選取路徑 +@msg select one whole path or compound path 請選取一條完整路徑或複合路徑 +@msg select only one path 請只選取一條路徑 +@msg the path is hidden or locked, or cannot be selected here 該路徑已隱藏或鎖定,或無法在此處選取 +@msg object {value} is not the stored path: it changed, or the document is another one 物件 {value} 不是儲存的路徑:它已變更,或這是另一份文件 +@msg `fingerprint` must be "v1:" and 16 lowercase hex digits `fingerprint` 必須為 "v1:" 加上 16 位小寫十六進位數字 Select Object… 選取物件… Select Object 選取物件 Set Selection 設定選取範圍 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 964be00d61..c54d45cf4f 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -983,6 +983,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::stop(app, p), crate::panels::actions::INSERT_STOP => crate::panels::actions::insert_stop(app, p), crate::panels::actions::INSERT_MENU_ITEM => crate::panels::actions::insert_menu_item(app, p), + crate::panels::actions::INSERT_SELECT_PATH => crate::panels::actions::insert_select_path(app, p), crate::panels::actions::INSERT_SELECT_OBJECT => crate::panels::actions::insert_select_object(app, p), crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p), crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 08c1bbb4c6..623a6967b1 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1440,6 +1440,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}", ), + ( + crate::panels::actions::INSERT_SELECT_PATH, + "Insert Select Path", + "", + "{set?: index, action?: index, step?: index} insert a Select Path step for the path selected in the active document, without changing the selection: the step `select.storedPath {id, fingerprint}` stores the path's id and a fingerprint of its kind, fill rule and anchors, and playback selects only that path while it is still there unchanged and can be selected, else the step is an error. The selection must be one whole path or compound path that can be selected: not anchors or segments alone, several objects, a group, type or another kind of object, a guide, a member of a compound path, or a hidden or locked object. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. No document, a selection that is not such a path, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Select Path runs this, for the selected step or action or the recording in progress → {set, action, step}, or {recording: true}", + ), ( crate::panels::actions::INSERT_SELECT_OBJECT, "Select Object", diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index fc70693ce8..8196ad27c2 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -948,6 +948,31 @@ pub fn insert_menu_item(app: &mut VectorcraftApp, p: &Value) -> Result Result { + if !p.is_object() { + return Err("`params` must be an object".into()); + } + let st = app.session.active().ok_or("no document open")?; + let params = vectorcraft_engine::stored_path_step(st)?; + insert(app, p, (SELECT_PATH_STEP.to_string(), params)) +} + /// The command a Select Object step runs, with {note, caseSensitive, wholeWord} as its params: it /// selects the objects whose Attributes note matches. pub const SELECT_OBJECT_STEP: &str = "select.byNote"; @@ -1757,6 +1782,17 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let step = selected_step(app, ui.ctx(), selected); crate::dialogs::action_insert_menu_item::open(app, if recording { None } else { selected }, step); } + let path = app.session.active().is_some_and(|st| vectorcraft_engine::storable_selection(st).is_ok()); + if widgets::menu_item(ui, tl!("Insert Select Path"), (recording || has) && path, false) { + let place = match (recording, selected, selected_step(app, ui.ctx(), selected)) { + (true, ..) | (false, None, _) => json!({}), + (false, Some((s, a)), None) => json!({"set": s, "action": a}), + (false, Some((s, a)), Some(step)) => json!({"set": s, "action": a, "step": step}), + }; + if let Err(e) = app.run(INSERT_SELECT_PATH, place) { + app.status(e); + } + } if widgets::menu_item(ui, tl!("Select Object…"), recording || has, false) { let step = selected_step(app, ui.ctx(), selected); crate::dialogs::action_select_object::open(app, if recording { None } else { selected }, step); @@ -1947,11 +1983,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { current = Some(si); sets_selected = vec![si]; } - // A stop's row shows its message, and a Select Object step's its note; other + // A stop's row shows its message, a Select Path step's its name and a Select Object step's its note; other // steps show their command. let shown = if command == STOP_STEP { let message = params.get("message").and_then(Value::as_str).and_then(|m| m.lines().next()).unwrap_or_default(); crate::i18n::fmt(tl!("Stop: {message}"), &[("message", message)]) + } else if command == SELECT_PATH_STEP { + tl!("Select Path").to_string() } else if command == SELECT_OBJECT_STEP { let note = params.get("note").and_then(Value::as_str).and_then(|m| m.lines().next()).unwrap_or_default(); crate::i18n::fmt(tl!("Select Object: {note}"), &[("note", note)]) @@ -3146,7 +3184,7 @@ mod tests { (json!(long.to_string()), "an action in the action set file has more than 10,000 steps"), (json!(" ".repeat(files::MAX_SET_FILE + 1)), "the action set file is larger than 8 MiB"), ( - json!("{\"format\": \"vcactions\", \"version\": 5, \"name\": \"S\", \"actions\": []}"), + json!("{\"format\": \"vcactions\", \"version\": 6, \"name\": \"S\", \"actions\": []}"), "the action set file needs a later version of VectorCraft", ), ] { @@ -4533,6 +4571,178 @@ mod tests { assert_eq!(recorded.steps, [("object.group".to_string(), json!({}))]); } + /// [`app_with_three_moves`] with a second rectangle, 100 pt to the right, and the first one + /// selected again → (the app, the left edge of the first one as it moves, the ids of both). + fn app_with_two_rectangles() -> (VectorcraftApp, impl Fn(&VectorcraftApp) -> f64, [u64; 2]) { + let (mut app, x) = app_with_three_moves(); + let first = app.session.active().unwrap().selection.objects[0].0; + let second = app.run("shape.rectangle", json!({"x": 110, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [first]})).unwrap(); + (app, x, [first, second]) + } + + fn left_of(app: &VectorcraftApp, id: u64) -> f64 { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0 + } + + #[test] + fn insert_select_path_stores_the_selected_path_changes_nothing_and_plays_by_selecting_it() { + let (mut app, x, [first, second]) = app_with_two_rectangles(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 2]); + let doc = app.session.active().unwrap().doc.clone(); + let journal = app.session.journal.len(); + let undo = undo_steps(&app); + let r = app.run(INSERT_SELECT_PATH, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + assert_eq!(r, json!({"set": 0, "action": 0, "step": 1})); + assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc), "inserting edits nothing"); + assert_eq!(app.session.journal.len(), journal, "and records nothing"); + assert_eq!(undo_steps(&app), undo, "and adds no undo step"); + let action = &app.ui.action_sets[0].actions[0]; + assert_eq!(action.steps[1].0, SELECT_PATH_STEP); + assert_eq!(action.steps[1].1["id"], json!(first)); + assert!(action.steps[1].1["fingerprint"].as_str().is_some_and(|f| f.starts_with("v1:")), "{:?}", action.steps[1]); + assert_eq!(action.excluded_steps, BTreeSet::from([0, 3]), "the step excluded after the insertion point moved with it"); + assert!(!action.excluded_steps.contains(&1), "the new step is included"); + + app.run("select.set", json!({"ids": [second]})).unwrap(); + let before = undo_steps(&app); + play(&mut app, 0, 0).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(first)], "the step selected the stored path"); + assert_eq!(x(&app), 2.0, "the move after it moved the stored path"); + assert_eq!(left_of(&app, second), 110.0, "and not the path selected before"); + assert_eq!(undo_steps(&app), before + 1, "the action plays as one undo step"); + } + + #[test] + fn a_select_path_step_plays_step_by_step_and_a_changed_path_ends_the_playback() { + let (mut app, x, [_, second]) = app_with_two_rectangles(); + app.ui.action_sets[0].actions[0].steps.truncate(1); + app.run(INSERT_SELECT_PATH, json!({"set": 0, "action": 0})).unwrap(); + app.ui.action_sets[0].actions[0].steps.push(("object.move".into(), json!({"dx": 4, "dy": 0}))); + app.run(PLAYBACK_OPTIONS, json!({"speed": "step"})).unwrap(); + let before = undo_steps(&app); + app.run("select.set", json!({"ids": [second]})).unwrap(); + assert_eq!(undo_steps(&app), before, "a selection change alone is no undo step"); + play(&mut app, 0, 0).unwrap(); + play_out(&mut app); + assert_eq!(left_of(&app, second), 111.0, "the first move moved the path selected when the action started"); + assert_eq!(x(&app), 4.0, "the step selected the stored path, and the last move moved it"); + assert_eq!(undo_steps(&app), before + 2, "each move its own undo step, and the selection change none, as select.set adds none"); + + // The stored path has moved since: the step ends the playback, and the move after it does not run. + app.run("select.set", json!({"ids": [second]})).unwrap(); + play(&mut app, 0, 0).unwrap(); + play_out(&mut app); + assert!(app.ui.status.contains("is not the stored path"), "{}", app.ui.status); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(second)], "the selection stays as it was"); + assert_eq!((left_of(&app, second), x(&app)), (112.0, 4.0), "the step before it ran, the one after it did not"); + } + + #[test] + fn insert_select_path_refuses_what_is_not_one_whole_path_and_changes_nothing() { + let (mut app, _, [first, second]) = app_with_two_rectangles(); + let place = json!({"set": 0, "action": 0}); + let refused = |app: &mut VectorcraftApp, params: Value, want: &str| { + let selection = app.session.active().map(|st| st.selection.objects.clone()); + assert_eq!(app.run(INSERT_SELECT_PATH, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "{params}"); + assert_eq!(app.session.active().map(|st| st.selection.objects.clone()), selection, "{params}"); + }; + app.run("select.set", json!({"ids": [first, second]})).unwrap(); + refused(&mut app, place.clone(), "select only one path"); + app.run("select.none", json!({})).unwrap(); + refused(&mut app, place.clone(), "select a path"); + app.run("select.anchors", json!({"id": first, "anchors": [[0, 0]], "mode": "set"})).unwrap(); + refused(&mut app, place.clone(), "select one whole path or compound path"); + app.run("select.set", json!({"ids": [first, second]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + refused(&mut app, place.clone(), "select one whole path or compound path"); + app.run("select.set", json!({"ids": [first]})).unwrap(); + for (params, want) in [ + (json!({}), "no such action"), + (json!({"set": 0}), "no such action"), + (json!({"set": 1, "action": 0}), "no such action"), + (json!({"set": 0, "action": 1}), "no such action"), + (json!({"set": "0", "action": 0}), "no such action"), + (json!({"set": 0, "action": 0, "step": 3}), "no such action step"), + (json!({"set": 0, "action": 0, "step": null}), "no such action step"), + (json!(5), "`params` must be an object"), + (json!(null), "`params` must be an object"), + ] { + refused(&mut app, params, want); + } + let mut empty = app_with_sets(); + assert_eq!(empty.run(INSERT_SELECT_PATH, json!({"set": 0, "action": 0})).unwrap_err(), "no document open"); + } + + #[test] + fn insert_select_path_goes_into_the_recording() { + let (mut app, _, [first, _]) = app_with_two_rectangles(); + app.run(START_RECORDING, json!({})).unwrap(); + assert_eq!(app.run(INSERT_SELECT_PATH, json!({})).unwrap(), json!({"recording": true})); + assert_eq!(app.session.active().unwrap().selection.objects, [vectorcraft_doc::NodeId(first)]); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!(recorded.steps.len(), 1); + assert_eq!((recorded.steps[0].0.as_str(), &recorded.steps[0].1["id"]), (SELECT_PATH_STEP, &json!(first))); + } + + #[test] + fn a_saved_select_path_step_survives_and_a_damaged_one_is_refused() { + let (mut app, _, _) = app_with_two_rectangles(); + app.run(INSERT_SELECT_PATH, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(serde_json::from_str::(&saved).unwrap()["version"], json!(5), "older builds must refuse a Select Path step"); + let loaded = files::read_set(&saved, "Test").unwrap(); + assert_eq!(loaded.actions[0].steps, app.ui.action_sets[0].actions[0].steps); + let step = app.ui.action_sets[0].actions[0].steps[1].clone(); + let nested = json!({"commands": [{"command": SELECT_PATH_STEP, "params": step.1}]}); + let set = ActionSet::new("Nested", vec![Action::new("A", vec![("command.batch".into(), nested)])]); + assert_eq!(serde_json::from_str::(&files::write_set(&set)).unwrap()["version"], json!(5), "a step in a recorded batch counts"); + let mut both = app.ui.action_sets[0].clone(); + both.actions[0].steps.push((SELECT_OBJECT_STEP.into(), json!({"note": "a", "caseSensitive": false, "wholeWord": false}))); + assert_eq!(serde_json::from_str::(&files::write_set(&both)).unwrap()["version"], json!(5), "with a Select Object step too"); + for params in + [json!({"id": 3}), json!({"id": -1, "fingerprint": step.1["fingerprint"]}), json!({"id": 3, "fingerprint": "v1:xyz"}), json!(42)] + { + let bad = json!({"command": SELECT_PATH_STEP, "params": params}); + for s in [bad.clone(), json!({"command": "command.batch", "params": {"commands": [bad]}})] { + let file = json!({"format": "vcactions", "version": 5, "name": "Bad", "actions": [{"name": "A", "steps": [s]}]}); + assert_eq!(files::read_set(&file.to_string(), "Test").unwrap_err(), "not an action set file", "{s}"); + } + } + } + + #[test] + fn insert_select_path_in_the_menu_needs_a_path_and_shows_its_row() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let (mut app, _, [first, _]) = app_with_two_rectangles(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let menu_click = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let texts = frame_events(ctx, app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Insert Select Path").map(|(_, r)| *r).expect("Insert Select Path in the menu"); + click(ctx, app, item.center(), menu); + }; + menu_click(&ctx, &mut app); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "no action selected and nothing recording: the item is off"); + + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show); + app.run("select.none", json!({})).unwrap(); + menu_click(&ctx, &mut app); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "no path selected: the item is off"); + + app.run("select.set", json!({"ids": [first]})).unwrap(); + menu_click(&ctx, &mut app); + let steps = &app.ui.action_sets[0].actions[0].steps; + assert_eq!((steps.len(), steps[3].0.as_str()), (4, SELECT_PATH_STEP), "the action selected: the step goes at its end"); + let texts = frame_events(&ctx, &mut app, vec![], show); + assert!(texts.iter().any(|(s, _)| s == "Select Path"), "the step's row names it: {texts:?}"); + } + /// An action stays one that Load Actions… reads back: a stop or a menu item that would give it /// more than 10,000 steps, or its set's file more than 8 MiB, is refused, and the action keeps its /// steps and exclusions as they were. @@ -5579,7 +5789,7 @@ mod tests { json!({"format": "vcactions", "version": version, "name": "Old", "actions": [{"name": "A", "steps": [{"command": "object.move"}]}]}); assert_eq!(files::read_set(&old.to_string(), "Test").unwrap().actions[0].steps.len(), 1, "version {version}"); } - let later = json!({"format": "vcactions", "version": 5, "name": "New", "actions": []}); + let later = json!({"format": "vcactions", "version": 6, "name": "New", "actions": []}); assert_eq!(files::read_set(&later.to_string(), "Test").unwrap_err(), "the action set file needs a later version of VectorCraft"); for params in [json!({"message": 5}), json!({"allowContinue": "yes"}), json!(42)] { let stop = json!({"command": "action.stop", "params": params}); diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index ee6304690b..6aa8b95df4 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -1,16 +1,18 @@ //! Action set files: the Actions panel menu's Save Actions… and Load Actions…. //! -//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 to 4, "name", +//! An action set file (`.vcactions`) is JSON: `{"format": "vcactions", "version": 1 to 5, "name", //! "actions": [{"name", "steps": [{"command", "params", "excluded"?}], "key"?, "color"?}]}`. Each step is a //! command id and its params, as `command.batch` takes them, or a stop: the command `action.stop` //! with `{"message", "allowContinue"}`. A Select Object step is the command `select.byNote` with -//! `{"note", "caseSensitive", "wholeWord"}`. `key` is the action's function key +//! `{"note", "caseSensitive", "wholeWord"}`, and a Select Path step is the command `select.storedPath` +//! with `{"id", "fingerprint"}`. `key` is the action's function key //! (`{"key": 1 to 15, "cmd", "shift"}`) and `color` its Button Mode color; an action without them, //! or with a value that is not one, has none. `excluded: true` keeps a step but skips it during //! playback. Sets with exclusions use version 2 so older builds refuse them rather than replay //! excluded steps, sets with stops use version 3 so older builds refuse them rather than fail at a -//! stop, and sets with Select Object steps use version 4 so older builds refuse them rather than -//! fail at that step. Other sets use version 1, and earlier version 1 to 3 files still work. +//! stop, sets with Select Object steps use version 4 and sets with Select Path steps use version 5 so +//! older builds refuse them rather than fail at that step. Other sets use version 1, and earlier +//! version 1 to 4 files still work. //! Load Actions… also reads the action sets another app saves as text (`.aia`, through //! `actionSet.read`). @@ -145,6 +147,11 @@ fn steps_in<'a>(kind: &str, command: &str, params: &'a Value) -> Vec<&'a Value> steps.filter(|step| step.get("command").and_then(Value::as_str) == Some(kind)).map(|step| step.get("params").unwrap_or(&Value::Null)).collect() } +/// The params of the Select Path steps in a step, as [`stops_in`] finds stops. +fn select_paths_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { + steps_in(super::SELECT_PATH_STEP, command, params) +} + /// `set` as the text of an action set file. pub fn write_set(set: &ActionSet) -> String { let actions = set @@ -164,12 +171,14 @@ pub fn write_set(set: &ActionSet) -> String { .collect(); let steps = || set.actions.iter().flat_map(|a| &a.steps); let has_select_object = steps().any(|(command, params)| !select_objects_in(command, params).is_empty()); + let has_select_path = steps().any(|(command, params)| !select_paths_in(command, params).is_empty()); let has_stop = steps().any(|(command, params)| !stops_in(command, params).is_empty()); - let version = match (has_select_object, has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { - (true, ..) => 4, - (false, true, _) => 3, - (false, false, true) => 2, - (false, false, false) => 1, + let version = match (has_select_path, has_select_object, has_stop, set.actions.iter().any(|a| !a.excluded_steps.is_empty())) { + (true, ..) => 5, + (false, true, ..) => 4, + (false, false, true, _) => 3, + (false, false, false, true) => 2, + (false, false, false, false) => 1, }; let file = SetFile { format: ACTIONS_EXT.into(), version, name: set.name.clone(), actions }; serde_json::to_string_pretty(&file).unwrap_or_default() @@ -184,7 +193,7 @@ pub fn read_set(text: &str, name: &str) -> Result { if f.format != ACTIONS_EXT { return Err("not an action set file".into()); } - if !matches!(f.version, 1..=4) { + if !matches!(f.version, 1..=5) { return Err("the action set file needs a later version of VectorCraft".into()); } if f.actions.len() > MAX_SET_ACTIONS { @@ -197,6 +206,10 @@ pub fn read_set(text: &str, name: &str) -> Result { if stops.any(|p| super::stop_params(p).is_err()) { return Err("not an action set file".into()); } + let mut select_paths = f.actions.iter().flat_map(|a| &a.steps).flat_map(|s| select_paths_in(&s.command, &s.params)); + if select_paths.any(|p| vectorcraft_engine::stored_path_params(p).is_err()) { + return Err("not an action set file".into()); + } let mut select_objects = f.actions.iter().flat_map(|a| &a.steps).flat_map(|s| select_objects_in(&s.command, &s.params)); if select_objects.any(|p| super::select_object_params(p).is_err()) { return Err("not an action set file".into()); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 292fd498c5..948651ef80 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -635,9 +635,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { clicked = Some((row.name.to_string(), row.item.is_none(), ui.input(|i| i.modifiers))); } }; + // In a sized panel the list fills the space above the footer (#2992), as the Artboards list + // does; elsewhere it keeps its 220 pt (280 pt for large thumbnails). + let fixed = widgets::panel_footer_bottom(ui).is_some(); + let list_height = widgets::panel_list_height(ui, if view == View::LargeThumb { 280.0 } else { 220.0 }, 2.0 + ui.spacing().item_spacing.y); widgets::list_box(ui, |ui| { - let max_height = if view == View::LargeThumb { 280.0 } else { 220.0 }; - egui::ScrollArea::vertical().id_salt(key(&panel, "scroll")).max_height(max_height).show(ui, |ui| { + let scroll = egui::ScrollArea::vertical() + .id_salt(key(&panel, "scroll")) + .max_height(list_height) + .min_scrolled_height(list_height.min(64.0)) + .auto_shrink([false, !fixed]); + scroll.show(ui, |ui| { ui.set_width(ui.available_width()); if rows.is_empty() { widgets::dim_label(ui, if query.trim().is_empty() { tl!("This library is empty.") } else { tl!("No matches.") }); diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index 10acd4603e..034a90e5b0 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -690,3 +690,32 @@ fn the_panel_menu_has_persistent() { assert!(text.contains(item), "{item}: {text}"); } } + +/// In a panel made taller the library list fills the space above the panel's footer instead of +/// scrolling inside 220 pt (#2992); outside a sized panel it keeps that height. +#[test] +fn the_list_fills_a_tall_panel_above_its_footer() { + let mut app = app(); + let ctx = context(); + app.run("window.swatchLibrary", json!({"library": "web-safe-216"})).unwrap(); + let panel = "library:swatches:web-safe-216"; + app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); + // The clip rect of the rows' names (the colors' hex codes): the list's visible area. + let mut list = |bottom: Option| { + let mut clip = None; + for _ in 0..3 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 800.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let info = egui::UiStackInfo::default().with_tag_value(widgets::PANEL_FOOTER, bottom); + ui.scope_builder(egui::UiBuilder::new().ui_stack_info(info), |ui| crate::dock::panel_body(&mut app, ui, panel, 256.0, 760.0)); + }); + out.textures_delta.clear(); + clip = out.shapes.iter().find(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text().starts_with('#'))).map(|c| c.clip_rect); + } + clip.expect("a row is drawn") + }; + let tall = list(Some(700.0)); + assert!(tall.height() > 400.0 && tall.bottom() <= 700.0 - 31.0, "the list fills the panel above its footer: {tall:?}"); + let loose = list(None); + assert!(loose.height() <= 220.5, "unsized, the list keeps its height: {loose:?}"); +} diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 38f9848760..aced758482 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -5,8 +5,9 @@ //! Go To, Update and Edit Original act on the selected rows (Relink through its dialog, with the //! Replace choice: `ui.relinkDialog`); Link Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: //! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Select Linked / Embedded / Missing / -//! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size -//! and Show in Folder. +//! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size, +//! Show in Folder and Show DCS Transparency Interactions (a warning on the rows of images placed +//! from DCS files that are transparent or overlap transparent objects). //! //! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI //! commands: they hand the file to the system). @@ -36,19 +37,22 @@ struct Options { thumb: f32, /// The Link Info section is open. info: bool, + /// Show DCS Transparency Interactions: rows of DCS images that are transparent or overlap + /// transparent objects get a warning. + dcs: bool, } impl Default for Options { fn default() -> Self { - Self { show: "all", sort: None, thumb: 28.0, info: true } + Self { show: "all", sort: None, thumb: 28.0, info: true, dcs: false } } } /// `links.list` and the first selected image's `links.info`, with what they were fetched for. #[derive(Clone, Default)] struct Cache { - /// (document uid, revision, show, sort). - key: Option<(u64, u64, &'static str, Option<&'static str>)>, + /// (document uid, revision, show, sort, Show DCS Transparency Interactions). + key: Option<(u64, u64, &'static str, Option<&'static str>, bool)>, at: f64, list: Value, info: Option<(u64, Value)>, @@ -68,13 +72,13 @@ fn set_options(ctx: &egui::Context, f: impl FnOnce(&mut Options)) { /// seconds) and the Link Info of the first selected image when the section is open. fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option { let st = app.session.active()?; - let key = Some((st.uid, st.revision, o.show, o.sort)); + let key = Some((st.uid, st.revision, o.show, o.sort, o.dcs)); let first = selected_images(app).first().copied(); let now = ctx.input(|i| i.time); let mut c: Cache = pstate(ctx, "links-cache"); let stale = c.key != key || now - c.at > RECHECK; if stale { - c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort })).ok()?; + c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort, "dcsTransparency": o.dcs })).ok()?; (c.key, c.at) = (key, now); } let want = first.filter(|_| o.info); @@ -279,6 +283,11 @@ fn status_tip(r: &Value) -> String { "embedded" => tl!("Embedded").into(), _ => path.to_string(), }; + let tip = if r["dcsTransparency"] == true { + format!("{tip}\n{}", tl!("Transparent, or overlaps transparent objects: check its separations")) + } else { + tip + }; // An image shown at reduced size or too large to draw (`links.list`'s note). match r["note"].as_str() { Some(note) => format!("{tip}\n{}", crate::i18n::msg(note)), @@ -367,6 +376,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { r["status"].as_str().unwrap_or_default(), ); let mut text_right = right - 4.0; + if r["dcsTransparency"] == true { + badge(ui, egui::Rect::from_center_size(egui::pos2(text_right - 8.0, rect.center().y), vec2(16.0, 16.0)), "modified"); + text_right -= 20.0; + } if let Some(page) = r["page"].as_u64() { let g = ui.painter().layout_no_wrap(format!("{page}"), egui::FontId::proportional(11.5), t.text_dim); text_right -= g.size().x; @@ -628,4 +641,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Show Link Info"), true, o.info) { set_options(ui.ctx(), |o| o.info = !o.info); } + if menu_item(ui, tl!("Show DCS Transparency Interactions"), true, o.dcs) { + set_options(ui.ctx(), |o| o.dcs = !o.dcs); + } } diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 9cebea0ba6..08f0819b3a 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -612,6 +612,42 @@ fn a_geometry_effect_after_the_3d_effect_hides_the_graphics_handles() { handles_frame(&mut app, &ctx, vec![primary(handle, false)]); } +#[test] +fn a_hidden_fill_shows_no_handles_for_its_graphics() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + // The 3D effect, with its graphic, moves onto the square's fill. + let fill = { + let st = app.session.active_mut().unwrap(); + let id = st.selection.objects[0]; + let n = std::sync::Arc::make_mut(&mut st.doc).node_mut(id).unwrap(); + let fx = n.appearance.effects.remove(0); + let fill = n.appearance.items.iter().position(|i| matches!(i, vectorcraft_doc::AppearanceItem::Fill(_))).unwrap(); + n.appearance.effects_mut(Some(fill)).unwrap().push(fx); + fill + }; + let shown = |app: &VectorcraftApp| { + let mut shown = false; + ctx.run_ui(egui::RawInput::default(), |ui| { + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + shown = super::graphic_handles(app, ui.ctx()).is_some_and(|(_, _, handles)| !handles.is_empty()); + }) + .textures_delta + .clear(); + shown + }; + assert!(shown(&app), "the visible fill's graphic has a handle"); + { + let st = app.session.active_mut().unwrap(); + let id = st.selection.objects[0]; + let n = std::sync::Arc::make_mut(&mut st.doc).node_mut(id).unwrap(); + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = n.appearance.items.get_mut(fill) { + f.visible = false; + } + } + assert!(!shown(&app), "the hidden fill draws no graphic, so it has no handle"); +} + #[test] fn a_press_that_closes_the_context_menu_does_not_grab_a_graphics_handle() { let (mut app, ctx) = materials_app(); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index 805abbd51f..1d829353f1 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -324,6 +324,10 @@ fn graphic_handles(app: &VectorcraftApp, ctx: &egui::Context) -> Option let st = app.session.active()?; let n = st.selection.objects.first().and_then(|id| st.doc.node(*id))?; let t = find(n)?; + // A hidden fill or stroke draws no solid and no graphics, so its graphics get no handles. + if t.item.is_some_and(|k| n.appearance.items.get(k).is_none_or(|it| !it.visible())) { + return None; + } // The frame of the effect whose graphics are listed: none while that effect is hidden. let frame = vectorcraft_effects::graphic_frame_at(Some(&st.doc), n, t.item, t.index)?; // A geometry effect after the 3D effect of any selected object moves or bends its drawn diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 19f245175d..802ffb4a94 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -762,8 +762,8 @@ the `PATH` elsewhere), and any other file, or a raster image without PhotoCraft Package: File → Package… (`ui.packageDialog`) opens the `package` dialog for a saved document (a document never saved gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the location), `name` (the package folder), -`copyLinks`, `linksFolder`, `relink`, `copyFonts`, `report`. `ui.dialog.confirm` saves unsaved changes, runs -`file.package` and then opens a `confirm` dialog whose OK runs `file.showPackage {folder}`; the web downloads the zip +`copyLinks`, `linksFolder`, `relink`, `copyFonts`, `report`. `ui.dialog.confirm` saves unsaved changes (when that save +fails, no package is written, also with Background Save and Background Export on), runs `file.package` and then opens a `confirm` dialog whose OK runs `file.showPackage {folder}`; the web downloads the zip instead. Document Info's flyout picks a category and Save… runs `docInfo.save {path?, selectionOnly?, category?}` with the category the panel shows (none for All Categories, which writes every category). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 465f1701a8..88be82c614 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-12 · **Change:** minor (`.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-12 · **Change:** minor (`.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). @@ -59,7 +59,7 @@ private editing data. VectorCraft: and the paragraph's hyphenation options and tab stops. Stroked type keeps its stroke's cap, join and miter limit. Type filled or stroked with a pattern keeps its pattern, placed in the document as a path's pattern fill would be: a character's paint names an art style of one paint, and the pattern is in that style (its colour in the text document is black). A pattern the file - doesn't define, or a style that isn't a pattern, keeps the colour as before. Type keeps its manual kerning (key 8 of a story's text, thousandths of an em after a character). Area type of one + doesn't define, or a style that isn't a pattern, keeps the colour as before. Type keeps its manual kerning (key 8 of a story's text, thousandths of an em after a character). Its font is an installed face of the PostScript name its font record gives: one of exactly that name, case and punctuation included (`SampleSans` isn't the face named `SAMPLESANS`, nor `SampleSerif-Bold` the one named `SampleSerifBold`), when one is installed, else one whose name differs only in them. The type takes that face's own family and style, not another family spelled alike (`SAMPLE INK` beside `Sample/Ink`, whose PostScript name is `SampleInk`). Among those faces the record's version (key 5, name ID 5) picks the one in that version when it is installed; a version that isn't installed gives the first, without a note. The type names the face's version when its family and style alone resolve to another installed copy. Copies that share their family, style and version (an OpenType and a TrueType build of one release) can't be told apart: only the first of them loads. Type that a PostScript program sets, and legacy `.ai` type, take the face of their font's name the same way, with no version to read. Area type of one frame whose last lines the file sets but which don't fit here (the fallback font, or a descent past the frame's bottom) may get a frame taller from its bottom, by its lines' deepest descent plus 0.05 pt and only when that shows more of those lines, with a warning when it does. RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb` @@ -132,7 +132,7 @@ private editing data. VectorCraft: and paragraph attributes are left out with a warning. A font of the text document's font set that is a variable font also names where in its design space the type is set (each `fvar` axis in the font's order, as 16.16 fixed: the named instance's setting, or the run's own Variable Font setting) and its version string, so the type doesn't open at the - font's default instance (#6388); two settings of one font are two fonts of the set, and a static font names neither. Each run's baseline direction is written in the form the reader + font's default instance (#6388); two settings of one font are two fonts of the set. A static font names no design space, and names its version when its PostScript name alone would give the reader another installed copy (another version, or a copy of that name in another family), so the type opens in its own copy again; two such copies are two fonts of the set. A version longer than the reader takes (256 bytes) can't pick a copy: type in a copy its name alone doesn't give back is then reported as losing attributes. Each run's baseline direction is written in the form the reader below reads it: 1 when Standard Vertical Roman Alignment is on, 2 when it is off and 3 for Tate-chu-yoko; the document's defaults and the normal character style carry 1. A run with both Tate-chu-yoko and Standard Vertical Roman Alignment is written as Tate-chu-yoko, with the type attributes warning. Type in opacity masks, pattern tiles, symbols and art cut to a @@ -271,6 +271,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314); a DeviceN image of one colorant opens the same way, and one of process plates only (the ink `None` may be among them) opens in CMYK with each colorant's tint in its plate (#6348) 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. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined CMap (`UniJIS-UCS2-H`, `90ms-RKSJ-H`, `GBK-EUC-H`, `KSCms-UHC-H`, `ETen-B5-H`), set as type in the font of the CIDFont's name, vertical over a `-V` CMap; a name that ends in the row-and-cell CMap `H` or `V` splits off the CMap only when the file defines the CIDFont of that name, or the name has a hyphen and an installed face that covers Japanese has it; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | +| DCS 1.0 / 2.0 | place linked | place linked only (one image of the plates; spot plates as spot swatches; the composite's clipping path; each plate on its own ink in Separations Preview and print separations) | no | `crates/eps/src/dcs/tests.rs`, `crates/engine/src/tests/dcs.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`); SVG Options › Include Variable Data writes the text variables, data sets and bound type objects, and opening the SVG reads them back (`tests::variables::an_svg_with_variable_data_opens_with_its_variables_data_sets_and_bindings`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | | 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` | @@ -322,6 +323,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | `.ai` and EPS import (#6983): type from the text document is set in an installed face of the PostScript name its font record gives, one of exactly that name first. That face's family is no longer swapped for another family spelled alike but for case and punctuation (`FontDb::find_family`, which picked `SAMPLE INK` for `SampleInk`, the PostScript name of `Sample/Ink`); among the faces `FontDb::postscript_faces` lists for the name, the import takes one whose name matches in case and punctuation before one that matches only without them; and when the family and style alone resolve to another installed copy, the type names the face's version. A font record's key 5, the version string (name ID 5), picks that version among the faces of its PostScript name, also when the record spells the name with other punctuation (`Sample-Slab` for faces named `SampleSlab`); each record is resolved once. Type a PostScript program sets and legacy `.ai` type take the face of their font's name the same way. The `.ai` save writes a static font's version (`/5`) when its PostScript name alone would give the reader another installed copy (another version, or the same name in another family), so two such copies are two fonts of the set, and checks that the type comes back in the same family, style and face, taking the record's version as the reader does (up to 256 bytes): such type round-trips without the type attributes note, and so does a version that isn't installed, which draws in the face the font resolves to anyway. In 224 users' files (used locally, never committed; all PDF-compatible, so the PostScript and legacy paths are not exercised), 16 files set some type in another installed face; 12 render differently against their own page, 10 closer (in one from 2.55% to 0.01% of the page differing) and 2 further by 0.03% of the page (an envelope of type that now opens live, and a copy of the same widths in the version the record names); envelopes of type that open live go from 154 to 159, none fewer in any file; no file opens in another way. The notes of how far a file's layers differ from its page shrink in 2 files and appear in 2 where an envelope of type now opens live, so the file is compared with its page. Two installed copies of one PostScript name, family and style that differ in widths, in files whose records keep no version, still take the copy the family and style resolve to: only the page's widths tell them apart. Tests: `tests_native::type_from_the_text_document_takes_the_face_of_its_exact_postscript_name`, `tests_native::type_from_the_text_document_takes_the_version_its_font_record_keeps`, `tests_fontnames::shown_type_takes_the_face_of_its_exact_postscript_name`, `ai::tests::legacy_type_takes_the_face_of_its_exact_postscript_name`, `ai_editing::tests::type_in_a_font_beside_a_family_spelled_alike_keeps_its_font`, `ai_editing::tests::type_in_another_version_of_a_static_font_keeps_it`, `ai_editing::tests::type_in_a_copy_its_postscript_name_alone_doesnt_give_keeps_it`, `ai_editing::tests::a_version_longer_than_the_reader_takes_is_reported`, `vectorcraft-text` `tests::versions::every_face_of_a_postscript_name_is_found`. Scores unchanged. | | 2026-11-12 | minor | `.ai` and EPS import: type from the text document keeps its manual kerning (#6899). Key 8 of a story's text holds it as runs of characters (key 1), each with a value (key 0 of key 0, in thousandths of an em) or empty where the automatic kerning applies; a character with a value takes it as its Kerning, the space between it and the next character ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)), of the em as its Horizontal Scale draws it, as tracking. It wasn't read, so type kerned by hand came in spaced by the font alone and drifted from the page from each kerned pair on, and envelopes of such type drew apart from their drawn art and kept it. In users' files, used locally and never committed, 48 stories in 17 of 224 files have it, and the drawn art of envelopes of such type sets each kerned gap wider or narrower by exactly that value (a heading's first pair at -140, the letters of a line at 20 to 220). Measured against each file's own page, 14 of those files draw closer (in 4 the pixels that differ go from 2.7–4.7% to under 0.6%) and 2 a little further, both where an envelope of type now opens live in place of its drawn art; envelopes of type that open live go from 154 to 159 of 203, none fewer in any file, and two notes that the type differs from the page go. A value that can't be read keeps the automatic kerning, a run whose length can't be read ends the runs there, and past 65,536 runs the characters keep the automatic kerning. `.ai` save still leaves kerning out of the editing data, so saving such a file as `.ai` again now gives the note that attributes of type are left out for type with manual kerning. Tests `ate::tests::a_story_keeps_its_manual_kerning`, `tests_native::type_keeps_its_manual_kerning`; scores unchanged | | 2026-11-12 | minor | `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, after the ways it was tried before (its glyphs alone and grown by half its strokes' weight, #6535, on its own warp and on its saved grid), before a grid moved onto its drawn art, and only where that box is another: its glyph outlines with their fills and strokes baked as the envelope bakes them (geometry effects on them applied: a stroke a Transform effect moves counts where it is moved to), each piece measured by its visual bounds, which are exact for miter joins (a tip counts where the miter limit keeps it, not the miter allowance at every corner that the bounds with strokes take) and round joins and an upper bound for bevel joins, miters the limit cuts, width profiles and square-capped dashes; a contour of zero length, which paints nothing, doesn't count. Effects on the type object itself are left out, as VectorCraft applies them after the envelope bends the type. The box is measured only when that try comes and the import has a check left for it, and once for an envelope. Groups round type and other objects in the contents are measured as before. In users' files (used locally, never committed) the drawn art of some stroked type in an envelope maps from that box: where the type has sharp corners at its edges (slanted stems, pointed feet) and miter joins, or a stroke moved by a Transform effect (a drop shadow), its glyphs grown by half the weight gave too small a box and the envelope kept its drawn art; and where a glyph holds a contour of zero length at its edge, the glyphs' box counted it. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 161 in 50, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 6 files, by at most 0.94% of their pixels, each where an envelope of type now opens live in place of its drawn art (the page's own art): 1 closer to the file's own page and 5 further; the notes change in the same 6 files: the note on type in an envelope goes where none keeps its drawn art, and a file whose envelopes of type all kept their drawn art, which opened without the page comparison, now opens with it and carries its notes (the share of art or type that differs from the page, type the page has without a text object, the PDF part's note on DeviceN colours). A synthetic file of 256 such envelopes of 2,000 characters each opens in the same time as before (13.3 s in a debug build): the box is measured only when its try comes. Tests `ai::envelope::tests::mitred_type_is_also_tried_mapped_from_what_its_stroke_paints_miter_tips_included`, `type_with_a_mitred_character_stroke_is_also_tried_mapped_from_what_it_paints`, `a_stroke_moved_by_a_transform_effect_counts_where_it_paints`, `round_joined_type_is_not_tried_twice_for_the_same_box`, `a_contour_of_zero_length_paints_nothing`, `a_side_within_the_slack_of_the_geometric_box_is_that_side`, `tests_native::a_warp_of_mitred_type_maps_from_what_its_stroke_paints_where_its_drawn_art_does` (#6824, part of #3179) | | 2026-11-12 | minor | `.ai` and EPS import: type filled or stroked with a pattern swatch keeps its pattern. In the text document a character's fill or stroke is a colour (key 0) and the name of an art style (key 5). In users' files (used locally, never committed) that style is one paint (`/SimpleStyle`): a colour, the one key 0 already has, or a pattern, `(name) … [matrix] p`, whose key 0 is black. Such type came in black, or stroked in black. It now takes the pattern, its matrix in art space as a path's pattern fill has it (the files' PDF parts tile such type with that matrix), placed in the type's text space so that it stays where the document has it. A pattern the file doesn't define or can't place, and a style of any other paint, keep the colour as before. A `.ai` saved again writes such type as its outlines, as it writes all type with pattern paint, with that warning. On 224 of our own files: envelopes of type that open live go from 154 in 45 files to 157 in 46, and no file loses one; no file changes whether it opens from its layers or its PDF part; renders at 40 ppi change in 10 of the 13 files with patterned type, 8 closer to the file's own page (one by under 0.01 points, in a file whose render differs from its page almost everywhere either way), 1 further by 0.03 points of its pixels (an envelope of type there now opens live in place of its drawn art), 1 not compared (its page renders at another size); notes change in 3 files: one loses its type note, one loses the note for type in an envelope and, now compared with its page, gains its type notes, and one that opens from its PDF part names a smaller difference. Tests `ate::tests::a_run_painted_by_a_patterns_art_style_takes_the_pattern`, `a_runs_pattern_stays_where_the_document_has_it`, `tests_native::type_painted_with_a_pattern_takes_it_from_the_art_style_its_paint_names`. Scores and estimates unchanged. (#6856) | diff --git a/docs/mcp.md b/docs/mcp.md index 5596fd0bf5..87b7212f82 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -578,6 +578,8 @@ ramp runs across them. `paint.setFill` without `item` on a grayscale or 1-bit image sets the color the image is drawn in: each pixel takes the color as far as the pixel is dark, and white pixels are transparent. `none` removes the color. An image with an alpha channel takes only a spot color, black or white; a process color is refused. +A colorized image whose link is missing shows the placeholder, and a color set on it replaces its colorize color. A +process color is refused there when the image is colorized with a spot color, black or white. For `draw_shape` and `draw_path`, omitted paint fields inherit the current [new-art appearance](#new-art). For a filled shape or path without an outline, pass an explicit `fill` and `stroke: "none"`. Lines start with no fill and a visible stroke. @@ -768,7 +770,9 @@ leaves room for a legend label's stored indents, spacing and leading. `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape -those outlines. On paths, compound paths and images it does not change what is drawn. +those outlines. On paths, compound paths and images it does not change what is drawn. On an image's own fill or stroke +it gives the outline of the image's opaque area, or the image's frame, as described under images' fills and strokes +below. Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with `isError: true` and a message. The model can read the message and retry. @@ -786,7 +790,8 @@ art's colours (by default as global swatches the art links to). them in the Swatches panel does); give colour group names instead to reorder the groups. Dropping a swatch on art 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), `swatch.merge {names}` (the first is kept), `swatch.ungroup {name}` and +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 `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` @@ -2991,14 +2996,15 @@ already walks Image Trace. Short agent entry points: the file at `path`, else a picked one, or the file's text `data`, adds its set after the other sets and returns `{set, name, actions}`. A set named like one already in the panel gets the next free number (`Print 2`), and the other sets and the current set stay as they are. An action set file (`.vcactions`) is JSON: - `{"format": "vcactions", "version": 1 to 4, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step + `{"format": "vcactions", "version": 1 to 5, "name", "actions": [{"name", "steps": [{"command", "params", "excluded"?}]}]}`, each step a command id and its params as `command.batch` takes them, or a stop: the command `action.stop` with `{message, allowContinue}`. A Select Object step is the command `select.byNote` with `{note, caseSensitive, - wholeWord}`. `excluded: true` preserves a step but omits it from playback; - an absent or false flag includes the step. Files with Select Object steps use version 4, files with stops and no - Select Object step use version 3, and files with excluded steps and neither use version 2, so older builds refuse them - instead of playing the excluded steps or failing at a stop or a Select Object step; other files use version 1. A file with another `format` or a `version` other than - 1 to 4, a stop whose message is not text or whose `allowContinue` is not true or false, a Select Object step whose params `ui.action.insertSelectObject` would refuse, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are + wholeWord}`, and a Select Path step is the command `select.storedPath` with `{id, fingerprint}`. `excluded: true` preserves a step but omits it from playback; + an absent or false flag includes the step. Files with Select Path steps use version 5, files with Select Object steps + and no Select Path step use version 4, files with stops and neither use version 3, and files with excluded steps and + none of these use version 2, so older builds refuse them instead of playing the excluded steps or failing at a stop, a + Select Object step or a Select Path step; other files use version 1, and version 1 to 4 files still load. A file with another `format` or a `version` other than + 1 to 5, a stop whose message is not text or whose `allowContinue` is not true or false, a Select Object step whose params `ui.action.insertSelectObject` would refuse, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are refused, and no set is added. So is a file whose set `ui.actionSet.save` would write larger than 8 MiB (a file with less white space, a set name that gets a number, or an `.aia` set), with the error `the action set would be larger than 8 MiB`. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is @@ -3091,6 +3097,26 @@ already walks Image Trace. Short agent entry points: `actionInsertMenuItem` dialog (`find`, `command`, `params`, `set`, `action`, `step`): typing in Find and pressing Find picks the one item whose menu path, shown name or command id holds every word typed, or lists the matches, and OK runs this. +- Actions: `ui.action.insertSelectPath {set?, action?, step?}` inserts a Select Path step for the path selected in the + active document, without changing the selection or the art. The step is `select.storedPath {id, fingerprint}`: the + path's id and a fingerprint of its kind, fill rule and anchors (`v1:` and 16 lowercase hex digits, FNV-1a over them). + The selection must be one whole path or compound path that can be selected. Anchors or segments of a path alone, + several objects, a path with selected ruler guides, a group, type or any other object, a guide, a member of a compound + path, and a hidden or locked object are refused. A path inside a group can be stored on its own. The place, the selected step and the result are + as for `ui.action.insertStop`. No document, a selection that is not such a path, params that are not an object, an + index past the last set, action or step, no action and no recording, or a step past the 10,000-step and 8 MiB limits + is an error, and then nothing changes. The panel menu's Insert Select Path runs this for the selected step or action, + or the recording in progress, and is off while the selection is not such a path. The step's row in the panel reads + Select Path. An action set file holding the step is version 5, and Load Actions… refuses a step whose params are not + an id and such a fingerprint. +- Actions: `select.storedPath {id, fingerprint}` selects only the path `id` while its fingerprint is still + `fingerprint` and it can be selected: not hidden or locked, nor in a hidden or locked layer or group, a template + layer or on a locked artboard, and inside the isolated object in isolation mode. A missing object, another kind of + object, a path whose kind, fill rule or anchors changed (moving it changes its anchors), or one that cannot be selected + is an error, and the selection stays as it was. So a Select Path step finds its path in the document it was stored + from, and in another document only an object with the same id and the same fingerprint. A selection change alone adds + no undo step. In an Accelerated playback the error undoes the steps before it, and at Step by Step and Pause For it + ends the playback after them. Returns `{count, ids}`. These rules are VectorCraft's own. - Actions: `ui.action.insertSelectObject {set?, action?, step?, note, caseSensitive?, wholeWord?}` inserts a Select Object step without running it. The step is `select.byNote {note, caseSensitive, wholeWord}`: during playback it selects the objects whose Attributes note matches `note`, and the steps after it act on them. `note` loses its @@ -3759,12 +3785,24 @@ the fill or stroke paints, and SVG, PDF, EPS and the other vector exports write holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. An image's own fills and strokes draw only when they carry a visible Convert to Shape or Outline Object effect: Convert -to Shape sizes its shape from the image's box, Outline Object gives the image's frame, the effects after it reshape -that shape, and the effects before it are left out. Its Contents row stands for the image; `appearance.addFill` and +to Shape sizes its shape from the image's box, Outline Object gives the outline of the image's opaque area, the +effects after it reshape that shape, and the effects before it are left out. The opaque area is the pixels whose alpha +is at least 50%. Its outline is traced as Image Trace's Black and White mode traces at threshold 128 (Paths and +Corners at their defaults, no area left out as noise): the edge follows anti-aliased edges to a fraction of a pixel +and is fitted with curves. Outline Object gives the image's frame when the image has no transparency (a format +without alpha, or every pixel at least half opaque), when its pixels can't be read, when the outline would take more +than 100,000 anchor points, and when the image has more than 64 megapixels or its decoded pixels take more than 256 MB +(a 16-bit RGBA image of more than 32 megapixels). An image with no pixel at least half opaque gives nothing to paint. +An image of more than 4 megapixels is traced with its alpha averaged down to 4 megapixels; an averaged pixel that +covers a pixel at least half opaque counts as at least half opaque, so opaque lines thinner than the reduction stay in +the outline. The outline is traced the first time it is needed and kept for as long as the image's pixels are loaded. +After the pixels are gone, it is dropped when another outline is traced or within the next 256 outline lookups. When +the image's pixels change (`links.update` reading the linked file), the canvas draws the outline of the new pixels. +These are VectorCraft's own rules. The image's Contents row stands for the image; `appearance.addFill` and `appearance.addStroke` put a new item right below that row, so it paints under the image. SVG, PDF, EPS and the other vector exports write those items as paths, and `effect.expandAppearance` turns them into objects in a group with the image. -Use Preview Bounds measures an image by its frame. +Use Preview Bounds measures an image by its frame and the shapes its own fills and strokes paint. A Warp effect (`warp.arc` … `warp.twist`) on an image, or on a group that holds one, bends the image's pixels with its frame: after the last effect in the list, the image is cut into 10 × 10 cells of two triangles, each a clipped piece of the image under its own affine map. The cell count is VectorCraft's own. Effects after the Warp reshape only the frame, @@ -4703,13 +4741,15 @@ an `anchor` or `segment` in the same way. The document does not change and no un after the new anchor on its subpath are numbered one higher, and a selected segment that the new anchor splits stays selected as its two halves, with the new anchor as an end of both that moves with them. Undo restores the path and the selection in one step. -`path.close {id, subpath?, in?, out?, independent?}` closes an open subpath (by default the last open one). `in` places +`path.close {id, subpath?, at?, in?, out?, independent?}` closes an open subpath (by default the last open one). `at` +moves the first anchor to `[x, y]`, its handles with it, before `in` and `out` are placed. `in` places the first anchor's incoming handle as `path.setHandle` places a handle: a smooth anchor turns its outgoing handle to stay in line and keeps that handle's length, and a corner keeps its outgoing handle where it is. With `independent: true` the anchor becomes a corner first, so its outgoing handle does not move. `out`, placed after `in`, is the first anchor's outgoing handle, and makes the anchor a corner. A Pen press on the first anchor sends `in` at the anchor with `independent: true`. The Pen's and the Smart Pen's closing drags send the mirror of the pointer as -`in`, and once Alt is held they also send `out` at the outgoing handle's position when Alt went down. +`in`, and once Alt is held they also send `out` at the outgoing handle's position when Alt went down. Once Space held +during the Pen's closing drag has moved the first anchor, the drag also sends `at` at the anchor's new position. `path.reshape {id, x, y, dx, dy, tol?, focal?, radius?}` (a Reshape tool drag) grabs the path at `x`, `y`, adding an anchor there unless one lies within `tol` (3 by default), and moves that point by `dx`, `dy`. @@ -4758,7 +4798,7 @@ an `x` without a `y`, or a `y` without an `x`, with "x and y must be given toget `path.blob` refuse a point in `points` whose x or y is not a number, such as `["10", 10]` or a point written as a string, with "x and y must both be numbers". All of them except `path.curvature` refuse a pen pressure, tilt or rotation that is neither a number nor null with `` `pressure` must be a number, not "0.5" ``, naming the key -(`pressure`, `tiltX`, `tiltY` or `rotation`). `path.close` refuses an `in` or `out` that is not `[x, y]` +(`pressure`, `tiltX`, `tiltY` or `rotation`). `path.close` refuses an `at`, `in` or `out` that is not `[x, y]` with `` `in` must be [x, y] ``, naming the key. These commands refuse a string, a number or another value that is not true or false with @@ -5948,6 +5988,40 @@ of its art, as one clipped group. A PDF, `.ai` or `.ait` page read from `path` ( `textAs: "outlines"`) is a placed document the same way, showing page `page` clipped to its `crop` box (or its art's bounds); with `link: false` it is placed as one clipped group. EPS and PostScript `.ai` files are embedded. `at` centres it, `rect` fits it, `replace` swaps the selected object (keeping its place and transform), `template` puts it on a new template layer. +A DCS file (Desktop Color Separation, version 1.0 or 2.0: an EPS file whose header names one EPS program per printing +plate, <http://www.telegraphics.net/doc/DCS.txt>) is placed only from its `path` with link, as one linked image +([Import DCS files](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/import-dcs-files.html)). +`file.place` with `link: false` or from `dataBase64`, `document.open` and `links.embed` fail with "DCS files can only +be placed linked" and change nothing. The plates are read from the file (`%%PlateFile: (name) EPS #offset size`) or +from the plate files it names (`%%PlateFile: (name) EPS Local file`, and the `%%CyanPlate:`, `%%MagentaPlate:`, +`%%YellowPlate:` and `%%BlackPlate:` comments of DCS 1.0), which are looked for by file name in the DCS file's folder. +Each plate is the one image its program draws, and a dark pixel is ink; a plate program that draws more than one +image, art besides its image, or its image through a clipping path is an error naming the plate. Each spot plate adds a spot swatch (when +the file is placed, and when `links.update` or `links.relink` reads it) in the +CMYK values of the file's `%%CMYKCustomColor:` comment (else `%%RGBCustomColor:`; black, with a warning, when the file +gives neither). A swatch of the same name that the document already has is kept, with a warning (in `warnings` of +`file.place`, `links.update` and `links.relink`) when it is not a spot color of the same values; Separations Preview +and `file.print` separations then show and print the plate in that swatch's color, and a plate whose name is a +process swatch prints as process inks in that swatch's color. `swatch.unused` does not list a spot or process swatch +while such an image that the art shows has a plate of its name; deleting the swatch makes the plate print as its CMYK values. The clipping path the +file's composite is drawn through clips the image (a clipping group, its path under the nonzero fill rule, an even-odd +clipping path kept as the same area), and `file.place` answers the image's id as +`image`, since the Links commands take the image and not the group. `links.update` and `links.relink` clip the image +by the file's clipping path again (in a new clipping group when none holds it, and inside a clipping mask made in the +document, whose path is kept), unless Placement Options clip the image, the same way for the images in symbols, graph designs, patterns and opacity masks; when the new file has no +clipping path, a clipping group holding just the image whose path is the old file's clipping path loses that path and +stays as a plain group of the image, with its id, name, visibility and transparency (one whose path was given the +even-odd fill rule stays as it is). The link shows as modified when a plate file of its own changes +or is gone, as it does when the DCS file changes, and the app's link watch acts on a changed plate file as on a +changed DCS file. `links.relink {copy: true}` fails for a DCS file that reads plate files of its own, and copies +nothing. These rules are VectorCraft's own: the screen, composite print and exports show the image as its plates +mixed into CMYK (each ink amount times the ink's CMYK values, plates combined as `1 − (1 − a)(1 − b)`); Separations +Preview and `file.print` separations show each plate on its own ink, and a spot plate prints as the document's swatch +of its ink (else its CMYK values) with `spotsToProcess`. Separations Preview shows a reduced image's plates as the +mean of the plate pixels each shown pixel covers. A file may name 64 plates, a plate file may be 1 GB, and the plates together may hold 512 MB of +ink amounts; a plate that can't be read is an error naming the plate, and nothing is placed. The plates go on the first +plate's pixel grid, grown to hold every plate where it prints; a plate of another size or position is resampled onto +it, and a file naming more than 64 plates is an error. `file.place.info` answers `plates` (the ink names) for a DCS file. The art's bounds are the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects, guides and template layers do not count, and symbol instances are measured by their art. A page or artboard holds the objects whose shown art lies on it; a hidden object on it is still placed and stays hidden, as do the hidden members of the groups placed. @@ -6667,6 +6741,14 @@ allowance has bytes, drawn as a crossed gray box on the canvas and left out of e counter-clockwise), `placement` and, for a linked file, `fileName`, `location`, `fileSize`, `created`, `modified`. With automation read roots, a file outside them has no `path` or `found` in `links.list` and `links.check`, and no `link`, `path`, `location`, `fileSize` or `modified` in `links.info`; its row keeps the file's name. +An image placed from a DCS file has the `format` `DCS`. `links.list {dcsTransparency: true}` (the panel menu's Show +DCS Transparency Interactions, off by default) adds `dcsTransparency: true` to the rows of such images that +[are transparent or overlap transparent objects](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/eps-and-dcs-file-import-in-illustrator.html), +and the panel shows a warning mark on them. VectorCraft's rule for it: the image, or a group or layer holding it, has +an opacity below 100%, a blend mode other than Normal, an opacity mask or an effect, or its bounds overlap the +bounds of another visible object that has one of those or a gradient with a transparent stop (in a fill, a stroke or +a character's fill or stroke); a DCS image with transparency of its own counts as such an object. The check looks at +every object in the layers each time the list is read. A `dcsTransparency` that is not true or false is an error. `links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file (another extension converts it) and links to it, and without `path` returns `{name, dataBase64}`. @@ -6915,7 +6997,11 @@ visible type that fits its frame or path, on the layers `printLayers` picks, on that print, and the type in the symbols, patterns, scatter brushes, opacity masks and placed documents of the art that prints. The warnings also give the ids of the area type and type on a path that print and whose text doesn't fit (objects of the document only, not type in the art of symbols, patterns, brushes, opacity masks or placed documents; -see [Fitting area type](#fitting-area-type-auto-size-shrink-text-to-fit-overflow)). +see [Fitting area type](#fitting-area-type-auto-size-shrink-text-to-fit-overflow)). The warnings of `file.print` +and `print.preview` list what the job leaves out of the document or prints differently first (art outside the +imageable area, type in a fallback font, transparency flattened for PostScript). The notes on the options follow: an +option not applied or applied another way, such as halftone screens, a fixed flatness or copies per sheet that don't +fit. Each kind keeps the order it is given in. ```json {"name":"run_command","arguments":{"command":"print.setup","params":{"settings":{"media":"a4","scaling":"fit","marks":{"trim":true}}}}} @@ -9072,7 +9158,8 @@ font's variation axes (`tag`, `name`, `min`, `default`, `max`, `hidden`, and `va first style), empty for a static font. `text.rescanFonts` (Character panel menu › Refresh Font List) scans the font folders again, for fonts installed or removed since the app started: type set in a font that became available redraws in it, and type set in a family that was removed redraws in -the fallback font and is listed as missing, without editing the document. The app does so by +the fallback font and is listed as missing, without editing the document. The type in opacity masks and in the art of +symbols and pattern swatches is laid out again too, and inline graphics take the new size of their symbols' art. The app does so by itself when its window comes to the front and a font folder changed meanwhile. ```json diff --git a/docs/roadmap.md b/docs/roadmap.md index 5d2a1dc435..cf22043f6f 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-13 · **Change:** minor (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-13 · **Change:** minor (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). > @@ -52,6 +52,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. | | 2026-11-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. | | 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | @@ -148,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M0 | Skeleton + vertical slice | ✅ done | — | | 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 | 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 | +| 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); | 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) | 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 | 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d62ca2f683..fee574dfbc 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-13 · **Change:** minor (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-13 · **Change:** minor (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) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -53,6 +53,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches panel: its menu's Show Recent Colors hides and shows the panel's Recent Colors row, checked by default and kept between sessions apart from the Color panel's (#6617); scores unchanged. | | 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | @@ -495,7 +496,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Selection, transform & align tools | 6 | 85% | Direct Selection segment click/Delete corrected (#1278); remaining: Free Transform/Scale/Reflect along a rotated box, Global Edit of similar groups, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | -| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | +| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill 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); freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | | Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (nine generated in code including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | @@ -591,7 +592,7 @@ since 2026-10-01. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. - **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged. -- **What a save or an export loses, named first:** Each writer marks its notes as a loss (something of the document the file leaves out or writes differently) or a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The replies of the save and export commands list the losses first, each kind in the order given, across every file an export writes. The status message after a save or an export then names a loss when there is one: an export to lossy WebP of type in a font that isn't installed names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in. Scores and estimates are unchanged. +- **What a save or an export loses, named first:** Each writer marks its notes as a loss (something of the document the file leaves out or writes differently) or a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The replies of the save and export commands list the losses first, each kind in the order given, across every file an export writes. The status message after a save or an export then names a loss when there is one: an export to lossy WebP of type in a font that isn't installed names the fallback-font note instead of the lossy WebP note. A print to a file and the print preview list theirs the same way: art outside the imageable area, type in a fallback font and transparency flattened for PostScript come before notes such as a fixed flatness that isn't written or copies per sheet that don't fit. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. @@ -744,6 +745,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). - Actions panel: the panel menu's Insert Menu Item… adds a menu command to the recording in progress, after the selected step, or at the end of the selected action, without running it, and playback runs it as the menu does ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertMenuItem`). The dialog's Find box picks the one item that matches the words typed, and a click on a listed item picks it. VectorCraft's own rules, which the page does not cover: items that open a dialog, the Object › Align items, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern, and the File, Window and Help menu items are not listed, and an action holds at most 10,000 steps and its set file at most 8 MiB, for stops too. + - Actions panel: with a path selected, the panel menu's Insert Select Path adds a step that selects that path to the recording in progress, after the selected step, or at the end of the selected action, without changing the selection ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertSelectPath`, `select.storedPath`). VectorCraft's own rules, which the page does not cover: the selection must be one whole path or compound path that can be selected, the step stores the path's id and a fingerprint of its anchors, and playback selects the path only while it is still there unchanged and can be selected, else the step is an error and the selection stays; the row reads Select Path, and a set file with the step is version 5. - Actions panel: the panel menu's Select Object… opens Set Selection, where the note typed names the objects to select, and adds a step to the recording in progress, after the selected step, or at the end of the selected action; playback selects the objects whose Attributes note matches it, and the steps after it act on them ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertSelectObject`, `select.byNote`). VectorCraft's own rules, which the page does not cover: the note matches anywhere and in any letter case unless Case Sensitive or Whole Word is checked, hidden and locked objects are left out, the objects found replace the selection, a step that finds none stops the playback with an error, and the note is at most 1,024 bytes. Action sets with the step save as version 4. - Actions panel: Delete Selection deletes the selected action set with its actions and keeps the other sets ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.delete`). VectorCraft's own rules, which the overview does not cover: deleting a set asks first, Shift-click on a set's name selects a range of sets to delete together, the panel menu's Delete does the same, and Default Actions and the last set can be deleted. Undo does not bring a deleted set back. - Actions panel: the panel menu's Duplicate copies the selected action, else the current action set with all its actions ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `ui.actionSet.duplicate`). VectorCraft's own rules, which the overview does not cover: the copy goes right after the original, is named after it with " copy" added and is selected. @@ -859,6 +861,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). - 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`). - 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`). - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, 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. @@ -1026,7 +1029,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Transform Each opens on the scale, move, rotate, reflect, Random, reference point, Transform Objects and Preview it was last confirmed with (kept with the UI state; Copy never is), and its Copy button, left of Cancel, transforms a copy of each object as the [Scale multiple objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-multiple-objects.html) help page has it; the Copy checkbox is gone (#1732; the remembered values follow a maintainer comment on the issue, not the help page). Its Scale section has the Relative and Absolute radio buttons the same page describes: percentages of each object's size, or the width and height it becomes (`object.transformEach` `relative`, which Transform Again repeats); Absolute measures each object's own box, and the scales act along its axes about that box's reference point, so a turned shape keeps its shape and objects of different sizes come out alike; the fields show the width and height the selected objects share and read "Mixed" when they differ or a side is 0 (a line's), each object measured by its own box, rotated with it, as the Transform panel and the Control bar show it (sizes they show alike count as the same), and a size typed into them sets every object to it (a side left Mixed keeps its size), our choices where the page leaves them open; a live Repeat scales its spacing by the geometric mean of the two factors, so its Absolute size is approximate. The link beside the two scales keeps them in proportion, as the shape dialogs' Width and Height link does (`__constrain`, kept with the dialog's values; where it sits is our choice, the public pages don't establish it). The panel lists Scale, Move and Rotate, then the Options in two columns (Transform Objects, Transform Patterns, Scale Strokes & Effects, Scale Corners; Reflect X, Reflect Y, Random), the Reference Point, and Preview at the left of the button row, level with Copy, Cancel and OK. Transform Again uses the Copy, Transform Patterns, Scale Strokes & Effects and Scale Corners choices of the transform it repeats; after a bounding-box handle drag on area type, it resizes the type area again and the type keeps its size (`object.transformAgain`). With anchors selected with Direct Selection, Move, Rotate, Scale, Reflect, Shear and Transform Again (the commands, their dialogs and the four tools' drags) transform only those anchors, their handles with them, about the centre of the box around them unless a point was clicked; a copy still copies the whole object (#1408; behaviour reference: [adjust path segments](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html); the default reference point and the copy are our choices). The Move, Rotate, Scale, Reflect and Shear dialogs have a Preview checkbox, off when they open: while it is on the canvas follows the values, Cancel rolls it back and OK is still one undo step; Up/Down step the Rotate and Shear angles and the Scale percentages by 1, Shift by 10 (#1412). The five dialogs and Transform Each have Transform Objects beside Transform Patterns: off, the art stays and only its pattern tiles transform (`objects` on the transform commands), and Transform Again repeats the choice (#1448; behaviour reference: [scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html); the checkboxes are always enabled and the Scale page's pair is applied to all of them, our choices). Reflect's axis is Horizontal, Vertical or an angle (#1460; [reflect objects along an axis](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/reflect-objects-along-an-axis.html)). - - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve and the release closes the path wherever it ends (#501). The first anchor's incoming handle mirrors the pointer, and a click leaves it retracted and the anchor a corner. Its outgoing handle stays where it is on a corner, so the first segment keeps its shape, and on a smooth anchor it turns to stay in line at its own length; Alt makes the anchor a corner and leaves the outgoing handle where it was when Alt went down. The Smart Pen's closing drag does the same (fixes #6634; `path.close`'s `in` and `out`; behavior reference: [draw with the Pen, Curvature, or Pencil tool](https://web.archive.org/web/20220614224656/https://helpx.adobe.com/illustrator/using/drawing-pen-curvature-or-pencil.html), where a click or drag on the first anchor closes the path and Alt breaks the pairing of the closing anchor's handles; which handles the drag sets are our choices). Moving the closing anchor with Space held, which that page also describes, is still missing. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). A drag's preview (the Pen's, the Anchor Point tool's, Direct Selection's handles and anchors) follows every position the pointer is seen at (#834): the canvas sends the tool a drag whenever the pointer's document position differs from the last one it sent, not only when egui's frame delta is non-zero (zero when a compositor coalesces motion and in the frame the pointer comes back into the window after leaving it), and a move that arrives in the release's own frame shapes the preview before the release commits it, once the press has dragged. With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). + - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve and the release closes the path wherever it ends (#501). The first anchor's incoming handle mirrors the pointer, and a click leaves it retracted and the anchor a corner. Its outgoing handle stays where it is on a corner, so the first segment keeps its shape, and on a smooth anchor it turns to stay in line at its own length; Alt makes the anchor a corner and leaves the outgoing handle where it was when Alt went down. The Smart Pen's closing drag does the same (fixes #6634; `path.close`'s `in` and `out`; behavior reference: [draw with the Pen, Curvature, or Pencil tool](https://web.archive.org/web/20220614224656/https://helpx.adobe.com/illustrator/using/drawing-pen-curvature-or-pencil.html), where a click or drag on the first anchor closes the path and Alt breaks the pairing of the closing anchor's handles; which handles the drag sets are our choices). Space held during the Pen's closing drag moves the first anchor, handles and all, as that page also describes (`path.close`'s `at`). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494). A drag's preview (the Pen's, the Anchor Point tool's, Direct Selection's handles and anchors) follows every position the pointer is seen at (#834): the canvas sends the tool a drag whenever the pointer's document position differs from the last one it sent, not only when egui's frame delta is non-zero (zero when a compositor coalesces motion and in the frame the pointer comes back into the window after leaving it), and a move that arrives in the release's own frame shapes the preview before the release commits it, once the press has dragged. With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). - **Direct Selection (#1278):** clicking a segment picks its connection, with hollow endpoint anchors and only that curve's direction handles. Delete leaves the neighboring curves and appearance intact, opens/splits contours in one undo step, and another Delete removes the remaining selected path. A whole-path drag and deliberate anchor selection keep their existing behavior; egui frames and native control/art checked, OS mouse/visual QA pending. Where anchors of overlapping objects coincide, a click or drag takes the anchor of the selected object, also when another object lies over it (#1509, fixes #1508). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Double-clicking type (including type nested inside a group) switches to the Type tool and puts the caret at the clicked character. With Direct Selection and Group Selection, a drag that starts on an object while the selection also holds direct-selected anchors or segments moves those anchors and the objects selected whole in one undo step (`path.moveAnchors`), as a drag of a selected anchor does, and the other anchors of a partly selected path stay in place. While Alt is held during the drag, it copies the selected objects instead (`object.transform`). When direct-selected anchors are moved or transformed together with objects selected whole (such a drag, an anchor drag, an arrow key, or Move, Rotate, Scale, Reflect and Shear without a copy), a path inside a selected group is transformed once, with the group. A path segment with a control point whose x or y is infinite or larger in size than `f64::MAX / 16` is measured by its control points, so its bounds hold the whole curve and the canvas check refuses an edit that leaves such a path selected (`path.create` included). @@ -1206,7 +1209,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Recent font pickers (#3515):** The shared Character, Properties, Glyphs and Find Font family picker lists recently applied families first, once each, in most-recent order and limited by Preferences › Type › Number of Recent Fonts. Search and filters apply to both sections; unavailable families are omitted. Section headings cannot be selected or previewed. Confirmed character-range font edits update the same saved history; previews and failed edits do not. Requirement: [issue #3515](https://github.com/storytold/vectorcraft/issues/3515). - **Find Font: Replace With Font From:** a dropdown under Replace With Font chooses the list the replacement font comes from: Document lists the families the document uses, once each; Recent lists the recently applied families that are installed (fonts applied with Find Font's Change and Change All included), newest first, as many as Preferences › Type › Number of Recent Fonts allows; System, the default, lists the installed families available in the font menu. The dialog opens on System with Source Sans 3 chosen, or the font menu's first family when Source Sans 3 is not installed. Choosing a list keeps the chosen font when the list has it and it is installed; otherwise Document and Recent choose the list's first installed font, and System chooses Source Sans 3 or the font menu's first family. Change and Change All are unavailable when the list has no installed font (Recent before any font was applied) and when the chosen family is not installed or is not in the shown list. Behavior reference: [Find and replace fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) (public documentation). Tests: `crates/ui-egui/src/find_font.rs`. - **Select Text by Font: scope (a VectorCraft addition, #5529):** `select.font` takes `scope`: `"document"` (the default, as before), `{artboards: [index, …]}` or `{layers: [id, …]}` (sublayers included). On an artboard means what All on Active Artboard takes (the bounds overlap it or the center is on it; one shared test, `select::on_artboard`); hidden and locked text stays out as before; a symbol instance counts where the instance is. Type › Find Font…'s Find In dropdown passes the active artboard or the selected layers (else the current layer). Tests: `crates/engine/src/cmd/fonts.rs`, `crates/ui-egui/src/find_font.rs`, `crates/engine/tests/command_sweep.rs`. -- **Find Font: type in opacity masks and pattern swatches:** Type › Find Font…'s Document list (`text.fonts`), Find (`select.font`), Change and Change All (`text.replaceFont`), Replace Missing Fonts (`addon.cleanup.replaceFonts`), the Missing Fonts dialog (`text.missingFonts`), Document Info's Fonts and Font Details for the whole document (`document.info`), File › Package and the export warning about fonts that aren't available include the type in opacity masks (on objects and layers, disabled masks and masks inside mask, symbol or pattern art included) and in the art of pattern swatches, also a swatch that nothing paints with. A pattern's type is listed and changed once, however many objects the pattern paints. Find selects the objects whose mask holds the font and the objects painted with a pattern whose art uses it (VectorCraft's own rule; a layer's mask and a swatch that nothing paints with select nothing); Change and Change All leave the type in the mask of a hidden or locked object in its font and count it in `skipped`, and Change does the same with the type of a pattern that only hidden or locked selected objects paint with. The help page (https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) says the Document list holds all the fonts the document uses. Tests: `crates/engine/src/cmd/fonts.rs`, `crates/engine/src/cmd/addons/cleanup/text/tests.rs`, `crates/engine/src/cmd/docinfo.rs`. +- **Find Font: type in opacity masks and pattern swatches:** Type › Find Font…'s Document list (`text.fonts`), Find (`select.font`), Change and Change All (`text.replaceFont`), Replace Missing Fonts (`addon.cleanup.replaceFonts`), the Missing Fonts dialog (`text.missingFonts`), Document Info's Fonts and Font Details for the whole document (`document.info`), File › Package and the export warning about fonts that aren't available include the type in opacity masks (on objects and layers, disabled masks and masks inside mask, symbol or pattern art included) and in the art of pattern swatches, also a swatch that nothing paints with. A pattern's type is listed and changed once, however many objects the pattern paints. Find selects the objects whose mask holds the font, the text whose inline graphics draw a symbol whose art uses it, and the objects painted with a pattern whose art uses it by a visible fill or stroke above 0% opacity (VectorCraft's own rule; a layer's mask and a swatch that nothing paints with select nothing); Change and Change All leave the type in the mask of a hidden or locked object in its font and count it in `skipped`, and Change does the same with the type of a pattern that only hidden or locked selected objects paint with. The help page (https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) says the Document list holds all the fonts the document uses. Tests: `crates/engine/src/cmd/fonts.rs`, `crates/engine/src/cmd/addons/cleanup/text/tests.rs`, `crates/engine/src/cmd/docinfo.rs`. - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the menu's saved font filters but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `<use>` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). @@ -1429,7 +1432,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing. The Add New Effect popup (Appearance panel, Properties), the Appearance panel's swatch picker, the Gradient panel's gradient swatches menu and the Image Trace button's list of presets stop at the bottom of a short window, and the mouse wheel or the scroll bar reaches their last rows. A list that fits does not scroll (`a_long_swatch_picker_scrolls_inside_a_short_window`). - An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame. + An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box, or the outline of the image's opaque area or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds then measures an image by its frame. + Outline Object on an image's own fill or stroke follows the outline of the image's opaque area, and the image's frame when the image has no transparency. Path effects apply to a fill or stroke added to a bitmap object ([archived reference manual, page 561](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=561)), and with Outline Object such a stroke follows the boundary of the art when the image has transparent areas and the image's frame when it has none ([CreativePro article on strokes for placed images](https://web.archive.org/web/20250815214529/https://creativepro.com/add-strokes-placed-images-illustrator/)). VectorCraft's own rules: the opaque area is the pixels at least 50% opaque, and its outline is traced as Image Trace's Black and White mode traces at threshold 128 and fitted with curves. Evidence: `tests::imageoutline` (vectorcraft-effects), `opaque::tests` (vectorcraft-trace), `tests::containers::an_images_stroke_with_outline_object_follows_its_opaque_area` and `tests::containers::an_images_outline_object_stroke_exports_selects_and_measures_along_its_opaque_area` (vectorcraft-engine), `tests::objectfx::an_images_outline_object_stroke_follows_new_pixels_under_the_same_key` (vectorcraft-render). A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own. A geometry effect on a symbol instance's fill reshapes the filled shapes of the symbol's art and leaves their strokes as they are; one on the instance's stroke reshapes the stroked shapes and leaves their fills as they are. The shapes keep their colors. Only the instance's topmost fill and topmost stroke count, and only while they are visible with an opacity above 0. The canvas and Object › Expand Appearance use the same art, SVG and PDF export bake the same shapes, and Expand Appearance turns the instance into an editable group in one undo step. A group's own fills and strokes in the art take the effects too. An instance inside the art passes the effects on to its own art, and a dynamic symbol's instance reshapes the fills and strokes it paints on the art. How the effects map onto the symbol's art is VectorCraft's own rule. The help page <https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/apply-and-modify-effects.html> describes effects on one fill or stroke, and the help page <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/expand-objects.html> describes Expand Appearance (engine `expand::a_symbol_instance_fill_effect_reshapes_its_filled_art_and_expands`, `a_symbol_instance_stroke_effect_reshapes_its_stroked_art_and_expands`, `a_stroke_effect_reshapes_a_stroke_a_dynamic_instance_adds`, `a_fill_effect_reshapes_the_fill_of_a_group_in_the_art`, `only_the_topmost_visible_instance_fill_reshapes_the_art`, `a_nested_symbol_instance_takes_the_outer_fill_effect`, `export_bakes_a_symbol_instance_fill_effect`, `a_fill_effect_on_an_instance_of_a_symbol_that_holds_itself_ends`). Existing documents with a geometry effect on a symbol instance's fill or stroke draw differently: before, the effect drew nothing. The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values. @@ -1573,6 +1577,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - JPEG XL (`.jxl`) images open and place, linked or embedded, as their first frame, decoded with jxl-oxide and stored as PNG: 16-bit and floating-point samples become 8 bits, alpha is kept, and an image past the pixel allowance is refused from its header (part of #2306; `crates/engine/src/cmd/fileio/tests_jxlread.rs`). - The Open and Place dialogs list PostScript `.ps` files and `.epsf` files. A `.ps` file opens and places through the EPS reader (its first page), also when its header is only `%!`, as the `ps` format, which exports don't write (Print to PostScript does); an `.epsf` file opens and places as EPS. Copied in a file manager, these files paste as EPS, and `clipboard.importEps` also reads PostScript whose header is only `%!` (`crates/engine/src/cmd/fileio/tests_epsimport.rs`). `document.pdfInfo` reads an EPS, a `.ps` file or a `.ai` in PostScript form as one page the size of its bounding box (a US Letter page without one), and its `thumbnail: 1` is a PNG of what that page prints (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). + - DCS 1.0 and 2.0 files (an EPS file with one program per printing plate) place linked as one image of their plates, and Open, Embed and Place without Link refuse them ([Import DCS files](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/import-dcs-files.html)). Spot plates add spot swatches, the composite's clipping path clips the image, Separations Preview and print separations show each plate on its own ink, and the Links panel menu has Show DCS Transparency Interactions (`links.list {dcsTransparency}`). The plates are read as <http://www.telegraphics.net/doc/DCS.txt> describes them (`crates/eps/src/dcs/tests.rs`, `crates/engine/src/tests/dcs.rs`, `crates/doc/src/separated.rs`). - EPS export with Use Artboards writes a central EPS file of the whole document, bounded by its visible art, beside the file of each artboard when it writes two or more artboards; a document with no visible art gets only the artboard files (`crates/engine/src/cmd/fileio/tests_eps.rs`). EPS export writes a linear or radial gradient of tints of one spot color (and paper white) in that ink's Separation space, at Level 2 and 3, and at Level 3 one that mixes a spot color with process or gray colors, or two spot colors, in a DeviceN space 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. At Level 2, such a mix is written in process colors with a warning. In an RGB export, a gradient that mixes a spot color with RGB or Lab colors is written without the spot ink, with a warning; a CMYK export converts those stops to CMYK first, and the gradient follows the rules above. A Perceptual gradient of spot colors, and a mix whose DeviceN space would need more than 32 inks or a spot color named Cyan, Magenta, Yellow or Black, are also written in process colors with a warning (`crates/eps/src/tests.rs`). 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), is written 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 and DSC comments; Registration still prints on every plate (`tests::spot_inks_named_all_or_none_are_written_under_names_of_their_own`). 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`). diff --git a/docs/three-d.md b/docs/three-d.md index e4cd4fa76f..38b59023dc 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -326,7 +326,8 @@ These rules are VectorCraft's own: - A graphic is drawn on the visible face of each of the object's solids (a fill's, and a stroke's outline), turned, foreshortened by the perspective and twisted and tapered with the face, and clipped to it. It is shaded - with the face's light: each color is multiplied by the light that falls on the face, and drawn with the + with the face's light: each color is multiplied by the light that falls on the face, except in pattern tiles, + which keep their own colors. It is drawn with the opacity and blending mode of the fill or stroke the face is painted with. A graphic on a face turned away from the viewer is not drawn. - On the back cap, X runs to the right as the back is seen from behind, so the graphic reads the right way round diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5f10ea4841..86af6a6956 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (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: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag 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: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: 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; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; 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; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (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: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag 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: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: 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; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; 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; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638). Scores unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -166,6 +167,7 @@ tools, never checked side by side) isn't captured row by row. | Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain | | Icon-strip flyout resizing (#5713) | done | Canvas-side edge and bottom; user size persists after reopening and workspace restore; Glyphs and Artboards pointer/cursor regression | | Icon-strip flyout resizing from the top (storytold/craft-libs#1094) | done | Top edge (a 4 pt band over the tab strip) and the canvas-side top corner; the bottom and the icon-column edge stay; the moved top persists after reopening and workspace restore; `an_icon_flyout_grows_upward_from_its_top_edge_and_free_corner` | +| Library panels' lists fill a taller panel (#2992) | done | Swatch (Color Books among them), brush, symbol and graphic style library panels: docked, floating or in a resized flyout, the list takes the height above the panel's footer; outside a sized panel it stays 220 pt (280 pt for large thumbnails); `the_list_fills_a_tall_panel_above_its_footer` in `panels/library_panel/tests.rs` | | Swatches list fills a taller panel (#1638) | done | Docked, floating or in a resized flyout, the swatch list takes the height above the panel's footer, as the Artboards list does; outside a sized panel it stays 150 pt (200 pt for large thumbnails); `the_list_fills_a_tall_panel_above_its_footer` in `panels/swatches/tests.rs` | | Layers panel lists a blend's spine (#4820) | done | an open blend shows a `<Path>` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) | | Layers panel: a layer whose Layer Options › Print is off shows its name italic (#5739) | done | the panel row and its drag ghost; a printing layer stays upright, and Print on again restores it; `panels::layers` test `a_layer_that_does_not_print_has_its_name_italic` | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 8a6f5f0256..3777f8fb81 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -85,6 +85,9 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[ &["three-d", "effects"], // Inflate cuts its surface's grid cells to the shape with Pathfinder's booleans. &["pathops", "three-d"], + // Outline Object on an image's fill or stroke follows the outline Image Trace makes of its + // opaque area. + &["trace", "effects"], ]; fn intra_layer_allowed(from: &str, to: &str) -> bool {