diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index d663db702..712845186 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -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) { 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) { for (i, w) in words.iter().enumerate() { if *w == "scn" { let nums: Vec = 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()) } diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index cd65936bd..3302766df 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -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 = 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 = + 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::>>().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(); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index b3ad2eea6..e442c02d5 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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; diff --git a/crates/pdf/src/tests/mesh_export.rs b/crates/pdf/src/tests/mesh_export.rs new file mode 100644 index 000000000..cf8ad25e3 --- /dev/null +++ b/crates/pdf/src/tests/mesh_export.rs @@ -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); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 01064160b..cf2fbe39e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 |