diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 0812095826..1c231f7bbe 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -194,6 +194,27 @@ impl ImageBlob { .collect(); Some((w, h, cover)) } + + /// The pixels as straight RGBA, row by row from the top, with the width and the height; + /// `None` when the image can't be decoded within [`MAX_RASTER_ALLOC`]. + pub fn rgba(&self) -> Option<(u32, u32, Vec)> { + let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?; + let (w, h) = img.dimensions(); + Some((w, h, img.into_raw())) + } + + /// The width and height in pixels the image's file says it has; `None` for data that isn't an + /// image the decoder reads. Reads only its header. + pub fn pixel_size(&self) -> Option<(u32, u32)> { + image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions().ok() + } + + /// Whether the image's file is a PNG that stores palette indices (an indexed-color image). + /// Reads only its header. + pub fn indexed_png(&self) -> bool { + // The signature, then the IHDR chunk: length, type, width, height, bit depth, color type. + self.bytes.starts_with(b"\x89PNG\r\n\x1a\n") && self.bytes.get(12..16) == Some(b"IHDR".as_slice()) && self.bytes.get(25) == Some(&3) + } } impl ImageBlob { diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index bbc56dbc15..b2b44a898d 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -3,7 +3,11 @@ //! shared colour visitor [`Recolor`]: fills and strokes (solid colours, gradient stops and text //! runs), gradient meshes, embedded images and pattern fills. Colours linked to a global swatch are //! unlinked when they change; Adjust Color Balance's Global mode shifts their tints instead. +//! +//! Convert to Indexed Color uses the same visitor for embedded images only, and stores each one as +//! a palette PNG. +use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -15,7 +19,9 @@ use vectorcraft_doc::live::lerp_color; use vectorcraft_doc::pattern::PatternDef; pub(crate) use vectorcraft_doc::recolor::map_paint; use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; -use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Document, ImageBlob, MAX_RASTER_ALLOC, Node, NodeId, NodeKind}; +use vectorcraft_render::encode::png::{PngOptions, encode_indexed}; +use vectorcraft_render::encode::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_to}; use super::edit::selected_roots; use super::{ @@ -66,6 +72,15 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "edit.colors.toIndexed", + "Convert to Indexed Color…", + ["Edit", "Edit Colors"], + None, + "{reduction?: \"selective\" (default)|\"perceptual\"|\"adaptive\"|\"web\"|\"gray\"|\"blackWhite\" (chosen as for a PNG-8 export)|\"uniform\" (evenly spaced levels of red, green and blue: the most levels, 2 to 6, whose cube fits in `colors` with one entry left for transparency)|\"document\" (the colors of the document's swatches, at most 255), colors?: 2..256 (default 256; the most palette entries, the transparent one included; at least 9 for uniform; refused for blackWhite and document), dither?: \"diffusion\" (default)|\"pattern\"|\"noise\"|\"none\", ditherAmount?: 0..100 (default 100), ids?} store each embedded image in the selection (in groups too) as an indexed-color PNG: a palette of at most 256 RGB colors, each pixel the index of one; pixels under half opacity become one transparent entry and the others opaque, keeping their color; linked images, colorized images and the images of color glyphs in outlined type keep their pixels; each image changed becomes a new image, one undo step for all; an image that is an indexed PNG already and would show the same pixels is left as it is, and no undo step is recorded when no image changes; an image of more than 134,217,728 pixels (33,554,432 in the web app) is refused with an error and nothing changes; reduction and dither ids are read as for a PNG-8 export, ignoring case → {changed: images changed, notChanged: the locked and hidden objects Select All left out that hold an image that is not an indexed PNG yet, warning?} (as edit.colors.invert)", + has_selection, + to_indexed + ), cmd!( "edit.colors.saturate", "Saturate…", @@ -149,6 +164,13 @@ fn gray_level(c: Color) -> f32 { /// gradient stop): `(colour, link, tint)`, true when it changed them. pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option, &mut f32) -> bool + 'a; +/// What a pass that remakes whole images makes of one: its new copy, `None` when it stays as it is. +type Remake<'a> = dyn Fn(&ImageBlob) -> Option + 'a; + +/// The objects a pass recolors, and the locked and hidden objects the selection left out, each +/// with whether it is hidden ([`pass_targets`]). +type Targets = (Vec, Vec<(NodeId, bool)>); + /// The identity colour map (a pass that only maps linked colours). fn same(c: Color) -> Color { c @@ -191,6 +213,8 @@ pub(crate) struct Recolor<'a> { scope: Scope, /// Recolored images are stored with one gray channel ([`ImageBlob::map_gray`]). gray_images: bool, + /// Makes each embedded image's new copy in place of `f` (`None`: it stays as it is). + remake: Option<&'a Remake<'a>>, /// Image key → the key of its recoloured copy (`None`: unchanged). images: HashMap>, /// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured). @@ -207,6 +231,7 @@ impl<'a> Recolor<'a> { links: None, scope, gray_images: false, + remake: None, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], @@ -229,7 +254,7 @@ impl<'a> Recolor<'a> { /// A pass that maps colors as this one does, with nothing recolored yet. fn again(&self) -> Self { - Self { links: self.links, gray_images: self.gray_images, ..Self::new(self.scope, self.f) } + Self { links: self.links, gray_images: self.gray_images, remake: self.remake, ..Self::new(self.scope, self.f) } } /// The entries of `objects` (ids, each with a flag) whose object this pass would change, other @@ -328,7 +353,9 @@ impl<'a> Recolor<'a> { } let f = self.f; let blob = d.images.get(key)?; - let new = if self.gray_images { + let new = if let Some(remake) = self.remake { + remake(blob) + } else if self.gray_images { blob.map_gray(|[r, g, b]| f(Color::rgb8(r, g, b)).to_rgba8(1.0)[0]) } else { blob.map_rgb(|[r, g, b]| { @@ -534,6 +561,187 @@ impl NotChanged { } } +/// The objects a pass recolors (the `ids` param's, default: the selection's) and the locked and +/// hidden objects the selection left out ([`left_out`]), each with whether it is hidden. +fn pass_targets(s: &Session, p: &Value, cmd: &str) -> Result { + Ok(match ids_param(p, "ids", cmd)? { + Some(v) => (v, vec![]), + None => { + let roots = selected_roots(s)?; + let st = s.doc()?; + let left = left_out(&st.doc, st.isolation, &roots); + (roots, left) + } + }) +} + +/// Edit › Edit Colors › Convert to Indexed Color: the embedded images of the objects stored as +/// indexed-color PNGs ([`Indexing`]). The pass runs on a copy of the document, so no undo step is +/// recorded when no image changes. +fn to_indexed(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.colors.toIndexed"; + let indexing = Indexing::of(s, p)?; + // A typing session ends here as `s.edit` would end it, before the copy is taken, so that the + // copy holds what was typed. + if !s.previewing && super::typing_in_progress(s) { + super::settle_interaction(s)?; + } + let remake = |b: &ImageBlob| indexing.convert(b); + let images = Scope { fill: false, stroke: false, images: true, patterns: false }; + let pass = Recolor { remake: Some(&remake), ..Recolor::new(images, &same) }; + // A left-out image counts as one the pass would change when it is an image that is not an + // indexed PNG yet; it is not converted to find out. The empty PNG only gives it a new key. + let would = |b: &ImageBlob| (!b.indexed_png() && b.pixel_size().is_some()).then(|| ImageBlob::png(vec![])); + let (ids, left) = pass_targets(s, p, C)?; + let st = s.doc()?; + let colorized_ok = super::paint::check_colorized(&st.doc).is_ok(); + let not_changed = NotChanged::of( + Recolor { remake: Some(&would), ..Recolor::new(images, &same) }.would_change(&st.doc, &left).into_iter().map(|(_, hidden)| hidden), + ); + let mut doc = (*st.doc).clone(); + let n = pass.run(&mut doc, &ids); + if let Some((w, h)) = indexing.too_large.get() { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to convert (at most {} pixels)", Indexing::MAX_PIXELS))); + } + if n > 0 { + s.edit("Convert to Indexed Color", |d, _| { + *d = doc; + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e)) + })?; + } + let mut out = json!({ "changed": n }); + not_changed.report(&mut out); + Ok(out) +} + +/// How Convert to Indexed Color reduces an image's colors: the palette options of a PNG-8 +/// export, with transparency kept and no matte, and the fixed palette of the uniform and document +/// reductions. +struct Indexing { + options: PaletteOptions, + fixed: Option>, + /// The size of an image [`Self::convert`] left as it is because it has more than + /// [`Self::MAX_PIXELS`] pixels. + too_large: Cell>, +} + +impl Indexing { + /// The most pixels an image may have. Converting one holds at most about 8 bytes a pixel at + /// once: decoding (the decoded samples and the RGBA), quantizing (the RGBA, the colors and the + /// indices) and writing the PNG after the RGBA is freed (the indices, the packed and filtered + /// rows and the compressed data). 16 bytes a pixel keeps that within [`MAX_RASTER_ALLOC`] twice + /// over. + const MAX_PIXELS: u64 = MAX_RASTER_ALLOC / 16; + + /// The reduction `p` asks for (see `edit.colors.toIndexed`); a value out of range or of the + /// wrong type is an error. Reduction and dither ids are read as a PNG-8 export reads them. + fn of(s: &Session, p: &Value) -> Result { + const C: &str = "edit.colors.toIndexed"; + let reduction = text_param(p, "reduction", C)?.unwrap_or("selective"); + let colors = match p.get("colors") { + None | Some(Value::Null) => None, + Some(v) => { + Some(v.as_u64().filter(|n| (2..=256).contains(n)).ok_or_else(|| bad(C, "`colors` must be a whole number from 2 to 256"))? as u16) + } + }; + let dither = match text_param(p, "dither", C)? { + None => Dither::Diffusion, + Some(d) => Dither::from_id(d).ok_or_else(|| bad(C, format!("unknown dither `{d}` (diffusion, pattern, noise or none)")))?, + }; + let dither_amount = match p.get("ditherAmount") { + None | Some(Value::Null) => 100, + Some(v) => { + v.as_f64().filter(|x| (0.0..=100.0).contains(x)).ok_or_else(|| bad(C, "`ditherAmount` must be a number from 0 to 100"))?.round() as u8 + } + }; + let options = + |r: Reduction| PaletteOptions { colors: colors.unwrap_or(256), reduction: r, dither, dither_amount, transparency: true, matte: None }; + let refuse_colors = |name: &str| match colors { + Some(_) => Err(bad(C, format!("`colors` does not apply to the {name} reduction"))), + None => Ok(()), + }; + let fixed = |palette: Vec<[u8; 3]>| Self { options: options(Reduction::Adaptive), fixed: Some(palette), too_large: Cell::new(None) }; + Ok(match reduction.to_ascii_lowercase().as_str() { + "uniform" => { + let n = colors.unwrap_or(256); + // One entry is kept for transparent pixels. + let levels = + (2..=6u16).rev().find(|l| l.pow(3) < n).ok_or_else(|| bad(C, "`colors` must be at least 9 for the uniform reduction"))?; + let level = |i: u16| (u32::from(i) * 255 / u32::from(levels - 1)) as u8; + let mut palette = Vec::with_capacity(usize::from(levels.pow(3))); + for r in 0..levels { + for g in 0..levels { + for b in 0..levels { + palette.push([level(r), level(g), level(b)]); + } + } + } + fixed(palette) + } + "document" => { + refuse_colors("document")?; + let mut palette: Vec<[u8; 3]> = vec![]; + for c in s.doc()?.doc.swatches_iter().filter_map(|sw| sw.paint.color()) { + let [r, g, b, _] = c.to_rgba8(1.0); + if !palette.contains(&[r, g, b]) { + palette.push([r, g, b]); + } + } + if palette.is_empty() { + return Err(bad(C, "the document has no color swatches")); + } + if palette.len() > 255 { + return Err(bad(C, format!("the document's swatches hold {} colors; the document reduction takes at most 255", palette.len()))); + } + fixed(palette) + } + id => { + let r = Reduction::from_id(id).ok_or_else(|| { + bad(C, format!("unknown reduction `{reduction}` (selective, perceptual, adaptive, web, gray, blackWhite, uniform or document)")) + })?; + if r == Reduction::BlackWhite { + refuse_colors("blackWhite")?; + } + Self { options: options(r), fixed: None, too_large: Cell::new(None) } + } + }) + } + + /// Image `b` as an indexed-color PNG; `None` when it can't be decoded, when it has more than + /// [`Self::MAX_PIXELS`] pixels (noted in `too_large`), or when it is an indexed PNG already and + /// every pixel would show as it does. + fn convert(&self, b: &ImageBlob) -> Option { + let (w, h) = b.pixel_size()?; + if u64::from(w) * u64::from(h) > Self::MAX_PIXELS { + self.too_large.set(Some((w, h))); + return None; + } + let (w, h, rgba) = b.rgba()?; + let ix = match &self.fixed { + Some(palette) => quantize_to(&rgba, w, h, &self.options, palette), + None => quantize(&rgba, w, h, &self.options), + }; + if b.indexed_png() && shows_same(&ix, &rgba) { + return None; + } + // The PNG writer's buffers take the place of the pixels. + drop(rgba); + Some(ImageBlob::png(encode_indexed(&ix, &PngOptions::default()).ok()?)) + } +} + +/// Whether palette image `ix` shows every pixel of straight RGBA `rgba` as it is: transparent +/// where they are transparent, and the same color where they are opaque. +fn shows_same(ix: &Indexed, rgba: &[u8]) -> bool { + let px = rgba.as_chunks::<4>().0; + px.len() == ix.indices.len() + && px.iter().zip(&ix.indices).all(|(p, &i)| match (p[3], ix.transparent == Some(i)) { + (0, clear) => clear, + (255, false) => ix.palette.get(usize::from(i)).is_some_and(|c| c[..] == p[..3]), + _ => false, + }) +} + fn saturate(s: &mut Session, p: &Value) -> Result { let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32; recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| { diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index a43325becd..a9fb07bdf8 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts (the type in opacity masks and in the art of symbols and patterns included; with selectionOnly: the selected type's), images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone. brushNames, brushes, spotObjects, patternObjects and gradientObjects leave out hidden objects, objects at an opacity of 0 and the objects inside a hidden group or layer or one at an opacity of 0 (with selectionOnly too), their characters' paints and colorized images included; objects still counts them as paths, groups and the other kinds", has_doc, info ), @@ -425,6 +425,64 @@ fn applied_brushes(d: &Document, n: &Node, members: &HashSet<*const Node>) -> Ve if follows { used } else { vec![] } } +/// Whether `n` draws, as far as its own state goes: a hidden object, and an object at an opacity +/// of 0, paint no color, and neither do their members. +fn draws(n: &Node) -> bool { + n.visible && n.opacity > 0.0 +} + +/// The object of `d` at index path `path`, with whether the groups and layers around it draw +/// ([`draws`]). +fn node_and_drawn<'a>(d: &'a Document, path: &[usize]) -> Option<(&'a Node, bool)> { + let (first, rest) = path.split_first()?; + let mut at: &Node = d.layers.get(*first)?; + let mut drawn = true; + for &i in rest { + drawn &= draws(at); + at = &**at.children()?.get(i)?; + } + Some((at, drawn)) +} + +/// The spot colors, the patterns and the gradients (`spots`: [`spot_swatches`]) that object `n` +/// paints, each once: those of its own fills and strokes ([`own_paints`], `members` included), of +/// its drawn characters and, for a grayscale or 1-bit image, of the color it is colorized with. +fn object_paints(d: &Document, spots: &HashSet<&str>, members: &HashSet<*const Node>, n: &Node) -> [Vec; 3] { + let (spot, pattern, gradient) = (|p: &Paint| spot_labels(spots, p), one_label(pattern_label), one_label(gradient_label)); + let labels: [&PaintNames<'_>; 3] = [&spot, &pattern, &gradient]; + let painted_runs = painted_runs(d, spots, n); + let mut used = labels.map(|label| own_paints(d, n, members, painted_runs.as_ref(), label)); + // The characters' own paints: an inline graphic draws no outline and does not take its run's + // paint (a list marker set in its run does), and a stroke with no width paints nothing. + if let Some((t, painted)) = &painted_runs { + for run in painted.iter().filter_map(|i| t.runs.get(*i)) { + let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); + for paint in std::iter::once(&run.style.fill).chain(stroke) { + for (used, label) in used.iter_mut().zip(labels) { + for name in label(paint) { + if !used.contains(&name) { + used.push(name); + } + } + } + } + } + } + // A grayscale or 1-bit image colorized with a spot color prints it, while its pixels take the + // color, as the renderer draws it. + if let NodeKind::Image(im) = &n.kind + && let Some(colorize) = n.extras().colorize.as_deref() + && d.image_blob(im).is_some_and(|b| b.takes_colorize()) + { + for name in spot(colorize) { + if !used[0].contains(&name) { + used[0].insert(0, name); + } + } + } + used +} + fn info(s: &mut Session, p: &Value) -> Result { const C: &str = "document.info"; let category = str_param(p, "category"); @@ -440,8 +498,13 @@ fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let d = &st.doc; let selection_only = bool_or(p, "selectionOnly", false); - let roots: Vec<&Node> = - if selection_only { st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() } else { d.layers.iter().map(|l| &**l).collect() }; + // Each root with whether the groups and layers around it draw: a selected object inside a hidden group or layer, or one + // at an opacity of 0, paints nothing. + let roots: Vec<(&Node, bool)> = if selection_only { + st.selection.objects.iter().filter_map(|id| node_and_drawn(d, &d.index_path(*id)?)).collect() + } else { + d.layers.iter().map(|l| (&**l, true)).collect() + }; let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); // The paths' total length, as Document Info › Objects shows it (the length of a cut line, a // strip of LEDs along the letters, #1958). @@ -467,7 +530,6 @@ fn info(s: &mut Session, p: &Value) -> Result { let mut spot_objects: Vec = vec![]; let mut spot_rows: Vec<(String, String)> = vec![]; let mut spot = BTreeSet::new(); - let (spot_label, pattern, gradient) = (|p: &Paint| spot_labels(&spot_swatches, p), one_label(pattern_label), one_label(gradient_label)); let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() }; // `library` holds the Brushes panel's brushes, and `brushes` the brushes the visited objects' strokes draw with, each once, // in walk order. @@ -494,10 +556,18 @@ fn info(s: &mut Session, p: &Value) -> Result { members.extend(children.iter().map(|c| std::ptr::from_ref(&**c))); } }); - for r in roots { - r.walk(&mut |n| { + // Depth first, in paint order, as `Node::walk` visits: each object with whether it and the groups and layers around it + // draw. An object that does not draw is still counted, and its members are visited. + let mut stack: Vec<(&Node, bool)> = vec![]; + for (r, drawn) in roots { + stack.push((r, drawn)); + while let Some((n, around)) = stack.pop() { if !seen.insert(std::ptr::from_ref(n)) { - return; + continue; + } + let drawn = around && draws(n); + if let Some(ch) = n.children() { + stack.extend(ch.iter().rev().map(|c| (&**c, drawn))); } let k = match &n.kind { // The Guides row counts paths made into guides; ruler guides are not objects. @@ -545,42 +615,8 @@ fn info(s: &mut Session, p: &Value) -> Result { if let Some(k) = k { *counts.entry(k).or_default() += 1; } - // The spot colors, the patterns and the gradients this object's fills and strokes paint, each once. - let painted_runs = painted_runs(d, &spot_swatches, n); - let mut used_spots = own_paints(d, n, &members, painted_runs.as_ref(), &spot_label); - let mut used_patterns = own_paints(d, n, &members, painted_runs.as_ref(), &pattern); - let mut used_gradients = own_paints(d, n, &members, painted_runs.as_ref(), &gradient); - // The characters' own paints: an inline graphic draws no outline and does not take its - // run's paint (a list marker set in its run does), and a stroke with no width paints - // nothing. - if let Some((t, painted)) = &painted_runs { - for run in painted.iter().filter_map(|i| t.runs.get(*i)) { - let stroke = (run.style.stroke_width > 0.0).then_some(&run.style.stroke); - for paint in std::iter::once(&run.style.fill).chain(stroke) { - let labels: [(&mut Vec, &PaintNames<'_>); 3] = - [(&mut used_spots, &spot_label), (&mut used_patterns, &pattern), (&mut used_gradients, &gradient)]; - for (used, label) in labels { - for name in label(paint) { - if !used.contains(&name) { - used.push(name); - } - } - } - } - } - } - // A grayscale or 1-bit image colorized with a spot color prints it, while its pixels take - // the color, as the renderer draws it. - if let NodeKind::Image(im) = &n.kind - && let Some(colorize) = n.extras().colorize.as_deref() - && d.image_blob(im).is_some_and(|b| b.takes_colorize()) - { - for name in spot_label(colorize) { - if !used_spots.contains(&name) { - used_spots.insert(0, name); - } - } - } + // The spot colors, the patterns and the gradients this object paints, each once; none when it does not draw. + let [used_spots, used_patterns, used_gradients] = if drawn { object_paints(d, &spot_swatches, &members, n) } else { Default::default() }; // Objects counts the objects that Spot Color Objects, Pattern Objects and Gradient Objects list. if !used_spots.is_empty() { let name = n.display_name(); @@ -615,9 +651,11 @@ fn info(s: &mut Session, p: &Value) -> Result { { styles.push(&g.name); } - for b in applied_brushes(d, n, &members) { - if !brushes.contains(&b) { - brushes.push(b); + if drawn { + for b in applied_brushes(d, n, &members) { + if !brushes.contains(&b) { + brushes.push(b); + } } } if let NodeKind::Repeat(spec) = &n.kind { @@ -625,7 +663,7 @@ fn info(s: &mut Session, p: &Value) -> Result { repeats.push(entry); repeat_rows.push(row); } - }); + } } // The document's fonts include those of the type in opacity masks and in the art of its // symbols and patterns, as Find Font lists them. @@ -1455,6 +1493,115 @@ mod tests { assert_eq!(spots(&mut s), (vec![], None, None)); } + /// Brushes, Spot Color Objects, Pattern Objects and Gradient Objects leave out a hidden object, + /// an object at an opacity of 0 and the objects inside a hidden layer or a group at an opacity of + /// 0, with Selection Only too: their fills, strokes, characters and colorized pixels paint no + /// color. Objects still counts them. + #[test] + fn info_skips_paints_on_hidden_and_clear_objects_and_their_members() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"model": "cmyk", "c": 0, "m": 1, "y": 1, "k": 0}, "spot": true})).unwrap(); + let square = |s: &mut Session, x: f64| { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap() + }; + let tile = square(&mut s, 0.0); + s.execute("object.pattern.make", &json!({"ids": [tile], "name": "Dots", "edit": false})).unwrap(); + // A patterned square that stays drawn, an object at an opacity of 0 and a hidden one. + let (shown, clear, hidden) = (square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0)); + s.execute("paint.setFill", &json!({"ids": [shown, clear], "swatch": "Dots"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [hidden], "swatch": "Ink"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [shown, clear, hidden], "none": true})).unwrap(); + // A brushed square in a group. + let (brushed, other) = (square(&mut s, 80.0), square(&mut s, 100.0)); + s.execute("brush.apply", &json!({"ids": [brushed], "name": "Charcoal"})).unwrap(); + s.execute("select.set", &json!({"ids": [brushed, other]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + // Type whose characters are filled with the spot color. + let word = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"ids": [word], "swatch": "Ink"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [word], "none": true})).unwrap(); + // A grayscale image colorized with the spot color. + let mut bytes = Vec::new(); + image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()) + .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png) + .unwrap(); + let image = s + .edit("Place", |d, sel| { + d.images.insert("gray".into(), vectorcraft_doc::ImageBlob::png(bytes)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "gray".into(), + width: 2, + height: 1, + xf: Default::default(), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + // A square with a gradient on a layer of its own. + let layer = s.execute("layer.new", &json!({"name": "Back"})).unwrap()["id"].as_u64().unwrap(); + let ramp = square(&mut s, 120.0); + s.execute( + "paint.setFill", + &json!({"ids": [ramp], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}}), + ) + .unwrap(); + s.execute("paint.setStroke", &json!({"ids": [ramp], "none": true})).unwrap(); + let listed = |s: &mut Session, p: Value| { + let i = s.execute("document.info", &p).unwrap(); + let ids = |k: &str| -> Vec { + let mut ids: Vec = i[k].as_array().unwrap().iter().map(|o| o["id"].as_u64().unwrap()).collect(); + ids.sort_unstable(); + ids + }; + (i["brushNames"].clone(), ids("spotObjects"), ids("patternObjects"), ids("gradientObjects"), i["objects"].clone()) + }; + // Everything drawn: each object is listed. + let (brushes, spots, patterns, gradients, before) = listed(&mut s, json!({})); + assert_eq!(brushes, json!(["Charcoal"])); + // New squares take the new spot color as their fill, the group's members included. + assert_eq!((spots, patterns, gradients), (vec![hidden, brushed, other, word, image.0], vec![shown, clear], vec![ramp])); + s.edit("hide", |d, _| { + d.node_mut(NodeId(clear)).unwrap().opacity = 0.0; + d.node_mut(NodeId(hidden)).unwrap().visible = false; + d.node_mut(NodeId(group)).unwrap().opacity = 0.0; + d.node_mut(NodeId(word)).unwrap().opacity = 0.0; + d.node_mut(image).unwrap().opacity = 0.0; + d.node_mut(NodeId(layer)).unwrap().visible = false; + Ok(()) + }) + .unwrap(); + let (brushes, spots, patterns, gradients, after) = listed(&mut s, json!({})); + assert_eq!((brushes, spots, patterns, gradients), (json!([]), vec![], vec![shown], vec![])); + // Objects counts the listed objects, and every object as before. + assert_eq!((after["spots"].clone(), after["patterns"].clone(), after["gradients"].clone()), (Value::Null, json!(1), Value::Null)); + for k in ["paths", "groups", "textObjects", "images"] { + assert_eq!(after[k], before[k], "{k}: {after}"); + } + let rows = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["sections"][0]["rows"].clone(); + assert_eq!(rows, json!([])); + // Selection Only: an object selected inside a group at an opacity of 0 or a hidden layer paints nothing either. + s.execute("select.set", &json!({"ids": [brushed, ramp]})).unwrap(); + let (brushes, _, _, gradients, after) = listed(&mut s, json!({"selectionOnly": true})); + assert_eq!((brushes, gradients, after["paths"].clone()), (json!([]), vec![], json!(2))); + // Drawn again, the group's brush and the layer's gradient are listed. + s.edit("show", |d, _| { + d.node_mut(NodeId(group)).unwrap().opacity = 1.0; + d.node_mut(NodeId(layer)).unwrap().visible = true; + Ok(()) + }) + .unwrap(); + let (brushes, _, _, gradients, _) = listed(&mut s, json!({"selectionOnly": true})); + assert_eq!((brushes, gradients), (json!(["Charcoal"]), vec![ramp])); + } + /// Document Info › Objects counts an object with a gradient or a pattern on both its fill and /// its stroke once, and with Selection Only a selection that holds a group and an object inside /// it counts that object once. diff --git a/crates/engine/src/cmd/interactivity.rs b/crates/engine/src/cmd/interactivity.rs index 4bcc123dcf..359c2d00d6 100644 --- a/crates/engine/src/cmd/interactivity.rs +++ b/crates/engine/src/cmd/interactivity.rs @@ -3,10 +3,14 @@ //! document links (`svgInteractivity.addFile`, `removeFile`). SVG export writes the events as //! event attributes (`onclick="…"`) and the files as `