PDF import: gradient-filled shapes open as one path, not a clipped rectangle (#1304)

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 <[email protected]>
This commit is contained in:
Aaron GuoandClaude Opus 5.5 authored and bflatastic committed 2026-10-12 14:49:59 +07:00
1 parent 21c580a934
commit 563ff62aa0
4 files changed
+108 -9

No files matched your search

+5 -2
View File
@@ -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);
+27 -5
View File
@@ -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<No
pub(crate) fn clipped(clip: Node, node: Node, id: vectorcraft_doc::NodeId) -> 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<Node>]) -> Option<Node> {
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)
}
+34 -2
View File
@@ -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.