//! Raster (painted) effects: descriptions for the renderer. use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Color}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; use vectorcraft_geom::{Point, Rect}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; use crate::util::*; /// Effect › Rasterize's id. pub const RASTERIZE: &str = "rasterize"; /// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses /// a Gaussian with σ = blur / 2 (so the visible spread is about 1.5 × blur). #[derive(Clone, Debug, PartialEq)] pub enum RasterFx { DropShadow { mode: BlendMode, opacity: f32, dx: f64, dy: f64, blur: f64, color: Color, /// Darkness (0..1): the shadow is the object's own colors with this share of black added /// instead of `color`; `None` when it is `color`. darkness: Option, }, /// Blurs the art's solid silhouette grown by a quarter of a pixel of the document's raster grid, /// as the glows AI files export measure. OuterGlow { mode: BlendMode, opacity: f32, blur: f64, color: Color, }, InnerGlow { mode: BlendMode, opacity: f32, blur: f64, color: Color, center: bool, }, /// Soften the object's edges inward over `radius`. Feather { radius: f64, }, /// Blur the whole object (σ = radius / 2). GaussianBlur { radius: f64, }, /// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…). Pixel(PixelFx), /// Effect › Rasterize: the object drawn as an image of square pixels. Rasterize(Rasterize), } impl RasterFx { /// Painted below the object (shadows, outer glows)? pub fn is_below(&self) -> bool { matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. }) } /// The blend mode it paints with (shadows and glows). pub fn mode(&self) -> Option { match self { RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } => Some(*mode), _ => None, } } /// How far this effect reaches beyond `bounds`, what it paints around (Radial Blur reaches /// further around larger objects). pub fn outset(&self, bounds: Rect) -> f64 { match self { RasterFx::DropShadow { dx, dy, blur, .. } => dx.abs().max(dy.abs()) + 1.5 * blur, RasterFx::OuterGlow { blur, .. } => 1.5 * blur, RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0, RasterFx::GaussianBlur { radius } => 1.5 * radius, RasterFx::Pixel(p) => p.outset(bounds), RasterFx::Rasterize(r) => r.outset(), } } /// This effect with a Rasterize that takes the document's resolution at `ppi` ([`Rasterize::at_document_ppi`]). pub fn at_document_ppi(&self, ppi: f64) -> RasterFx { match self { RasterFx::Rasterize(r) => RasterFx::Rasterize(r.at_document_ppi(ppi)), fx => fx.clone(), } } /// What this effect paints over when what it paints around covers `bounds`: Rasterize's image /// ([`Rasterize::image`], `line` the document's raster grid), `bounds` grown by /// [`Self::outset`] for the others. pub fn spread(&self, bounds: Rect, line: f64) -> Rect { match self { RasterFx::Rasterize(r) => r.image(bounds, line), RasterFx::OuterGlow { .. } => { let o = self.outset(bounds) + 0.25 * line; bounds.inflate(o, o) } fx => { let o = fx.outset(bounds); bounds.inflate(o, o) } } } } /// Effect › Rasterize's options, read from its parameters. The image covers the object's bounds /// (as far as the effects before it spread the object) and `add_around` points around them, in /// pixels `72 / ppi` points wide whose first one starts at that area's top-left corner, as Object /// › Rasterize makes its image. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Rasterize { /// Pixels per inch (1 to 2400); `None` takes Document Raster Effects Settings › Resolution. pub ppi: Option, /// The image's color model. pub color_model: RasterColorModel, /// A white background: the image is opaque. Otherwise it keeps its transparency. pub white: bool, /// Smooth edges (Art Optimized or Type Optimized). Off, each pixel takes the art at its center. pub anti_alias: bool, /// Type Optimized: smooth edges with type fitted to the pixels. pub hinted: bool, /// The image is clipped to the art's outline. pub clipping_mask: bool, /// Room around the art, in points (0 to [`MAX_ADD_AROUND`]). pub add_around: f64, } impl Rasterize { /// The options in the effect's parameters `p` (defaults merged in). fn parse(p: &Value) -> Self { let ppi = (!flag(p, "useDocumentResolution", true)).then(|| num(p, "resolution", 72.0).clamp(1.0, 2400.0)); let color_model = match text(p, "colorModel", "document").to_ascii_lowercase().as_str() { "grayscale" | "gray" => RasterColorModel::Grayscale, "bitmap" => RasterColorModel::Bitmap, _ => RasterColorModel::Document, }; Self { ppi, color_model, white: text(p, "background", "transparent").eq_ignore_ascii_case("white"), anti_alias: !text(p, "antiAlias", "art").eq_ignore_ascii_case("none"), hinted: text(p, "antiAlias", "art").eq_ignore_ascii_case("type"), clipping_mask: flag(p, "clippingMask", false), add_around: num(p, "addAround", 0.0).clamp(0.0, MAX_ADD_AROUND), } } /// The width of a pixel of the image in document units: 72 / `ppi`, or `line` (72 / the /// document's raster effects resolution) when it takes the document's. pub fn cell(&self, line: f64) -> f64 { self.ppi.map_or(line, |ppi| 72.0 / ppi) } /// These options at `ppi` when they take the document's resolution. pub fn at_document_ppi(self, ppi: f64) -> Self { Self { ppi: self.ppi.or(Some(ppi)), ..self } } /// How far beyond the object's bounds the image reaches: the room around it and, at a known /// resolution, one pixel more for the last column and row, which are whole pixels. pub fn outset(&self) -> f64 { self.add_around + self.ppi.map_or(0.0, |ppi| 72.0 / ppi) } /// How far (document units) from a pixel the content that decides it lies: across its pixel /// of the image (72 points when the resolution is the document's, not known here). pub fn reach(&self) -> f64 { self.cell(72.0) * std::f64::consts::SQRT_2 } /// The image of art over `bounds`: `bounds` and the room around them, in whole pixels /// ([`Self::cell`] of `line`) from that area's top-left corner. pub fn image(&self, bounds: Rect, line: f64) -> Rect { let cell = self.cell(line); let area = bounds.inflate(self.add_around, self.add_around); let (cols, rows) = ((area.width() / cell).ceil().max(1.0), (area.height() / cell).ceil().max(1.0)); Rect::new(area.x0, area.y0, area.x0 + cols * cell, area.y0 + rows * cell) } /// Give `pixels` (premultiplied RGBA8), the art drawn on the image's own grid, the background /// and then the color model, as Object › Rasterize does. pub fn finish(&self, pixels: &mut [u8]) { for p in pixels.as_chunks_mut::<4>().0 { if self.white { let under = 255 - p[3]; *p = p.map(|c| c.saturating_add(under)); } } RasterEffectsSettings { color_model: self.color_model, ..Default::default() }.finish_pixels(pixels); } /// Draw `image` into `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as /// `space` says: `image` is `cols` × `rows` premultiplied RGBA8 pixels covering `rect` in the /// document. In a view that isn't turned, each pixel of `data` takes the mean of the image over /// its area (transparent outside the image); otherwise the mean of the image at up to 8 × 8 /// points across it. Does nothing when the sizes don't match. #[allow(clippy::too_many_arguments)] pub fn compose(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, image: &[u8], cols: usize, rows: usize, rect: Rect) { let (px, rest) = data.as_chunks_mut::<4>(); let (src, src_rest) = image.as_chunks::<4>(); let fits = |n: usize, w: usize, h: usize, rest: usize| w > 0 && h > 0 && rest == 0 && w.checked_mul(h) == Some(n); if !(fits(px.len(), w, h, rest.len()) && fits(src.len(), cols, rows, src_rest.len()) && rect.width() > 0.0 && rect.height() > 0.0) { return; } let (sx, sy) = (cols as f64 / rect.width(), rows as f64 / rect.height()); // Document → image pixels. let to_image = |p: Point| ((p.x - rect.x0) * sx, (p.y - rect.y0) * sy); let at = |i: f64, j: f64| { (i >= 0.0 && j >= 0.0 && i < cols as f64 && j < rows as f64).then(|| src.get(j as usize * cols + i as usize).copied()).flatten() }; let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0); // Points across a raster pixel in a turned view: about one an image pixel. let n = (space.px * sx.max(sy)).ceil().clamp(1.0, 8.0) as usize; for (k, p) in px.iter_mut().enumerate() { let (x, y) = ((k % w) as f64, (k / w) as f64); let mut acc = [0.0f64; 4]; let weight = if straight { let (a, b) = (to_image(space.to_doc * Point::new(x, y)), to_image(space.to_doc * Point::new(x + 1.0, y + 1.0))); let (u0, u1, v0, v1) = (a.0.min(b.0), a.0.max(b.0), a.1.min(b.1), a.1.max(b.1)); let (i0, i1) = (u0.floor().max(0.0), u1.ceil().min(cols as f64)); let (j0, j1) = (v0.floor().max(0.0), v1.ceil().min(rows as f64)); let mut j = j0; while j < j1 { let mut i = i0; while i < i1 { let share = (u1.min(i + 1.0) - u0.max(i)).max(0.0) * (v1.min(j + 1.0) - v0.max(j)).max(0.0); if let Some(q) = at(i, j) { for (s, v) in acc.iter_mut().zip(q) { *s += share * f64::from(v); } } i += 1.0; } j += 1.0; } (u1 - u0) * (v1 - v0) } else { for t in 0..n * n { let (fx, fy) = (((t % n) as f64 + 0.5) / n as f64, ((t / n) as f64 + 0.5) / n as f64); let (i, j) = to_image(space.to_doc * Point::new(x + fx, y + fy)); if let Some(q) = at(i.floor(), j.floor()) { for (s, v) in acc.iter_mut().zip(q) { *s += f64::from(v); } } } (n * n) as f64 }; *p = if weight > 0.0 { acc.map(|s| (s / weight).round().clamp(0.0, 255.0) as u8) } else { [0; 4] }; } } /// Turn `data`, `w` × `h` premultiplied RGBA8 pixels of the object lying in the document as /// `space` says, into its image over `bounds` (what the object covers after the effects before /// this one), when the image is too large to draw on its own grid. Each pixel of the image more /// than a raster pixel wide takes the mean of the raster pixels whose centers lie in it /// (anti-aliased) or the raster pixel at its center (not anti-aliased). Smaller pixels are left /// as drawn. Then the background and the color model apply as Object › Rasterize applies them. /// What lies outside the image becomes transparent, and in a view that isn't turned the raster /// pixels along its edges keep the share of them it covers. Does nothing when the sizes don't /// match. The renderer draws the content with hard edges when it goes into Rasterize with /// anti-aliasing off and image pixels at least a raster pixel wide. pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace, bounds: Rect) { let cell = self.cell(space.line); let (px, rest) = data.as_chunks_mut::<4>(); if w == 0 || h == 0 || !rest.is_empty() || w.checked_mul(h) != Some(px.len()) || !(cell.is_finite() && cell > 0.0 && space.px > 0.0) { return; } let area = self.image(bounds, space.line); let (cols, rows) = ((area.width() / cell).round().max(1.0), (area.height() / cell).round().max(1.0)); // The pixel of the image (column, row) that a raster pixel's center lies in. let pixel_at = |x: usize, y: usize| { let p = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5); let (c, r) = (((p.x - area.x0) / cell).floor(), ((p.y - area.y0) / cell).floor()); (c >= 0.0 && r >= 0.0 && c < cols && r < rows).then_some((c, r)) }; let wide = cell / space.px; // The image's pixels the raster covers: columns c0..c1, rows r0..r1. let seen = space.to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); let (c0, c1) = (((seen.x0 - area.x0) / cell).floor().clamp(0.0, cols), ((seen.x1 - area.x0) / cell).ceil().clamp(0.0, cols)); let (r0, r1) = (((seen.y0 - area.y0) / cell).floor().clamp(0.0, rows), ((seen.y1 - area.y0) / cell).ceil().clamp(0.0, rows)); let span = (c1 - c0) as usize; let slot = |(c, r): (f64, f64)| ((r - r0) as usize).saturating_mul(span).saturating_add((c - c0) as usize); // Anti-aliased: the sums of each pixel's raster pixels (premultiplied) and their count, // which saturate past 16 million raster pixels to an image pixel. They are kept for the // whole grid when it has no more pixels than the raster, else for the pixels the raster // reaches (a turned view of a narrow raster). let grid = span.saturating_mul((r1 - r0) as usize); let whole = grid <= px.len(); let mut dense: Vec<[u32; 5]> = vec![]; let mut sparse: std::collections::HashMap = std::collections::HashMap::new(); if self.anti_alias && wide > 1.0 { if whole { dense = vec![[0; 5]; grid]; } for (i, p) in px.iter().enumerate() { let Some(k) = pixel_at(i % w, i / w).map(slot) else { continue }; let s = if whole { dense.get_mut(k) } else { Some(sparse.entry(k).or_default()) }; if let Some(s) = s { for (acc, v) in s.iter_mut().zip(p) { *acc = acc.saturating_add(u32::from(*v)); } s[4] = s[4].saturating_add(1); } } } let sum = |k: usize| if whole { dense.get(k) } else { sparse.get(&k) }; // Not anti-aliased: the raster as drawn, to read each pixel's center from. let drawn = (!self.anti_alias && wide > 1.0).then(|| px.to_vec()); let to_raster = space.to_doc.inverse(); let model = RasterEffectsSettings { color_model: self.color_model, ..Default::default() }; // In a view that isn't turned, the raster pixels along the image's edges take the share of // them it covers; otherwise those whose center lies in it are in it. let straight = matches!(space.to_doc.as_coeffs(), [_, b, c, _, _, _] if b == 0.0 && c == 0.0); let image = to_raster.transform_rect_bbox(area); let cover = |x: f64, y: f64| (image.x1.min(x + 1.0) - image.x0.max(x)).clamp(0.0, 1.0) * (image.y1.min(y + 1.0) - image.y0.max(y)).clamp(0.0, 1.0); for (i, p) in px.iter_mut().enumerate() { let (x, y) = (i % w, i / w); let (at, share) = if straight { // The image pixel nearest the raster pixel's center. let q = space.to_doc * Point::new(x as f64 + 0.5, y as f64 + 0.5); let at = (((q.x - area.x0) / cell).floor().clamp(0.0, cols - 1.0), ((q.y - area.y0) / cell).floor().clamp(0.0, rows - 1.0)); (Some(at), cover(x as f64, y as f64)) } else { (pixel_at(x, y), 1.0) }; let Some(at) = at.filter(|_| share > 0.0) else { *p = [0; 4]; continue; }; // An image pixel's color, over the share of the raster pixel the image covers. (A raster // pixel left as drawn holds art the image covers whole.) let made = if let Some(s) = sum(slot(at)).filter(|s| s[4] > 0) { Some([0, 1, 2, 3].map(|k| ((u64::from(s[k]) + u64::from(s[4] / 2)) / u64::from(s[4])).min(255) as u8)) } else { drawn.as_ref().map(|drawn| { let center = to_raster * Point::new(area.x0 + (at.0 + 0.5) * cell, area.y0 + (at.1 + 0.5) * cell); let (x, y) = (center.x.floor(), center.y.floor()); let inside = x >= 0.0 && y >= 0.0 && x < w as f64 && y < h as f64; if inside { drawn.get(y as usize * w + x as usize).copied().unwrap_or_default() } else { [0; 4] } }) }; if let Some(v) = made { *p = if share < 1.0 { v.map(|c| (f64::from(c) * share).round() as u8) } else { v }; } // White goes under the art first, over the image's share of the pixel, then the color // model applies, as in Object › Rasterize. if self.white { let under = ((255.0 * share).round() as u8).saturating_sub(p[3]); *p = p.map(|c| c.saturating_add(under)); } model.finish_pixels(p); } } } /// Parse a blend mode name (`"multiply"`, `"Screen"`, `"color-burn"`…). pub fn blend_mode(s: &str) -> BlendMode { let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); BlendMode::ALL.iter().copied().find(|m| format!("{m:?}").to_ascii_lowercase() == key).unwrap_or(BlendMode::Normal) } /// An effect's `color` parameter (`"#rrggbb"`, `[r, g, b]` 0..1 or a colour object). pub(crate) fn color_param(p: &Value, default: Color) -> Color { match p.get("color") { Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default), Some(Value::Array(a)) if a.len() >= 3 => { let f = |i: usize| a[i].as_f64().unwrap_or(0.0) as f32; Color::rgb(f(0), f(1), f(2)) } Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).unwrap_or(default), _ => default, } } fn opacity(p: &Value) -> f32 { (num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32 } /// The visible raster effects of `effects`, in stack order. pub fn raster_effects(effects: &[Effect]) -> Vec { effects .iter() .filter(|e| e.visible) .filter_map(|e| { let p = merged_params(&e.id, &e.params); let blur = num(&p, "blur", 5.0).clamp(0.0, 1000.0); Some(match e.id.as_str() { "stylize.dropShadow" => RasterFx::DropShadow { mode: blend_mode(text(&p, "mode", "multiply")), opacity: opacity(&p), dx: num(&p, "x", 7.0).clamp(-1e4, 1e4), dy: num(&p, "y", 7.0).clamp(-1e4, 1e4), blur, color: color_param(&p, Color::BLACK), darkness: text(&p, "shade", "color") .eq_ignore_ascii_case("darkness") .then(|| (num(&p, "darkness", 100.0) / 100.0).clamp(0.0, 1.0) as f32), }, "stylize.outerGlow" => RasterFx::OuterGlow { mode: blend_mode(text(&p, "mode", "screen")), opacity: opacity(&p), blur, color: color_param(&p, Color::rgb(1.0, 1.0, 0.0)), }, "stylize.innerGlow" => RasterFx::InnerGlow { mode: blend_mode(text(&p, "mode", "screen")), opacity: opacity(&p), blur, color: color_param(&p, Color::WHITE), center: text(&p, "source", "edge").eq_ignore_ascii_case("center"), }, "stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, "blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, RASTERIZE => RasterFx::Rasterize(Rasterize::parse(&p)), id => RasterFx::Pixel(pixel::parse(id, &p)?), }) }) .collect() } /// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected) /// geometry's, in document units. Geometry effects are accounted for by evaluating them. pub fn outset(effects: &[Effect], bounds: Rect) -> f64 { raster_effects(effects).iter().map(|fx| fx.outset(bounds)).fold(0.0, f64::max) }