From 75e8f69734f107a5223872279c631c20ff6b793f Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:46:28 +0300 Subject: [PATCH 1/2] PDF import: large mesh shadings and mesh-painted strokes come in as gradient meshes Patch and triangle mesh shadings of more than 4,096 patches, and strokes painted with a mesh shading, came in as a flat grey. Illustrator writes meshes of up to 14,355 patches, and writes a freeform gradient on a stroke as a stroke painted with a type 7 shading. - MAX_PATCHES is 16,384, as in the .ai editing-data reader. - A stroke painted with a mesh shading is the meshes clipped to the area the stroke paints (stroke_outline: width, caps, joins, miter limit and dashes, built in the path's space and then transformed); draw_meshes always clips a stroke's meshes. --- crates/pdf/src/import/builder.rs | 7 +-- crates/pdf/src/import/device.rs | 24 +++++++--- crates/pdf/src/import/state.rs | 21 +++++++++ crates/pdf/src/import_shading.rs | 4 +- crates/pdf/src/tests/import_fidelity.rs | 62 +++++++++++++++++++++---- docs/file-format-parity.md | 1 + 6 files changed, 100 insertions(+), 19 deletions(-) diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 853262bb5..15a05c472 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -661,8 +661,9 @@ impl<'p> Builder<'p> { } /// Mesh shadings filling `region` (document space): gradient meshes, clipped to the region - /// when they reach past it. - pub(super) fn draw_meshes(&mut self, meshes: Vec, region: &BezPath, opacity: f32) { + /// when they reach past it, or always (`clip`: a stroke's outline, which a mesh can lie + /// within the box of without filling it). + pub(super) fn draw_meshes(&mut self, meshes: Vec, region: &BezPath, opacity: f32, clip: bool) { let nodes: Vec> = meshes.into_iter().map(|m| Arc::new(Node::new(self.id(), NodeKind::Mesh(m)))).collect(); let Some(art) = bounds(&nodes) else { return }; let mut content = match <[Arc; 1]>::try_from(nodes) { @@ -670,7 +671,7 @@ impl<'p> Builder<'p> { Err(all) => Node::group(self.id(), all), }; content.opacity *= opacity; - if !contains(region.bounding_box().inflate(0.5, 0.5), art) { + if clip || !contains(region.bounding_box().inflate(0.5, 0.5), art) { let clip = self.clip_node(region, FillRule::NonZero); content = clipped(clip, content, self.id()); } diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index 60b00fb2e..3338124ad 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -1,6 +1,7 @@ use super::entry::{INVISIBLE_TEXT_NOTE, MAX_NESTED, OUTLINED_TEXT_NOTE, UNSORTED, WHITE_KEY}; use super::state::{ Builder, Decoded, Frame, FrameKind, GlyphRun, blend, fill_rule, line_width, mean_scale, resize_alpha, rgba_png, same_outline, stroke_layer, + stroke_outline, }; use super::{ Affine, Appearance, AppearanceItem, Arc, BezPath, BlendMode, ClipPath, Device, FillLayer, FillRule, Glyph, GlyphDrawMode, Image, ImageBlob, @@ -52,12 +53,23 @@ impl<'a> Device<'a> for Builder<'_> { }, craft_pdf::interpret::Paint::Color(_) => None, }; - // Patch and triangle meshes are gradient meshes. - if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading) - && let Some(meshes) = mesh_shading(sp, &mut self.colors, walked.and_then(|w| self.scan.ink(w, false, true))) - { - self.draw_meshes(meshes, &bp, sp.opacity); - return; + // Patch and triangle meshes are gradient meshes; a stroke's, clipped to the stroke's outline + // (as Illustrator writes a freeform gradient on a stroke). + if let Some(sp) = shading { + let stroke = match draw_mode { + PathDrawMode::Stroke(p) => Some(p), + PathDrawMode::Fill(_) => None, + }; + if let Some(meshes) = mesh_shading(sp, &mut self.colors, walked.and_then(|w| self.scan.ink(w, stroke.is_some(), true))) { + match stroke { + None => return self.draw_meshes(meshes, &bp, sp.opacity, false), + Some(p) => { + if let Some(outline) = stroke_outline(path, transform, p) { + return self.draw_meshes(meshes, &outline, sp.opacity, true); + } + } + } + } } let pd = PathData::from_bezpath(&bp); let stroke = matches!(draw_mode, PathDrawMode::Stroke(_)); diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index ee3d2a5d3..e5011e0ca 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -246,6 +246,27 @@ pub(super) fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f /// The most entries of a dash array kept (an even count keeps it a pattern of dashes and gaps). const MAX_DASH_ENTRIES: usize = 64; +/// The area stroke `p` paints along `path` (in its own space, before `transform`), in the document: +/// where a shading painting the stroke shows. Width, caps, joins and dashes as [`stroke_layer`] +/// takes them. `None` when it paints nothing. +pub(super) fn stroke_outline(path: &BezPath, transform: Affine, p: &StrokeProps) -> Option { + let scale = mean_scale(transform); + if !(scale.is_finite() && scale > 1e-9) { + return None; + } + let mut style = + kurbo::Stroke::new(line_width(p, scale) / scale).with_join(p.line_join).with_caps(p.line_cap).with_miter_limit(p.miter_limit as f64); + let dashes: Vec = p.dash_array.iter().take(MAX_DASH_ENTRIES).map(|v| *v as f64).collect(); + let dash = Dash { pattern: dashes.clone(), offset: p.dash_offset as f64, align_corners: false }; + if dash.is_dashed() { + style = style.with_dashes(dash.offset, dashes); + } + // To a hundredth of a point in the document. + let mut out = kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.01 / scale); + out.apply_affine(transform); + (!out.elements().is_empty()).then_some(out) +} + pub(crate) fn round3(v: f32) -> f32 { (v * 1000.0).round() / 1000.0 } diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index 1d2bc9cca..a8a8b25ea 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -21,8 +21,8 @@ use crate::import_color::{Colors, Native}; use crate::import_mask::{contains, is_rectangle, luminance}; /// The most mesh patches (or triangles) imported per shading: past it, the shading is a flat -/// colour. -const MAX_PATCHES: usize = 4096; +/// colour. (Illustrator writes meshes of up to 14,355 in files users sent.) +const MAX_PATCHES: usize = 16_384; /// An axial or radial shading as a gradient paint, with whether it extends past its start and /// its end. `inks`: the colorant names of its space, from the page's walk ([`Colors::solid`]). diff --git a/crates/pdf/src/tests/import_fidelity.rs b/crates/pdf/src/tests/import_fidelity.rs index 9f5b98dfb..25d84735a 100644 --- a/crates/pdf/src/tests/import_fidelity.rs +++ b/crates/pdf/src/tests/import_fidelity.rs @@ -395,10 +395,12 @@ fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02X}")).collect::>().join("") + ">" } -#[test] -fn a_coons_patch_mesh_becomes_a_gradient_mesh() { - // One patch over (10, 10)–(90, 90): corner colours red (0, 0), green (0, 1), blue (1, 1), - // white (1, 0) in u/v. +/// A Coons patch mesh shading's dictionary for [`coons_patch`] data. +const COONS_MESH: &str = "/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode"; + +/// One patch over (10, 10)–(90, 90): corner colours red (0, 0), green (0, 1), blue (1, 1), white +/// (1, 0) in u/v. +fn coons_patch() -> Vec { let pts: [(u8, u8); 12] = [(10, 10), (10, 37), (10, 63), (10, 90), (37, 90), (63, 90), (90, 90), (90, 63), (90, 37), (90, 10), (63, 10), (37, 10)]; let mut data = vec![0u8]; @@ -406,11 +408,13 @@ fn a_coons_patch_mesh_becomes_a_gradient_mesh() { data.extend([x, y]); } data.extend([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]); + data +} + +#[test] +fn a_coons_patch_mesh_becomes_a_gradient_mesh() { let s = first_extra(1); - let sh = stream( - "/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode", - &hex(&data), - ); + let sh = stream(COONS_MESH, &hex(&coons_patch())); let d = open(&one_page("/Sh0 sh", &format!("/Shading << /Sh0 {s} 0 R >>"), &[&sh])); let meshes: Vec = all(&d).into_iter().filter_map(|n| if let NodeKind::Mesh(m) = n.kind { Some(m) } else { None }).collect(); @@ -427,6 +431,48 @@ fn a_coons_patch_mesh_becomes_a_gradient_mesh() { assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })), "the mesh lies inside the page"); } +/// Illustrator writes meshes of many thousands of patches (14,355 in one of a user's files): one +/// of 5,000 comes in as gradient meshes, not a flat grey. +#[test] +fn a_mesh_shading_of_thousands_of_patches_becomes_gradient_meshes() { + let data: Vec = std::iter::repeat_n(coons_patch(), 5000).flatten().collect(); + let s = first_extra(1); + let sh = stream(COONS_MESH, &hex(&data)); + let r = open_with(&one_page("/Sh0 sh", &format!("/Shading << /Sh0 {s} 0 R >>"), &[&sh]), |_| {}); + assert!(!r.warnings.iter().any(|w| w.contains("flat grey")), "{:?}", r.warnings); + assert_eq!(all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Mesh(_))).count(), 5000); +} + +/// A stroke painted with a mesh shading (how Illustrator writes a freeform gradient on a stroke) +/// is the mesh clipped to the area the stroke paints, dashes included, not a flat grey. +#[test] +fn a_stroke_painted_with_a_mesh_shading_is_the_mesh_clipped_to_the_stroke() { + let s = first_extra(1); + let sh = stream(COONS_MESH, &hex(&coons_patch())); + let resources = format!("/Pattern << /P0 << /PatternType 2 /Shading {s} 0 R >> >>"); + // A 10 pt line from (20, 50) to (80, 50) with butt caps: page y 45 to 55. Dashed 10 on, 10 + // off: pieces at 20–30, 40–50 and 60–70. + for (dash, pieces, end) in [("[] 0 d", 1, 80.0), ("[10 10] 0 d", 3, 70.0)] { + let content = format!("/Pattern CS /P0 SCN 10 w {dash} 20 50 m 80 50 l S"); + let r = open_with(&one_page(&content, &resources, &[&sh]), |_| {}); + assert!(!r.warnings.iter().any(|w| w.contains("flat grey")), "{:?}", r.warnings); + let nodes = all(&r.document); + assert_eq!(nodes.iter().filter(|n| matches!(n.kind, NodeKind::Mesh(_))).count(), 1, "{dash}"); + let clip = nodes + .iter() + .find_map(|n| match &n.kind { + NodeKind::Group { children, clip: true } => children.first().cloned(), + _ => None, + }) + .expect("a clip group"); + let NodeKind::Path { path, .. } = &clip.kind else { panic!("{:?}", clip.kind) }; + let b = path.bounds().unwrap(); + assert!((b.x0 - 20.0).abs() < 0.01 && (b.x1 - end).abs() < 0.01 && (b.y0 - 45.0).abs() < 0.01 && (b.y1 - 55.0).abs() < 0.01, "{dash}: {b:?}"); + let subpaths = path.to_bezpath().elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count(); + assert_eq!(subpaths, pieces, "{dash}"); + } +} + #[test] fn gradient_stop_opacity_round_trips() { let mut d = Document::new(100.0, 100.0); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e7f7d8e1a..45eb9f834 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 import: a patch or triangle mesh shading of up to 16,384 patches comes in as gradient meshes (it was 4,096; Illustrator writes up to 14,355 in files users sent), and a stroke painted with one (how Illustrator writes a freeform gradient on a stroke) is the meshes clipped to the area the stroke paints, with its width, caps, joins and dashes; both came in as a flat grey. In two files of a user's set, the formerly grey area now matches a reference rendering in 99% of its pixels. Own-fixture tests in `tests::import_fidelity`; scores unchanged | | 2026-10-30 | minor | `.ai`/EPS editing data: gradient meshes the reader marked unread, which made the whole file come in as its page or PDF part, are read: a mesh starts grey and its `/CS` is written only where it changes, before the corner it applies to (within a patch too), so each corner takes the space last set before it; named colours (`[c m y k /DeviceCMYK (name) /Process]` or `/Spot`) paint at the corner's tint and `/NChannel` inks overprint at theirs; meshes up to 128 patches a side and 16,384 patches are read (files users sent had up to 97 × 72). Ten of eleven such files from a user's set now open from their layers; meshes with handle weights other than 1 still don't. Own-fixture tests in `import::ai::mesh`; scores unchanged | | 2026-10-30 | minor | `.ai` import: a placed (linked) file is put where its matrix takes the canvas point of art space's origin, and, when the layers then don't draw as the page, at the corner of the box it is written with, which many files use; six files of a user's set whose photos landed off their artboards now open from their layers. Own-fixture test in `import::tests_native`; scores unchanged | | 2026-10-30 | minor | SVG export: axis-aligned rectangles are written as ``, circles and ellipses drawn with four standard arcs as ``/``, straight lines as `` and other straight-segment paths as ``/``, at the written precision; a rectangle or ellipse with a dashed stroke, turned ellipses, curves and compound paths stay `` (#4652); scores unchanged | From b127918be5a1389ebeea613a7865718aede55dba Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 19:45:52 +0700 Subject: [PATCH 2/2] PDF import: a mesh-painted stroke's outline keeps dash and tolerance in reason --- crates/pdf/src/import/state.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index e5011e0ca..0f02e7a26 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -245,6 +245,8 @@ pub(super) fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f /// The most entries of a dash array kept (an even count keeps it a pattern of dashes and gaps). const MAX_DASH_ENTRIES: usize = 64; +/// Most dash periods [`stroke_outline`] cuts a stroke into. +const MAX_OUTLINE_DASHES: f64 = 1e5; /// The area stroke `p` paints along `path` (in its own space, before `transform`), in the document: /// where a shading painting the stroke shows. Width, caps, joins and dashes as [`stroke_layer`] @@ -258,11 +260,14 @@ pub(super) fn stroke_outline(path: &BezPath, transform: Affine, p: &StrokeProps) kurbo::Stroke::new(line_width(p, scale) / scale).with_join(p.line_join).with_caps(p.line_cap).with_miter_limit(p.miter_limit as f64); let dashes: Vec = p.dash_array.iter().take(MAX_DASH_ENTRIES).map(|v| *v as f64).collect(); let dash = Dash { pattern: dashes.clone(), offset: p.dash_offset as f64, align_corners: false }; - if dash.is_dashed() { + // A dash pattern far finer than the path would cut it into countless pieces: it strokes solid. + let period: f64 = dashes.iter().sum(); + if dash.is_dashed() && period > 0.0 && kurbo::Shape::perimeter(path, 1e-3) / period < MAX_OUTLINE_DASHES { style = style.with_dashes(dash.offset, dashes); } - // To a hundredth of a point in the document. - let mut out = kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.01 / scale); + // To a hundredth of a point in the document, within reason at extreme scales. + let tolerance = (0.01 / scale).clamp(1e-4, 1.0); + let mut out = kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), tolerance); out.apply_affine(transform); (!out.elements().is_empty()).then_some(out) } @@ -301,3 +306,22 @@ pub(super) fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { None => vec![255; (w * h) as usize], } } + +#[cfg(test)] +mod stroke_outline_tests { + use super::*; + + /// A dash pattern far finer than the path strokes it solid instead of cutting it into + /// millions of pieces; one in reason dashes it. + #[test] + fn a_dash_far_finer_than_the_path_strokes_solid() { + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((1000.0, 0.0)); + let props = |dash: f32| StrokeProps { line_width: 2.0, dash_array: [dash; 4].into(), ..StrokeProps::default() }; + let solid = stroke_outline(&path, Affine::IDENTITY, &props(1e-6)).unwrap(); + assert!(solid.elements().len() < 20, "{} elements", solid.elements().len()); + let dashed = stroke_outline(&path, Affine::IDENTITY, &props(10.0)).unwrap(); + assert!(dashed.elements().len() > 100, "dashed: {} elements", dashed.elements().len()); + } +}