From f75d7628c7a418952bc086fac303334013e61e4a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 01:29:57 +0700 Subject: [PATCH] M3.91: Object > Flatten Transparency turns transparent art into opaque art that looks the same object.flattenTransparency {preset?|options, ids?} splits the targets into groups of overlapping objects. Groups with transparency become atomic regions (faces of the arrangement of every fill, outlined stroke and clipping path) filled with the colour the renderer composites there: blend modes, group opacity, isolated and non-isolated groups, knockout groups and knockout shapes, over white or (Preserve Alpha) over nothing. Regions reached by gradients, patterns, images, opacity masks or raster effects are rendered into one image per group, under the regions, clipped to them with Clip Complex Regions. The raster/vector balance rasterizes whole groups past its region budget (all at 0). Options and presets live in FlattenOptions; single visible paints keep their colour and overprint; text and strokes can be outlined everywhere. One undo step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 23 +- crates/engine/src/cmd/flatten.rs | 891 +++++++++++++++++++++++++++ crates/engine/src/cmd/menucmds.rs | 6 +- crates/engine/src/cmd/mod.rs | 5 + crates/engine/src/cmd/pathops.rs | 46 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_colormgmt.rs | 2 +- crates/engine/src/tests_flatten.rs | 456 ++++++++++++++ crates/render/src/brush_fx.rs | 2 +- crates/render/src/lib.rs | 1 + docs/mcp.md | 17 + 11 files changed, 1417 insertions(+), 34 deletions(-) create mode 100644 crates/engine/src/cmd/flatten.rs create mode 100644 crates/engine/src/tests_flatten.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 02ba945f..c2441bac 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -151,26 +151,29 @@ fn redo(s: &mut Session, _: &Value) -> Result { /// Selected top-level objects in paint order, dropping any whose ancestor is also selected. pub(crate) fn selected_roots(s: &Session) -> Result> { let st = s.doc()?; - // A selected compound-path member acts as its compound (Illustrator treats compounds as one object). - let mut sel: Vec = st - .selection - .in_paint_order(&st.doc) + Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc))) +} + +/// The top-level objects among `ids` (in paint order): compound-path members stand for their +/// compound, and ids whose ancestor is also listed, and layers, are dropped. +pub(crate) fn roots_of(doc: &Document, ids: Vec) -> Vec { + // A compound-path member acts as its compound (compounds are one object). + let mut sel: Vec = ids .into_iter() - .map(|id| match st.doc.parent_of(id).and_then(|p| st.doc.node(p).map(|n| (p, n))) { + .map(|id| match doc.parent_of(id).and_then(|p| doc.node(p).map(|n| (p, n))) { Some((p, n)) if matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => p, _ => id, }) .collect(); sel.dedup(); - Ok(sel - .iter() + sel.iter() .copied() .filter(|id| { - let anc = st.doc.ancestry(*id).unwrap_or_default(); + let anc = doc.ancestry(*id).unwrap_or_default(); !anc[..anc.len().saturating_sub(1)].iter().any(|a| sel.contains(a)) }) - .filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer())) - .collect()) + .filter(|id| doc.node(*id).is_some_and(|n| !n.is_layer())) + .collect() } fn copy(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs new file mode 100644 index 00000000..1f1a7a3e --- /dev/null +++ b/crates/engine/src/cmd/flatten.rs @@ -0,0 +1,891 @@ +//! Object → Flatten Transparency: transparent art becomes opaque art that looks the same. +//! +//! The targets (`ids` or the selection) split into groups of overlapping objects. Groups without +//! transparency stay as they are (their type and strokes are outlined on request). The others are +//! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and +//! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour +//! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by +//! [`composite`]), over white (or over nothing with Preserve Alpha). Each region becomes a path +//! filled with that flat colour. Where gradients, patterns, images, opacity masks or raster +//! effects reach there is no single colour: those regions are rendered into one image per group, +//! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the +//! regions it overlaps). The raster/vector balance rasterizes whole groups that split into more +//! regions than it allows (all of them at 0). + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_color::blend::composite; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; +use vectorcraft_doc::{Appearance, Document, Effect, ImageBlob, ImageObject, Knockout, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; +use vectorcraft_pathops as po; +use vectorcraft_render::effects; + +use super::edit::roots_of; +use super::menucmds::{MAX_PIXELS, isolated_doc, unique_key}; +use super::pathops::{node_path, outline_strokes, outline_strokes_under, shape_node}; +use super::*; + +pub fn specs() -> Vec { + vec![cmd!( + "object.flattenTransparency", + "Flatten Transparency…", + ["Object"], + None, + "{preset?: \"high\"|\"medium\"|\"low\" (default medium), balance?: 0..100 (raster/vector balance; 0 rasterizes everything), lineArtPpi?: 1..2400, gradientPpi?: 1..2400 (areas only gradients and meshes reach), textToOutlines?, strokesToOutlines?, clipComplexRegions? (clip images to the region outlines, else rectangles), antiAlias?, preserveAlpha? (composite over nothing instead of white), preserveOverprints? (areas showing one paint keep its colour and overprint; false clears overprints), options?: {the same keys}, ids?} overlapping transparent objects become one group of flat-colour regions, plus an image where gradients, patterns, images, masks or raster effects reach; objects without transparency stay → {ids, rasterized: images made, vector: regions made, options}", + has_doc, + flatten + )] +} + +/// Object → Flatten Transparency settings: a preset's, adjusted by the command's params. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct FlattenOptions { + /// Raster/vector balance, 0–100: 0 rasterizes all flattened art; 100 keeps everything vector + /// that can be; in between, groups that split into more regions than it allows are rasterized. + pub balance: f64, + /// Resolution (ppi) of rasterized art. + pub line_art_ppi: f64, + /// Resolution (ppi) of rasterized areas that only gradients and meshes reach. + pub gradient_ppi: f64, + /// Outline all type, also where it isn't transparent. + pub text_to_outlines: bool, + /// Outline all strokes, also where they aren't transparent. + pub strokes_to_outlines: bool, + /// Clip rasterized areas to their region outlines (else they are rectangles). + pub clip_complex_regions: bool, + /// Smooth the edges of rasterized areas. + pub anti_alias: bool, + /// Keep the art's own alpha: composite over nothing instead of over white. + pub preserve_alpha: bool, + /// Areas showing a single paint keep it (spot and process colours, swatches) with its + /// overprint; off, the flattened objects lose their overprints. + pub preserve_overprints: bool, +} + +impl Default for FlattenOptions { + fn default() -> Self { + Self::preset("medium").expect("built-in preset") + } +} + +impl FlattenOptions { + /// Preset ids, finest first. + pub const PRESETS: [&'static str; 3] = ["high", "medium", "low"]; + + /// Display name of a preset id. + pub fn preset_label(id: &str) -> Option<&'static str> { + Some(match id { + "high" => "High Resolution", + "medium" => "Medium Resolution", + "low" => "Low Resolution", + _ => return None, + }) + } + + /// A preset by id or display name, any case. + pub fn preset(name: &str) -> Option { + let key = name.trim().to_ascii_lowercase(); + let id = Self::PRESETS.into_iter().find(|id| key == *id || Self::preset_label(id).is_some_and(|l| l.eq_ignore_ascii_case(&key)))?; + let (balance, line_art_ppi, gradient_ppi, clip_complex_regions, anti_alias) = match id { + "high" => (100.0, 1200.0, 300.0, true, false), + "medium" => (75.0, 300.0, 300.0, false, true), + _ => (75.0, 300.0, 150.0, false, true), + }; + Some(Self { + balance, + line_art_ppi, + gradient_ppi, + text_to_outlines: false, + strokes_to_outlines: false, + clip_complex_regions, + anti_alias, + preserve_alpha: false, + preserve_overprints: true, + }) + } + + /// The options `p` asks for: `preset` (default medium) adjusted by the option keys given at the + /// top level or in `options`. + pub fn from_params(p: &Value) -> std::result::Result { + let base = match str_param(p, "preset") { + Some(name) => Self::preset(name).ok_or_else(|| format!("unknown preset `{name}` (high, medium or low)"))?, + None => Self::default(), + }; + let mut v = serde_json::to_value(&base).map_err(|e| e.to_string())?; + for src in [Some(p), p.get("options")].into_iter().flatten().filter_map(Value::as_object) { + for (k, val) in src { + if let Some(slot) = v.get_mut(k) { + *slot = val.clone(); + } + } + } + let o: Self = serde_json::from_value(v).map_err(|e| e.to_string())?; + if !(0.0..=100.0).contains(&o.balance) { + return Err("balance must be between 0 and 100".into()); + } + if ![o.line_art_ppi, o.gradient_ppi].iter().all(|r| (1.0..=2400.0).contains(r)) { + return Err("resolutions must be between 1 and 2400 ppi".into()); + } + Ok(o) + } + + /// Whether a group that splits into `regions` atomic regions is rasterized whole. + fn rasterize_all(&self, regions: usize) -> bool { + self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0)) + } +} + +fn flatten(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.flattenTransparency"; + let o = FlattenOptions::from_params(p).map_err(|m| bad(C, m))?; + let roots = target_roots(s, p)?; + if roots.is_empty() { + return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into())); + } + let src = s.doc()?.doc.clone(); + let plan = plan(&src, &roots, &o); + let options = serde_json::to_value(&o).unwrap_or_default(); + if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) { + return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::>(), "rasterized": 0, "vector": 0, "options": options })); + } + let (ids, vector, rasterized) = s.edit("Flatten Transparency", |d, sel| apply(d, sel, plan, &o))?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "rasterized": rasterized, "vector": vector, "options": options })) +} + +/// The top-level objects among `ids` (or the selection), in paint order: layers stand for their +/// contents; hidden and locked objects and clipping paths are left alone. +fn target_roots(s: &Session, p: &Value) -> Result> { + let d = &s.doc()?.doc; + let mut ids = vec![]; + for id in targets(s, p)? { + match d.node(id) { + Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().map(|c| c.id)), + Some(_) => ids.push(id), + None => {} + } + } + let mut keyed: Vec<(Vec, NodeId)> = ids.into_iter().filter_map(|id| d.index_path(id).map(|p| (p, id))).collect(); + keyed.sort(); + keyed.dedup(); + let roots = roots_of(d, keyed.into_iter().map(|(_, id)| id).collect()); + // A clipping path stays where it clips. + let clipping = |id: NodeId| { + d.parent_of(id).and_then(|p| d.node(p)).is_some_and(|p| p.clips() && p.children().and_then(|c| c.first()).is_some_and(|c| c.id == id)) + }; + Ok(roots.into_iter().filter(|id| d.is_visible(*id) && d.is_editable(*id) && !clipping(*id)).collect()) +} + +// ---------- plan ---------- + +/// What the flattening does, worked out before the document changes. +struct Plan { + flat: Vec, + /// Objects without transparency (they stay, but for the outline options). + kept: Vec, +} + +/// One group of overlapping objects with transparency and what replaces it. +struct Flat { + /// Its objects in paint order; the result takes the place of the last. + roots: Vec, + regions: Vec<(PathData, RegionFill)>, + raster: Option, +} + +/// A flat-colour region's paint. +struct RegionFill { + paint: Paint, + alpha: f32, + overprint: bool, +} + +/// A rendered image of complex regions, clipped to them or not. +struct Raster { + png: Vec, + width: u32, + height: u32, + xf: Affine, + clip: Option, +} + +fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions) -> Plan { + let tmp = isolated_doc(src, roots.iter().filter_map(|id| src.node(*id).cloned()).collect()); + // Geometry effects first: what is left on the art are raster effects. + let baked = effects::bake_document(&tmp).unwrap_or(tmp); + let art: Vec = baked.layers[0].children().into_iter().flatten().map(|a| (**a).clone()).collect(); + let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() }; + let plain: Vec = art.iter().map(|n| sc.plain(n)).collect(); + let reaches: Vec> = plain.iter().map(reach).collect(); + let mut out = Plan { flat: vec![], kept: vec![] }; + for g in overlapping(&reaches) { + let ids: Vec = g.iter().map(|&i| roots[i]).collect(); + let flat = g.iter().any(|&i| transparent(&plain[i])).then(|| { + let art: Vec = g.iter().map(|&i| art[i].clone()).collect(); + let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect(); + flatten_group(&baked, &art, &plain, o) + }); + match flat.flatten() { + Some((regions, raster)) => out.flat.push(Flat { roots: ids, regions, raster }), + None => out.kept.extend(ids), + } + } + out +} + +/// The regions and image replacing one group of overlapping objects (`art` as rendered, `plain` +/// as composited); `None` when it paints nothing. +#[allow(clippy::type_complexity)] +fn flatten_group(doc: &Document, art: &[Node], plain: &[&Node], o: &FlattenOptions) -> Option<(Vec<(PathData, RegionFill)>, Option)> { + let mut b = Builder::default(); + let elems: Vec = plain.iter().filter_map(|n| b.elem(n)).collect(); + if b.shapes.is_empty() { + return None; + } + // An unclipped full raster needs no regions. + let whole = o.balance <= 0.0 && !o.clip_complex_regions; + let regions = if whole { vec![] } else { po::regions(&b.shapes) }; + // An arrangement that failed (degenerate geometry) leaves only rasterizing. + let all = regions.is_empty() || o.rasterize_all(regions.len()); + let mut vector: Vec<(usize, RegionFill)> = vec![]; + let mut complex: Vec = vec![]; + let mut gradients_only = !all; + for (k, r) in regions.iter().enumerate() { + if all || r.sources.iter().any(|&i| b.kinds[i] != Kind::Vector) { + gradients_only &= r.sources.iter().all(|&i| b.kinds[i] == Kind::Gradient); + complex.push(k); + } else if let Some(fill) = region_fill(&elems, &r.sources, o) { + vector.push((k, fill)); + } + } + let bounds = |ks: &[usize]| ks.iter().filter_map(|&k| regions[k].path.bounds()).reduce(|a, b| a.union(b)); + if !o.clip_complex_regions && !all { + // A rectangle of raster takes in every region it overlaps. + while let Some(r) = bounds(&complex) { + let before = complex.len(); + let rect = shapes::rectangle(r); + vector.retain(|(k, _)| { + let path = ®ions[*k].path; + let hit = path.bounds().is_some_and(|b| b.intersect(r).area() > 0.0) + && po::area(&po::boolean(path, FillRule::NonZero, &rect, FillRule::NonZero, po::BoolOp::Intersect), FillRule::NonZero) > 1e-6; + if hit { + complex.push(*k); + } + !hit + }); + if complex.len() == before { + break; + } + gradients_only = false; + } + } + let raster_rect = if all { plain.iter().filter_map(|n| reach(n)).reduce(|a, b| a.union(b)) } else { bounds(&complex) }; + let raster = raster_rect.map(|rect| { + let clip = o.clip_complex_regions.then(|| PathData::new(complex.iter().flat_map(|&k| regions[k].path.subpaths.iter().cloned()).collect())); + let ppi = if gradients_only { o.gradient_ppi } else { o.line_art_ppi }; + render_raster(doc, art, rect, ppi, clip.filter(|c| !c.is_empty()), o) + }); + let vector = vector.into_iter().map(|(k, f)| (regions[k].path.clone(), f)).collect(); + Some((vector, raster)) +} + +/// The flat colour of a region covered by `sources`; `None` where nothing paints it. +fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions) -> Option { + let mut c = Composer { sources, top: None }; + let alone = c.eval(elems, CLEAR); + if alone[3] < 0.5 / 255.0 { + return None; + } + let px = if o.preserve_alpha { + alone + } else { + c.top = None; + c.eval(elems, WHITE) + }; + let rgb = [px[0], px[1], px[2]]; + // A paint showing as it is keeps its colour (spot, process, swatch) and overprint. + let same = |f: &Fill| px[3] >= 1.0 - 1e-4 && f.rgb.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-3); + let (paint, overprint) = match c.top { + Some(f) if o.preserve_overprints && same(f) => (f.paint.clone(), f.overprint), + _ => (Paint::solid(Color::rgb(rgb[0], rgb[1], rgb[2])), false), + }; + Some(RegionFill { paint, alpha: px[3], overprint }) +} + +/// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out +/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and +/// its `clip` reach a pixel further. +fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option, o: &FlattenOptions) -> Raster { + let mut scale = ppi / 72.0; + scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); + let px = 1.0 / scale; + let rect = rect.inflate(px, px); + let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c)); + let region = Rect::new( + rect.x0, + rect.y0, + rect.x0 + (rect.width() * scale).ceil().max(1.0) / scale, + rect.y0 + (rect.height() * scale).ceil().max(1.0) / scale, + ); + let mut r = vectorcraft_render::Renderer::new(); + let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha); + if !o.preserve_alpha || !o.anti_alias { + // The art's coverage: drawn opaque, so only its own edges (and soft effects) are partial. + let cover: Vec = art + .iter() + .map(|n| { + let mut c = n.clone(); + opaque(&mut c); + c + }) + .collect(); + let cover = r.render_region(&isolated_doc(doc, cover), region, scale, false); + for (px, c) in img.pixels.chunks_exact_mut(4).zip(cover.pixels.chunks_exact(4)) { + let k = c[3]; + let k2 = if o.anti_alias || k == 0 || k == 255 { + k + } else if k >= 128 { + 255 + } else { + 0 + }; + if o.preserve_alpha { + let f = k2 as f32 / k.max(1) as f32; + for v in px.iter_mut() { + *v = (*v as f32 * f).round().min(255.0) as u8; + } + } else { + let white = 255 - k2; + for v in &mut px[..3] { + *v = v.saturating_sub(white).min(k2); + } + px[3] = k2; + } + } + } + let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); + Raster { png: img.to_png(), width: img.width, height: img.height, xf, clip } +} + +/// `n` at full opacity with Normal blending throughout (objects, fills and strokes). +fn opaque(n: &mut Node) { + n.opacity = 1.0; + n.blend = BlendMode::Normal; + for i in &mut n.appearance.items { + match i { + AppearanceItem::Fill(f) => (f.opacity, f.blend) = (1.0, BlendMode::Normal), + AppearanceItem::Stroke(s) => (s.opacity, s.blend) = (1.0, BlendMode::Normal), + } + } + if let Some(ch) = n.children_mut() { + for c in ch { + opaque(Arc::make_mut(c)); + } + } +} + +// ---------- apply ---------- + +fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: &FlattenOptions) -> Result<(Vec, usize, usize)> { + let (mut vector, mut rasterized) = (0, 0); + let mut ids = vec![]; + for f in plan.flat { + let mut children = vec![]; + // The image goes under the regions, reaching a pixel past its own: their edges then fall on + // matching pixels instead of on what is behind the group. + if let Some(r) = f.raster { + children.push(Arc::new(raster_node(d, r))); + rasterized += 1; + } + for (path, fill) in f.regions { + let mut n = shape_node(d, path, None); + n.appearance = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { overprint: fill.overprint, ..FillLayer::new(fill.paint) })], + ..Appearance::default() + }; + n.opacity = fill.alpha.min(1.0); + children.push(Arc::new(n)); + vector += 1; + } + let top = *f.roots.last().expect("a group has objects"); + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + let g = Node::group(d.alloc_id(), children); + ids.push(d.insert(par, idx + 1, g)?); + for r in &f.roots { + d.remove(*r)?; + } + } + let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] }; + for mut id in plan.kept { + if o.text_to_outlines { + id = outline_texts_under(d, id)?; + } + if o.strokes_to_outlines { + id = outline_strokes_under(d, &brushes, id)?.0; + } + if !o.preserve_overprints + && let Some(n) = d.node_mut(id) + { + drop_overprints(n); + } + ids.push(id); + } + ids.sort_by_key(|id| d.index_path(*id)); + sel.set(ids.iter().copied()); + Ok((ids, vector, rasterized)) +} + +/// An image of `r` (in a clip group when it is clipped). +fn raster_node(d: &mut Document, r: Raster) -> Node { + let key = unique_key(d, "flattened"); + d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(r.png) }); + let image = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None })); + let Some(path) = r.clip else { return image }; + let mut clip = shape_node(d, path, None); + clip.appearance = Appearance::basic(Paint::None, Paint::None, 0.0); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }) +} + +/// Replace the type under `root` by its outlines: → the id now standing for `root`. +fn outline_texts_under(d: &mut Document, root: NodeId) -> Result { + let mut texts = vec![]; + if let Some(n) = d.node(root) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Text(_)) { + texts.push(c.clone()); + } + }); + } + let mut out = root; + for t in texts { + let Some(o) = outlined_text(&t) else { continue }; + let o = d.reid(&o); + let (par, idx, _) = d.position(t.id).ok_or(EngineError::NoNode(t.id))?; + let nid = d.insert(par, idx, o)?; + d.remove(t.id)?; + if t.id == root { + out = nid; + } + } + Ok(out) +} + +/// Whether the outline and overprint options change `n`, an object without transparency. +fn kept_work(n: &Node, o: &FlattenOptions) -> bool { + let mut any = false; + n.walk(&mut |c| { + any |= (o.text_to_outlines && matches!(c.kind, NodeKind::Text(_))) + || (o.strokes_to_outlines + && matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) + && c.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0))) + || (!o.preserve_overprints && overprints(c)); + }); + any +} + +fn overprints(n: &Node) -> bool { + n.appearance.items.iter().any(AppearanceItem::overprint) + || matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke)) +} + +fn drop_overprints(n: &mut Node) { + if overprints(n) { + for i in &mut n.appearance.items { + *i.overprint_mut() = false; + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + (r.style.overprint_fill, r.style.overprint_stroke) = (false, false); + } + } + } + if let Some(ch) = n.children_mut() { + for c in ch { + drop_overprints(Arc::make_mut(c)); + } + } +} + +// ---------- plain art ---------- + +/// Converts art into what the compositor works on. +struct Scratch { + /// A copy of the document the art comes from (outlining allocates ids in it). + doc: Document, + brushes: Vec, +} + +impl Scratch { + /// `n` as plain filled shapes: type outlined, symbol instances replaced by their art, live + /// objects evaluated and strokes outlined (brush strokes as their art), each keeping the + /// object's transparency. + fn plain(&mut self, n: &Node) -> Node { + if !n.visible { + return n.clone(); + } + let made = match &n.kind { + NodeKind::Text(_) => outlined_text(n), + NodeKind::SymbolInstance { symbol, .. } => { + let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n)); + art.and_then(|a| effects::outline_art(n, Some(&a))).map(|g| carry(n, g)) + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { + let hook: &dyn Fn(&Node) -> Option = &effects::outline_text; + Some(carry(n, vectorcraft_doc::live::expanded_group(n, Some(hook)))) + } + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => outline_strokes(&mut self.doc, &self.brushes, n), + _ => None, + }; + let mut m = match made { + // The pieces of one object never knock each other out. + Some(m) if m.is_container() => Node { knockout: Knockout::Off, ..m }, + Some(m) => m, + None => n.clone(), + }; + if !matches!(m.kind, NodeKind::Compound { .. }) + && let Some(ch) = m.children_mut() + { + for c in ch.iter_mut() { + *c = Arc::new(self.plain(c)); + } + } + m + } +} + +/// Type as outlines painted like it ([`effects::outline_text`]), keeping its transparency. +fn outlined_text(n: &Node) -> Option { + effects::outline_text(n).map(|g| Node { knockout: Knockout::Off, ..carry(n, g) }) +} + +/// `to` (art made from `from`) with `from`'s name, transparency, opacity mask and effects. +fn carry(from: &Node, mut to: Node) -> Node { + to.name = from.name.clone(); + to.opacity = from.opacity; + to.blend = from.blend; + to.isolate = from.isolate; + to.knockout_shape = from.knockout_shape; + to.mask = from.mask.clone(); + to.appearance.effects = from.appearance.effects.clone(); + to +} + +fn visible_fx(fx: &[Effect]) -> bool { + fx.iter().any(|e| e.visible) +} + +/// Effects on the object or on one of its fills or strokes (raster effects, once baked). +fn has_effects(n: &Node) -> bool { + visible_fx(&n.appearance.effects) || n.appearance.items.iter().any(|i| visible_fx(i.effects())) +} + +fn masked(n: &Node) -> bool { + n.mask.as_ref().is_some_and(|m| !m.disabled) +} + +/// Whether plain art `n` shows any transparency: opacity, blend modes, masks, raster effects or +/// transparent gradient stops. +fn transparent(n: &Node) -> bool { + let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); + n.visible + && (n.opacity < 1.0 + || n.blend != BlendMode::Normal + || masked(n) + || has_effects(n) + || n.appearance.items.iter().any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()))) + || n.children().is_some_and(|ch| ch.iter().any(|c| transparent(c)))) +} + +/// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). +fn reach(n: &Node) -> Option { + if !n.visible { + return None; + } + let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects())).fold(effects::outset(&n.appearance.effects), f64::max); + let b = match n.children() { + Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?, + _ => n.visual_bounds()?, + }; + Some(b.inflate(fx + 1.0, fx + 1.0)) +} + +/// Indices of `bounds` grouped by overlap (transitively), each group in index order. +fn overlapping(bounds: &[Option]) -> Vec> { + fn find(p: &mut [usize], mut i: usize) -> usize { + while p[i] != i { + p[i] = p[p[i]]; + i = p[i]; + } + i + } + let mut parent: Vec = (0..bounds.len()).collect(); + let mut order: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.map(|b| (i, b))).collect(); + order.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0)); + // Sweep left to right, comparing each box with those still open at its left edge. + let mut open: Vec<(usize, Rect)> = vec![]; + for (i, b) in order { + open.retain(|(_, o)| o.x1 >= b.x0); + for (j, o) in &open { + if o.y0 <= b.y1 && b.y0 <= o.y1 { + let (a, c) = (find(&mut parent, i), find(&mut parent, *j)); + parent[a.max(c)] = a.min(c); + } + } + open.push((i, b)); + } + let mut groups: Vec> = vec![]; + let mut slot: Vec> = vec![None; bounds.len()]; + for i in 0..bounds.len() { + let r = find(&mut parent, i); + match slot[r] { + Some(g) => groups[g].push(i), + None => { + slot[r] = Some(groups.len()); + groups.push(vec![i]); + } + } + } + groups +} + +// ---------- the compositor ---------- + +/// What an arrangement shape stands for. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + /// Solid fills and clipping paths: regions inside get a flat colour. + Vector, + /// A gradient or mesh: regions inside are rasterized at the gradient resolution. + Gradient, + /// Anything else that has no single colour (patterns, images, masks, raster effects). + Other, +} + +/// A solid fill as the compositor uses it. +struct Fill { + rgb: [f32; 3], + opacity: f32, + blend: BlendMode, + paint: Paint, + overprint: bool, +} + +/// One object of the art being flattened, with the transparency the renderer gives it. +struct Elem { + opacity: f32, + blend: BlendMode, + isolate: bool, + knockout: Knockout, + knockout_shape: bool, + /// Blending inside reaches the backdrop ([`Node::blends_through`]). + blends: bool, + /// Inside a knockout group its children are elements of their own ([`Node::passes_knockout_through`]). + pass_through: bool, + body: Body, +} + +enum Body { + /// Solid fills (bottom first) of arrangement shape `shape`. + Fills { shape: usize, fills: Vec }, + /// A group's children, clipped to arrangement shape `clip` in a clip group. + Group { clip: Option, children: Vec }, + /// Art without a flat colour: its regions are rasterized, so it never composites here. + Complex, +} + +/// Builds the compositor's objects and the arrangement shapes they cover. +#[derive(Default)] +struct Builder { + shapes: Vec, + kinds: Vec, +} + +impl Builder { + fn shape(&mut self, path: PathData, rule: FillRule, kind: Kind) -> Option { + path.bounds()?; + self.shapes.push(po::Shape::new(path, rule, self.shapes.len() as u64)); + self.kinds.push(kind); + Some(self.shapes.len() - 1) + } + + fn elem(&mut self, n: &Node) -> Option { + if !n.visible { + return None; + } + let body = if masked(n) || has_effects(n) { + self.shape(shapes::rectangle(reach(n)?), FillRule::NonZero, Kind::Other)?; + Body::Complex + } else { + match &n.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + let (clip, rest) = if n.clips() { + let (first, rest) = children.split_first()?; + // Nothing to clip by hides the clipped art. + let (bp, rule) = effects::clip_outline(first)?; + (Some(self.shape(PathData::from_bezpath(&bp), rule, Kind::Vector)?), rest) + } else { + (None, &children[..]) + }; + Body::Group { clip, children: rest.iter().filter_map(|c| self.elem(c)).collect() } + } + NodeKind::Path { guide: true, .. } => return None, + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + let (path, rule) = node_path(n)?; + let mut fills = vec![]; + for item in n.appearance.items.iter().filter(|i| i.is_fill() && i.visible() && !i.paint().is_none()) { + match item.paint() { + Paint::Solid { color, .. } => fills.push(Fill { + rgb: color.to_rgb(), + opacity: item.opacity(), + blend: item.blend(), + paint: item.paint().clone(), + overprint: item.overprint(), + }), + p => { + let kind = if matches!(p, Paint::Gradient(_)) { Kind::Gradient } else { Kind::Other }; + self.shape(path.clone(), rule, kind); + } + } + } + if fills.is_empty() { Body::Complex } else { Body::Fills { shape: self.shape(path, rule, Kind::Vector)?, fills } } + } + NodeKind::Image(im) => { + let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); + self.shape(frame, FillRule::NonZero, Kind::Other)?; + Body::Complex + } + NodeKind::Mesh(_) => { + self.shape(shapes::rectangle(reach(n)?), FillRule::NonZero, Kind::Gradient)?; + Body::Complex + } + _ => { + self.shape(shapes::rectangle(reach(n)?), FillRule::NonZero, Kind::Other)?; + Body::Complex + } + } + }; + Some(Elem { + opacity: n.opacity, + blend: n.blend, + isolate: n.isolate, + knockout: n.knockout, + knockout_shape: n.knockout_shape, + blends: n.blends_through(), + pass_through: n.passes_knockout_through(), + body, + }) + } +} + +/// A straight (not premultiplied) RGBA colour in 0..=1. +type Px = [f32; 4]; +const CLEAR: Px = [0.0; 4]; +const WHITE: Px = [1.0; 4]; + +fn premul(p: Px) -> Px { + [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]] +} + +fn unpremul(q: Px) -> Px { + if q[3] <= 0.0 { CLEAR } else { [q[0] / q[3], q[1] / q[3], q[2] / q[3], q[3].min(1.0)] } +} + +/// Composites the objects covering one atomic region exactly as the renderer composites pixels +/// (its transparency groups, non-isolated groups, knockout groups and clip groups): the same +/// paints cover the whole region, so one colour stands for all of it. +struct Composer<'a> { + /// Arrangement shapes covering the region, ascending. + sources: &'a [usize], + /// The last solid fill composited. + top: Option<&'a Fill>, +} + +impl<'a> Composer<'a> { + fn inside(&self, shape: usize) -> bool { + self.sources.binary_search(&shape).is_ok() + } + + /// `elems` (paint order) composited over `start`. + fn eval(&mut self, elems: &'a [Elem], start: Px) -> Px { + elems.iter().fold(start, |acc, e| self.draw(e, acc, false, false, false)) + } + + /// `e` drawn over backdrop `b` inside a group that knocks out when `enclosing`. `as_shape` + /// draws it as its knockout shape (full object opacity); `nested` is drawing inside a layer, + /// where a non-isolated group can't copy its backdrop and is drawn isolated. + fn draw(&mut self, e: &'a Elem, b: Px, enclosing: bool, as_shape: bool, nested: bool) -> Px { + let opacity = if as_shape { 1.0 } else { e.opacity }; + let ko = matches!(e.body, Body::Group { .. }) && e.knockout.resolve(enclosing); + if opacity >= 1.0 && e.blend == BlendMode::Normal && !e.isolate && !ko { + return self.content(e, b, ko, None, nested); + } + if e.blends && !e.isolate && !nested { + // Drawn over a copy of the backdrop, so blending inside reaches it. + let c = self.content(e, b, ko, Some(b), false); + if e.blend == BlendMode::Normal { + let (pb, pc) = (premul(b), premul(c)); + return unpremul([0, 1, 2, 3].map(|i| pb[i] + (pc[i] - pb[i]) * opacity)); + } + // The group's own colour: the backdrop's share taken out by the group's coverage. + let (pc, pa, pb) = (premul(c), premul(self.content(e, CLEAR, ko, None, false)), premul(b)); + let keep = 1.0 - pa[3]; + let g = [0, 1, 2].map(|i| (pc[i] - keep * pb[i]).clamp(0.0, pa[3])); + let g = unpremul([g[0], g[1], g[2], pa[3]]); + return composite(e.blend, b, [g[0], g[1], g[2], g[3] * opacity]); + } + let inner_nested = !(e.blends && (e.isolate || !nested)); + let g = self.content(e, CLEAR, ko, None, inner_nested); + composite(e.blend, b, [g[0], g[1], g[2], g[3] * opacity]) + } + + /// What `e` draws over `b` inside its group (`ko`: its children knock each other out, against + /// `backdrop` when the group was drawn over a copy of it). + fn content(&mut self, e: &'a Elem, b: Px, ko: bool, backdrop: Option, nested: bool) -> Px { + match &e.body { + Body::Fills { shape, fills } if self.inside(*shape) => fills.iter().fold(b, |acc, f| { + self.top = Some(f); + composite(f.blend, acc, [f.rgb[0], f.rgb[1], f.rgb[2], f.opacity]) + }), + Body::Group { clip, children } if clip.is_none_or(|c| self.inside(c)) => { + if ko { + self.knockout(children, b, backdrop) + } else { + children.iter().fold(b, |acc, c| self.draw(c, acc, false, false, nested)) + } + } + _ => b, + } + } + + /// The children of a knockout group over `b`: each first erases what the ones below it drew + /// by its knockout shape, then adds itself composited against the group's backdrop. + fn knockout(&mut self, children: &'a [Elem], b: Px, backdrop: Option) -> Px { + fn elements<'e>(children: &'e [Elem], out: &mut Vec<&'e Elem>) { + for c in children { + match &c.body { + Body::Group { children, .. } if c.pass_through => elements(children, out), + _ => out.push(c), + } + } + } + let mut els = vec![]; + elements(children, &mut els); + let mut acc = premul(b); + for c in els { + let s = self.draw(c, CLEAR, true, !c.knockout_shape, true)[3]; + let own = match backdrop { + Some(b0) => premul(self.draw(c, b0, true, false, true)).map(|v| v * s), + None => premul(self.draw(c, CLEAR, true, false, true)), + }; + acc = [0, 1, 2, 3].map(|i| acc[i] * (1.0 - s) + own[i]); + } + unpremul(acc) + } +} diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 5c0c5f64..0fcd2de8 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -364,10 +364,10 @@ fn drop_live(n: &mut Node) { // ---------- Rasterize / Crop ---------- -const MAX_PIXELS: f64 = 64.0e6; +pub(crate) const MAX_PIXELS: f64 = 64.0e6; /// A copy of `doc` whose only content is `nodes` (paint order), for offscreen rendering. -fn isolated_doc(doc: &vectorcraft_doc::Document, nodes: Vec) -> vectorcraft_doc::Document { +pub(crate) fn isolated_doc(doc: &vectorcraft_doc::Document, nodes: Vec) -> vectorcraft_doc::Document { let mut tmp = doc.clone(); let mut layer = Node::layer(NodeId(u64::MAX), "raster", vectorcraft_doc::LayerColor::Preset(0)); if let Some(ch) = layer.children_mut() { @@ -377,7 +377,7 @@ fn isolated_doc(doc: &vectorcraft_doc::Document, nodes: Vec) -> vectorcraf tmp } -fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String { +pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String { let mut i = d.images.len() + 1; loop { let k = format!("{stem}-{i}"); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f997e181..8c451e25 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ mod draw2; mod edit; mod effectcmd; pub mod fileio; +pub(crate) mod flatten; mod fonts; pub(crate) mod freeform; pub(crate) mod gradient; @@ -198,6 +199,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(overprint::specs()); v.extend(swatchlib::specs()); v.extend(freeform::specs()); + v.extend(flatten::specs()); v }) } @@ -358,3 +360,6 @@ pub use xform::EyedropperOptions; /// The Attributes panel's state ([`Session::attributes_info`]). pub use overprint::AttributesInfo; + +/// Object → Flatten Transparency settings (`object.flattenTransparency`). +pub use flatten::FlattenOptions; diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index bf2f907f..44ca0785 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -490,7 +490,7 @@ fn outlined_stroke(d: &mut Document, brushes: &[vectorcraft_brush::Brush], path: /// Path `l` with every stroke outlined, in appearance order: runs of fills stay on copies of /// the path, each stroke becomes its outline (or brush art), grouped when there are several. /// `None` when `l` has no stroke to outline. -fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { +pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option { if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) { return None; } @@ -536,6 +536,29 @@ fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &N Some(root) } +/// Outline the strokes of every path under `root` in place: → (the id now standing for `root`, +/// how many paths changed). +pub(crate) fn outline_strokes_under(d: &mut Document, brushes: &[vectorcraft_brush::Brush], root: NodeId) -> Result<(NodeId, usize)> { + let Some(n) = d.node(root).cloned() else { return Ok((root, 0)) }; + let mut lv = vec![]; + leaves(&n, &mut lv); + let (mut replacement, mut changed) = (root, 0); + for l in lv { + if matches!(l.kind, NodeKind::Text(_)) { + continue; + } + let Some(new) = outline_strokes(d, brushes, &l) else { continue }; + let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?; + let nid = d.insert(par, idx, new)?; + d.remove(l.id)?; + changed += 1; + if l.id == root { + replacement = nid; + } + } + Ok((replacement, changed)) +} + fn outline_stroke(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Outline Stroke", |d, sel| { @@ -543,24 +566,9 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { let mut new_sel = vec![]; let mut changed = 0; for root in &roots { - let Some(n) = d.node(*root).cloned() else { continue }; - let mut lv = vec![]; - leaves(&n, &mut lv); - let mut root_replacement = None; - for l in lv { - if matches!(l.kind, NodeKind::Text(_)) { - continue; - } - let Some(new) = outline_strokes(d, &brushes, &l) else { continue }; - let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?; - let nid = d.insert(par, idx, new)?; - d.remove(l.id)?; - changed += 1; - if l.id == *root { - root_replacement = Some(nid); - } - } - new_sel.push(root_replacement.unwrap_or(*root)); + let (id, n) = outline_strokes_under(d, &brushes, *root)?; + new_sel.push(id); + changed += n; } if changed == 0 { return Err(EngineError::Other("Outline Stroke: no stroked paths selected".into())); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fd59c012..f80b262e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -775,6 +775,8 @@ mod tests_effectedit; #[cfg(test)] mod tests_file; #[cfg(test)] +mod tests_flatten; +#[cfg(test)] mod tests_freeform; #[cfg(test)] mod tests_gradient; diff --git a/crates/engine/src/tests_colormgmt.rs b/crates/engine/src/tests_colormgmt.rs index 15b20411..4b3f1b8c 100644 --- a/crates/engine/src/tests_colormgmt.rs +++ b/crates/engine/src/tests_colormgmt.rs @@ -14,7 +14,7 @@ use vectorcraft_render::{RenderOptions, Renderer}; use super::*; /// Every test here reads or changes the process-wide proof view or colour settings: one at a time. -static GLOBAL: Mutex<()> = Mutex::new(()); +pub(crate) static GLOBAL: Mutex<()> = Mutex::new(()); fn session() -> Session { let mut s = Session::new(); diff --git a/crates/engine/src/tests_flatten.rs b/crates/engine/src/tests_flatten.rs new file mode 100644 index 00000000..f5456fda --- /dev/null +++ b/crates/engine/src/tests_flatten.rs @@ -0,0 +1,456 @@ +//! Object → Flatten Transparency (M3.91): atomic regions with the composited colours, rasterized +//! complex regions, the options and presets, undo and the look of the result. + +use serde_json::{Value, json}; +use vectorcraft_color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; +use vectorcraft_geom::{BezPath, Point, Rect, Shape}; +use vectorcraft_render::{Rendered, Renderer}; + +use super::*; + +/// Colours here go through the process-wide colour settings, which the colour management tests +/// change: one at a time with them. +fn settings_lock() -> std::sync::MutexGuard<'static, ()> { + crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()) +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} + +/// An unstroked rectangle filled with `color`. +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, color: &str) -> u64 { + let id = run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap(); + run(s, "paint.setFill", json!({"color": color, "ids": [id]})); + run(s, "paint.setStroke", json!({"none": true, "ids": [id]})); + id +} + +fn node(s: &Session, id: u64) -> Node { + s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone() +} + +fn exists(s: &Session, id: u64) -> bool { + s.doc().unwrap().doc.node(NodeId(id)).is_some() +} + +/// The document over white at 1 px per point. +fn picture(s: &Session) -> Rendered { + Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true) +} + +fn flatten(s: &mut Session, p: Value) -> (Vec, u64, u64) { + let r = run(s, "object.flattenTransparency", p); + let ids = r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + (ids, r["vector"].as_u64().unwrap(), r["rasterized"].as_u64().unwrap()) +} + +fn outline(n: &Node) -> BezPath { + match &n.kind { + NodeKind::Path { path, .. } => path.to_bezpath(), + NodeKind::Compound { children, .. } => children.iter().fold(BezPath::new(), |mut bp, c| { + bp.extend(outline(c)); + bp + }), + _ => panic!("a region is a path: {}", n.kind_label()), + } +} + +/// The flat-colour regions in a flattened group. +fn regions(s: &Session, group: u64) -> Vec { + let g = node(s, group); + g.children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })).map(|c| (**c).clone()).collect() +} + +/// A pixel centre well inside `n` (2 pt from its edges). +fn interior(n: &Node) -> Option { + let bp = outline(n); + let b = bp.bounding_box(); + let inside = |p: Point| [(0.0, 0.0), (2.0, 0.0), (-2.0, 0.0), (0.0, 2.0), (0.0, -2.0)].iter().all(|(dx, dy)| bp.winding(p + (*dx, *dy)) != 0); + (b.y0.floor() as i32..b.y1.ceil() as i32) + .flat_map(|y| (b.x0.floor() as i32..b.x1.ceil() as i32).map(move |x| Point::new(x as f64 + 0.5, y as f64 + 0.5))) + .find(|p| inside(*p)) +} + +/// A region's colour over white, as 8-bit RGB. +fn shown(n: &Node) -> [u8; 3] { + let AppearanceItem::Fill(f) = &n.appearance.items[0] else { panic!("a region has one fill") }; + let [r, g, b] = f.paint.color().unwrap().to_rgb(); + let a = n.opacity; + let q = |v: f32| ((v * a + 1.0 - a) * 255.0).round() as u8; + [q(r), q(g), q(b)] +} + +fn pixel(img: &Rendered, p: Point) -> [u8; 3] { + let [r, g, b, _] = img.pixel(p.x as u32, p.y as u32); + [r, g, b] +} + +/// Every region of `group` shows the colour `before` had inside it, within `tol`/255: 1 where the +/// renderer composites once, 2 where it rounds a group's 8-bit pixels again (the regions' own +/// colours are exact). +fn assert_regions_match(s: &Session, before: &Rendered, group: u64, tol: u8, what: &str) { + let regions = regions(s, group); + assert!(!regions.is_empty(), "{what}: no regions"); + for r in ®ions { + let Some(p) = interior(r) else { continue }; + let (want, got) = (pixel(before, p), shown(r)); + assert!(want.iter().zip(got).all(|(a, b)| a.abs_diff(b) <= tol), "{what}: at {p:?} the art showed {want:?}, the region has {got:?}"); + } +} + +/// Share of pixels whose colour moved by more than 8/255. +fn changed(a: &Rendered, b: &Rendered) -> f64 { + let n = a.pixels.chunks_exact(4).zip(b.pixels.chunks_exact(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 8)).count(); + n as f64 / (a.pixels.len() / 4) as f64 +} + +#[test] +fn two_half_transparent_rects_give_three_regions_with_their_pixels() { + let _settings = settings_lock(); + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000"); + let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0000ff"); + run(&mut s, "transparency.set", json!({"ids": [a, b], "opacity": 50})); + let before = picture(&s); + let (ids, vector, rasterized) = flatten(&mut s, json!({"ids": [a, b]})); + assert_eq!((ids.len(), vector, rasterized), (1, 3, 0)); + assert!(!exists(&s, a) && !exists(&s, b), "the objects are replaced"); + let rs = regions(&s, ids[0]); + assert_eq!(rs.len(), 3); + assert!(rs.iter().all(|r| r.opacity == 1.0 && r.has_default_transparency()), "regions are opaque"); + assert_regions_match(&s, &before, ids[0], 1, "50% rects"); + // Red alone over white is pink; blue over red over white is purple. + let at = |p: Point| rs.iter().find(|r| outline(r).winding(p) != 0).map(shown).unwrap(); + assert_eq!(at(Point::new(30.0, 30.0)), [255, 128, 128]); + assert!(at(Point::new(85.0, 85.0)).iter().zip([128, 64, 191]).all(|(x, y)| x.abs_diff(y) <= 1), "{:?}", at(Point::new(85.0, 85.0))); + assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(ids[0])]); +} + +#[test] +fn every_blend_mode_flattens_to_its_pixels() { + let _settings = settings_lock(); + for mode in BlendMode::ALL { + // Normal at full opacity is no transparency: nothing to flatten. + for opacity in if mode == BlendMode::Normal { &[70][..] } else { &[100, 70][..] } { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#cc6633"); + let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#4d99e6"); + run(&mut s, "transparency.set", json!({"ids": [b], "blend": mode.label(), "opacity": opacity})); + let before = picture(&s); + let (ids, vector, _) = flatten(&mut s, json!({"ids": [a, b]})); + assert_eq!(vector, 3, "{mode:?}"); + // The renderer rounds the blended source again before mixing it in by its opacity. + assert_regions_match(&s, &before, ids[0], 2, &format!("{mode:?} at {opacity}%")); + // The overlap is the reference formula's colour. + let [r, g, b] = [0xcc, 0x66, 0x33].map(|v| v as f32 / 255.0); + let src = [0x4d, 0x99, 0xe6].map(|v| v as f32 / 255.0); + let want = vectorcraft_color::blend::composite(mode, [r, g, b, 1.0], [src[0], src[1], src[2], *opacity as f32 / 100.0]); + let overlap = regions(&s, ids[0]).into_iter().find(|r| outline(r).winding(Point::new(85.0, 85.0)) != 0).unwrap(); + let got = shown(&overlap); + assert!((0..3).all(|i| (want[i] * 255.0 - got[i] as f32).abs() <= 0.5 + 1e-3), "{mode:?} at {opacity}%: {got:?} vs {want:?}"); + } + } +} + +#[test] +fn groups_flatten_as_they_composite() { + let _settings = settings_lock(); + type Setup = fn(&mut Session, u64, u64, u64); + let cases: [(&str, Setup); 8] = [ + ("group with its own fill", |s, _, b, c| { + run(s, "select.set", json!({"ids": [b, c]})); + let g = run(s, "object.group", json!({}))["id"].clone(); + run(s, "appearance.addFill", json!({"ids": [g]})); + run(s, "appearance.setItem", json!({"ids": [g], "index": 0, "color": "#ffff00", "opacity": 50, "blend": "Multiply"})); + }), + ("knockout shape", |s, _, b, c| { + run(s, "transparency.set", json!({"ids": [b], "opacity": 50})); + run(s, "transparency.set", json!({"ids": [c], "opacity": 40, "knockoutShape": true})); + run(s, "select.set", json!({"ids": [b, c]})); + run(s, "object.group", json!({})); + run(s, "transparency.set", json!({"knockout": "on"})); + }), + ("knockout group", |s, _, b, c| { + run(s, "transparency.set", json!({"ids": [b, c], "opacity": 50})); + run(s, "select.set", json!({"ids": [b, c]})); + run(s, "object.group", json!({})); + run(s, "transparency.set", json!({"knockout": "on"})); + }), + ("non-isolated group with opacity", |s, _, b, c| { + run(s, "transparency.set", json!({"ids": [c], "blend": "Multiply"})); + run(s, "select.set", json!({"ids": [b, c]})); + run(s, "object.group", json!({})); + run(s, "transparency.set", json!({"opacity": 60})); + }), + ("isolated group", |s, _, b, c| { + run(s, "transparency.set", json!({"ids": [c], "blend": "Difference"})); + run(s, "select.set", json!({"ids": [b, c]})); + run(s, "object.group", json!({})); + run(s, "transparency.set", json!({"opacity": 80, "isolate": true})); + }), + ("blending group with blending content", |s, _, b, c| { + run(s, "transparency.set", json!({"ids": [c], "blend": "Multiply"})); + run(s, "select.set", json!({"ids": [b, c]})); + run(s, "object.group", json!({})); + run(s, "transparency.set", json!({"blend": "Screen", "opacity": 90})); + }), + ("non-isolated knockout group", |s, _, b, c| { + run(s, "transparency.set", json!({"ids": [b], "opacity": 50})); + run(s, "transparency.set", json!({"ids": [c], "blend": "Multiply", "opacity": 80})); + run(s, "select.set", json!({"ids": [b, c]})); + run(s, "object.group", json!({})); + run(s, "transparency.set", json!({"knockout": "on"})); + }), + ("clip group", |s, _, b, c| { + run(s, "transparency.set", json!({"ids": [b], "opacity": 50})); + let e = run(s, "shape.ellipse", json!({"x": 40, "y": 40, "width": 120, "height": 120}))["id"].clone(); + run(s, "select.set", json!({"ids": [b, c, e]})); + run(s, "object.clippingMask.make", json!({})); + }), + ]; + for (what, setup) in cases { + let mut s = session(); + let a = rect(&mut s, 0.0, 80.0, 200.0, 40.0, "#33aa55"); + let b = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#e04020"); + let c = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#2060e0"); + setup(&mut s, a, b, c); + let before = picture(&s); + run(&mut s, "select.all", json!({})); + let (ids, vector, rasterized) = flatten(&mut s, json!({})); + assert!(ids.len() == 1 && vector >= 3 && rasterized == 0, "{what}: {ids:?} {vector} {rasterized}"); + assert_regions_match(&s, &before, ids[0], 2, what); + assert!(changed(&before, &picture(&s)) < 0.02, "{what}: the look changed"); + } +} + +#[test] +fn balance_trades_vector_regions_for_an_image() { + let _settings = settings_lock(); + let setup = || { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff8000"); + let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0080ff"); + run(&mut s, "transparency.set", json!({"ids": [b], "opacity": 50, "blend": "Multiply"})); + (s, a, b) + }; + let (mut s, a, b) = setup(); + assert_eq!(flatten(&mut s, json!({"ids": [a, b], "balance": 100})).1, 3); + let (mut s, a, b) = setup(); + let before = picture(&s); + let (ids, vector, rasterized) = flatten(&mut s, json!({"ids": [a, b], "balance": 0, "clipComplexRegions": true, "lineArtPpi": 144})); + assert_eq!((vector, rasterized), (0, 1)); + let g = node(&s, ids[0]); + let clip = &g.children().unwrap()[0]; + assert!(clip.clips(), "clipped to the regions"); + let NodeKind::Image(im) = &clip.children().unwrap()[1].kind else { panic!("an image") }; + assert!(im.width >= 300, "at the line art resolution: {}", im.width); + assert!(changed(&before, &picture(&s)) < 0.02); + // Unclipped, the image is a plain rectangle. + let (mut s, a, b) = setup(); + let (ids, ..) = flatten(&mut s, json!({"ids": [a, b], "balance": 0, "clipComplexRegions": false})); + assert!(matches!(node(&s, ids[0]).children().unwrap()[0].kind, NodeKind::Image(_))); + assert!(changed(&before, &picture(&s)) < 0.02); + // In between, a group splitting into more regions than the balance allows is rasterized. + let (mut s, a, b) = setup(); + assert_eq!(flatten(&mut s, json!({"ids": [a, b], "balance": 5})).2, 1); +} + +#[test] +fn gradients_rasterize_and_flat_areas_stay_vector() { + let _settings = settings_lock(); + for clip in [true, false] { + let mut s = session(); + let g = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#000000"); + run( + &mut s, + "paint.setFill", + json!({"ids": [g], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}), + ); + let t = rect(&mut s, 80.0, 80.0, 100.0, 100.0, "#20c040"); + run(&mut s, "transparency.set", json!({"ids": [t], "opacity": 50})); + let before = picture(&s); + let (ids, vector, rasterized) = flatten(&mut s, json!({"ids": [g, t], "preset": "high", "clipComplexRegions": clip})); + assert_eq!(rasterized, 1, "clip {clip}"); + assert!(vector >= 1, "the green alone stays vector (clip {clip})"); + assert_regions_match(&s, &before, ids[0], 1, "gradient"); + assert!(changed(&before, &picture(&s)) < 0.02, "clip {clip}"); + } +} + +#[test] +fn flatten_is_one_undo_step() { + let _settings = settings_lock(); + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000"); + let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0000ff"); + run(&mut s, "transparency.set", json!({"ids": [b], "opacity": 50})); + let steps = s.doc().unwrap().history.undo.len(); + let doc = s.doc().unwrap().doc.clone(); + flatten(&mut s, json!({"ids": [a, b]})); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Flatten Transparency"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().doc.layers, doc.layers); + run(&mut s, "edit.redo", json!({})); + assert!(!exists(&s, a)); +} + +#[test] +fn objects_without_transparency_stay_unless_outlined() { + let _settings = settings_lock(); + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0, "#ff0000"); + run(&mut s, "transparency.set", json!({"ids": [a], "opacity": 50})); + let far = rect(&mut s, 120.0, 120.0, 50.0, 50.0, "#00ff00"); + run(&mut s, "paint.setStroke", json!({"color": "#000000", "ids": [far]})); + let text = run(&mut s, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 24}))["id"].as_u64().unwrap(); + // Nothing transparent: nothing happens. + let steps = s.doc().unwrap().history.undo.len(); + let (ids, vector, rasterized) = flatten(&mut s, json!({"ids": [far, text]})); + assert_eq!((ids, vector, rasterized, s.doc().unwrap().history.undo.len()), (vec![far, text], 0, 0, steps)); + // The transparent rectangle is flattened; the others keep their ids. + let (ids, vector, _) = flatten(&mut s, json!({"ids": [a, far, text]})); + assert_eq!(vector, 1); + assert!(exists(&s, far) && exists(&s, text) && !exists(&s, a) && ids.contains(&far) && ids.contains(&text)); + // On request, type and strokes are outlined too. + let (ids, ..) = flatten(&mut s, json!({"ids": [far, text], "textToOutlines": true, "strokesToOutlines": true})); + assert!(!exists(&s, far) && !exists(&s, text) && ids.len() == 2); + for id in ids { + node(&s, id).walk(&mut |c| { + assert!(!matches!(c.kind, NodeKind::Text(_)), "type is outlined"); + assert!(c.appearance.stroke().is_none_or(|st| st.paint.is_none()), "strokes are outlined"); + }); + } +} + +#[test] +fn preserve_alpha_composites_over_nothing() { + let _settings = settings_lock(); + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#ff0000"); + run(&mut s, "transparency.set", json!({"ids": [a], "opacity": 50})); + let before = picture(&s); + let (ids, ..) = flatten(&mut s, json!({"ids": [a], "preserveAlpha": true})); + let r = ®ions(&s, ids[0])[0]; + assert_eq!((r.opacity, r.appearance.fill_paint()), (0.5, Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); + assert_regions_match(&s, &before, ids[0], 1, "preserve alpha"); + // Over white by default: opaque pink. + run(&mut s, "edit.undo", json!({})); + let (ids, ..) = flatten(&mut s, json!({"ids": [a]})); + let r = ®ions(&s, ids[0])[0]; + assert_eq!((r.opacity, shown(r)), (1.0, [255, 128, 128])); +} + +#[test] +fn single_paints_keep_their_colour_and_overprint() { + let _settings = settings_lock(); + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 100.0, "#000000"); + run(&mut s, "paint.setFill", json!({"ids": [a], "color": {"c": 0, "m": 100, "y": 50, "k": 0}})); + run(&mut s, "object.setOverprint", json!({"ids": [a], "fill": true})); + let b = rect(&mut s, 60.0, 60.0, 100.0, 100.0, "#0000ff"); + run(&mut s, "transparency.set", json!({"ids": [b], "opacity": 50})); + let cmyk_region = |s: &Session, ids: &[u64]| regions(s, ids[0]).into_iter().find(|r| outline(r).winding(Point::new(30.0, 30.0)) != 0).unwrap(); + let (ids, ..) = flatten(&mut s, json!({"ids": [a, b]})); + let r = cmyk_region(&s, &ids); + let AppearanceItem::Fill(f) = &r.appearance.items[0] else { panic!() }; + assert!(matches!(f.paint.color(), Some(Color::Cmyk { .. })) && f.overprint, "{:?}", f); + run(&mut s, "edit.undo", json!({})); + let (ids, ..) = flatten(&mut s, json!({"ids": [a, b], "preserveOverprints": false})); + let r = cmyk_region(&s, &ids); + let AppearanceItem::Fill(f) = &r.appearance.items[0] else { panic!() }; + assert!(matches!(f.paint.color(), Some(Color::Rgb { .. })) && !f.overprint); +} + +#[test] +fn options_come_from_presets_and_params() { + let _settings = settings_lock(); + use crate::cmd::FlattenOptions; + let high = FlattenOptions::preset("High Resolution").unwrap(); + assert_eq!((high.balance, high.line_art_ppi, high.clip_complex_regions), (100.0, 1200.0, true)); + assert_eq!(FlattenOptions::default(), FlattenOptions::preset("medium").unwrap()); + assert_ne!(FlattenOptions::preset("low"), FlattenOptions::preset("medium")); + let o = FlattenOptions::from_params(&json!({"preset": "low", "balance": 40, "options": {"antiAlias": false}})).unwrap(); + assert_eq!((o.balance, o.gradient_ppi, o.anti_alias), (40.0, 150.0, false)); + for bad in [json!({"preset": "ultra"}), json!({"balance": 120}), json!({"lineArtPpi": 0}), json!({"balance": "lots"})] { + assert!(FlattenOptions::from_params(&bad).is_err(), "{bad}"); + } + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0, "#ff0000"); + run(&mut s, "transparency.set", json!({"ids": [a], "opacity": 50})); + assert!(s.execute("object.flattenTransparency", &json!({"ids": [a], "balance": -1})).is_err()); + run(&mut s, "select.none", json!({})); + assert!(s.execute("object.flattenTransparency", &json!({})).is_err(), "nothing to flatten"); + let r = run(&mut s, "object.flattenTransparency", json!({"ids": [a], "preset": "high"})); + assert_eq!(r["options"]["lineArtPpi"], 1200.0); +} + +#[test] +fn a_mixed_scene_looks_the_same_flattened() { + let _settings = settings_lock(); + for preset in FlattenPresets::ALL { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 200.0, 200.0, "#ffcc00"); + let r = rect(&mut s, 30.0, 30.0, 80.0, 80.0, "#3355cc"); + run(&mut s, "paint.setStroke", json!({"color": "#000000", "ids": [r]})); + run(&mut s, "stroke.set", json!({"weight": 8, "ids": [r]})); + run(&mut s, "transparency.set", json!({"ids": [r], "opacity": 60})); + let t = run(&mut s, "text.create", json!({"x": 40, "y": 170, "text": "Hi", "size": 48, "color": "#e01010"}))["id"].clone(); + run(&mut s, "transparency.set", json!({"ids": [t], "opacity": 70, "blend": "Multiply"})); + let e = run(&mut s, "shape.ellipse", json!({"x": 120, "y": 40, "width": 60, "height": 60}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"color": "#20a040", "ids": [e]})); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [e]})); + let m = rect(&mut s, 130.0, 130.0, 40.0, 40.0, "#8040c0"); + run(&mut s, "transparency.set", json!({"ids": [m], "opacity": 50})); + run(&mut s, "select.set", json!({"ids": [m]})); + let sym = run(&mut s, "symbol.new", json!({"name": "Half"}))["id"].as_u64().unwrap(); + assert!(matches!(node(&s, sym).kind, NodeKind::SymbolInstance { .. })); + let before = picture(&s); + run(&mut s, "select.all", json!({})); + let (ids, vector, rasterized) = flatten(&mut s, json!({"preset": preset, "lineArtPpi": 216, "gradientPpi": 144})); + assert!(ids.len() == 1 && rasterized == 1, "{preset}: the shadow is rasterized ({ids:?}, {rasterized})"); + assert!(preset == "low" || vector > 4, "{preset}: {vector} regions"); + let mut kinds = vec![]; + node(&s, ids[0]).walk(&mut |c| kinds.push(c.kind_label())); + assert!(!kinds.iter().any(|k| ["Type", "Symbol"].contains(k)), "{preset}: {kinds:?}"); + let diff = changed(&before, &picture(&s)); + assert!(diff < 0.03, "{preset}: {:.1}% of the pixels changed", diff * 100.0); + } +} + +struct FlattenPresets; +impl FlattenPresets { + const ALL: [&'static str; 3] = crate::cmd::FlattenOptions::PRESETS; +} + +#[test] +fn opacity_masks_are_rasterized_with_their_look() { + let _settings = settings_lock(); + let mut s = session(); + rect(&mut s, 0.0, 0.0, 200.0, 200.0, "#ffffff"); + let a = rect(&mut s, 20.0, 20.0, 160.0, 160.0, "#d02060"); + let m = rect(&mut s, 40.0, 40.0, 120.0, 120.0, "#000000"); + run( + &mut s, + "paint.setFill", + json!({"ids": [m], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}}), + ); + run(&mut s, "transparency.makeOpacityMask", json!({"ids": [a, m]})); + assert!(node(&s, a).mask.is_some()); + let before = picture(&s); + run(&mut s, "select.all", json!({})); + let (ids, _, rasterized) = flatten(&mut s, json!({"lineArtPpi": 144})); + assert_eq!((ids.len(), rasterized), (1, 1)); + let mut masks = 0; + node(&s, ids[0]).walk(&mut |c| masks += c.mask.is_some() as usize); + assert_eq!(masks, 0, "no transparency is left"); + assert!(changed(&before, &picture(&s)) < 0.02); +} diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index d089cc60..901ace4a 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -63,7 +63,7 @@ pub(crate) fn cull_bounds(n: &Node) -> Option { /// A symbol's art for instance `inst`, stained by the instance's fill (Symbol Stainer: the fill /// colour mixed in by the fill layer's opacity). -pub(crate) fn instance_art(art: &Node, inst: &Node) -> Node { +pub fn instance_art(art: &Node, inst: &Node) -> Node { let mut a = art.clone(); if let Some(f) = inst.appearance.fill() && f.visible diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 04986353..f95b0334 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -26,6 +26,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources}; use group::Composite; +pub use brush_fx::instance_art; pub use effects::stroke::width_outline; pub use live::expand_live; pub use pattern::render_pattern_swatch; diff --git a/docs/mcp.md b/docs/mcp.md index 65c17038..2c9ccc7f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -349,3 +349,20 @@ characters as its place relative to the Characters row says. To stroke some characters, use `text.setRangeStyle {id, start, end, strokeOptions: {weight?, cap?, join?, miterLimit?, dash?, dashOffset?, alignDashes?}}`. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first run's) in `stroke.set` terms. `stroke.set` on a group leaves the images and symbol instances in it alone. + +## Flatten Transparency + +`object.flattenTransparency {ids?, preset?, …options}` turns transparent art into opaque art that looks the same, in +one undo step. The targets split into groups of overlapping objects; groups without transparency stay as they are. +Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over +white; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, +opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without). +`preset` is `high`, `medium` (the default) or `low`; option keys (`balance` 0–100, `lineArtPpi`, `gradientPpi`, +`textToOutlines`, `strokesToOutlines`, `clipComplexRegions`, `antiAlias`, `preserveAlpha`, `preserveOverprints`) +override it, at the top level or in `options`. `balance: 0` rasterizes everything; lower balances rasterize groups +that split into many regions. The result reports `{ids, vector, rasterized, options}`. + +```json +{"name":"run_command","arguments":{"command":"object.flattenTransparency","params":{"ids":[12,15],"preset":"high"}}} +{"name":"run_command","arguments":{"command":"object.flattenTransparency","params":{"balance":0,"lineArtPpi":150}}} +```