mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
Merge pull request #4702 from mockydevs/fix/pdf-large-mesh-shading
PDF import: large mesh shadings and mesh-painted strokes come in as gradient meshes
This commit is contained in:
@@ -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<vectorcraft_doc::live::GradientMesh>, 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<vectorcraft_doc::live::GradientMesh>, region: &BezPath, opacity: f32, clip: bool) {
|
||||
let nodes: Vec<Arc<Node>> = 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<Node>; 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());
|
||||
}
|
||||
|
||||
@@ -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(_));
|
||||
|
||||
@@ -245,6 +245,32 @@ 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`]
|
||||
/// takes them. `None` when it paints nothing.
|
||||
pub(super) fn stroke_outline(path: &BezPath, transform: Affine, p: &StrokeProps) -> Option<BezPath> {
|
||||
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<f64> = 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 };
|
||||
// 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, 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)
|
||||
}
|
||||
|
||||
pub(crate) fn round3(v: f32) -> f32 {
|
||||
(v * 1000.0).round() / 1000.0
|
||||
@@ -280,3 +306,22 @@ pub(super) fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec<u8> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -395,10 +395,12 @@ fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().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<u8> {
|
||||
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<vectorcraft_doc::live::GradientMesh> =
|
||||
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<u8> = 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);
|
||||
|
||||
@@ -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 `<rect>`, circles and ellipses drawn with four standard arcs as `<circle>`/`<ellipse>`, straight lines as `<line>` and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision; a rectangle or ellipse with a dashed stroke, turned ellipses, curves and compound paths stay `<path>` (#4652); scores unchanged |
|
||||
|
||||
Reference in New Issue
Block a user