mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
The bounds of the visible art (`vectorcraft_render::encode::art_bounds`) are the crop of Export with Use Artboards off, EPS without artboards, Export for Screens' Full Document and Save for Web (Legacy) with Clip to Artboard off, and the size Fit to Artwork Bounds gives an artboard. They measured a group or layer by its members and left out its own effects, and measured type and images without their effects. A group's drop shadow, or the shadow of a line of type, was cut off at the image's edge and left outside the fitted artboard. They also counted a Shaper group's transparent source art. Each visible object is now measured by `vectorcraft_render::effect_bounds`, the measure of Export Selection's crop. A group or layer without effects of its own is still measured by its visible members, skipping a Shaper group's source art. A group or layer with a drop shadow, a glow or another raster effect is measured without its hidden members, guides and template layers, unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of a chosen artboard also keeps an object whose effects alone reach the artboard. Docs: docs/mcp.md, docs/target-app-parity.md.
359 lines
17 KiB
Rust
359 lines
17 KiB
Rust
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeKind, TextObject};
|
||
use vectorcraft_geom::{Point, shapes};
|
||
|
||
use super::png::{PngOptions, encode, pixels_per_metre};
|
||
use super::*;
|
||
|
||
/// A chunk's data by type (first match).
|
||
fn chunk_data<'a>(file: &'a [u8], ty: &[u8; 4]) -> Option<&'a [u8]> {
|
||
let mut i = 8;
|
||
while i + 8 <= file.len() {
|
||
let len = u32::from_be_bytes(file[i..i + 4].try_into().unwrap()) as usize;
|
||
if &file[i + 4..i + 8] == ty {
|
||
return Some(&file[i + 8..i + 8 + len]);
|
||
}
|
||
i += 12 + len;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Deterministic straight-alpha noise with some fully transparent and some opaque pixels.
|
||
fn noise(w: u32, h: u32, opaque: bool) -> Vec<u8> {
|
||
let mut x: u32 = 0x1234_5678;
|
||
(0..w * h * 4)
|
||
.map(|i| {
|
||
x ^= x << 13;
|
||
x ^= x >> 17;
|
||
x ^= x << 5;
|
||
if i % 4 == 3 && opaque { 255 } else { (x >> 24) as u8 }
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn decode(png: &[u8]) -> Vec<u8> {
|
||
image::load_from_memory_with_format(png, image::ImageFormat::Png).expect("decodes").to_rgba8().into_raw()
|
||
}
|
||
|
||
#[test]
|
||
fn png_round_trips_plain_and_interlaced_at_odd_sizes() {
|
||
for (w, h) in [(1, 1), (3, 5), (9, 9), (17, 2), (8, 8), (33, 31)] {
|
||
for opaque in [false, true] {
|
||
let px = noise(w, h, opaque);
|
||
for interlaced in [false, true] {
|
||
let file = encode(&px, w, h, &PngOptions { ppi: None, interlaced }).unwrap();
|
||
assert_eq!(&file[..8], b"\x89PNG\r\n\x1a\n");
|
||
assert_eq!(file[28], u8::from(interlaced), "IHDR interlace method");
|
||
assert_eq!(file[25], if opaque { 2 } else { 6 }, "opaque images are written as RGB");
|
||
assert_eq!(decode(&file), px, "{w}×{h} opaque={opaque} interlaced={interlaced}");
|
||
assert!(chunk_data(&file, b"pHYs").is_none());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn phys_holds_pixels_per_metre() {
|
||
let file = encode(&noise(2, 2, true), 2, 2, &PngOptions { ppi: Some(300.0), interlaced: false }).unwrap();
|
||
let phys = chunk_data(&file, b"pHYs").expect("pHYs");
|
||
let ppm = (300.0f64 / 0.0254).round() as u32;
|
||
assert_eq!(ppm, pixels_per_metre(300.0));
|
||
assert_eq!(phys, [ppm.to_be_bytes().as_slice(), ppm.to_be_bytes().as_slice(), &[1]].concat());
|
||
}
|
||
|
||
#[test]
|
||
fn png_refuses_a_buffer_of_the_wrong_size() {
|
||
assert!(encode(&[0; 12], 2, 2, &PngOptions::default()).is_err());
|
||
assert!(encode(&[], 0, 0, &PngOptions::default()).is_err());
|
||
}
|
||
|
||
/// A 40×30 pt document with a black circle off the pixel grid.
|
||
fn circle_doc() -> Document {
|
||
let mut d = Document::new(40.0, 30.0);
|
||
let id = d.alloc_id();
|
||
let n = Node::path(id, shapes::ellipse(Rect::new(5.3, 4.7, 31.1, 26.2)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
d
|
||
}
|
||
|
||
fn export(d: &Document, format: RasterFormat, o: &RasterExportOptions) -> image::RgbaImage {
|
||
let bytes = Renderer::new().export_region(d, d.artboards[0].rect, format, o).unwrap();
|
||
image::load_from_memory(&bytes).unwrap().to_rgba8()
|
||
}
|
||
|
||
#[test]
|
||
fn resolution_sets_the_pixel_size_and_is_stored() {
|
||
let d = circle_doc();
|
||
assert_eq!(export(&d, RasterFormat::Png, &RasterExportOptions::default()).dimensions(), (40, 30));
|
||
let o = RasterExportOptions { ppi: 144.0, ..Default::default() };
|
||
assert_eq!(export(&d, RasterFormat::Png, &o).dimensions(), (80, 60), "144 ppi doubles the size");
|
||
let png = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &o).unwrap();
|
||
assert_eq!(&chunk_data(&png, b"pHYs").unwrap()[..4], pixels_per_metre(144.0).to_be_bytes());
|
||
let jpg = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Jpeg, &o).unwrap();
|
||
// JFIF APP0: units 1 (dots per inch), then the x and y density.
|
||
let app0 = jpg.windows(5).position(|w| w == b"JFIF\0").unwrap();
|
||
assert_eq!(&jpg[app0 + 7..app0 + 12], [1, 0, 144, 0, 144]);
|
||
}
|
||
|
||
#[test]
|
||
fn backgrounds_are_opaque_or_transparent() {
|
||
let d = circle_doc();
|
||
let transparent = export(&d, RasterFormat::Png, &RasterExportOptions::default());
|
||
assert_eq!(transparent.get_pixel(0, 0)[3], 0);
|
||
for (bg, want) in [([255, 255, 255], [255, 255, 255, 255]), ([0, 0, 0], [0, 0, 0, 255]), ([30, 120, 200], [30, 120, 200, 255])] {
|
||
let img = export(&d, RasterFormat::Png, &RasterExportOptions { background: Some(bg), ..Default::default() });
|
||
assert!(img.pixels().all(|p| p[3] == 255), "no alpha below 255 on {bg:?}");
|
||
assert_eq!(img.get_pixel(0, 0).0, want);
|
||
}
|
||
let webp = export(&d, RasterFormat::WebP, &RasterExportOptions { background: Some([0, 0, 0]), ..Default::default() });
|
||
assert_eq!(webp.get_pixel(0, 0).0, [0, 0, 0, 255]);
|
||
}
|
||
|
||
/// #787: translucent art over opaque art (or a background) stays exactly opaque, its anti-aliased
|
||
/// edges too, so the file is written as RGB.
|
||
#[test]
|
||
fn translucent_edges_over_opaque_art_stay_opaque() {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let l = d.layers[0].id;
|
||
let under = Node::path(
|
||
d.alloc_id(),
|
||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||
);
|
||
let mut over =
|
||
Node::path(d.alloc_id(), shapes::ellipse(Rect::new(10.0, 10.0, 90.0, 90.0)), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
|
||
over.opacity = 0.5;
|
||
let only_over = {
|
||
let mut d = d.clone();
|
||
d.insert(Some(l), 0, over.clone()).unwrap();
|
||
d
|
||
};
|
||
d.insert(Some(l), 0, under).unwrap();
|
||
d.insert(Some(l), 1, over).unwrap();
|
||
// Over a black background at 72 ppi, and over an opaque rectangle at a scale that puts the
|
||
// edges on fractions of a pixel.
|
||
for (doc, o) in [
|
||
(&only_over, RasterExportOptions { background: Some([0, 0, 0]), ..Default::default() }),
|
||
(&d, RasterExportOptions { ppi: 72.0 * 0.3515625, ..Default::default() }),
|
||
] {
|
||
let png = Renderer::new().export_region(doc, doc.artboards[0].rect, RasterFormat::Png, &o).unwrap();
|
||
let img = image::load_from_memory(&png).unwrap().to_rgba8();
|
||
let below: Vec<_> = img.pixels().filter(|p| p[3] < 255).collect();
|
||
assert!(below.is_empty(), "ppi {}: {} pixels below alpha 255, e.g. {:?}", o.ppi, below.len(), below.first());
|
||
assert_eq!(png[25], 2, "IHDR colour type: RGB");
|
||
}
|
||
// The inside is half white over black.
|
||
let img = export(&only_over, RasterFormat::Png, &RasterExportOptions { background: Some([0, 0, 0]), ..Default::default() });
|
||
assert!(img.get_pixel(50, 50).0[..3].iter().all(|c| (127..=128).contains(c)), "{:?}", img.get_pixel(50, 50));
|
||
}
|
||
|
||
#[test]
|
||
fn interlaced_export_decodes_to_the_same_pixels() {
|
||
let d = circle_doc();
|
||
let plain = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &RasterExportOptions::default()).unwrap();
|
||
let o = RasterExportOptions { interlaced: true, ..Default::default() };
|
||
let laced = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &o).unwrap();
|
||
assert_eq!((plain[28], laced[28]), (0, 1));
|
||
assert_eq!(decode(&plain), decode(&laced));
|
||
}
|
||
|
||
#[test]
|
||
fn anti_alias_none_paints_whole_pixels() {
|
||
let d = circle_doc();
|
||
let soft = export(&d, RasterFormat::Png, &RasterExportOptions::default());
|
||
assert!(soft.pixels().any(|p| p[3] != 0 && p[3] != 255), "art anti-aliasing has partial edge pixels");
|
||
let hard = export(&d, RasterFormat::Png, &RasterExportOptions { anti_alias: AntiAlias::None, ..Default::default() });
|
||
assert!(hard.pixels().all(|p| p[3] == 0 || p[3] == 255), "no anti-aliasing: every alpha is 0 or 255");
|
||
let covered = |img: &image::RgbaImage| img.pixels().filter(|p| p[3] >= 128).count() as i64;
|
||
assert!((covered(&hard) - covered(&soft)).abs() <= 8, "the same shape, only harder edges");
|
||
}
|
||
|
||
#[test]
|
||
fn type_anti_aliasing_snaps_text_to_pixels() {
|
||
let mut d = Document::new(80.0, 30.0);
|
||
let id = d.alloc_id();
|
||
let t = TextObject::point(Point::new(4.37, 18.61), "Hill", CharStyle { size: 12.0, ..CharStyle::default() });
|
||
let n = Node::new(id, NodeKind::Text(Box::new(t)));
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
let art = export(&d, RasterFormat::Png, &RasterExportOptions::default());
|
||
let typ = export(&d, RasterFormat::Png, &RasterExportOptions { anti_alias: AntiAlias::Type, ..Default::default() });
|
||
assert!(art.pixels().any(|p| p[3] > 0), "the text is drawn");
|
||
assert_ne!(art, typ, "type moves to whole pixels");
|
||
// Snapping moves glyphs by under a pixel: the ink stays where it was.
|
||
let ink = |img: &image::RgbaImage| img.pixels().map(|p| p[3] as u64).sum::<u64>() as f64;
|
||
assert!((ink(&typ) / ink(&art) - 1.0).abs() < 0.1);
|
||
}
|
||
|
||
#[test]
|
||
fn anti_alias_none_is_the_same_on_every_thread_count() {
|
||
// Glows are filtered offscreen when the renderer is multithreaded: their clip must be hard too.
|
||
let mut d = Document::new(80.0, 80.0);
|
||
let id = d.alloc_id();
|
||
let mut n = Node::path(id, shapes::rectangle(Rect::new(20.5, 20.3, 60.4, 59.6)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||
let glow = serde_json::json!({"color": "#ffffff", "mode": "normal", "opacity": 100, "blur": 4});
|
||
n.appearance.effects = vec![vectorcraft_doc::Effect { id: "stylize.innerGlow".into(), params: glow, visible: true }];
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
let opts = RenderOptions { anti_alias: AntiAlias::None, ..Default::default() };
|
||
let render = |threads: u16| {
|
||
let mut r = Renderer::new();
|
||
r.threads = threads;
|
||
r.render_region_with(&d, d.artboards[0].rect, 1.0, &opts)
|
||
};
|
||
let (st, mt) = (render(0), render(3));
|
||
for img in [&st, &mt] {
|
||
assert!(img.to_straight().as_chunks::<4>().0.iter().all(|p| p[3] == 0 || p[3] == 255), "hard edges");
|
||
}
|
||
let worst = st.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
|
||
assert!(worst <= 4, "max channel difference {worst}");
|
||
}
|
||
|
||
#[test]
|
||
fn art_bounds_cover_visible_art_only() {
|
||
let mut d = circle_doc();
|
||
let l = d.layers[0].id;
|
||
let hidden = {
|
||
let id = d.alloc_id();
|
||
let mut n =
|
||
Node::path(id, shapes::rectangle(Rect::new(100.0, 100.0, 120.0, 120.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||
n.visible = false;
|
||
n
|
||
};
|
||
d.insert(Some(l), 1, hidden).unwrap();
|
||
let b = art_bounds(&d).unwrap();
|
||
assert!((b.x0 - 5.3).abs() < 1e-6 && (b.x1 - 31.1).abs() < 1e-6 && (b.y1 - 26.2).abs() < 1e-6, "{b:?}");
|
||
let empty = Document::new(10.0, 10.0);
|
||
assert_eq!(art_bounds(&empty), None);
|
||
}
|
||
|
||
#[test]
|
||
fn art_bounds_take_in_the_effects_in_a_symbols_art() {
|
||
// A symbol's art, (0, 0) to (10, 10), with a drop shadow 8 pt to the right, drawn from (20, 20).
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||
let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), black);
|
||
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true };
|
||
art.appearance.effects.push(shadow);
|
||
d.symbols.push(vectorcraft_doc::Symbol { name: "Box".into(), art: std::sync::Arc::new(art) });
|
||
let (id, l) = (d.alloc_id(), d.layers[0].id);
|
||
let xf = vectorcraft_geom::Affine::translate((20.0, 20.0));
|
||
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf })).unwrap();
|
||
let b = art_bounds(&d).unwrap();
|
||
assert!(b.x0 <= 20.0 && b.x1 >= 38.0, "the art from x 20 and its shadow to x 38: {b:?}");
|
||
}
|
||
|
||
/// A drop shadow 8 pt to the right, without blur.
|
||
fn shadow_right() -> vectorcraft_doc::Effect {
|
||
vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true }
|
||
}
|
||
|
||
#[test]
|
||
fn art_bounds_take_in_the_effects_of_groups_type_and_images() {
|
||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), black);
|
||
let group = Node::group(d.alloc_id(), vec![std::sync::Arc::new(square)]);
|
||
let t = TextObject::point(Point::new(10.0, 40.0), "Hill", CharStyle { size: 12.0, ..CharStyle::default() });
|
||
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
||
let im = vectorcraft_doc::ImageObject {
|
||
key: "image".into(),
|
||
width: 30,
|
||
height: 30,
|
||
xf: vectorcraft_geom::Affine::translate((20.0, 50.0)),
|
||
link: None,
|
||
placement: Default::default(),
|
||
};
|
||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||
let cut_off: Vec<_> = [("group", group), ("type", text), ("image", image)]
|
||
.into_iter()
|
||
.filter_map(|(what, mut n)| {
|
||
let mut d = d.clone();
|
||
let plain = n.visual_bounds().unwrap();
|
||
n.appearance.effects.push(shadow_right());
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
let b = art_bounds(&d).unwrap();
|
||
(b.x0 > plain.x0 || b.x1 < plain.x1 + 8.0).then(|| format!("{what}: {b:?}, the shadow 8 pt right of {plain:?}"))
|
||
})
|
||
.collect();
|
||
assert!(cut_off.is_empty(), "{cut_off:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn art_bounds_leave_out_the_hidden_members_of_a_group_with_effects() {
|
||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), black.clone());
|
||
let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(200.0, 10.0, 220.0, 20.0)), black.clone());
|
||
hidden.visible = false;
|
||
let mut guide = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-300.0, 10.0, -200.0, 20.0)), black);
|
||
if let NodeKind::Path { guide: g, .. } = &mut guide.kind {
|
||
*g = true;
|
||
}
|
||
let mut group = Node::group(d.alloc_id(), [square, hidden, guide].map(std::sync::Arc::new).to_vec());
|
||
// A geometry effect on the group's own fill that is turned off.
|
||
group.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into();
|
||
group.appearance.effects.push(shadow_right());
|
||
let off = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"scaleH": 50}), visible: false };
|
||
group.appearance.fill_mut().unwrap().effects.push(off);
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, group).unwrap();
|
||
let b = art_bounds(&d).unwrap();
|
||
assert!(b.x0 >= 0.0 && (28.0..50.0).contains(&b.x1), "the square and its shadow, not the hidden square or the guide: {b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn art_bounds_hold_a_groups_geometry_effect_measured_with_its_hidden_members() {
|
||
// The Transform effect halves the group's width about the center of all its members, the
|
||
// hidden one too: the visible rectangle is drawn from x 30 to x 80.
|
||
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
|
||
let mut d = Document::new(200.0, 100.0);
|
||
let shown = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 10.0, 100.0, 20.0)), black.clone());
|
||
let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(110.0, 10.0, 120.0, 20.0)), black);
|
||
hidden.visible = false;
|
||
let mut group = Node::group(d.alloc_id(), [shown, hidden].map(std::sync::Arc::new).to_vec());
|
||
let half = serde_json::json!({"scaleH": 50, "scaleV": 100});
|
||
group.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: half, visible: true });
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, group).unwrap();
|
||
let b = art_bounds(&d).unwrap();
|
||
assert!(b.x0 <= 30.0 && b.x1 >= 80.0, "{b:?}");
|
||
let img = Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, false);
|
||
let drawn = |x: u32| (0..100).any(|y| img.pixels[((y * 200 + x) * 4 + 3) as usize] > 0);
|
||
assert!(drawn(31) && drawn(78) && !drawn(28) && !drawn(82), "the rectangle is drawn from x 30 to x 80");
|
||
}
|
||
|
||
/// A black path round `r` in the first layer of `d`, made a guide when `guide`.
|
||
fn add_rect(d: &mut Document, r: Rect, guide: bool) {
|
||
let id = d.alloc_id();
|
||
let mut n = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||
if let NodeKind::Path { guide: g, .. } = &mut n.kind {
|
||
*g = guide;
|
||
}
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), 0, n).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn guides_dont_count_in_the_arts_bounds() {
|
||
let mut d = circle_doc();
|
||
add_rect(&mut d, Rect::new(0.0, 15.0, 400.0, 15.0), true);
|
||
let b = art_bounds(&d).unwrap();
|
||
assert!((b.x0 - 5.3).abs() < 1e-6 && (b.x1 - 31.1).abs() < 1e-6, "the guide doesn't count: {b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn art_bounds_end_at_a_clipping_layers_clip() {
|
||
let mut d = Document::new(40.0, 30.0);
|
||
// The layer clips its art to its first object (added last): what lies beyond isn't drawn.
|
||
add_rect(&mut d, Rect::new(-100.0, -100.0, 200.0, 200.0), false);
|
||
add_rect(&mut d, Rect::new(2.0, 3.0, 20.0, 10.0), false);
|
||
let layer = std::sync::Arc::make_mut(&mut d.layers[0]);
|
||
if let NodeKind::Layer { clip, .. } = &mut layer.kind {
|
||
*clip = true;
|
||
}
|
||
assert_eq!(art_bounds(&d), Some(Rect::new(2.0, 3.0, 20.0, 10.0)));
|
||
}
|