Merge pull request #1351 from aguo890/fix/1304-shape-builder-clip-gradient
PDF import: gradient-filled shapes open as one path, not a clipped rectangle (#1304)
This commit is contained in:
commit
4f280f0e1a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue