Files
vectorcraft/crates/pdf/src/export/nodes.rs
T
Don Omondi 43db0c00bc PDF export: gradient meshes as smooth Type 6 patch shadings
PDF export (and the PDF-compatible .ai it writes) cut each gradient mesh
patch into 8 x 8 flat-coloured quads. Viewers showed colour bands and
hairline seams between them, and files with many meshes became very
large and slow to write.

An opaque mesh is now written as one Type 6 (Coons patch) shading
(ISO 32000-1, 8.7.4.5.7) filling its outline, through the PDF writer's
MeshGradient: each patch blends its corner colours bilinearly, as the
canvas draws it. Colours go through page_color as other fills do, and a
mesh mixing RGB with other models goes through RGB. A mesh with
see-through points, an invalid one, or one past the coordinate range
keeps the quads.
2026-10-30 08:15:20 +07:00

550 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::exporter::{address, blend, color, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable, xf};
use super::{
Affine, AppearanceItem, BezPath, BlendMode, Exporter, Fill, FillRule, LineCap, LineJoin, Mark, Node, NodeKind, NormalizedF32, Paint, Path, Rect,
Shape, Stroke, StrokeAlign, StrokeDash, StrokeLayer, Surface, WrittenShape, krilla, stroke,
};
impl Exporter<'_> {
/// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white;
/// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)).
fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> krilla::mask::Mask {
let mut sb = s.stream_builder();
let mut ms = sb.surface();
// An opaque backdrop when it matters: white outside the art (no clip), or black for the
// inverting Difference pass below to turn white.
if !m.clip || m.invert {
cover(&mut ms, page, if m.clip { 0 } else { 255 }, 1.0);
}
// Mask art is a picture of its own: it takes no part in a knockout group around the object,
// and has no inks to overprint.
let knockout = std::mem::take(&mut self.knockout);
let overprint = std::mem::take(&mut self.overprint);
self.node(&mut ms, &m.art, page, true);
(self.knockout, self.overprint) = (knockout, overprint);
if m.invert {
ms.push_blend_mode(krilla::blend::BlendMode::Difference);
cover(&mut ms, page, 255, 1.0);
ms.pop();
}
ms.finish();
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
}
/// The children of a group, as the elements of a knockout group when it is one.
pub(super) fn children(&mut self, s: &mut Surface, children: &[std::sync::Arc<Node>], page: Rect) {
if !self.knockout {
for c in children {
self.node(s, c, page, false);
}
return;
}
// The PDF writer has no knockout groups: each element is drawn through a soft mask of
// where the elements above it don't paint (the same look for Normal blending). The masks
// nest, so element i sits inside the masks of elements i+1…n: open them outermost first.
self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)");
let elements: Vec<_> = Node::knockout_elements(children)
.into_iter()
.filter(|c| c.visual_bounds_with(&self.symbols).is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page)))
.collect();
let Some((first, rest)) = elements.split_first() else { return };
for c in rest.iter().rev() {
let mask = self.knockout_mask(s, c, page);
s.push_mask(mask);
}
self.node(s, first, page, false);
for c in rest {
s.pop();
self.node(s, c, page, false);
}
}
/// A luminosity mask that is 1 − the knockout shape of `c`: white, then black through an alpha
/// mask of `c` (at full object opacity without its own mask, unless those define its shape).
fn knockout_mask(&mut self, s: &mut Surface, c: &Node, page: Rect) -> krilla::mask::Mask {
let shape = if c.knockout_shape {
c.clone()
} else {
let mut s = c.clone().with_mask(None);
s.opacity = 1.0;
s
};
let mut sb = s.stream_builder();
let mut ms = sb.surface();
cover(&mut ms, page, 255, 1.0);
let alpha = {
let mut ab = ms.stream_builder();
let mut als = ab.surface();
let overprint = std::mem::take(&mut self.overprint);
self.node(&mut als, &shape, page, false);
self.overprint = overprint;
als.finish();
krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha)
};
ms.push_mask(alpha);
cover(&mut ms, page, 0, 1.0);
ms.pop();
ms.finish();
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
}
pub(super) fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) {
if self.layers
&& self.in_layer != address(n)
&& let NodeKind::Layer { printable, .. } = n.kind
&& (printable || self.non_printing)
&& let Some(&i) = self.layer_index.get(&address(n))
{
// A PDF layer: hidden layers and sublayers are written too (their group is off).
let enclosing = std::mem::replace(&mut self.in_layer, address(n));
crate::forms::form(s, Mark::Layer(i), |s| self.node(s, n, page, true));
self.in_layer = enclosing;
return;
}
if !force && !n.visible {
return;
}
if let NodeKind::Layer { template, printable, .. } = n.kind
&& (template || !(printable || self.non_printing))
{
return;
}
match n.visual_bounds_with(&self.symbols) {
Some(b) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return,
None if !n.is_container() => return,
_ => {}
}
self.warn_raster(&n.appearance.effects, || format!("{} objects", n.kind_label().to_lowercase()));
let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. });
// Whether this container's children knock each other out (a knockout group is written as a group).
let knockout = n.knocks_out(self.knockout);
let enclosing = std::mem::replace(&mut self.knockout, knockout);
let mut pushes = 0;
if n.blend != BlendMode::Normal {
s.push_blend_mode(blend(n.blend));
pushes += 1;
}
if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) {
if !n.isolate && n.blends_through() {
// Not isolated: blending inside reaches the art below the group.
let alpha = constant_mask(s, page, n.opacity);
s.push_mask(alpha);
} else if n.opacity < 1.0 {
s.push_opacity(norm(n.opacity));
} else {
s.push_isolated();
}
pushes += 1;
}
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
let alpha = match (&n.kind, mask) {
(NodeKind::Image(im), Some(m)) => self.masked_image(im, m, !n.extras().pixelated),
_ => None,
};
if let Some(m) = mask
&& alpha.is_none()
{
let mask = self.soft_mask(s, m, page);
s.push_mask(mask);
pushes += 1;
}
match &n.kind {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => self.children(s, children, page),
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// The region every output clips to; nothing to clip by hides the clipped art. The
// clipping path's fill paints behind the clipped art and its stroke over it, unclipped.
if let Some((clip, rest)) = children.split_first()
&& let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(s, &bp).map(|p| (p, r)))
{
let paint = clip.clip_paint();
s.push_clip_path(&p, &rule(r));
if let Some(fill) = &paint.fill {
self.node(s, fill, page, false);
}
self.children(s, rest, page);
s.pop();
if let Some(stroke) = &paint.stroke {
self.node(s, stroke, page, false);
}
}
}
NodeKind::Path { path, rule, guide, .. } => {
if !*guide {
self.shape(s, n, &path.to_bezpath(), *rule, page);
}
}
NodeKind::Compound { children, rule } => {
let mut bp = BezPath::new();
for c in children {
if let Some(p) = c.path_data() {
bp.extend(p.to_bezpath());
}
}
self.shape(s, n, &bp, *rule, page);
}
NodeKind::Text(t) => self.text(s, n, t, page),
NodeKind::Image(im) => {
let colorized = n.extras().colorize.as_deref().and_then(|p| self.colorized_image(im, p, !n.extras().pixelated));
self.image(s, im, colorized.or(alpha), n.extras().pixelated)
}
NodeKind::SymbolInstance { symbol, xf } => {
// Within the objects a frame may draw of symbol instances, as on screen: nested
// instances multiply.
let sym = self.doc.symbols.iter().find(|x| &x.name == symbol);
let total = sym.map_or(0, |sym| self.symbol_nodes.saturating_add(sym.art.count() as u64));
if let Some(sym) = sym.filter(|_| total <= vectorcraft_doc::MAX_SYMBOL_NODES) {
self.symbol_nodes = total;
let mut art = (*sym.art).clone();
art.transform(*xf, false);
self.node(s, &art, page, true);
}
}
// A gradient mesh is a smooth patch shading where it can be written as one.
NodeKind::Mesh(m) if self.mesh(s, m) => {}
// Live blends/envelopes/meshes export their evaluated (expanded) form.
NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
| NodeKind::Mesh(_)
| NodeKind::Repeat(_)
| NodeKind::PlacedDocument(_)
| NodeKind::Intertwine { .. } => {
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
for c in g.children().into_iter().flatten() {
self.node(s, c, page, false);
}
}
}
self.knockout = enclosing;
for _ in 0..pushes {
s.pop();
}
}
/// Gradient mesh `m` as one PDF Type 6 (Coons patch) shading filling its outline (ISO 32000-1,
/// 8.7.4.5.7): each patch blends its corner colours bilinearly, as the canvas draws it, and
/// the viewer or RIP does so smoothly at its own resolution. Cutting the mesh into flat quads
/// instead banded, left seams between them, and made files with many meshes very large.
/// Corner colours are written as [`Self::page_color`] writes any colour; RGB among other
/// models goes through RGB, as the writer needs one colour space a shading (ink-based ones:
/// CMYK and grey, merge). `false` when it isn't written so: the caller draws it expanded.
/// That is a mesh with see-through points (their opacities would be a soft mask of another
/// patch shading, which a common viewer, Poppler, draws with gaps and too opaque; the quads
/// carry their own opacity), an invalid mesh, or one beyond the coordinates a file holds.
fn mesh(&mut self, s: &mut Surface, m: &vectorcraft_doc::live::GradientMesh) -> bool {
if !m.is_valid() || !m.points.iter().all(|p| p.opacity >= 1.0) {
return false;
}
let Some(bounds) = m.bounds() else { return false };
if !writable(s, Affine::IDENTITY, bounds) {
return false;
}
let Some(path) = to_path(s, &m.outline().to_bezpath()) else { return false };
let page: Vec<vectorcraft_color::Color> = m.points.iter().map(|p| self.page_color(&p.color)).collect();
let mixed = page.windows(2).any(|w| w[0].model() != w[1].model());
let rgb = page.iter().any(|c| matches!(c, vectorcraft_color::Color::Rgb { .. } | vectorcraft_color::Color::Lab { .. }));
let colors: Vec<krilla::color::Color> =
page.iter().map(|c| if mixed && rgb { color(&c.in_model(vectorcraft_color::cms::Model::Rgb)) } else { color(c) }).collect();
let pt = |p: vectorcraft_geom::Point| (p.x as f32, p.y as f32);
let mut patches = Vec::with_capacity(m.rows as usize * m.cols as usize);
for r in 0..m.rows as usize {
for c in 0..m.cols as usize {
// The corners in the order a Type 6 shading lists them: down the left edge (v),
// along the bottom (u), back up the right edge and back along the top.
let corners = [m.idx(r, c), m.idx(r + 1, c), m.idx(r + 1, c + 1), m.idx(r, c + 1)];
let (left, bottom, right, top) = (m.edge_v(r, c), m.edge_u(r + 1, c), m.edge_v(r, c + 1), m.edge_u(r, c));
let points =
[left.p0, left.p1, left.p2, left.p3, bottom.p1, bottom.p2, bottom.p3, right.p2, right.p1, right.p0, top.p2, top.p1].map(pt);
let Some(colors) =
corners.iter().map(|&i| colors.get(i).cloned()).collect::<Option<Vec<_>>>().and_then(|v| <[_; 4]>::try_from(v).ok())
else {
return false;
};
patches.push(krilla::paint::MeshPatch { points, colors, opacities: [NormalizedF32::ONE; 4] });
}
}
let mesh = krilla::paint::MeshGradient { patches, transform: xf(Affine::IDENTITY), anti_alias: false };
s.set_stroke(None);
s.set_fill(Some(Fill { paint: mesh.into(), opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
s.set_fill(None);
true
}
fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) {
let Some(path) = to_path(s, bp) else { return };
let bounds = bp.bounding_box();
for item in &n.appearance.items {
match item {
AppearanceItem::Fill(fl) => {
if !fl.visible || fl.paint.is_none() {
continue;
}
self.warn_raster(&fl.effects, || "fills".into());
self.overprinting(s, fl.overprint, &fl.paint, |ex, s| {
if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds, true) {
return;
}
let Some(paint) = ex.paint(s, &fl.paint, bounds) else { return };
let bl = fl.blend != BlendMode::Normal;
if bl {
s.push_blend_mode(blend(fl.blend));
}
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) }));
s.draw_path(&path);
if bl {
s.pop();
}
});
}
AppearanceItem::Stroke(st) => {
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
continue;
}
self.warn_raster(&st.effects, || "strokes".into());
self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, bp, &path, r, st, page, bounds));
}
}
}
s.set_fill(None);
s.set_stroke(None);
}
/// Draw a fill or stroke of `paint` with `draw`: as a form marked to overprint when it
/// `overprints`, paints and overprints are kept (but not a white one with Discard White
/// Overprint).
pub(super) fn overprinting(&mut self, s: &mut Surface, overprints: bool, paint: &Paint, draw: impl FnOnce(&mut Self, &mut Surface)) {
let white = || self.doc.setup.discard_white_overprint && vectorcraft_doc::overprint::is_white(paint);
if overprints && self.overprint && !paint.is_none() && !white() {
self.overprinted = true;
crate::forms::form(s, Mark::Overprint, |s| draw(self, s));
} else {
draw(self, s);
}
}
/// Is `p` a paint PDF has no paint for when it paints a fill (`fill`) or a stroke, which
/// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels
/// ([`vectorcraft_color::GradientPaint::draws_as_pixels`])?
pub(super) fn area_paint(&self, p: &Paint, fill: bool) -> bool {
match p {
Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(),
Paint::Gradient(g) => g.draws_as_pixels(fill),
_ => false,
}
}
/// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity,
/// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a
/// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), or an image
/// of a gradient drawn as pixels placed on `bounds` (the painted object's box, as on the
/// canvas), clipped to it. False (nothing drawn) for other paints.
#[allow(clippy::too_many_arguments)]
pub(super) fn area(
&mut self,
s: &mut Surface,
clip: &Path,
rule: krilla::paint::FillRule,
p: &Paint,
transparency: (f32, BlendMode),
bounds: Rect,
area: Rect,
fill: bool,
) -> bool {
if !self.area_paint(p, fill) {
return false;
}
let (opacity, mode) = transparency;
let tiling = match p {
Paint::Pattern { pattern, xf } => self.tiling(s, pattern, *xf),
_ => None,
};
let mut pushes = 0;
if mode != BlendMode::Normal {
s.push_blend_mode(blend(mode));
pushes += 1;
}
if let Some(paint) = tiling {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(opacity), rule }));
s.draw_path(clip);
s.set_fill(None);
} else {
s.push_clip_path(clip, &rule);
pushes += 1;
if opacity < 1.0 {
s.push_opacity(norm(opacity));
pushes += 1;
}
let doc = self.doc;
match p {
Paint::Pattern { pattern, xf } => {
if let Some(def) = doc.pattern(pattern) {
self.warn("pattern fills nested more than 4 patterns deep, or with tiles too large for PDF, are written as their tiles");
for inst in def.instances_in(*xf, area) {
self.node(s, &inst, area, true);
}
}
}
Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill),
_ => {}
}
}
for _ in 0..pushes {
s.pop();
}
true
}
/// Paint stroke `st` of the shape `bp` (`path`), whose geometric bounds `bounds` place its
/// unplaced gradients.
#[allow(clippy::too_many_arguments)]
pub(super) fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, page: Rect, bounds: Rect) {
if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) {
return;
}
if self.area_paint(&st.paint, false) && st.path_gradient().is_none() {
// The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as
// Outline Stroke makes it, painted with the pattern's tiles or the gradient's image (a
// gradient along or across the stroke is written as slices below).
let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath();
if let Some(clip) = to_path(s, &region) {
let area = region.bounding_box();
self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area, false);
}
return;
}
let Some(paint) = self.paint(s, &st.paint, st.paint_bounds(bounds)) else { return };
let w = stroke::for_writer(bp, st);
// A stroke whose width, caps or miter spikes reach beyond [`MAX_COORD`] is left out.
if !writable(s, Affine::IDENTITY, w.reach(st, bp.control_box()).inflate(1.0, 1.0)) {
return;
}
let mut pushes = 0;
if st.blend != BlendMode::Normal {
s.push_blend_mode(blend(st.blend));
pushes += 1;
}
match w.side {
Some(StrokeAlign::Inside) => {
s.push_clip_path(path, &rule(r));
pushes += 1;
}
Some(StrokeAlign::Outside) => {
// Clip to everything outside the path: a frame around all the stroke reaches
// (miter spikes included) plus the path, even-odd.
let mut outside = w.reach(st, bounds).inflate(1.0, 1.0).to_path(0.1);
outside.extend(bp.iter());
if let Some(p) = to_path(s, &outside) {
s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd);
pushes += 1;
}
}
_ => {}
}
match &w.shape {
WrittenShape::Stroke { width } => {
let dash = st.dash.as_ref().filter(|d| d.is_dashed()).map(|d| {
let mut pat: Vec<f32> = d.pattern.iter().map(|v| *v as f32).collect();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
// An offset beyond [`MAX_COORD`] is written as its remainder after whole
// periods of the pattern.
let period: f64 = pat.iter().copied().map(f64::from).sum();
let offset = if in_range(&[d.offset]) { d.offset } else { d.offset.rem_euclid(period) };
StrokeDash { array: pat, offset: if offset.is_finite() { offset as f32 } else { 0.0 } }
});
s.set_fill(None);
s.set_stroke(Some(Stroke {
paint,
width: *width as f32,
miter_limit: st.miter_limit.max(1.0) as f32,
line_cap: match st.cap {
LineCap::Butt => krilla::paint::LineCap::Butt,
LineCap::Round => krilla::paint::LineCap::Round,
LineCap::Square => krilla::paint::LineCap::Square,
},
line_join: match st.join {
LineJoin::Miter => krilla::paint::LineJoin::Miter,
LineJoin::Round => krilla::paint::LineJoin::Round,
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
},
opacity: norm(st.opacity),
dash,
}));
s.draw_path(path);
s.set_stroke(None);
}
WrittenShape::Fill(outlines) if st.path_gradient().is_some() => {
// A gradient along or across the stroke: slices clipped to its outlines, under
// the stroke's opacity as a group.
if let Some(ws) = stroke::written_slices(bp, r, st, outlines)
&& let Some(clip) = to_path(s, &ws.clip)
{
self.warn("gradients along or across strokes are exported as slices of linear gradients");
if st.opacity < 1.0 {
s.push_opacity(norm(st.opacity));
pushes += 1;
}
s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero);
s.set_stroke(None);
for (shape, paint) in &ws.slices {
let b = shape.bounding_box();
if let (Some(paint), Some(p)) = (self.paint(s, paint, b), to_path(s, shape)) {
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&p);
}
}
s.set_fill(None);
s.pop();
}
}
WrittenShape::Fill(outlines) => {
// The line and its arrowheads overlap: they take the opacity once, as a group.
let grouped = outlines.len() > 1 && st.opacity < 1.0;
if grouped {
s.push_opacity(norm(st.opacity));
pushes += 1;
}
let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) };
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero }));
for outline in outlines {
if let Some(p) = to_path(s, outline) {
s.draw_path(&p);
}
}
s.set_fill(None);
}
}
for _ in 0..pushes {
s.pop();
}
}
/// Paint stroke `st` of the shape `bp` with its brush art (the stroke's opacity and blend mode
/// over all of it). False when it has no known brush.
fn brush_art(&mut self, s: &mut Surface, bp: &BezPath, st: &StrokeLayer, page: Rect) -> bool {
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false };
let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st);
let mut pushes = 0;
if st.blend != BlendMode::Normal {
s.push_blend_mode(blend(st.blend));
pushes += 1;
}
if st.opacity < 1.0 {
s.push_opacity(norm(st.opacity));
pushes += 1;
}
for piece in &art {
self.node(s, piece, page, false);
}
for _ in 0..pushes {
s.pop();
}
true
}
}