From 563ff62aa04da455525b044dcf0f8b3e0dcf559c Mon Sep 17 00:00:00 2001 From: Aaron Guo Date: Sun, 11 Oct 2026 23:20:35 -0500 Subject: [PATCH 1/2] PDF import: gradient-filled shapes open as one path, not a clipped rectangle (#1304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PDF, and the PDF part of a PDF-compatible .ai, writes a shape filled with a gradient as a clip followed by a shading fill: the shape, W n, then sh, which fills the clip's bounds. The importer kept that as a clip group of the shape (a clipping path) and a rectangle the size of its bounds filled with the gradient. The Shape Builder skips clipping paths, so it built its pieces from the rectangles: two overlapping gradient-filled diamonds gave three rectangles. The Fill swatch showed ? for such a selection, since it held groups. A clip whose only content is one rectangle covering the clip, filled with a gradient placed on the page and nothing else, now comes in as one path: the clip's outline, its subpaths closed, with the rectangle's fill, opacity, blend mode and mask. It draws as before and edits like a gradient-filled path drawn in the app. Clips holding anything else (a flat colour, several objects, a gradient that doesn't cover the clip, a shading that doesn't extend past its ends) stay clip groups. Tests: a diamond clipping a shading imports as one closed path of four anchors with the linear gradient where it was, and a gradient covering only part of the diamond or a flat colour stay clipped. Two overlapping diamonds in a hand-written PDF, clicked on their overlap with the Shape Builder, give what is left of each (9000 pt² each) and the overlap diamond (3000 pt²), each with its gradient; main gave three rectangles of 12000 pt². Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_build.rs | 42 +++++++++++++++++++++++++ crates/pdf/src/import.rs | 7 +++-- crates/pdf/src/import_shading.rs | 32 ++++++++++++++++--- crates/pdf/src/tests_import_fidelity.rs | 36 +++++++++++++++++++-- 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 8cf3e4e0..cfb194d9 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect}; +use vectorcraft_testkit::pdf::{PdfPage, pdf}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use super::*; @@ -172,6 +173,47 @@ fn shape_builder_tool_drives_the_command() { assert_eq!(layer_children(&s), 1); } +/// The first and last stop colours of a gradient paint. +fn gradient_ends(p: &Paint) -> Option<([f32; 3], [f32; 3])> { + let Paint::Gradient(g) = p else { return None }; + Some((g.gradient.stops.first()?.color.to_rgb(), g.gradient.stops.last()?.color.to_rgb())) +} + +#[test] +fn shape_builder_cuts_gradient_shapes_from_a_pdf_along_their_outlines() { + // Two overlapping diamonds (x 100–300 and 200–400, y 40–160), each written as gradient-filled + // shapes are in a PDF or a PDF-compatible .ai: the diamond as a clip, then a shading (#1304). + let shading = |x0: u32, c0: &str, c1: &str| { + format!( + "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [{x0} 0 {} 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [{c0}] /C1 [{c1}] /N 1 >> >>", + x0 + 200 + ) + }; + let diamond = |x0: u32| format!("q {x0} 100 m {} 160 l {} 100 l {} 40 l h W n", x0 + 100, x0 + 200, x0 + 100); + let page = PdfPage { + resources: format!("/Shading << /Sh0 {} /Sh1 {} >>", shading(100, "1 0 0", "1 1 0"), shading(200, "0 0 1", "0 1 1")), + ..PdfPage::new(500.0, 200.0, &format!("{} /Sh0 sh Q {} /Sh1 sh Q", diamond(100), diamond(200))) + }; + let bytes = pdf(&[page], None); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "diamonds.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[250, 100]]})).unwrap(); + let mut pieces: Vec<(f64, Rect, Paint)> = + ids(&r).into_iter().map(|i| (area(&s, i), node(&s, i).geometric_bounds().unwrap(), node(&s, i).appearance.fill_paint())).collect(); + pieces.sort_by(|a, b| a.1.x0.total_cmp(&b.1.x0)); + let near = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.01); + let [(left, lb, lp), (mid, mb, mp), (right, rb, rp)] = pieces.as_slice() else { panic!("three pieces: {pieces:?}") }; + // What is left of each diamond, and the diamond where they overlap: not their bounding boxes. + assert!((left - 9000.0).abs() < 1.0 && (mid - 3000.0).abs() < 1.0 && (right - 9000.0).abs() < 1.0, "{pieces:?}"); + assert!(near(*lb, Rect::new(100.0, 40.0, 250.0, 160.0)), "{lb:?}"); + assert!(near(*mb, Rect::new(200.0, 70.0, 300.0, 130.0)), "{mb:?}"); + assert!(near(*rb, Rect::new(250.0, 40.0, 400.0, 160.0)), "{rb:?}"); + // Each keeps its gradient; the overlap takes the front diamond's. + let (first, second) = (Some(([1.0, 0.0, 0.0], [1.0, 1.0, 0.0])), Some(([0.0, 0.0, 1.0], [0.0, 1.0, 1.0]))); + assert_eq!((gradient_ends(lp), gradient_ends(mp), gradient_ends(rp)), (first, second, second)); +} + /// A red 100 × 100 square crossed by a stroke-only vertical line at x = 50, both selected. fn square_and_line(s: &mut Session) -> (NodeId, NodeId) { let a = rect(s, 0.0, 0.0, 100.0, 100.0); diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index a65a42d6..b4d77f21 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -22,7 +22,7 @@ use vectorcraft_geom::{FillRule, PathData}; use crate::import_color::{Colors, Native}; use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover}; use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key}; -use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient}; +use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_shape, mesh_shading, shading_gradient}; use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; @@ -774,7 +774,7 @@ fn fill_rule(r: hayro_interpret::FillRule) -> FillRule { } /// `p` with every subpath closed: how a fill reads it. -fn closed_subpaths(p: &BezPath) -> BezPath { +pub(crate) fn closed_subpaths(p: &BezPath) -> BezPath { let mut out = BezPath::new(); let mut open = false; for el in p.elements() { @@ -1062,6 +1062,9 @@ impl<'p> Builder<'p> { // A rectangle around all of its art (a form's box) clips nothing. grouped(f.children, f.child_groups, group).for_each(|(c, g)| self.emit(c, g)); None + } else if let Some(shape) = gradient_shape(&clip, &f.children) { + // One path, so tools such as the Shape Builder see the shape, not its box (#1304). + Some(shape) } else { let mut ch = vec![Arc::new(*clip)]; ch.extend(f.children); diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index 6330184e..a5783cf0 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -1,7 +1,8 @@ //! Shadings: axial and radial ones become gradients (their stops sampled from the shading //! function in its own colour model; an end that doesn't extend clips the painted area), patch -//! and triangle meshes become gradient meshes, and a gradient drawn through a luminosity mask that -//! is the same gradient in grey (how stop opacity is written) gets its opacity stops back. +//! and triangle meshes become gradient meshes, a gradient drawn through a luminosity mask that is +//! the same gradient in grey (how stop opacity is written) gets its opacity stops back, and a shape +//! clipping a gradient that fills it becomes one path with the gradient. use std::sync::Arc; @@ -10,11 +11,12 @@ use hayro_interpret::shading::{ShadingFunction, ShadingType}; use kurbo::{BezPath, Circle, Point, Rect, Shape, Vec2}; use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::live::{GradientMesh, lerp_color}; -use vectorcraft_doc::{Node, NodeKind, OpacityMask}; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind, OpacityMask}; +use vectorcraft_geom::PathData; -use crate::import::round3; +use crate::import::{closed_subpaths, round3}; use crate::import_color::{Colors, Native}; -use crate::import_mask::luminance; +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. @@ -266,3 +268,23 @@ pub(crate) fn fold_stop_opacity(content: &Node, mask: &OpacityMask) -> Option Node { Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(node)], clip: true }) } + +/// A shape filled with a gradient, as files write it (the shape as a clip, then `sh` filling the +/// clip's bounds), as one path: the clip's outline with `content`'s look. `None` unless `content` +/// is one rectangle covering the clip, filled with a gradient placed on the page and nothing else. +pub(crate) fn gradient_shape(clip: &Node, content: &[Arc]) -> Option { + let ([only], NodeKind::Path { path: outline, rule, .. }) = (content, &clip.kind) else { return None }; + let NodeKind::Path { path: area, clipping: false, guide: false, .. } = &only.kind else { return None }; + let [AppearanceItem::Fill(f)] = only.appearance.items.as_slice() else { return None }; + let Paint::Gradient(g) = &f.paint else { return None }; + let (area, outline) = (area.to_bezpath(), outline.to_bezpath()); + let covers = is_rectangle(&area) && contains(area.bounding_box().inflate(0.01, 0.01), outline.bounding_box()); + if g.geom.is_none() || !covers { + return None; + } + let mut shape = (**only).clone(); + // A clip closes its subpaths, as a fill does. + let path = PathData::from_bezpath(&closed_subpaths(&outline)); + shape.kind = NodeKind::Path { path, rule: *rule, live: None, clipping: false, guide: false }; + Some(shape) +} diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index a05176f2..d14f4cba 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -1,6 +1,7 @@ //! Import fidelity: soft masks → opacity masks, isolated and knockout groups, tiling patterns → -//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity -//! and shading Extend flags; strokes stay live strokes. +//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity, +//! shading Extend flags and shapes filled with a shading → gradient-filled paths; strokes stay +//! live strokes. use std::sync::Arc; @@ -352,6 +353,37 @@ fn a_shading_that_does_not_extend_paints_only_between_its_ends() { assert!(b.x0 <= 0.0 && (b.x1 - 80.0).abs() < 0.01, "{b:?}"); } +/// A diamond (10–90 × 10–90) clipping `paint`, which fills its bounds: how a shape filled with a +/// gradient is written (`W n`, then `sh`). +fn clipped_diamond(paint: &str) -> Document { + let shading = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [10 0 90 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >>"; + let resources = format!("/Shading << /Sh0 {shading} >> /Pattern << /P0 << /PatternType 2 /Shading {shading} >> >>"); + open(&one_page(&format!("q 10 50 m 50 90 l 90 50 l 50 10 l h W n {paint} Q"), &resources, &[])) +} + +#[test] +fn a_shape_filled_with_a_shading_becomes_one_path_with_the_gradient() { + let d = clipped_diamond("/Sh0 sh"); + assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "no group"); + let objs = objects(&d); + let [n] = objs.as_slice() else { panic!("one object: {objs:#?}") }; + let p = n.path_data().unwrap(); + assert!(p.is_closed() && p.anchor_count() == 4, "the diamond: {p:?}"); + assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", n.geometric_bounds()); + let Some(Paint::Gradient(g)) = n.appearance.fill().map(|f| &f.paint) else { panic!("a gradient fill: {:?}", n.appearance.items) }; + assert_eq!(g.gradient.kind, GradientKind::Linear); + assert_eq!(g.gradient.stops.first().map(|s| s.color.to_rgb()), Some([1.0, 0.0, 0.0])); + assert_eq!(g.gradient.stops.last().map(|s| s.color.to_rgb()), Some([0.0, 0.0, 1.0])); + let geom = g.geom.as_ref().unwrap(); + assert!((geom.start.x - 10.0).abs() < 0.01 && (geom.end.x - 90.0).abs() < 0.01, "placed as before: {geom:?}"); + assert!(n.appearance.stroke().is_none()); + // A gradient that doesn't cover the clip, and a flat colour, stay clipped. + for paint in ["/Pattern cs /P0 scn 40 40 20 20 re f", "1 0 0 rg 0 0 100 100 re f"] { + let d = clipped_diamond(paint); + assert!(all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })), "{paint}"); + } +} + #[test] fn appearance_items_are_untouched_for_plain_art() { // Plain art keeps importing as before: no masks, groups or clips appear. From 3428a224bb6b6597722f9168aebe91dd245b2220 Mon Sep 17 00:00:00 2001 From: Aaron Guo Date: Mon, 12 Oct 2026 00:32:36 -0500 Subject: [PATCH 2/2] Contributors: add aguo890 (Aaron Guo) --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index ae99c8a4..7daffe73 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -85,3 +85,8 @@ sync_github_name = false [people.sanyamsingh001] display_name = "sanyamsingh001" sync_github_name = false + +[people.aguo890] +real_name = "Aaron Guo" +display_name = "Aaron Guo" +sync_github_name = false