mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 06:42:31 +00:00
Vector footage: faster, budget-safe clipping in the SVG/PDF rasteriser
Continuously rasterised vector footage (an A4 PDF scaled 200-400 %) is a page of millions of pixels, and the rasteriser did page-sized work per element: - Every path, run of text and image computed and walked a coverage mask the size of the whole page. Coverage and paint now run over the pixels the shape (fill, or stroke outline) or image can reach. A generated A4 page of 720 text-run paths and 60 strokes rasterises at 300 % in 0.18 s instead of 4.97 s (200 %: 0.13 s instead of 2.44 s), with the same pixels. - Every clipping group copied the whole backdrop and drew on a second page-size surface, so deeply nested clips ran past the 512 MB surface budget and their artwork was dropped (at 200 % from the eighth level, at 400 % from the second). A plain clipping group (no soft mask, blend mode, group opacity or knockout, Normal content throughout) is now drawn straight onto the page when every clip is an axis-aligned rectangle covering each pixel the content can reach, and otherwise on a surface the size of its clip. Both give the same pixels; groups with a blend mode or knockout inside keep the backdrop path. Never crash: drawing stops below 256 levels of groups, and the PDF/EPS builder keeps groups at most 200 deep (a crafted page nesting `q ... W n` twenty thousand deep overflowed the stack when the document was drawn or dropped). The 596 x 842 px import size of an A4 page is unchanged: the page in points at 72 dpi, rounded up, as After Effects takes a point as a pixel. Refs #2895
This commit is contained in:
1 parent
bb2d2005ce
commit
e30f4f6435
5 files changed
+583
-23
No files matched your search
@@ -596,3 +596,78 @@ fn ai_composition_retains_layer_sizes_and_positions() {
|
||||
assert!(frame.get(39, 10)[0] > 0.99, "reload invalidates cached pixels");
|
||||
}
|
||||
}
|
||||
|
||||
/// An A4 page (595.28 × 841.89 pt) whose artwork sits inside a page clip and ten nested
|
||||
/// clipping groups, as PDFs exported from layout and office applications nest them: a black bar
|
||||
/// whose left edge is at x = 30 pt, from 41.89 to 191.89 pt down the page.
|
||||
fn a4_clipped_pdf() -> Vec<u8> {
|
||||
let mut content = String::from(
|
||||
"q 0 0 595.28 841.89 re W n
|
||||
",
|
||||
);
|
||||
for i in 0..10 {
|
||||
content.push_str(&format!(
|
||||
"q {} {} {} {} re W n
|
||||
",
|
||||
20 + i,
|
||||
600 + i,
|
||||
300 - 2 * i,
|
||||
230 - 2 * i
|
||||
));
|
||||
}
|
||||
content.push_str(
|
||||
"0 0 0 rg 30 650 40 150 re f
|
||||
",
|
||||
);
|
||||
content.push_str(
|
||||
&"Q
|
||||
"
|
||||
.repeat(11),
|
||||
);
|
||||
effectcraft_pdf::write::pdf(
|
||||
&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Resources << >> /Contents 4 0 R >>".into(), None),
|
||||
(4, "<< >>".into(), Some(content.into_bytes())),
|
||||
],
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
/// #2895: an A4 PDF imports at its size in points, rounded up (596 × 842 px; After Effects
|
||||
/// also takes a point as a pixel), and scaled 200 % with Continuously Rasterize on it is drawn
|
||||
/// at that scale: the bar's left edge stays one pixel wide. It used to come out empty: at
|
||||
/// 200 % (a 32 MB page) each nested clipping group copied the whole page and drew on a second
|
||||
/// page-size surface, so the eighth level of clips ran past the rasteriser's 512 MB surface
|
||||
/// budget. With the switch off the layer is the page's pixels enlarged, soft as before.
|
||||
#[test]
|
||||
fn a4_pdf_scaled_200_percent_continuously_rasterizes_sharp() {
|
||||
let path = tmp("a4-clipped.pdf");
|
||||
std::fs::write(&path, a4_clipped_pdf()).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let item = r["items"][0].as_u64().unwrap();
|
||||
let effectcraft_project::ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!("footage") };
|
||||
assert_eq!((f.codec.as_str(), f.width, f.height), ("PDF", 596, 842), "A4 in points");
|
||||
s.execute("comp.new", json!({"name": "A4", "width": 400, "height": 400, "frameRate": 30, "duration": 1})).unwrap();
|
||||
let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
|
||||
for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([200, 200]))] {
|
||||
s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap();
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let t = effectcraft_time::Tick::ZERO;
|
||||
// Row 240 (120 pt down the page) crosses the bar's left edge at x = 60 (30 pt × 2).
|
||||
let soft_pixels = |img: &Image| (50..70).filter(|&x| (0.05..0.95).contains(&img.get(x, 240)[3])).count();
|
||||
let off = s.render(cid, t, RenderOpts::default());
|
||||
assert!(off.get(100, 240)[3] > 0.99, "the bar shows with the switch off");
|
||||
assert!(soft_pixels(&off) >= 2, "off: the page's pixels enlarged, a soft edge ({} partial pixels)", soft_pixels(&off));
|
||||
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap();
|
||||
let on = s.render(cid, t, RenderOpts::default());
|
||||
assert!(on.get(100, 240)[3] > 0.99, "the clipped bar is drawn at 200 %: {:?}", on.get(100, 240));
|
||||
assert!(on.get(61, 240)[3] > 0.99 && on.get(58, 240)[3] < 0.01, "a hard edge at x = 60: {:?} {:?}", on.get(58, 240), on.get(61, 240));
|
||||
assert!(soft_pixels(&on) <= 1, "on: the edge is at most one pixel wide ({} partial pixels)", soft_pixels(&on));
|
||||
// Outside the clips (x = 10 pt) nothing is drawn.
|
||||
assert!(on.get(20, 240)[3] < 0.01);
|
||||
}
|
||||
+19
-3
@@ -7,6 +7,10 @@ use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape
|
||||
/// PostScript program, or the PDF content stream.
|
||||
pub(crate) const MAX_PATH: usize = 1 << 20;
|
||||
|
||||
/// The deepest groups (clips, layers, transparency groups) nest in the document; below the
|
||||
/// renderer's own limit (256), so everything kept is drawn.
|
||||
pub(crate) const MAX_GROUP_DEPTH: usize = 200;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Plain,
|
||||
@@ -68,17 +72,29 @@ impl Builder {
|
||||
g.clip.push((path, rule));
|
||||
// Clipping is not a transparency group: blend modes inside reach the backdrop.
|
||||
g.isolated = false;
|
||||
self.stack.push((g, Kind::Clip));
|
||||
self.push_group(g, Kind::Clip);
|
||||
}
|
||||
|
||||
/// Open an optional-content layer (or a named group).
|
||||
pub fn open(&mut self, name: &str, layer: bool) {
|
||||
self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain }));
|
||||
self.push_group(group(name), if layer { Kind::Layer } else { Kind::Plain });
|
||||
}
|
||||
|
||||
/// Open a group (blend mode, soft mask) closed with the graphics state.
|
||||
pub fn open_group(&mut self, g: Group) {
|
||||
self.stack.push((g, Kind::Plain));
|
||||
self.push_group(g, Kind::Plain);
|
||||
}
|
||||
|
||||
/// Open a group, unless groups are already nested [`MAX_GROUP_DEPTH`] deep: the document
|
||||
/// is a tree that is drawn, measured, copied and dropped recursively, and a crafted file
|
||||
/// can nest `q … W n` without end. Deeper groups are left out (their content is drawn
|
||||
/// in the group around them) and the document says so.
|
||||
fn push_group(&mut self, g: Group, kind: Kind) {
|
||||
if self.stack.len() >= MAX_GROUP_DEPTH {
|
||||
self.skip("groups nested deeper than 200 levels");
|
||||
return;
|
||||
}
|
||||
self.stack.push((g, kind));
|
||||
}
|
||||
|
||||
/// Add a finished node (an image, pattern tiles).
|
||||
|
||||
@@ -1139,3 +1139,32 @@ fn cmap_entries_at_the_top_of_the_code_range_do_not_overflow() {
|
||||
// Code 0 is CID u32::MAX; code 2 would be past it, so it falls back to CID 0 like an unmapped code.
|
||||
assert_eq!((f.width(0), f.width(2)), (0.5, 0.7));
|
||||
}
|
||||
|
||||
/// Never crash: a crafted page nests `q … W n` twenty thousand deep. The document is a tree
|
||||
/// that is drawn, measured and dropped recursively, so groups past the nesting limit are left
|
||||
/// out (said in the skip notes) and the page parses, draws and drops on a thread with a small
|
||||
/// stack; the artwork inside them still draws, clipped by the groups that were kept.
|
||||
#[test]
|
||||
fn clip_groups_nested_without_end_are_capped() {
|
||||
let run = std::thread::Builder::new().stack_size(2 << 20).spawn(|| {
|
||||
let n = 20_000;
|
||||
let mut content = "q 0 0 50 50 re W n\n".repeat(n);
|
||||
content.push_str("0 0 1 rg 0 0 50 50 re f\n");
|
||||
content.push_str(&"Q\n".repeat(n));
|
||||
let bytes = pdf(
|
||||
&[
|
||||
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None),
|
||||
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), None),
|
||||
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_string(), None),
|
||||
(4, "<< >>".to_string(), Some(content.into_bytes())),
|
||||
],
|
||||
1,
|
||||
);
|
||||
let doc = parse(&bytes).unwrap();
|
||||
(doc.skipped.clone(), px(&doc, 25, 75), px(&doc, 75, 25))
|
||||
});
|
||||
let (skipped, inside, outside) = run.unwrap().join().unwrap();
|
||||
assert!(skipped.iter().any(|s| s.contains("nested deeper")), "{skipped:?}");
|
||||
assert!(inside[2] > 0.99 && inside[3] > 0.99, "the artwork draws inside the clips: {inside:?}");
|
||||
assert!(outside[3] < 0.01, "and is clipped: {outside:?}");
|
||||
}
|
||||
+270
-20
@@ -20,7 +20,13 @@ pub fn rasterize(doc: &Doc, w: u32, h: u32, scale: f64) -> Image {
|
||||
|
||||
/// Rasterise `doc` with `m` mapping document pixels to image pixels.
|
||||
pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image {
|
||||
let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes: MAX_LIVE_SURFACE_BYTES };
|
||||
rasterize_with_budget(doc, w, h, m, MAX_LIVE_SURFACE_BYTES)
|
||||
}
|
||||
|
||||
/// [`rasterize_with`] with at most `limit_bytes` of image surfaces (the destination included)
|
||||
/// live at once.
|
||||
pub(crate) fn rasterize_with_budget(doc: &Doc, w: u32, h: u32, m: Affine, limit_bytes: usize) -> Image {
|
||||
let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes, depth: 0, plain: false };
|
||||
let Some(mut img) = budget.image(w, h, None) else { return Image::default() };
|
||||
draw_group(&doc.root, m, 1.0, &mut img, &mut budget);
|
||||
img
|
||||
@@ -29,9 +35,19 @@ pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image {
|
||||
/// Bound the simultaneously live offscreen image surfaces created by nested groups.
|
||||
const MAX_LIVE_SURFACE_BYTES: usize = 512 * 1024 * 1024;
|
||||
|
||||
/// The deepest group nesting drawn: drawing recurses, and a crafted document can nest groups
|
||||
/// without end, so groups deeper than this are not drawn.
|
||||
const MAX_GROUP_DEPTH: usize = 256;
|
||||
|
||||
/// The state of one rasterisation: the surface budget and where the drawing is in the tree.
|
||||
struct SurfaceBudget {
|
||||
live_bytes: usize,
|
||||
limit_bytes: usize,
|
||||
/// Groups entered on the way to the one being drawn.
|
||||
depth: usize,
|
||||
/// Every group being drawn below the current one composites Normal, without knockout
|
||||
/// (known once for a whole subtree, so it isn't walked again at every level).
|
||||
plain: bool,
|
||||
}
|
||||
|
||||
impl SurfaceBudget {
|
||||
@@ -61,6 +77,15 @@ impl SurfaceBudget {
|
||||
}
|
||||
|
||||
fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
|
||||
if budget.depth >= MAX_GROUP_DEPTH {
|
||||
return;
|
||||
}
|
||||
budget.depth += 1;
|
||||
draw_group_entered(g, m, opacity, dst, budget);
|
||||
budget.depth -= 1;
|
||||
}
|
||||
|
||||
fn draw_group_entered(g: &Group, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
|
||||
let m = m * g.transform;
|
||||
let o = opacity * g.opacity;
|
||||
if o <= 0.0 {
|
||||
@@ -77,9 +102,192 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image, budget: &mut
|
||||
}
|
||||
return;
|
||||
}
|
||||
let only_clip = !g.clip.is_empty() && g.mask.is_none() && g.blend == BlendMode::Normal && o >= 1.0 && !g.knockout;
|
||||
let plain = only_clip && (budget.plain || !needs_backdrop(&g.children, MAX_GROUP_DEPTH.saturating_sub(budget.depth)));
|
||||
if plain {
|
||||
let outer = std::mem::replace(&mut budget.plain, true);
|
||||
let left = MAX_GROUP_DEPTH.saturating_sub(budget.depth);
|
||||
if clips_cover_content(g, m, dst.width, dst.height, left) {
|
||||
for c in &g.children {
|
||||
draw_node(c, m, 1.0, dst, budget);
|
||||
}
|
||||
} else {
|
||||
draw_clip_local(g, m, dst, budget);
|
||||
}
|
||||
budget.plain = outer;
|
||||
return;
|
||||
}
|
||||
draw_offscreen_group(g, m, o, dst, budget);
|
||||
}
|
||||
|
||||
/// Whether every clip of `g` is a rectangle along the pixel grid's axes that fully covers each
|
||||
/// pixel of a `w`×`h` image its children can draw on: the clips change nothing there, so the
|
||||
/// children are drawn straight onto the image, without a surface (a page or artboard clip
|
||||
/// around the artwork inside it, the outermost groups of most PDF pages). `levels`: how many
|
||||
/// levels of groups below are drawn.
|
||||
fn clips_cover_content(g: &Group, m: Affine, w: u32, h: u32, levels: usize) -> bool {
|
||||
let Some(rects) = g.clip.iter().map(|(p, _)| axis_rect(p, m)).collect::<Option<Vec<_>>>() else { return false };
|
||||
// Children that draw nothing need no clip.
|
||||
let Some(b) = draw_bounds(&g.children, m, levels) else { return true };
|
||||
let (fw, fh) = (f64::from(w), f64::from(h));
|
||||
// The pixels the children can touch (a pixel of margin for anti-aliasing), on the image.
|
||||
let (bx0, by0) = ((b.x0.floor() - 1.0).max(0.0), (b.y0.floor() - 1.0).max(0.0));
|
||||
let (bx1, by1) = ((b.x1.ceil() + 1.0).min(fw), (b.y1.ceil() + 1.0).min(fh));
|
||||
if !(bx0 < bx1 && by0 < by1) {
|
||||
// Nothing reaches the image (or the bounds aren't numbers): keep the clip.
|
||||
return false;
|
||||
}
|
||||
rects.iter().all(|r| r.x0.ceil() <= bx0 && bx1 <= r.x1.floor() && r.y0.ceil() <= by0 && by1 <= r.y1.floor())
|
||||
}
|
||||
|
||||
/// A device-space rectangle holding every pixel `nodes` drawn with `m` can cover, `None` when
|
||||
/// they draw nothing. Cheap rather than tight: a stroke counts as its path's bounds grown by half
|
||||
/// its width times its miter limit (at least √2, for square caps), and anything it can't
|
||||
/// bound (more than `levels` levels deep, a transform that isn't finite) as the whole plane.
|
||||
fn draw_bounds(nodes: &[Node], m: Affine, levels: usize) -> Option<kurbo::Rect> {
|
||||
let everything = kurbo::Rect::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
if !m.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return Some(everything);
|
||||
}
|
||||
let mut out: Option<kurbo::Rect> = None;
|
||||
for n in nodes {
|
||||
let r = match n {
|
||||
Node::Group(g) if levels == 0 => (!g.children.is_empty()).then_some(everything),
|
||||
Node::Group(g) => {
|
||||
let gm = m * g.transform;
|
||||
let mut r = draw_bounds(&g.children, gm, levels - 1);
|
||||
for (clip, _) in &g.clip {
|
||||
r = r.map(|r| r.intersect(gm.transform_rect_bbox(clip.bounding_box())));
|
||||
}
|
||||
r
|
||||
}
|
||||
Node::Shape(s) => {
|
||||
let sm = m * s.transform;
|
||||
let b = s.geom.to_path().bounding_box();
|
||||
let fill = s.fill.as_ref().map(|_| sm.transform_rect_bbox(b));
|
||||
let stroke = s.stroke.as_ref().map(|st| {
|
||||
let pad = (st.width.abs() * 0.5 * st.miter.max(std::f64::consts::SQRT_2)).max(0.0);
|
||||
sm.transform_rect_bbox(b.inflate(pad, pad))
|
||||
});
|
||||
match (fill, stroke) {
|
||||
(Some(a), Some(b)) => Some(a.union(b)),
|
||||
(a, b) => a.or(b),
|
||||
}
|
||||
}
|
||||
Node::Image(im) => Some((m * im.transform).transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)))),
|
||||
};
|
||||
if let Some(r) = r {
|
||||
let r = if [r.x0, r.y0, r.x1, r.y1].iter().any(|v| v.is_nan()) { everything } else { r };
|
||||
out = Some(out.map_or(r, |o| o.union(r)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The device-space rectangle a clip path is when it is one along the pixel grid's axes: a
|
||||
/// move, three or four lines and an optional close, its sides alternately horizontal and
|
||||
/// vertical.
|
||||
pub(crate) fn axis_rect(path: &kurbo::BezPath, m: Affine) -> Option<kurbo::Rect> {
|
||||
use kurbo::PathEl;
|
||||
let els = path.elements();
|
||||
let mut pts: Vec<Point> = Vec::with_capacity(5);
|
||||
for (i, el) in els.iter().enumerate() {
|
||||
match el {
|
||||
PathEl::MoveTo(p) if i == 0 => pts.push(m * *p),
|
||||
PathEl::LineTo(p) if i > 0 && pts.len() < 5 => pts.push(m * *p),
|
||||
PathEl::ClosePath if i > 0 && i + 1 == els.len() => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
const EPS: f64 = 1e-6;
|
||||
let same = |a: Point, b: Point| (a.x - b.x).abs() <= EPS && (a.y - b.y).abs() <= EPS;
|
||||
if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| same(*a, *b)) {
|
||||
pts.pop();
|
||||
}
|
||||
let p: [Point; 4] = pts.try_into().ok()?;
|
||||
let sides = [(p[0], p[1]), (p[1], p[2]), (p[2], p[3]), (p[3], p[0])];
|
||||
// Each side horizontal or vertical (and not a point), the next one the other way.
|
||||
let horizontal = |(a, b): (Point, Point)| -> Option<bool> {
|
||||
let (dx, dy) = ((b.x - a.x).abs(), (b.y - a.y).abs());
|
||||
match (dx > EPS, dy > EPS) {
|
||||
(true, false) => Some(true),
|
||||
(false, true) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let kinds = sides.map(horizontal);
|
||||
let [Some(k0), Some(k1), Some(k2), Some(k3)] = kinds else { return None };
|
||||
if k0 == k1 || k1 == k2 || k2 == k3 || k3 == k0 {
|
||||
return None;
|
||||
}
|
||||
let r = kurbo::Rect::from_points(p[0], p[2]);
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r)
|
||||
}
|
||||
|
||||
/// Whether drawing `nodes` reads what is below them beyond Normal compositing: a blend mode or
|
||||
/// a knockout group anywhere inside. Past `levels` levels of groups the answer is yes.
|
||||
fn needs_backdrop(nodes: &[Node], levels: usize) -> bool {
|
||||
nodes.iter().any(|n| match n {
|
||||
Node::Group(g) => levels == 0 || g.blend != BlendMode::Normal || g.knockout || needs_backdrop(&g.children, levels - 1),
|
||||
Node::Shape(_) | Node::Image(_) => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// A plain clipping group (no soft mask, blend mode, group opacity or knockout) whose content
|
||||
/// composites Normal throughout: drawn into a surface the size of its clip's bounds and
|
||||
/// composited over `dst`. With Normal content that is the same as drawing it over a copy of
|
||||
/// the backdrop and keeping the clip (`(C over B)·c + B·(1 − c) = (C·c) over B`), without two
|
||||
/// full-size copies per nesting level: a page continuously rasterised at 400 % lost its
|
||||
/// nested clipped artwork to the surface budget (#2895).
|
||||
fn draw_clip_local(g: &Group, m: Affine, dst: &mut Image, budget: &mut SurfaceBudget) {
|
||||
let mut bounds: Option<kurbo::Rect> = None;
|
||||
for (path, _) in &g.clip {
|
||||
let b = m.transform_rect_bbox(path.bounding_box());
|
||||
bounds = Some(bounds.map_or(b, |a| a.intersect(b)));
|
||||
}
|
||||
let Some((x0, y0, w, h)) = bounds.and_then(|b| pixel_rect(b, dst.width, dst.height)) else { return };
|
||||
let Some(mut tmp) = budget.image(w, h, None) else { return };
|
||||
let local = to_local((x0, y0), m);
|
||||
for c in &g.children {
|
||||
draw_node(c, local, 1.0, &mut tmp, budget);
|
||||
}
|
||||
let cov = clip_coverage(g, local, w, h);
|
||||
tmp.data.par_iter_mut().zip(cov.par_iter()).for_each(|(p, c)| {
|
||||
for v in p.iter_mut() {
|
||||
*v *= c;
|
||||
}
|
||||
});
|
||||
over_at(dst, &tmp, (x0, y0), 1.0);
|
||||
budget.release(&tmp);
|
||||
}
|
||||
|
||||
/// The pixels of a `w`×`h` image that the device-space rectangle `r` touches, with a pixel of
|
||||
/// margin for anti-aliasing, as `(x, y, width, height)`; `None` when it misses the image. A
|
||||
/// rectangle that isn't finite stands for the whole image.
|
||||
fn pixel_rect(r: kurbo::Rect, w: u32, h: u32) -> Option<(u32, u32, u32, u32)> {
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return Some((0, 0, w, h));
|
||||
}
|
||||
let (fw, fh) = (f64::from(w), f64::from(h));
|
||||
let x0 = (r.x0.min(r.x1) - 1.0).floor().clamp(0.0, fw);
|
||||
let y0 = (r.y0.min(r.y1) - 1.0).floor().clamp(0.0, fh);
|
||||
let x1 = (r.x0.max(r.x1) + 1.0).ceil().clamp(0.0, fw);
|
||||
let y1 = (r.y0.max(r.y1) + 1.0).ceil().clamp(0.0, fh);
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return None;
|
||||
}
|
||||
Some((x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32))
|
||||
}
|
||||
|
||||
/// A translation by `-origin`: maps device space onto a surface whose top-left pixel sits at
|
||||
/// `origin` of the device image.
|
||||
fn to_local(origin: (u32, u32), m: Affine) -> Affine {
|
||||
Affine::translate((-f64::from(origin.0), -f64::from(origin.1))) * m
|
||||
}
|
||||
|
||||
/// Draws a group through an offscreen buffer, then composites it onto `dst`.
|
||||
fn draw_offscreen_group(g: &Group, m: Affine, o: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
|
||||
let only_clip = g.mask.is_none() && g.blend == BlendMode::Normal && o >= 1.0 && !g.knockout;
|
||||
@@ -191,7 +399,10 @@ fn apply_soft_mask(sm: &SoftMask, m: Affine, tmp: &mut Image, budget: &mut Surfa
|
||||
let Some(mut mk) = budget.image(tmp.width, tmp.height, None) else {
|
||||
return false;
|
||||
};
|
||||
// The mask's content is a tree of its own: what is known of the masked group's isn't.
|
||||
let plain = std::mem::replace(&mut budget.plain, false);
|
||||
draw_group(&sm.content, m, 1.0, &mut mk, budget);
|
||||
budget.plain = plain;
|
||||
let bc = sm.backdrop.map(|v| v as f32);
|
||||
let lum = sm.luminosity;
|
||||
tmp.data.par_iter_mut().zip(mk.data.par_iter()).for_each(|(p, q)| {
|
||||
@@ -421,12 +632,16 @@ fn draw_image(im: &crate::Image, m: Affine, opacity: f64, dst: &mut Image) {
|
||||
if !inv.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let quad = kurbo::Rect::new(0.0, 0.0, iw as f64, ih as f64).to_path(0.01);
|
||||
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&quad), &mat3(full), dst.width, dst.height, effectcraft_path::FillRule::NonZero);
|
||||
let w = dst.width as usize;
|
||||
if w == 0 {
|
||||
// Coverage over the pixels the image's quadrilateral reaches only (see `draw_shape`).
|
||||
let rect = kurbo::Rect::new(0.0, 0.0, iw as f64, ih as f64);
|
||||
let Some((x0, y0, rw, rh)) = pixel_rect(full.transform_rect_bbox(rect), dst.width, dst.height) else { return };
|
||||
let quad = rect.to_path(0.01);
|
||||
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&quad), &mat3(to_local((x0, y0), full)), rw, rh, effectcraft_path::FillRule::NonZero);
|
||||
let (w, cw) = (dst.width as usize, cov.width as usize);
|
||||
if w == 0 || cw == 0 {
|
||||
return;
|
||||
}
|
||||
let (ox, oy) = (x0 as usize, y0 as usize);
|
||||
let px = |x: usize, y: usize| -> [f32; 4] {
|
||||
let i = (y * iw + x) * 4;
|
||||
let a = im.rgba[i + 3] as f32 / 255.0;
|
||||
@@ -438,9 +653,10 @@ fn draw_image(im: &crate::Image, m: Affine, opacity: f64, dst: &mut Image) {
|
||||
let fy = (c[2] * c[2] + c[3] * c[3]).sqrt();
|
||||
let taps = (fx.max(fy).ceil() as usize).clamp(1, 8);
|
||||
let k = opacity as f32;
|
||||
dst.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
for (x, d) in row.iter_mut().enumerate() {
|
||||
let cv = cov.data[y * w + x];
|
||||
dst.data.par_chunks_mut(w).enumerate().skip(oy).zip(cov.data.par_chunks(cw)).for_each(|((y, row), crow)| {
|
||||
let Some(seg) = row.get_mut(ox..) else { return };
|
||||
for (lx, (d, &cv)) in seg.iter_mut().zip(crow).enumerate() {
|
||||
let x = ox + lx;
|
||||
if cv <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
@@ -486,6 +702,30 @@ fn over(dst: &mut Image, src: &Image, k: f32) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `dst = src·k over dst` for `src` placed with its top-left pixel at `origin` of `dst`.
|
||||
fn over_at(dst: &mut Image, src: &Image, origin: (u32, u32), k: f32) {
|
||||
let (w, sw) = (dst.width as usize, src.width as usize);
|
||||
if w == 0 || sw == 0 {
|
||||
return;
|
||||
}
|
||||
let (x0, y0) = (origin.0 as usize, origin.1 as usize);
|
||||
dst.data.par_chunks_mut(w).skip(y0).zip(src.data.par_chunks(sw)).for_each(|(row, srow)| {
|
||||
let Some(seg) = row.get_mut(x0..) else { return };
|
||||
for (d, s) in seg.iter_mut().zip(srow) {
|
||||
let a = s[3] * k;
|
||||
if a > 0.0 {
|
||||
for c in 0..3 {
|
||||
d[c] = s[c] * k + d[c] * (1.0 - a);
|
||||
}
|
||||
d[3] = a + d[3] * (1.0 - a);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw a shape. Its coverage is computed over the pixels its fill or stroke can reach only,
|
||||
/// not the whole image: a page drawn at a large scale cost a full-size coverage pass per path
|
||||
/// and per run of text (#2895).
|
||||
fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) {
|
||||
let m = m * s.transform;
|
||||
let path = s.geom.to_path();
|
||||
@@ -496,22 +736,32 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) {
|
||||
FillRule::NonZero => effectcraft_path::FillRule::NonZero,
|
||||
FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd,
|
||||
};
|
||||
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mat3(m), w, h, rule);
|
||||
paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox);
|
||||
if let Some((x0, y0, rw, rh)) = pixel_rect(m.transform_rect_bbox(bbox), w, h) {
|
||||
let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mat3(to_local((x0, y0), m)), rw, rh, rule);
|
||||
paint(dst, &cov, (x0, y0), fill, (opacity * s.fill_opacity) as f32, m, bbox);
|
||||
}
|
||||
}
|
||||
if let Some(st) = &s.stroke {
|
||||
// The outline as `effectcraft_path::stroke_coverage` makes it (the resolution scale of
|
||||
// the whole transform, which a translation doesn't change).
|
||||
let style = st.style();
|
||||
let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h);
|
||||
paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox);
|
||||
if let Some(outline) = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &style, mat3(m).mean_scale())
|
||||
&& let Some((x0, y0, rw, rh)) = pixel_rect(m.transform_rect_bbox(outline.bounding_box()), w, h)
|
||||
{
|
||||
let cov =
|
||||
effectcraft_path::fill_coverage(std::slice::from_ref(&outline), &mat3(to_local((x0, y0), m)), rw, rh, effectcraft_path::FillRule::NonZero);
|
||||
paint(dst, &cov, (x0, y0), &st.paint, (opacity * st.opacity) as f32, m, bbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite paint through coverage.
|
||||
fn paint(dst: &mut Image, cov: &Mask, p: &Paint, opacity: f32, m: Affine, bbox: kurbo::Rect) {
|
||||
let w = dst.width as usize;
|
||||
if w == 0 {
|
||||
/// Composite paint through coverage `cov`, whose top-left pixel sits at `origin` of `dst`.
|
||||
fn paint(dst: &mut Image, cov: &Mask, origin: (u32, u32), p: &Paint, opacity: f32, m: Affine, bbox: kurbo::Rect) {
|
||||
let (w, cw) = (dst.width as usize, cov.width as usize);
|
||||
if w == 0 || cw == 0 {
|
||||
return;
|
||||
}
|
||||
let (x0, y0) = (origin.0 as usize, origin.1 as usize);
|
||||
let shader = match p {
|
||||
Paint::Color(c) => Shader::Solid([c[0] as f32, c[1] as f32, c[2] as f32, 1.0]),
|
||||
Paint::Gradient(g) => {
|
||||
@@ -523,13 +773,13 @@ fn paint(dst: &mut Image, cov: &Mask, p: &Paint, opacity: f32, m: Affine, bbox:
|
||||
Shader::Gradient(g, to_grad)
|
||||
}
|
||||
};
|
||||
dst.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
for (x, d) in row.iter_mut().enumerate() {
|
||||
let c = cov.data[y * w + x];
|
||||
dst.data.par_chunks_mut(w).enumerate().skip(y0).zip(cov.data.par_chunks(cw)).for_each(|((y, row), crow)| {
|
||||
let Some(seg) = row.get_mut(x0..) else { return };
|
||||
for (lx, (d, &c)) in seg.iter_mut().zip(crow).enumerate() {
|
||||
if c <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let col = shader.at(x as f64 + 0.5, y as f64 + 0.5);
|
||||
let col = shader.at((x0 + lx) as f64 + 0.5, y as f64 + 0.5);
|
||||
let a = col[3] * c * opacity;
|
||||
if a <= 0.0 {
|
||||
continue;
|
||||
|
||||
@@ -446,3 +446,193 @@ fn at_rule_blocks_are_skipped_by_brace_depth() {
|
||||
assert_eq!(sheet.rules.len(), 1);
|
||||
assert_eq!(sheet.rules[0].classes, vec!["e".to_string()]);
|
||||
}
|
||||
|
||||
fn solid_rect(r: kurbo::Rect, c: [f64; 3]) -> Node {
|
||||
Node::Shape(Shape {
|
||||
name: "rect".into(),
|
||||
geom: Geom::Rect { x: r.x0, y: r.y0, w: r.width(), h: r.height(), rx: 0.0, ry: 0.0 },
|
||||
transform: Affine::IDENTITY,
|
||||
fill: Some(Paint::Color(c)),
|
||||
fill_opacity: 1.0,
|
||||
fill_rule: FillRule::NonZero,
|
||||
stroke: None,
|
||||
opacity: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// A PDF-style clipping group (not isolated: blend modes inside reach the backdrop).
|
||||
fn clip_group(r: kurbo::Rect, children: Vec<Node>) -> Node {
|
||||
let mut g = Group::new("Clip Group");
|
||||
g.clip.push((kurbo::Shape::to_path(&r, 0.1), FillRule::NonZero));
|
||||
g.isolated = false;
|
||||
g.children = children;
|
||||
Node::Group(g)
|
||||
}
|
||||
|
||||
fn doc_of(w: f64, h: f64, children: Vec<Node>) -> Doc {
|
||||
let mut root = Group::new("page");
|
||||
root.children = children;
|
||||
Doc { width: w, height: h, root, skipped: vec![] }
|
||||
}
|
||||
|
||||
/// Nested clipping groups over a backdrop: the content shows where every clip covers, half
|
||||
/// where a clip edge halves a pixel, and the backdrop everywhere else (#2895: plain clipping
|
||||
/// groups are drawn on surfaces the size of their clip).
|
||||
#[test]
|
||||
fn nested_clip_groups_keep_the_backdrop_outside_their_clips() {
|
||||
let blue = [0.0, 0.0, 1.0];
|
||||
let red = [1.0, 0.0, 0.0];
|
||||
let inner = clip_group(kurbo::Rect::new(10.5, 0.0, 20.0, 40.0), vec![solid_rect(kurbo::Rect::new(0.0, 0.0, 40.0, 40.0), red)]);
|
||||
let doc =
|
||||
doc_of(40.0, 40.0, vec![solid_rect(kurbo::Rect::new(0.0, 0.0, 40.0, 40.0), blue), clip_group(kurbo::Rect::new(5.0, 5.0, 35.0, 35.0), vec![inner])]);
|
||||
let img = rasterize(&doc, 40, 40, 1.0);
|
||||
let (r, b) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]);
|
||||
for (x, y, want) in [(15, 20, r), (11, 6, r), (19, 34, r), (25, 20, b), (15, 2, b), (15, 36, b), (9, 20, b), (21, 20, b)] {
|
||||
assert!(near(px(&img, x, y), want, 1e-3), "({x}, {y}): {:?}", px(&img, x, y));
|
||||
}
|
||||
assert!(near(px(&img, 10, 20), [0.5, 0.0, 0.5, 1.0], 0.02), "half-covered edge pixel {:?}", px(&img, 10, 20));
|
||||
// Scaled 4×: the clip edge at 10.5 lands on a pixel boundary (42), hard on both sides.
|
||||
let big = rasterize(&doc, 160, 160, 4.0);
|
||||
assert!(near(px(&big, 41, 80), b, 0.01) && near(px(&big, 42, 80), r, 0.01), "{:?} {:?}", px(&big, 41, 80), px(&big, 42, 80));
|
||||
}
|
||||
|
||||
/// A clipping group whose content blends (Multiply) still composites with the real backdrop:
|
||||
/// red multiplied over blue is black inside the clip, blue outside.
|
||||
#[test]
|
||||
fn a_blend_mode_inside_a_clip_group_reaches_the_backdrop() {
|
||||
let mut multiply = Group::new("Multiply");
|
||||
multiply.blend = BlendMode::Multiply;
|
||||
multiply.children = vec![solid_rect(kurbo::Rect::new(0.0, 0.0, 20.0, 20.0), [1.0, 0.0, 0.0])];
|
||||
let doc = doc_of(
|
||||
20.0,
|
||||
20.0,
|
||||
vec![
|
||||
solid_rect(kurbo::Rect::new(0.0, 0.0, 20.0, 20.0), [0.0, 0.0, 1.0]),
|
||||
clip_group(kurbo::Rect::new(5.0, 5.0, 15.0, 15.0), vec![Node::Group(multiply)]),
|
||||
],
|
||||
);
|
||||
let img = rasterize(&doc, 20, 20, 1.0);
|
||||
assert!(near(px(&img, 10, 10), [0.0, 0.0, 0.0, 1.0], 1e-3), "{:?}", px(&img, 10, 10));
|
||||
assert!(near(px(&img, 2, 10), [0.0, 0.0, 1.0, 1.0], 1e-3), "{:?}", px(&img, 2, 10));
|
||||
}
|
||||
|
||||
/// A stroke reaches past its path's bounds by half its width: a horizontal line (a path
|
||||
/// without height) stroked 4 px wide covers 4 rows, and its butt caps end at the path's ends.
|
||||
#[test]
|
||||
fn a_stroke_draws_its_full_width_past_the_path_bounds() {
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((5.0, 20.0));
|
||||
line.line_to((35.0, 20.0));
|
||||
let shape = Node::Shape(Shape {
|
||||
name: "line".into(),
|
||||
geom: Geom::Path(line),
|
||||
transform: Affine::IDENTITY,
|
||||
fill: None,
|
||||
fill_opacity: 1.0,
|
||||
fill_rule: FillRule::NonZero,
|
||||
stroke: Some(Stroke { paint: Paint::Color([0.0, 0.0, 0.0]), opacity: 1.0, width: 4.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None }),
|
||||
opacity: 1.0,
|
||||
});
|
||||
let img = rasterize(&doc_of(40.0, 40.0, vec![shape]), 40, 40, 1.0);
|
||||
for y in 18..22 {
|
||||
assert!(px(&img, 20, y)[3] > 0.99, "row {y}: {:?}", px(&img, 20, y));
|
||||
}
|
||||
for (x, y) in [(20, 17), (20, 22), (3, 20), (36, 20)] {
|
||||
assert!(px(&img, x, y)[3] < 0.01, "({x}, {y}): {:?}", px(&img, x, y));
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of `n` RGBA f32 surfaces of `w`×`h` pixels (a surface budget for the tests below).
|
||||
fn surfaces(n: f64, w: u32, h: u32) -> usize {
|
||||
(n * f64::from(w) * f64::from(h) * 16.0) as usize
|
||||
}
|
||||
|
||||
/// #2895: an A4 page continuously rasterised at 400 % is a 128 MB surface, and every nested
|
||||
/// clipping group used to copy the backdrop and draw on a second surface of that size, so the
|
||||
/// second level of clips ran past the 512 MB surface budget and its artwork was dropped. Clip
|
||||
/// groups now draw on surfaces the size of their clip: with room for four canvas-size surfaces,
|
||||
/// artwork inside four nested clips still draws (it used to need nine).
|
||||
#[test]
|
||||
fn deeply_clipped_artwork_draws_within_the_surface_budget() {
|
||||
let red = [1.0, 0.0, 0.0];
|
||||
let mut node = solid_rect(kurbo::Rect::new(0.0, 0.0, 300.0, 300.0), red);
|
||||
for i in 1..=4 {
|
||||
let inset = 10.0 * f64::from(i);
|
||||
node = clip_group(kurbo::Rect::new(inset, inset, 300.0 - inset, 300.0 - inset), vec![node]);
|
||||
}
|
||||
let img = crate::raster::rasterize_with_budget(&doc_of(300.0, 300.0, vec![node]), 300, 300, Affine::IDENTITY, surfaces(4.0, 300, 300));
|
||||
assert_eq!((img.width, img.height), (300, 300));
|
||||
assert!(near(px(&img, 150, 150), [1.0, 0.0, 0.0, 1.0], 1e-3), "centre {:?}", px(&img, 150, 150));
|
||||
assert!(near(px(&img, 45, 150), [1.0, 0.0, 0.0, 1.0], 1e-3), "just inside every clip {:?}", px(&img, 45, 150));
|
||||
assert!(px(&img, 35, 150)[3] < 0.01, "outside the outermost clip {:?}", px(&img, 35, 150));
|
||||
}
|
||||
|
||||
/// #2895: clips that cover the whole canvas around the artwork inside them (a page clip, an
|
||||
/// artboard clip, the outermost groups of most PDF pages) change no pixel, so they are drawn
|
||||
/// without a surface: with room for two and a half canvas-size surfaces, the artwork inside two
|
||||
/// of them still draws, and an inner clip still cuts it.
|
||||
#[test]
|
||||
fn canvas_covering_clips_draw_without_a_surface() {
|
||||
let red = [1.0, 0.0, 0.0];
|
||||
let art = clip_group(kurbo::Rect::new(150.0, 0.0, 300.0, 350.0), vec![solid_rect(kurbo::Rect::new(100.0, 100.0, 250.0, 250.0), red)]);
|
||||
let page = clip_group(kurbo::Rect::new(0.0, 0.0, 350.0, 350.0), vec![art]);
|
||||
let outer = clip_group(kurbo::Rect::new(-1.0, -1.0, 351.0, 351.0), vec![page]);
|
||||
let img = crate::raster::rasterize_with_budget(&doc_of(350.0, 350.0, vec![outer]), 350, 350, Affine::IDENTITY, surfaces(2.5, 350, 350));
|
||||
assert_eq!((img.width, img.height), (350, 350));
|
||||
for (x, y) in [(200, 200), (151, 101), (248, 248)] {
|
||||
assert!(near(px(&img, x, y), [1.0, 0.0, 0.0, 1.0], 1e-3), "({x}, {y}): {:?}", px(&img, x, y));
|
||||
}
|
||||
for (x, y) in [(120, 200), (148, 200), (200, 90), (260, 200)] {
|
||||
assert!(px(&img, x, y)[3] < 0.01, "({x}, {y}): {:?}", px(&img, x, y));
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a tree apart without recursing (a deep one would overflow the stack when dropped).
|
||||
fn dismantle(mut nodes: Vec<Node>) {
|
||||
while let Some(n) = nodes.pop() {
|
||||
if let Node::Group(mut g) = n {
|
||||
nodes.append(&mut g.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Never crash: drawing recurses, and clipping groups nested twenty thousand deep (a crafted
|
||||
/// file) are drawn without running out of stack, on a thread with a small one; groups past the
|
||||
/// nesting limit are not drawn, shallower artwork is.
|
||||
#[test]
|
||||
fn clip_groups_nested_without_end_do_not_overflow_the_stack() {
|
||||
let run = std::thread::Builder::new().stack_size(4 << 20).spawn(|| {
|
||||
let mut node = solid_rect(kurbo::Rect::new(0.0, 0.0, 64.0, 64.0), [1.0, 0.0, 0.0]);
|
||||
for _ in 0..20_000 {
|
||||
node = clip_group(kurbo::Rect::new(0.0, 0.0, 64.0, 64.0), vec![node]);
|
||||
}
|
||||
let shallow = solid_rect(kurbo::Rect::new(0.0, 0.0, 8.0, 8.0), [0.0, 0.0, 1.0]);
|
||||
let doc = doc_of(64.0, 64.0, vec![node, shallow]);
|
||||
let img = rasterize(&doc, 64, 64, 1.0);
|
||||
let out = (img.width, img.height, px(&img, 4, 4), px(&img, 40, 40));
|
||||
dismantle(vec![Node::Group(doc.root)]);
|
||||
out
|
||||
});
|
||||
let (w, h, shallow, deep) = run.unwrap().join().unwrap();
|
||||
assert_eq!((w, h), (64, 64));
|
||||
assert!(near(shallow, [0.0, 0.0, 1.0, 1.0], 1e-3), "{shallow:?}");
|
||||
assert!(deep[3] < 0.01, "artwork past the nesting limit is not drawn: {deep:?}");
|
||||
}
|
||||
|
||||
/// Only a rectangle along the pixel grid's axes counts as a clip that can be skipped: a
|
||||
/// rotated square or a path that folds back on itself does not.
|
||||
#[test]
|
||||
fn axis_rect_recognises_only_axis_aligned_rectangles() {
|
||||
use crate::raster::axis_rect;
|
||||
let rect = kurbo::Shape::to_path(&kurbo::Rect::new(1.0, 2.0, 5.0, 7.0), 0.1);
|
||||
assert_eq!(axis_rect(&rect, Affine::IDENTITY), Some(kurbo::Rect::new(1.0, 2.0, 5.0, 7.0)));
|
||||
assert_eq!(axis_rect(&rect, Affine::scale(2.0)), Some(kurbo::Rect::new(2.0, 4.0, 10.0, 14.0)));
|
||||
assert_eq!(axis_rect(&rect, Affine::rotate(0.3)), None);
|
||||
let mut folded = BezPath::new();
|
||||
folded.move_to((0.0, 0.0));
|
||||
folded.line_to((10.0, 0.0));
|
||||
folded.line_to((10.0, 10.0));
|
||||
folded.line_to((10.0, 0.0));
|
||||
folded.close_path();
|
||||
assert_eq!(axis_rect(&folded, Affine::IDENTITY), None);
|
||||
}
|
||||
Reference in new issue
Block a user