mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
347 lines
15 KiB
Rust
347 lines
15 KiB
Rust
//! Metafiles read back: VectorCraft's own EMF and WMF round-trip, hand-built records of other
|
||
//! writers play as they draw, and damaged files fail or import partly without a panic.
|
||
|
||
use vectorcraft_color::{Gradient, GradientPaint, Paint};
|
||
use vectorcraft_doc::{AppearanceItem, Dash, Document, Node, NodeKind};
|
||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||
|
||
use crate::bytes::Out;
|
||
use crate::tests::*;
|
||
use crate::*;
|
||
|
||
/// Every object of the imported document, depth first.
|
||
fn all(d: &Document) -> Vec<Node> {
|
||
let mut v = vec![];
|
||
for l in &d.layers {
|
||
for c in l.children().into_iter().flatten() {
|
||
c.walk(&mut |n| v.push(n.clone()));
|
||
}
|
||
}
|
||
v
|
||
}
|
||
|
||
fn paths(d: &Document) -> Vec<Node> {
|
||
all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { clipping: false, .. })).collect()
|
||
}
|
||
|
||
fn near(a: Rect, b: Rect, tol: f64) -> bool {
|
||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol)
|
||
}
|
||
|
||
fn fill_rgb(n: &Node) -> Option<[u8; 3]> {
|
||
n.appearance.items.iter().find_map(|i| match i {
|
||
AppearanceItem::Fill(f) => f.paint.color().map(|c| {
|
||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||
[r, g, b]
|
||
}),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
fn sample() -> Document {
|
||
let mut d = doc_with(200.0, 100.0, vec![]);
|
||
add(&mut d, path(shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)), red(), blue(), 4.0));
|
||
add(&mut d, path(shapes::ellipse(Rect::new(100.0, 20.0, 180.0, 80.0)), blue(), Paint::None, 0.0));
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn emf_output_round_trips() {
|
||
let d = sample();
|
||
let out = emf(&d);
|
||
let back = import(&out.bytes).unwrap();
|
||
assert_eq!(back.kind, Kind::Emf);
|
||
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
|
||
let ab = back.document.artboards[0].rect;
|
||
assert!(near(ab, Rect::new(0.0, 0.0, 200.0, 100.0), 0.02), "{ab:?}");
|
||
let p = paths(&back.document);
|
||
// The rectangle's fill (its stroke is drawn after it, as another path) and the ellipse.
|
||
let rect: Vec<&Node> = p.iter().filter(|n| fill_rgb(n) == Some([255, 0, 0])).collect();
|
||
assert_eq!(rect.len(), 1);
|
||
assert!(near(rect[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 60.0), 0.03));
|
||
let stroke = p.iter().find_map(|n| n.appearance.stroke().cloned()).unwrap();
|
||
assert!((stroke.width - 4.0).abs() < 0.02, "{}", stroke.width);
|
||
assert_eq!(stroke.paint.color().map(|c| c.to_rgba8(1.0)), Some([0, 0, 255, 255]));
|
||
let ellipse = p.iter().find(|n| fill_rgb(n) == Some([0, 0, 255])).unwrap();
|
||
assert!(near(ellipse.geometric_bounds().unwrap(), Rect::new(100.0, 20.0, 180.0, 80.0), 0.03));
|
||
assert!(
|
||
matches!(&ellipse.kind, NodeKind::Path { path, .. } if path.to_bezpath().elements().iter().any(|e| matches!(e, kurbo::PathEl::CurveTo(..))))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn wmf_output_round_trips() {
|
||
let d = sample();
|
||
let back = import(&wmf(&d).bytes).unwrap();
|
||
assert_eq!(back.kind, Kind::Wmf);
|
||
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
|
||
assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), 0.05));
|
||
let p = paths(&back.document);
|
||
let rect = p.iter().find(|n| fill_rgb(n) == Some([255, 0, 0])).unwrap();
|
||
// 1440 units an inch: within a twentieth of a point.
|
||
assert!(near(rect.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 60.0), 0.05));
|
||
let stroke = p.iter().find_map(|n| n.appearance.stroke().cloned()).unwrap();
|
||
assert!((stroke.width - 4.0).abs() < 0.05);
|
||
let ellipse = p.iter().find(|n| fill_rgb(n) == Some([0, 0, 255])).unwrap();
|
||
assert!(near(ellipse.geometric_bounds().unwrap(), Rect::new(100.0, 20.0, 180.0, 80.0), 0.1));
|
||
}
|
||
|
||
#[test]
|
||
fn dashes_gradients_and_images_round_trip_through_emf() {
|
||
let mut line = path(shapes::line((10.0, 90.0).into(), (190.0, 90.0).into()), Paint::None, red(), 2.0);
|
||
line.appearance.stroke_mut().unwrap().dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false });
|
||
let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||
let mut d = doc_with(200.0, 100.0, vec![line, path(shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 30.0)), g, Paint::None, 0.0)]);
|
||
let img = image_node(&mut d, png(4, 2, [0, 200, 0, 255]), 4, 2, Affine::translate((100.0, 10.0)) * Affine::scale(5.0));
|
||
add(&mut d, img);
|
||
let back = import(&emf(&d).bytes).unwrap();
|
||
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
|
||
let nodes = all(&back.document);
|
||
let dash = nodes.iter().find_map(|n| n.appearance.stroke().and_then(|s| s.dash.clone())).unwrap();
|
||
assert!((dash.pattern[0] - 6.0).abs() < 0.02 && (dash.pattern[1] - 3.0).abs() < 0.02, "{dash:?}");
|
||
// The gradient: an image clipped to its rectangle (the picture's own clip is dropped); the
|
||
// image: its pixels where they were.
|
||
let groups: Vec<&Node> = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
|
||
assert_eq!(groups.len(), 1);
|
||
let clipped = groups[0].children().unwrap();
|
||
assert!(near(clipped[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 60.0, 30.0), 0.03));
|
||
assert!(matches!(clipped[1].kind, NodeKind::Image(_)));
|
||
let im = nodes
|
||
.iter()
|
||
.find_map(|n| match &n.kind {
|
||
NodeKind::Image(im) if im.width == 4 => Some(im.clone()),
|
||
_ => None,
|
||
})
|
||
.unwrap();
|
||
assert_eq!(im.height, 2);
|
||
let placed = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, 4.0, 2.0));
|
||
assert!(near(placed, Rect::new(100.0, 10.0, 120.0, 20.0), 0.05), "{placed:?}");
|
||
let px = image::load_from_memory(&back.document.images[&im.key].bytes).unwrap().to_rgba8();
|
||
assert_eq!(px.get_pixel(0, 0).0, [0, 200, 0, 255]);
|
||
}
|
||
|
||
// ---------- hand-built records ----------
|
||
|
||
/// An EMF of `records` (type, body) with a frame of `w` × `h` hundredths of a millimetre on a
|
||
/// device of 100 pixels a millimetre.
|
||
fn hand_emf(w: i32, h: i32, records: &[(u32, Vec<u8>)]) -> Vec<u8> {
|
||
let mut o = Out::default();
|
||
let rec = |o: &mut Out, kind: u32, body: &[u8]| {
|
||
o.u32(kind);
|
||
o.u32(8 + body.len() as u32);
|
||
o.bytes(body);
|
||
};
|
||
let mut head = Out::default();
|
||
head.bytes(&[0; 16]);
|
||
for v in [0, 0, w, h] {
|
||
head.i32(v);
|
||
}
|
||
head.u32(0x464D_4520);
|
||
head.u32(0x10000);
|
||
head.bytes(&[0; 8]);
|
||
head.u16(1);
|
||
head.u16(0);
|
||
head.bytes(&[0; 12]);
|
||
for v in [10000, 10000, 100, 100] {
|
||
head.i32(v);
|
||
}
|
||
rec(&mut o, 1, &head.0);
|
||
for (k, body) in records {
|
||
rec(&mut o, *k, body);
|
||
}
|
||
rec(&mut o, 14, &[0, 0, 0, 0, 16, 0, 0, 0, 20, 0, 0, 0]);
|
||
o.0
|
||
}
|
||
|
||
fn le(values: &[i32]) -> Vec<u8> {
|
||
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn hand_built_emf_plays_like_gdi() {
|
||
let mut text = vec![0; 16];
|
||
text.extend(le(&[1, 0, 0]));
|
||
// At (1000, 1500), 2 characters at offset 76 (from the record start).
|
||
text.extend(le(&[1000, 1500, 2, 76, 0, 0, 0, 0, 0, 0]));
|
||
text.extend("Hi".encode_utf16().flat_map(u16::to_le_bytes));
|
||
let mut font = le(&[2, -1000, 0, 0, 0, 700]);
|
||
font.extend([0u8; 8]);
|
||
font.extend("Arial".encode_utf16().chain(std::iter::repeat_n(0, 27)).flat_map(u16::to_le_bytes));
|
||
let records = vec![
|
||
// A green brush in slot 1, selected; a null pen.
|
||
(39, le(&[1, 0, 0x00_ff_00, 0])),
|
||
(37, le(&[1])),
|
||
(37, le(&[0x8000_0008u32 as i32])),
|
||
// A rectangle of 10 × 5 mm at (10, 10) mm.
|
||
(43, le(&[1000, 1000, 2000, 1500])),
|
||
// A record nobody knows, and one of a kind not read.
|
||
(9999, le(&[1, 2])),
|
||
(41, le(&[0, 0, 10, 0, 0])),
|
||
// Clip to a rectangle; a black pen draws two lines one after the other.
|
||
(30, le(&[0, 0, 1500, 1500])),
|
||
(37, le(&[0x8000_0007u32 as i32])),
|
||
(27, le(&[0, 0])),
|
||
(54, le(&[500, 0])),
|
||
(54, le(&[500, 500])),
|
||
// A 10 mm bold font and red text.
|
||
(82, font),
|
||
(37, le(&[2])),
|
||
(24, le(&[0xff])),
|
||
(84, text),
|
||
];
|
||
let back = import(&hand_emf(5000, 3000, &records)).unwrap();
|
||
let mm = 72.0 / 25.4;
|
||
assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 50.0 * mm, 30.0 * mm), 0.01));
|
||
assert_eq!(back.warnings, vec!["2 records of kinds VectorCraft doesn't read were skipped".to_string()]);
|
||
let layer = back.document.layers[0].children().unwrap().clone();
|
||
// The rectangle outside the clip; the lines and the text in a clipping group.
|
||
assert_eq!(layer.len(), 2);
|
||
assert_eq!(fill_rgb(&layer[0]), Some([0, 255, 0]));
|
||
assert!(near(layer[0].geometric_bounds().unwrap(), Rect::new(10.0 * mm, 10.0 * mm, 20.0 * mm, 15.0 * mm), 0.01));
|
||
assert!(layer[0].appearance.stroke().is_none(), "the null pen");
|
||
let NodeKind::Group { children, clip: true } = &layer[1].kind else { panic!("a clipping group") };
|
||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||
assert!(near(children[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 15.0 * mm, 15.0 * mm), 0.01));
|
||
// The two lines drawn one after the other are one path.
|
||
let lines = &children[1];
|
||
assert_eq!(lines.appearance.items.len(), 1);
|
||
assert!(near(lines.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 5.0 * mm, 5.0 * mm), 0.01));
|
||
let NodeKind::Text(t) = &children[2].kind else { panic!("text") };
|
||
assert_eq!(t.plain_text(), "Hi");
|
||
let st = &t.runs[0].style;
|
||
assert_eq!((st.font_family.as_str(), st.font_style.as_str()), ("Arial", "Bold"));
|
||
assert!((st.size - 10.0 * mm).abs() < 0.01);
|
||
assert_eq!(st.fill.color().map(|c| c.to_rgba8(1.0)), Some([255, 0, 0, 255]));
|
||
}
|
||
|
||
#[test]
|
||
fn emf_mapping_modes_and_world_transforms() {
|
||
let records = vec![
|
||
// MM_ANISOTROPIC: a 100 × 100 window on a 1000 × 2000 viewport.
|
||
(17, le(&[8])),
|
||
(9, le(&[100, 100])),
|
||
(11, le(&[1000, 2000])),
|
||
(43, le(&[0, 0, 10, 10])),
|
||
// Then a world transform moving by (5, 0) logical units.
|
||
(35, [1.0f32, 0.0, 0.0, 1.0, 5.0, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect()),
|
||
(43, le(&[0, 0, 10, 10])),
|
||
];
|
||
let back = import(&hand_emf(10000, 10000, &records)).unwrap();
|
||
let p = paths(&back.document);
|
||
let mm = 72.0 / 25.4;
|
||
// 10 units → 100 × 200 pixels → 1 × 2 mm.
|
||
assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, mm, 2.0 * mm), 0.01));
|
||
assert!(near(p[1].geometric_bounds().unwrap(), Rect::new(0.5 * mm, 0.0, 1.5 * mm, 2.0 * mm), 0.01));
|
||
}
|
||
|
||
/// A placeable WMF of `records` (function, parameters) in a `w` × `h` box at 1440 units an inch.
|
||
fn hand_wmf(w: i16, h: i16, records: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||
let mut o = Out::default();
|
||
o.u32(0x9AC6_CDD7);
|
||
o.u16(0);
|
||
for v in [0, 0, w, h] {
|
||
o.i16(v);
|
||
}
|
||
o.u16(1440);
|
||
o.u32(0);
|
||
let sum = crate::wmf::checksum(&o.0);
|
||
o.u16(sum);
|
||
o.bytes(&[1, 0, 9, 0, 0, 3]);
|
||
o.bytes(&[0; 12]);
|
||
for (f, body) in records.iter().chain(&[(0, vec![])]) {
|
||
o.u32(3 + body.len() as u32 / 2);
|
||
o.u16(*f);
|
||
o.bytes(body);
|
||
}
|
||
o.0
|
||
}
|
||
|
||
fn le16(values: &[i16]) -> Vec<u8> {
|
||
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn hand_built_wmf_plays_like_gdi() {
|
||
let records = vec![
|
||
// A blue brush (slot 0), a 20-unit red pen with square caps (slot 1), selected.
|
||
(0x02FC, [le16(&[0]), 0x00ff_0000u32.to_le_bytes().to_vec(), le16(&[0])].concat()),
|
||
(0x02FA, [le16(&[0x100, 20, 0]), 0x0000_00ffu32.to_le_bytes().to_vec()].concat()),
|
||
(0x012D, le16(&[0])),
|
||
(0x012D, le16(&[1])),
|
||
// A rectangle (bottom, right, top, left): 1 × 0.5 inches at the origin.
|
||
(0x041B, le16(&[720, 1440, 0, 0])),
|
||
(0x0999, le16(&[1, 2])),
|
||
// Delete the brush: the next object takes slot 0 again.
|
||
(0x01F0, le16(&[0])),
|
||
(0x02FC, [le16(&[0]), 0x0000_ff00u32.to_le_bytes().to_vec(), le16(&[0])].concat()),
|
||
(0x012D, le16(&[0])),
|
||
(0x0418, le16(&[1440, 1440, 720, 0])),
|
||
];
|
||
let back = import(&hand_wmf(1440, 1440, &records)).unwrap();
|
||
assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 72.0, 72.0), 1e-9));
|
||
assert_eq!(back.warnings, vec!["1 record of kinds VectorCraft doesn't read was skipped".to_string()]);
|
||
let p = paths(&back.document);
|
||
assert_eq!(p.len(), 2);
|
||
assert_eq!(fill_rgb(&p[0]), Some([0, 0, 255]));
|
||
assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 36.0), 1e-9));
|
||
let st = p[0].appearance.stroke().unwrap();
|
||
assert!((st.width - 1.0).abs() < 1e-9 && st.cap == vectorcraft_doc::LineCap::Square);
|
||
assert_eq!(fill_rgb(&p[1]), Some([0, 255, 0]), "the new brush took the freed slot");
|
||
}
|
||
|
||
#[test]
|
||
fn wmf_window_is_mapped_onto_the_placeable_box() {
|
||
// The box is 1000 × 500 units at 1440 an inch: 50 × 25 pt.
|
||
let want = Rect::new(12.5, 6.25, 37.5, 18.75);
|
||
// Window 0,0 – 4000 × 2000: the rectangle 1000,500 – 3000,1500 is the middle half.
|
||
let ext = vec![(0x020C, le16(&[2000, 4000])), (0x041B, le16(&[1500, 3000, 500, 1000]))];
|
||
// Window 10000,20000 – 1000 × 500 (the box): the rectangle 10250,20125 – 10750,20375.
|
||
let org = vec![(0x020B, le16(&[20000, 10000])), (0x020C, le16(&[500, 1000])), (0x041B, le16(&[20375, 10750, 20125, 10250]))];
|
||
// Window 0,2000 – 4000 × -2000 (y up): the same rectangle, flipped onto the same place.
|
||
let flip = vec![(0x020B, le16(&[2000, 0])), (0x020C, le16(&[-2000, 4000])), (0x041B, le16(&[1500, 3000, 500, 1000]))];
|
||
for records in [ext, org, flip] {
|
||
let back = import(&hand_wmf(1000, 500, &records)).unwrap();
|
||
assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 50.0, 25.0), 1e-9));
|
||
let p = paths(&back.document);
|
||
assert_eq!(p.len(), 1);
|
||
assert!(near(p[0].geometric_bounds().unwrap(), want, 1e-6), "{:?}", p[0].geometric_bounds());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn damaged_files_fail_or_import_partly() {
|
||
let good = emf(&sample()).bytes;
|
||
for cut in [0, 8, 50, 107, 108, 120, good.len() / 2, good.len() - 4] {
|
||
let _ = import(&good[..cut]);
|
||
}
|
||
let cut = import(&good[..good.len() / 2]).unwrap();
|
||
assert!(cut.warnings.iter().any(|w| w.contains("ends early")));
|
||
let good = wmf(&sample()).bytes;
|
||
for cut in [0, 4, 22, 30, 40, good.len() / 2] {
|
||
let _ = import(&good[..cut]);
|
||
}
|
||
assert!(import(b"not a metafile").is_err());
|
||
// Huge counts, sizes and offsets are refused, not allocated.
|
||
let huge = hand_emf(100, 100, &[(3, le(&[0, 0, 0, 0, i32::MAX])), (81, le(&[0; 18])), (8, le(&[0, 0, 0, 0, i32::MAX, i32::MAX]))]);
|
||
let back = import(&huge).unwrap();
|
||
assert!(paths(&back.document).is_empty());
|
||
let huge = hand_wmf(100, 100, &[(0x0324, le16(&[i16::MAX])), (0x0538, le16(&[-1]))]);
|
||
assert!(import(&huge).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn clips_nest_only_so_deep() {
|
||
// Each clip inside the last one used to nest a clipping group one level deeper, past the stack.
|
||
let mut records = vec![(30, le(&[0, 0, 1500, 1500])); 200_000];
|
||
records.push((43, le(&[100, 100, 200, 200])));
|
||
let back = import(&hand_emf(5000, 3000, &records)).unwrap();
|
||
let mut depth = 0;
|
||
let mut n = back.document.layers[0].children().unwrap()[0].clone();
|
||
while let NodeKind::Group { children, clip: true } = &n.kind {
|
||
depth += 1;
|
||
n = children.last().unwrap().clone();
|
||
}
|
||
assert_eq!(depth, vectorcraft_doc::clipnest::MAX_NEST);
|
||
assert!(back.warnings.iter().any(|w| w.contains("clips nested more than")), "{:?}", back.warnings);
|
||
}
|