use super::exporter::{MAX_FIELD_PX, MAX_GRADIENT_PX, MAX_PATTERN_DEPTH, color, ctm, in_range, norm, q, to_path, writable, xf}; use super::{ Affine, Color, Exporter, GradientKind, LinearGradient, Paint, RadialGradient, Rect, SepColor, SeparationColorant, SeparationSpace, Shape, Size, SpreadMethod, Stop, Surface, Vec2, cmyk, krilla, rgb, }; impl Exporter<'_> { /// A colour for the page, as the Output settings write it ([`ColorOut::color`]): without /// conversion, RGB and Lab colours of CMYK documents are separated into CMYK through the /// document's colour spaces, so the file carries press values. fn col(&mut self, c: &Color) -> krilla::color::Color { let c = self.page_color(c); color(&c) } /// Color `c` as [`Self::col`] writes it, before it becomes the PDF writer's color. pub(super) fn page_color(&mut self, c: &Color) -> Color { let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; if let (true, true, Color::Rgb { r, g, b }) = (cmyk_doc, self.convert, c) { self.rgb_in_cmyk.colours.insert([r.to_bits(), g.to_bits(), b.to_bits()]); } let c = self.out.color(c, self.intent, cmyk_doc, self.convert); if let (Color::Rgb { .. }, Some(note)) = (c, &self.out.srgb_note) { let note = note.clone(); self.warn(note); } c } /// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate /// space; a Lab spot colour also gets a Lab one, [`crate::lab_spot`], unless the Spot Colors /// options use CMYK values); `None` when `name` isn't a spot colour. fn separation(&mut self, name: &str) -> Option { let (ink, full) = self.spot_ink(name)?; let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new_f32(full[0], full[1], full[2], full[3])); Some(SeparationSpace::new(SeparationColorant::Custom(ink), alt)) } /// The ink name of spot swatch `name` ([`Self::colorant_name`]) and its CMYK equivalent at /// 100% (a Lab spot color is noted for [`crate::lab_spot`]); `None` when `name` isn't a spot /// color. pub(super) fn spot_ink(&mut self, name: &str) -> Option<(String, [f32; 4])> { let doc = self.doc; let sw = doc.swatch(name).filter(|s| s.spot)?; let color = doc.linked_color(sw.paint.color()?, true); let ink = self.colorant_name(&sw.name); if let Color::Lab { l, a, b } = color && !self.lab_spots.iter().any(|(n, _)| *n == ink) { self.lab_spots.push((ink.clone(), vectorcraft_color::cms::Lab::new(l, a, b))); } Some((ink, self.out.cmyk(&color, self.intent))) } /// The colorant name spot swatch `name` is written under: its own name, except that `All` /// (every plate in PDF) and `None` (no plate) become `All Spot` and `None Spot`, numbered /// `All Spot 2`, `All Spot 3`, … past the names of the document's spot swatches. fn colorant_name(&self, name: &str) -> String { if name != "All" && name != "None" { return name.to_string(); } let taken = |c: &str| self.doc.swatches_iter().any(|s| s.spot && s.name == c); let mut colorant = format!("{name} Spot"); let mut n = 2; while taken(&colorant) { colorant = format!("{name} Spot {n}"); n += 1; } colorant } /// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch /// or to Registration (`/All`: every plate). fn solid(&mut self, c: &Color, link: Option<&str>, tint: f32) -> krilla::color::Color { if link == Some(vectorcraft_color::swatch::REGISTRATION) { let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(255, 255, 255, 255)); return SepColor::new_f32(tint, SeparationSpace::new(SeparationColorant::AllColorants, alt)).into(); } match link.and_then(|n| self.separation(n)) { Some(space) => SepColor::new_f32(tint, space).into(), None => self.col(c), } } /// The stops of a gradient whose every stop is a tint of one spot ink (or paper white, 0% of /// it) as tints of that ink — a Separation shading — with midpoints as explicit stops (the /// tint and opacity there are halfway). A gradient that mixes a spot ink with other colors /// gets [`Self::device_n_stops`]. `None` for gradients without a spot ink. fn spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { let ink = g.stops.iter().find_map(|s| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)))?; let tints: Option> = g .stops .iter() .map(|s| match s.swatch.as_deref() { // A stop that also prints process plates needs a DeviceN space. Some(n) if n == ink && s.plates.is_none() => Some(s.tint), _ if self.paper_stop(s) => Some(0.0), _ => None, }) .collect(); let Some(tints) = tints else { return self.device_n_stops(g); }; let space = self.separation(ink)?; let tint = |t: f32| -> krilla::color::Color { SepColor::new_f32(t, space.clone()).into() }; let mut out = vec![]; for (i, s) in g.stops.iter().enumerate() { out.push((s.offset, tint(tints[i]), s.opacity)); if let Some(n) = g.stops.get(i + 1) && (s.midpoint - 0.5).abs() > 1e-3 { let at = s.offset + (n.offset - s.offset) * s.midpoint; out.push((at, tint((tints[i] + tints[i + 1]) / 2.0), (s.opacity + n.opacity) / 2.0)); } } Some(out) } /// The stops of a gradient that mixes spot inks with process colors or with each other, in /// a DeviceN space of the process inks the stops use (in CMYK order) and the spot inks (in /// the order the stops name them), with midpoints as explicit stops (the tints and opacity /// there are halfway), or for a Perceptual gradient the stops on its curve /// ([`vectorcraft_color::Gradient::ink_mixes`]). Paper white adds no ink; a spot stop with /// process plates ([`vectorcraft_color::GradientStop::plates`]) prints them too. `None`, with a /// warning, when a stop is written in RGB or Lab or the spot inks can't share a DeviceN space; /// the gradient is then written in the colors of its stops. fn device_n_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { use krilla::color::device_n::{Color as DnColor, Colorant, DeviceNSpace, Process}; let doc = self.doc; fn spot_of<'s>(doc: &vectorcraft_doc::Document, s: &'s vectorcraft_color::GradientStop) -> Option<&'s str> { s.swatch.as_deref().filter(|n| doc.swatch(n).is_some_and(|w| w.spot)) } // Each spot ink: its swatch's name, then its colorant name and CMYK equivalent. let mut spots: Vec<(&str, (String, [f32; 4]))> = Vec::new(); for s in &g.stops { if let Some(n) = spot_of(doc, s) && !spots.iter().any(|(m, _)| *m == n) { spots.push((n, self.spot_ink(n)?)); } } // Each stop's ink coverage: the four process inks, then one tint per spot ink. let mut inks: Vec> = Vec::with_capacity(g.stops.len()); for s in &g.stops { let mut v = vec![0.0; 4 + spots.len()]; match spot_of(doc, s) { Some(n) => { let i = spots.iter().position(|(m, _)| *m == n)?; v[4 + i] = s.tint; if let Some(p) = s.plates { v[..4].copy_from_slice(&p.map(|t| t.clamp(0.0, 1.0))); } } None if self.paper_stop(s) => {} None => match self.page_color(&s.color) { Color::Cmyk { c, m, y, k } => v[..4].copy_from_slice(&[c, m, y, k]), Color::Gray { k } => v[3] = k, Color::Rgb { .. } | Color::Lab { .. } => { self.warn("gradients mixing a spot color with RGB or Lab colors are exported without the spot ink"); return None; } }, } inks.push(v); } let process: Vec = (0..4).filter(|&i| inks.iter().any(|v| v[i] > 0.0)).collect(); let colorants = process .iter() .map(|&i| Colorant::Process(Process::ALL[i])) .chain(spots.iter().map(|(_, (name, f))| Colorant::Spot { name: name.clone(), alternate: cmyk::Color::new_f32(f[0], f[1], f[2], f[3]) })); let Some(space) = DeviceNSpace::new(colorants) else { self.warn("gradients whose spot inks can't share a DeviceN color space are exported without the spot inks"); return None; }; let color = |v: &[f32]| -> Option { let tints = process.iter().map(|&i| v[i]).chain(v[4..].iter().copied()).collect(); Some(DnColor::new_f32(tints, space.clone())?.into()) }; // A Perceptual gradient: the stops on its curve, each a mix of two stops' inks. if let Some(curve) = g.ink_mixes() { return curve .into_iter() .map(|(o, i, w, opacity)| { let next = inks.get(i + 1).unwrap_or(&inks[i]); let mixed: Vec = inks[i].iter().zip(next).map(|(a, b)| a + (b - a) * w).collect(); Some((o, color(&mixed)?, opacity)) }) .collect(); } let mut out = vec![]; for (i, s) in g.stops.iter().enumerate() { out.push((s.offset, color(&inks[i])?, s.opacity)); if let Some(n) = g.stops.get(i + 1) && (s.midpoint - 0.5).abs() > 1e-3 { let at = s.offset + (n.offset - s.offset) * s.midpoint; let half: Vec = inks[i].iter().zip(&inks[i + 1]).map(|(a, b)| (a + b) / 2.0).collect(); out.push((at, color(&half)?, (s.opacity + n.opacity) / 2.0)); } } Some(out) } /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect: the /// app renders them to images before export (`vectorcraft_engine::export_pdf`), so the ones /// still here (on `what`) can't be written. pub(super) fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect], what: impl FnOnce() -> String) { if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { self.warn(format!("raster effects (shadows, glows, blur, feather) on {} are left out of the PDF", what())); } } pub(super) fn warn(&mut self, w: impl Into) { let w = w.into(); if !self.warnings.contains(&w) { self.warnings.push(w); } } /// Convert a paint to draw on `s`. `bounds` resolves unset gradient geometry (like the /// renderer). A gradient placed beyond [`MAX_COORD`] there gives no paint. pub(super) fn paint(&mut self, s: &Surface, p: &Paint, bounds: Rect) -> Option { self.paint_in(s, p, bounds, Affine::IDENTITY) } /// [`Self::paint`] drawn in another space: `space` maps the paint's space (where `bounds` /// is) to the one drawn in. pub(super) fn paint_in(&mut self, s: &Surface, p: &Paint, bounds: Rect, space: Affine) -> Option { match p { Paint::None => None, Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), Paint::Gradient(g) => { let geom = g.resolve(bounds); let spot = match g.gradient.kind { GradientKind::Freeform => None, _ => self.perceptual_spot_stops(&g.gradient).or_else(|| self.spot_stops(&g.gradient)), }; let colored = match spot { Some(v) => v, None => { let stops = g.gradient.expanded_stops(); // The PDF writer needs every stop in one colour space: stops of mixed // models (midpoints sample in RGB) go through RGB, or stay CMYK in a CMYK // document, where `col` separates the rest. let mixed = stops.windows(2).any(|w| w[0].1.model() != w[1].1.model()); let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; let one = |c: Color| match c { Color::Cmyk { .. } if cmyk_doc => c, _ if mixed => c.in_model(vectorcraft_color::cms::Model::Rgb), _ => c, }; stops.into_iter().map(|(o, c, a)| (o, self.col(&one(c)), a)).collect() } }; let mut stops: Vec = Vec::new(); let mut last = 0.0f32; for (o, color, a) in colored { let o = o.clamp(last, 1.0); last = o; stops.push(Stop { offset: norm(o), color, opacity: norm(a) }); } if stops.is_empty() { return None; } match g.gradient.kind { GradientKind::Linear => { if !writable(s, space, Rect::from_points(geom.start, geom.end)) { return None; } let (s, mut e) = (geom.start, geom.end); if s.distance(e) < 1e-9 { e = s + Vec2::new(1.0, 0.0); } Some( LinearGradient { x1: s.x as f32, y1: s.y as f32, x2: e.x as f32, y2: e.y as f32, transform: xf(space), spread_method: SpreadMethod::Pad, stops, anti_alias: false, } .into(), ) } GradientKind::Radial => { let r = geom.length().max(1e-6) as f32; let t = geom.radial_squash(); let (cx, cy) = (geom.start.x as f32, geom.start.y as f32); // An off-centre focal point: a two-point radial shading from it. let f = t.inverse() * geom.focal_point(); let circles = Rect::from_center_size(geom.start, (2.0 * f64::from(r), 2.0 * f64::from(r))).union_pt(f); if !writable(s, space * t, circles) { return None; } Some( RadialGradient { fx: f.x as f32, fy: f.y as f32, fr: 0.0, cx, cy, cr: r, transform: xf(space * t), spread_method: SpreadMethod::Pad, stops, anti_alias: false, } .into(), ) } // Painted areas get an image of the field ([`Self::area`]); elsewhere (gradient // slices) the average colour stands in. GradientKind::Freeform => { self.warn("freeform gradients are exported as their average colour"); let n = g.gradient.stops.len().max(1) as f32; let mut acc = [0.0f32; 3]; for s in &g.gradient.stops { let c = s.color.to_rgb(); for i in 0..3 { acc[i] += c[i] / n; } } Some(self.col(&Color::rgb(acc[0], acc[1], acc[2])).into()) } } } // Patterns paint through [`Self::area`]: only a missing one gets here. Paint::Pattern { .. } => { self.warn("missing patterns are exported as mid-grey"); Some(rgb::Color::new(128, 128, 128).into()) } } } /// Pattern swatch `name` placed by `at` (pattern space → the current space) as a PDF tiling /// pattern (ISO 32000-1, 8.7.3.1): one period of the tiling ([`vectorcraft_doc::PatternDef::period`], /// what the canvas repeats too) with every tile instance reaching into it, in paint order, so /// brick and hex tilings and overlapping tiles repeat as they are; clipped to the period when /// art reaches out of it. `None` for a pattern nested deeper than [`MAX_PATTERN_DEPTH`], a period /// or placement beyond [`MAX_COORD`] or a placement that flattens the tiles. pub(super) fn tiling(&mut self, s: &mut Surface, name: &str, at: Affine) -> Option { let doc = self.doc; let def = doc.pattern(name)?; let (pw, ph) = def.period(); if self.pattern_depth >= MAX_PATTERN_DEPTH || !in_range(&[pw, ph]) || !in_range(&at.as_coeffs()) || at.determinant().abs() <= 1e-12 { return None; } let cell = Rect::new(0.0, 0.0, pw, ph); let tiles = def.instances_in(Affine::IDENTITY, cell); let mut sb = s.stream_builder(); let mut cs = sb.surface(); let reaches_out = tiles.iter().any(|t| t.visual_bounds().is_some_and(|b| cell.intersect(b) != b)); let clip = reaches_out.then(|| to_path(&cs, &cell.to_path(0.1))).flatten(); if let Some(c) = &clip { cs.push_clip_path(c, &krilla::paint::FillRule::NonZero); } // Pattern art is a picture of its own: it takes no part in a knockout group around the object. let knockout = std::mem::take(&mut self.knockout); self.pattern_depth += 1; for t in &tiles { self.node(&mut cs, t, cell, true); } self.pattern_depth -= 1; self.knockout = knockout; if clip.is_some() { cs.pop(); } cs.finish(); Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into()) } /// Is stop `s` paper white ([`paper`]), unlinked or linked to a swatch that isn't a spot color /// (a white spot color is an ink of its own)? It adds no ink to a Separation or DeviceN shading. fn paper_stop(&self, s: &vectorcraft_color::GradientStop) -> bool { !s.swatch.as_deref().is_some_and(|n| self.doc.swatch(n).is_some_and(|w| w.spot)) && paper(&s.color) } /// The spot swatch whose tints are every stop of `g` that isn't paper white /// ([`Self::paper_stop`]); `None` when no stop is linked to a spot swatch or a stop has /// another color. fn one_spot_ink<'g>(&self, g: &'g vectorcraft_color::Gradient) -> Option<&'g str> { let spot = |s: &'g vectorcraft_color::GradientStop| s.swatch.as_deref().filter(|n| self.doc.swatch(n).is_some_and(|w| w.spot)); let ink = g.stops.iter().find_map(spot)?; g.stops.iter().all(|s| (spot(s) == Some(ink) && s.plates.is_none()) || self.paper_stop(s)).then_some(ink) } /// Can every stop of `g` be inks of a DeviceN space ([`Self::device_n_stops`]): is a stop /// linked to a spot swatch, and is every other stop paper white ([`Self::paper_stop`]) or /// written in CMYK or gray? fn device_n_inks_only(&self, g: &vectorcraft_color::Gradient) -> bool { let doc = self.doc; let cmyk_doc = doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; let spot = |s: &vectorcraft_color::GradientStop| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot)); let inks = |s: &vectorcraft_color::GradientStop| { self.paper_stop(s) || matches!(self.out.color(&s.color, self.intent, cmyk_doc, self.convert), Color::Cmyk { .. } | Color::Gray { .. }) }; g.stops.iter().any(spot) && g.stops.iter().all(|s| spot(s) || inks(s)) } /// Is `g` a Perceptual linear or radial gradient of spot inks, alone or mixed with process /// inks, that paints a fill (`fill`) without dither or a stroke, with a curve short enough for /// [`vectorcraft_color::Gradient::fits_ink_tints`]: one that [`Self::perceptual_spot_stops`] /// writes as a Separation shading, or [`Self::device_n_stops`] as a DeviceN shading, rather /// than an image? pub(super) fn perceptual_spot(&self, g: &vectorcraft_color::GradientPaint, fill: bool) -> bool { let gr = &g.gradient; !gr.method.is_classic() && gr.kind != GradientKind::Freeform && !(fill && gr.dither) && (self.one_spot_ink(gr).is_some() || self.device_n_inks_only(gr)) && gr.fits_ink_tints() } /// The stops of a Perceptual gradient of tints of one spot ink (or paper white, 0% of it) as /// tints of that ink, a Separation shading: the stops on its curve with the tints whose /// lightness is the curve's there ([`vectorcraft_color::Gradient::ink_tints`]). `None` for /// other gradients, and for an ink too light for lightness to tell its tints apart, which /// [`Self::spot_stops`] then writes with the stops' own tints. fn perceptual_spot_stops(&mut self, g: &vectorcraft_color::Gradient) -> Option> { if g.method.is_classic() { return None; } let ink = self.one_spot_ink(g)?; let doc = self.doc; let full = doc.linked_color(doc.swatch(ink)?.paint.color()?, true); let space = self.separation(ink)?; // The stops keep their own tints; paper white is 0% of the ink. let own: Vec = g.stops.iter().map(|s| if s.swatch.as_deref() == Some(ink) { s.tint } else { 0.0 }).collect(); Some(g.ink_tints(&full, &own)?.into_iter().map(|(o, t, a)| (o, SepColor::new_f32(t, space.clone()).into(), a)).collect()) } /// Warn when gradient `g`, drawn as an image, has stops linked to a spot swatch: the image is /// in process colors, so the spot inks have no plate of their own. fn warn_spot_image(&mut self, g: &vectorcraft_color::GradientPaint) { let doc = self.doc; let from_stops = g.gradient.kind != GradientKind::Freeform || !g.has_freeform_points(); if from_stops && g.gradient.stops.iter().any(|s| s.swatch.as_deref().is_some_and(|n| doc.swatch(n).is_some_and(|w| w.spot))) { self.warn( "spot colors in gradients exported as images (freeform or dithered gradients, and Perceptual ones with RGB or Lab stops or too many stops) are written in process colors", ); } } /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so /// scaled type gets its pixels too): a freeform gradient's colour field (at most /// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side), /// with dither's noise when it paints a fill (`fill`). pub(super) fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) { use vectorcraft_color::cms::Model; use vectorcraft_color::freeform::{painted_box, spread_scale}; let Some(b) = painted_box(bounds) else { return }; let area = area.abs(); // The area's sides in points on the page. let [m0, m1, m2, m3, _, _] = ctm(s).as_coeffs(); let (w, h) = (area.width() * m0.hypot(m1), area.height() * m2.hypot(m3)); let long = w.max(h); if !(long > 1e-9 && long.is_finite()) { return; } self.warn_spot_image(g); let freeform = g.gradient.kind == GradientKind::Freeform; let max = if freeform { MAX_FIELD_PX } else { MAX_GRADIENT_PX }; let k = (self.doc.raster_effects_ppi / 72.0).min(max / long); let along = |len: f64| (len * k).ceil().clamp(1.0, max) as u16; let (cols, rows) = (along(w), along(h)); let field = freeform.then(|| g.freeform_on(b).model_field(spread_scale(b))); let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk; let img = match &field { Some(field) if field.model() == Some(Model::Cmyk) || (cmyk_doc && field.model() == Some(Model::Gray)) => { let (mut inks, mut alpha) = (Vec::new(), Vec::new()); for (color, a) in field.grid(area, cols, rows) { let [c, m, y, k] = color.to_cmyk(); inks.extend([q(c), q(m), q(y), q(k)]); alpha.push(q(a)); } let convert = if self.convert { self.out.cmyk_pixels() } else { None }; crate::images::from_inks(inks, alpha, cols.into(), rows.into(), self.interpolate, convert) } _ => { let rgba: Vec = match &field { Some(field) => field.display_grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(), None => { self.warn("linear and radial gradients drawn as pixels are exported as images"); g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect() } }; let pixels = if self.convert { self.out.pixels() } else { None }; crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels) } }; let (Some(img), Some(size)) = (img, Size::from_wh(area.width() as f32, area.height() as f32)) else { return; }; s.push_transform(&xf(Affine::translate(area.origin().to_vec2()))); s.draw_image(img, size); s.pop(); } } /// Is `c` paper white: no ink in CMYK or gray, white in RGB or Lab? fn paper(c: &Color) -> bool { match *c { Color::Cmyk { c, m, y, k } => c <= 0.0 && m <= 0.0 && y <= 0.0 && k <= 0.0, Color::Gray { k } => k <= 0.0, _ => c.to_rgba8(1.0)[..3] == [255; 3], } }