mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
Merge pull request #4653 from mockydevs/fix/pdf-export-mesh-shading
PDF export: gradient meshes as smooth Type 6 patch shadings
This commit is contained in:
@@ -1224,7 +1224,9 @@ fn cmyk_mesh(create: Value) -> (Session, NodeId) {
|
||||
(s, a)
|
||||
}
|
||||
|
||||
/// The fill colours `document.exportPdf {standard: pdfX4}` writes (`… scn` operands), and its warnings.
|
||||
/// The fill colours `document.exportPdf {standard: pdfX4}` writes (`… scn` operands, and the
|
||||
/// corner colours of its gradient meshes, which are patch shadings, as PDF import reads them
|
||||
/// back), and its warnings.
|
||||
fn pdf_fill_colours(s: &mut Session) -> (Vec<Vec<f32>>, Vec<String>) {
|
||||
let v = s.execute("document.exportPdf", &json!({"standard": "pdfX4", "compression": {"compressText": false}})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
@@ -1235,10 +1237,21 @@ fn pdf_fill_colours(s: &mut Session) -> (Vec<Vec<f32>>, Vec<String>) {
|
||||
for (i, w) in words.iter().enumerate() {
|
||||
if *w == "scn" {
|
||||
let nums: Vec<f32> = words[..i].iter().rev().map_while(|x| x.parse().ok()).collect();
|
||||
out.push(nums.into_iter().rev().collect());
|
||||
// A pattern (`/P0 scn`) has no colour of its own.
|
||||
if !nums.is_empty() {
|
||||
out.push(nums.into_iter().rev().collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
vectorcraft_pdf::import(&bytes).unwrap().walk(|n| {
|
||||
if let NodeKind::Mesh(m) = &n.kind {
|
||||
out.extend(m.points.iter().map(|p| match p.color {
|
||||
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
|
||||
c => c.to_rgb().to_vec(),
|
||||
}));
|
||||
}
|
||||
});
|
||||
(out, serde_json::from_value(v["warnings"].clone()).unwrap())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::exporter::{address, blend, constant_mask, cover, in_range, norm, rects_overlap, rule, to_path, writable};
|
||||
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,
|
||||
@@ -198,6 +198,8 @@ impl Exporter<'_> {
|
||||
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 { .. }
|
||||
@@ -217,6 +219,56 @@ impl Exporter<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
@@ -29,6 +29,7 @@ mod import_overprint;
|
||||
mod layers;
|
||||
pub(super) mod live_text;
|
||||
mod marks;
|
||||
mod mesh_export;
|
||||
mod output;
|
||||
mod pdf13;
|
||||
mod pdfx;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Gradient meshes written as PDF Type 6 (Coons patch) shadings (ISO 32000-1, 8.7.4.5.7): the
|
||||
//! viewer blends each patch's corner colours smoothly, instead of the mesh being cut into small
|
||||
//! flat-coloured paths that band, show seams and make the file large.
|
||||
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::live::{GradientMesh, MeshAppearance};
|
||||
use vectorcraft_doc::{ColorMode, Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A 200 × 200 pt document with a 2 × 2 mesh over (20, 20)–(180, 180), its nine points coloured
|
||||
/// by `color(row, col)` and made `opacity` opaque.
|
||||
fn doc(color: impl Fn(usize, usize) -> Color, opacity: f32) -> Document {
|
||||
let mut m = GradientMesh::for_path(&shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 180.0)), 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0)
|
||||
.expect("a mesh");
|
||||
for r in 0..=2 {
|
||||
for c in 0..=2 {
|
||||
let i = m.idx(r, c);
|
||||
m.points[i].color = color(r, c);
|
||||
m.points[i].opacity = opacity;
|
||||
}
|
||||
}
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Mesh(m))).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn rgb(r: usize, c: usize) -> Color {
|
||||
Color::rgb(r as f32 / 2.0, c as f32 / 2.0, 1.0 - (r + c) as f32 / 4.0)
|
||||
}
|
||||
|
||||
/// How many meshes and paths `d` holds.
|
||||
fn kinds(d: &Document) -> (usize, usize) {
|
||||
let (mut meshes, mut paths) = (0, 0);
|
||||
d.walk(|n| match n.kind {
|
||||
NodeKind::Mesh(_) => meshes += 1,
|
||||
NodeKind::Path { .. } => paths += 1,
|
||||
_ => {}
|
||||
});
|
||||
(meshes, paths)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_mesh_is_written_as_a_smooth_patch_shading() {
|
||||
let d = doc(rgb, 1.0);
|
||||
let bytes = export(&d, &PdfOptions::default()).unwrap();
|
||||
let back = import(&bytes).unwrap();
|
||||
// The shading comes back as a mesh, not as the 256 flat quads 8 × 8 a patch made.
|
||||
let (meshes, paths) = kinds(&back);
|
||||
assert!(meshes >= 1 && paths <= 4, "meshes {meshes}, paths {paths}");
|
||||
let mut corners = vec![];
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Mesh(m) = &n.kind {
|
||||
corners.extend(m.points.iter().map(|p| p.color.to_rgb()));
|
||||
}
|
||||
});
|
||||
for want in [rgb(0, 0), rgb(2, 2), rgb(1, 1)] {
|
||||
let want = want.to_rgb();
|
||||
assert!(corners.iter().any(|c| c.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.02)), "{want:?} in {corners:?}");
|
||||
}
|
||||
assert_similar(&render_artboard(&d), &render_artboard(&back), 6.0, 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_mesh_is_written_smooth_in_its_inks() {
|
||||
let cmyk = |r: usize, c: usize| Color::Cmyk { c: r as f32 / 2.0, m: c as f32 / 2.0, y: 0.2, k: 0.0 };
|
||||
let mut d = doc(cmyk, 1.0);
|
||||
d.color_mode = ColorMode::Cmyk;
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let (meshes, paths) = kinds(&back);
|
||||
assert!(meshes >= 1 && paths <= 4, "meshes {meshes}, paths {paths}");
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Mesh(m) = &n.kind {
|
||||
assert!(m.points.iter().all(|p| matches!(p.color, Color::Cmyk { .. })), "{:?}", m.points);
|
||||
}
|
||||
});
|
||||
assert_similar(&render_artboard(&d), &render_artboard(&back), 6.0, 0.02);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_see_through_mesh_is_still_written_as_quads() {
|
||||
// Its opacities would need a soft mask of a second patch shading; the quads carry their own.
|
||||
let d = doc(rgb, 0.5);
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
assert_eq!(kinds(&back).0, 0);
|
||||
// The seams between quads, as before.
|
||||
assert_similar(&render_artboard(&d), &render_artboard(&back), 6.0, 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mesh_of_mixed_colour_models_still_exports() {
|
||||
// RGB, grey and CMYK points: the PDF writer needs one colour space a shading.
|
||||
let mixed = |r: usize, c: usize| match (r + c) % 3 {
|
||||
0 => Color::rgb(1.0, 0.2, 0.2),
|
||||
1 => Color::gray(0.4),
|
||||
_ => Color::Cmyk { c: 0.8, m: 0.0, y: 0.3, k: 0.0 },
|
||||
};
|
||||
let d = doc(mixed, 1.0);
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
assert!(kinds(&back).0 >= 1);
|
||||
assert_similar(&render_artboard(&d), &render_artboard(&back), 8.0, 0.02);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: a file whose PDF part is an empty page opens from its editing data) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (PDF export: opaque gradient meshes as smooth Type 6 patch shadings) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
|
||||
@@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | PDF export: an opaque gradient mesh is written as one PDF Type 6 (Coons patch) shading (ISO 32000-1, 8.7.4.5.7) filling its outline, so viewers blend its colours smoothly instead of showing the bands and seams of the 8 × 8 flat quads a patch it was cut into; a file of 65,558 small meshes exports in 14 s instead of 56 s, at half the size. A mesh with see-through points is still written as quads. Own-fixture tests in `tests::mesh_export`; scores unchanged |
|
||||
| 2026-10-30 | minor | `.ai` import: a file whose PDF part is an empty page opens from its editing data where that reads, instead of opening blank; scores unchanged |
|
||||
| 2026-10-30 | minor | `.ai` import: a file whose PDF part's pages are cropped to its art rather than its artboards no longer comes in as that PDF part (each page a copy of all the art) but from its layers, with its artboards, where they draw as the page does; own-fixture test in `tests_aiimport`; scores unchanged |
|
||||
| 2026-10-29 | minor | `.ai` and Illustrator EPS import: the editing data is read up to an eighth of installed memory, from 512 MiB to 2 GiB on 64-bit builds (it was 512 MiB on every machine), so a layered file whose embedded rasters unpack past 512 MiB keeps its layers, groups and text on a machine with 8 GiB or more instead of opening as its page or PDF part (#3510, #4628). Measured: 1.1 GB of editing data peaked 1.1 GB above the start; data past the limit is still refused (a 2 MB file unpacking to 2.2 GB stops in under a second). 32-bit and web builds keep 256 MiB. Own-fixture tests `import::ai::tests::editing_data_past_512_mib_is_read_where_memory_allows`, `the_editing_data_limit_follows_installed_memory`; scores and hours unchanged |
|
||||
|
||||
Reference in New Issue
Block a user