mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
21c580a934
commit
563ff62aa0
4 files changed
+108
-9
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user