Files
vectorcraft/crates/engine/src/cmd/fileio/tests_affinity.rs
T
dotCooCooandGert Pellin 1b744eae52 Gradients: conic gradients sweep the stops once around the center
A fourth gradient kind, conic (also accepted as `angle` or `conical`),
sweeps the stops once around the center (the gradient's start),
clockwise on the page from the vector, with a seam where the last stop
meets the first. A negative aspect mirrors the sweep, and reflecting an
object turns its sweep the other way, also for a conic without a
vector or with a flat extent.

- Gradient panel: a Conic button in the Type row; the Aspect Ratio
  field works for conic gradients and keeps a mirrored sweep mirrored.
- Gradient tool: the extent ellipse, aspect handle and center ring of a
  radial gradient, without a focal point.
- Rendering, swatch chips, transforms, warps, plug-in round trips, and
  Object > Expand into wedges or a mesh of columns around the center.
- Commands: a negative `aspect` is an error for linear and radial
  gradients, and so is a `focal` point for a conic gradient.
- PDF export writes a function-based shading whose calculator function
  turns the angle into the ramp, and PDF import reads a function-based
  shading that turns around the origin of its space back as a conic
  gradient, also when that origin lies outside the painted area. A
  PDF/A file, or a conic with more than 97 stops, gets an image
  instead.
- SVG export writes 360 solid wedges clipped to the shape for opaque
  stops and an image otherwise, and a linear gradient on characters.
- CSS export writes `conic-gradient(from ...)`, with the stops mirrored
  for a mirrored sweep.
- EPS and legacy .ai export write 360 solid wedges; the .ai editing data
  holds a linear gradient along the vector with the same stops.
- .af files: conical gradients open as conic gradients.
- An export that writes the gradient in another form gives a warning.

Supersedes #2326 and includes switch87's code from it.

Co-authored-by: Gert Pellin <gert@pellin.be>
2026-11-11 15:51:11 -08:00

564 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Affinity documents: native import (layers, artboards, curves, paints) through Open, Place and
//! templates, and the embedded preview only as an explicitly warned fallback.
use super::*;
use proptest::prelude::*;
use serde_json::json;
use std::io::Cursor;
fn png() -> Vec<u8> {
let im = image::RgbaImage::from_fn(2, 1, |x, _| if x == 0 { image::Rgba([220, 40, 60, 255]) } else { image::Rgba([0, 0, 0, 0]) });
let mut bytes = Vec::new();
im.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
bytes
}
/// Synthetic envelope without a native object graph; not a native Affinity writer.
fn file(png: &[u8]) -> Vec<u8> {
let mut b = vec![0; 72];
b[..4].copy_from_slice(vectorcraft_affinity::MAGIC);
b[4..6].copy_from_slice(&12u16.to_le_bytes());
b[8..12].copy_from_slice(b"nsrP");
b[12..16].copy_from_slice(b"#Inf");
b[24..32].copy_from_slice(&72u64.to_le_bytes());
b[64..68].copy_from_slice(b"Prot");
b.extend(b"\xff\xff\xff\xffThmb");
b.extend(1u32.to_le_bytes());
b.extend((png.len() as u32 + 13).to_le_bytes());
b.extend(29u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend((png.len() as u32).to_le_bytes());
b.push(1);
b.extend(png);
b
}
#[test]
fn preview_opens_with_a_warning_and_never_saves_to_the_source() {
let mut s = Session::new();
let bytes = file(&png());
for name in ["drawing.af", "renamed.png", "unknown"] {
let result = open_bytes(&mut s, name, &bytes, Some(format!("/source/{name}"))).unwrap();
assert_eq!(result["format"], "affinity");
let w = result["warnings"][0].as_str().unwrap();
assert!(w.contains("2×1") && w.contains("native Affinity document could not be read") && w.contains("no layers"), "{w}");
let st = s.doc().unwrap();
assert!(st.path.is_none());
assert_eq!(st.format, "vectorcraft");
assert_eq!((st.doc.artboards[0].rect.width(), st.doc.artboards[0].rect.height()), (2.0, 1.0));
assert_eq!(st.doc.images.values().next().unwrap().bytes.as_slice(), png());
let saved = vectorcraft_format::save_file(&st.doc);
let reopened = vectorcraft_format::load_file(&saved).unwrap();
assert_eq!(reopened.doc.images, st.doc.images);
let plan = save_plan(&s, SaveMode::Save, &json!({})).unwrap();
assert!(plan.path.is_none(), "Save must ask for a new destination");
assert_eq!(plan.format.id, "vectorcraft");
assert!(plan.name.ends_with(".vectorcraft"));
}
let f = format("affinity").unwrap();
assert!(f.read && !f.write);
for ext in ["af", "afdesign", "afpub"] {
assert!(OPEN_EXTS.contains(&ext) && PLACE_EXTS.contains(&ext), "{ext}");
}
assert!(!SAVE_FORMATS.contains(&"affinity"));
assert!(s.execute("document.serialize", &json!({"format":"af"})).is_err());
assert!(s.execute("document.export", &json!({"format":"affinity"})).is_err());
assert!(save_format(None, Some("drawing.af")).is_err());
}
#[test]
fn preview_reaches_the_document_open_command() {
let mut s = Session::new();
let r = s.execute("document.open", &json!({"name":"test.af", "dataBase64":vectorcraft_format::base64_encode(&file(&png()))})).unwrap();
assert_eq!(r["format"], "affinity");
assert_eq!(r["warnings"].as_array().unwrap().len(), 1);
}
#[test]
fn placing_a_preview_is_rejected_even_when_renamed_or_queued() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let original = s.doc().unwrap().doc.clone();
for name in ["test.af", "renamed.png", "renamed.txt"] {
let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png())), "link":true});
for cmd in ["file.place", "file.place.info"] {
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("File › Open") && e.contains("embedded preview"), "{e}");
}
let e = s.execute("file.place.queue", &json!({"files":[p]})).unwrap_err().to_string();
assert!(e.contains("File › Open") && e.contains("embedded preview"), "{e}");
}
assert_eq!(s.doc().unwrap().doc, original);
}
#[test]
fn templates_and_libraries_cannot_silently_extract_preview_content() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let original = s.doc().unwrap().doc.clone();
for name in ["test.af", "renamed.svg"] {
let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png()))});
for cmd in
["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary", "charStyle.load", "paraStyle.load", "textStyle.loadAll"]
{
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains("File › Open"), "{cmd}: {e}");
}
}
assert_eq!(s.documents().len(), 1);
assert_eq!(s.doc().unwrap().doc, original);
}
#[test]
fn malformed_crc_and_every_truncation_fail() {
let b = file(&png());
for end in 0..b.len() {
assert!(load("bad.af", &b[..end]).is_err(), "{end}");
}
let mut crc = b.clone();
crc[130] ^= 1;
assert!(load("bad.af", &crc).is_err());
// Affinity 1 and 2 (container versions 8 to 11) store the same preview record.
let mut legacy = b.clone();
legacy[4..6].copy_from_slice(&10u16.to_le_bytes());
assert!(load("old.afdesign", &legacy).is_ok());
let mut unknown = b.clone();
unknown[4..6].copy_from_slice(&13u16.to_le_bytes());
assert!(load("new.af", &unknown).err().unwrap().to_string().contains("unknown container version"));
}
#[test]
fn end_and_post_image_chunk_crcs_cannot_escape_pixel_decoding() {
let mut end_crc = png();
*end_crc.last_mut().unwrap() ^= 1;
assert!(load("bad-end.af", &file(&end_crc)).is_err());
let body = b"Note\0Synthetic";
let mut text = (body.len() as u32).to_be_bytes().to_vec();
text.extend(b"tEXt");
text.extend(body);
let mut h = crc32fast::Hasher::new();
h.update(b"tEXt");
h.update(body);
text.extend(h.finalize().to_be_bytes());
let mut post_image = png();
let at = post_image.len() - 12;
post_image.splice(at..at, text);
assert!(load("valid-text.af", &file(&post_image)).is_ok());
post_image[at + 8 + body.len()] ^= 1;
assert!(load("bad-text.af", &file(&post_image)).is_err());
}
#[test]
fn a_previewless_file_fails_without_replacing_the_active_document() {
let mut s = Session::new();
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
let original = s.doc().unwrap().doc.clone();
let mut bytes = file(&png());
bytes[24..32].copy_from_slice(&0u64.to_le_bytes());
let e = s.execute("document.open", &json!({"name":"no-preview.af", "dataBase64":vectorcraft_format::base64_encode(&bytes)})).unwrap_err();
assert!(e.to_string().contains("no embedded preview"));
assert_eq!(s.documents().len(), 1);
assert_eq!(s.doc().unwrap().doc, original);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(128))]
#[test]
fn bounded_mutations_do_not_panic(edits in prop::collection::vec((0usize..300, any::<u8>()), 0..16)) {
let mut b = file(&png());
for (i, value) in edits { if let Some(byte) = b.get_mut(i) { *byte = value; } }
let _ = load("mutated.af", &b);
}
}
mod native {
use super::*;
use vectorcraft_affinity::synth::{self, F, Method, tag};
use vectorcraft_doc::{NodeKind, appearance::AppearanceItem};
fn rgba(r: f32, g: f32, b: f32, a: f32) -> F {
F::Struct([r, g, b, a].iter().flat_map(|v| v.to_le_bytes()).collect())
}
fn solid(id: u32, c: F) -> F {
F::Def(
id,
vec![tag(b"FDsc")],
vec![(
tag(b"FDeF"),
F::Def(id + 1, vec![tag(b"FilS")], vec![(tag(b"Colr"), F::Def(id + 2, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]))]),
)],
)
}
fn node(x: f64, y: f64, kind: u8, role: u8) -> Vec<u8> {
let mut r = x.to_le_bytes().to_vec();
r.extend(y.to_le_bytes());
r.extend([kind, role]);
r
}
/// A version-12 document at 144 ppi: an artboard "Board" (moved 20, 10) holding a closed
/// triangle with a red fill and a 4 px blue stroke, and a hidden ellipse on a layer.
pub(super) fn document(method: Method) -> Vec<u8> {
let triangle = F::Def(
20,
vec![tag(b"PCrv"), tag(b"VNod"), tag(b"Node")],
vec![
(tag(b"Desc"), F::Str("Triangle".into())),
(
tag(b"Crvs"),
F::Obj(
tag(b"PCvD"),
vec![(
tag(b"Data"),
F::Pos(vec![
F::U8(0),
F::U32(1),
F::Bool(true),
F::Records(18, vec![node(0.0, 0.0, 1, 0), node(100.0, 0.0, 1, 0), node(50.0, 80.0, 1, 0), node(0.0, 0.0, 1, 0)]),
]),
)],
),
),
(tag(b"BFFl"), F::Shared(vec![solid(21, rgba(1.0, 0.0, 0.0, 1.0))])),
(tag(b"LIFl"), F::Shared(vec![solid(24, rgba(0.0, 0.0, 1.0, 1.0))])),
(
tag(b"LILn"),
F::Shared(vec![F::Def(
27,
vec![tag(b"LDsc")],
vec![(tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(4.0))])), (tag(b"LDSc"), F::Bool(true))],
)]),
),
],
);
let board = F::Def(
10,
vec![tag(b"ShpN"), tag(b"VNod"), tag(b"Node")],
vec![
(tag(b"Desc"), F::Str("Board".into())),
(tag(b"ABEn"), F::Bool(true)),
(tag(b"Shpe"), F::Def(11, vec![tag(b"ShNR")], vec![])),
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])),
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 20.0, 0.0, 1.0, 10.0])),
(tag(b"BFFl"), F::Shared(vec![solid(12, rgba(1.0, 1.0, 1.0, 1.0))])),
(tag(b"Chld"), F::Shared(vec![triangle])),
],
);
let ellipse = F::Def(
40,
vec![tag(b"ShpN")],
vec![
(tag(b"Visi"), F::Bool(false)),
(tag(b"Shpe"), F::Def(41, vec![tag(b"ShpE")], vec![])),
(tag(b"ShpB"), F::F64s(vec![300.0, 0.0, 400.0, 50.0])),
(tag(b"BFFl"), F::Shared(vec![F::Def(42, vec![tag(b"FDsc")], vec![(tag(b"FDeF"), F::Def(43, vec![tag(b"FilN")], vec![]))])])),
],
);
let layer = F::Def(30, vec![tag(b"Scop")], vec![(tag(b"Desc"), F::Str("Shapes".into())), (tag(b"Chld"), F::Shared(vec![ellipse]))]);
let spread =
F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 420.0, 120.0])), (tag(b"Chld"), F::Shared(vec![board, layer]))]);
let doc = synth::stream(&[
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])),
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
]);
synth::container(&[("doc.dat", &doc, method)], Some(&png()))
}
/// A version-12 document at 144 ppi holding a 100 px square filled with a red-to-blue conical
/// gradient centered at (50, 40) px, 40 px across: its unit +y points down the page, or up
/// (`mirror`).
fn conical_document(mirror: bool) -> Vec<u8> {
let stop = |id: u32, c: F| F::Def(id, vec![tag(b"RGBA")], vec![(tag(b"_col"), c)]);
let positions = [[0.0f32, 0.5], [1.0, 0.5]].iter().map(|p| p.iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
let grad = F::Obj(
tag(b"Grad"),
vec![
(tag(b"Cols"), F::Shared(vec![stop(23, rgba(1.0, 0.0, 0.0, 1.0)), stop(24, rgba(0.0, 0.0, 1.0, 1.0))])),
(tag(b"Posn"), F::Records(8, positions)),
],
);
let fill = F::Def(22, vec![tag(b"FilG")], vec![(tag(b"Type"), F::Enum(3, 0)), (tag(b"Grad"), grad)]);
let unit = F::F64s(vec![40.0, 0.0, 50.0, 0.0, if mirror { -40.0 } else { 40.0 }, 40.0]);
let gradient = F::Def(21, vec![tag(b"FDsc")], vec![(tag(b"FDeX"), unit), (tag(b"FDeF"), fill)]);
let square = F::Def(
20,
vec![tag(b"ShpN")],
vec![
(tag(b"Shpe"), F::Def(25, vec![tag(b"ShNR")], vec![])),
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, 100.0, 100.0])),
(tag(b"BFFl"), F::Shared(vec![gradient])),
],
);
let spread =
F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 200.0])), (tag(b"Chld"), F::Shared(vec![square]))]);
let doc = synth::stream(&[
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(144.0))])),
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
]);
synth::container(&[("doc.dat", &doc, Method::Stored)], Some(&png()))
}
#[test]
fn conical_gradients_open_as_conic_gradients() {
use vectorcraft_color::{GradientKind, Paint};
for mirror in [false, true] {
let mut s = Session::new();
let r = open_bytes(&mut s, "conic.af", &conical_document(mirror), None).unwrap();
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("conical")), "{r}");
let mut paint = None;
s.doc().unwrap().doc.walk(|n| {
if let Paint::Gradient(g) = n.appearance.fill_paint() {
paint = Some(g.clone());
}
});
let g = paint.expect("a gradient fill");
assert_eq!(g.gradient.kind, GradientKind::Conic);
let geom = g.geom.unwrap();
// At 144 ppi: centered at (25, 20) pt, 20 pt across, red where the sweep starts.
assert!(geom.start.distance(kurbo::Point::new(25.0, 20.0)) < 1e-9 && (geom.length() - 20.0).abs() < 1e-9, "{geom:?}");
assert_eq!(geom.is_mirrored(), mirror, "{geom:?}");
// A quarter turn on lies along the unit +y: below the center, or above it mirrored.
let quarter = kurbo::Point::new(25.0, if mirror { 10.0 } else { 30.0 });
assert!((geom.param_at(GradientKind::Conic, quarter) - 0.25).abs() < 1e-9, "{geom:?}");
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
}
}
#[test]
fn layers_artboards_curves_and_paints_open_as_editable_art() {
for method in [Method::Stored, Method::Zlib, Method::Zstd] {
let mut s = Session::new();
let r = open_bytes(&mut s, "native.af", &document(method), Some("/source/native.af".into())).unwrap();
assert_eq!(r["format"], "affinity");
// Affinity Photo's extension isn't listed, but its documents still open by their content.
assert_eq!(open_bytes(&mut Session::new(), "native.afphoto", &document(method), None).unwrap()["format"], "affinity");
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("preview")), "{r}");
let st = s.doc().unwrap();
assert!(st.path.is_none(), "Save must not write back over the Affinity file");
let d = &st.doc;
// 144 ppi: two document pixels per point.
assert_eq!(d.artboards.len(), 1);
assert_eq!(d.artboards[0].name, "Board");
assert_eq!(d.artboards[0].rect, kurbo::Rect::new(10.0, 5.0, 110.0, 55.0));
let names: Vec<_> = d.layers.iter().map(|l| l.name.clone().unwrap()).collect();
assert_eq!(names, ["Board", "Shapes"]);
let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!() };
let NodeKind::Group { children: clipped, clip: true } = &children[0].kind else { panic!("{:?}", children[0].kind) };
let NodeKind::Path { clipping: true, .. } = &clipped[0].kind else { panic!() };
let tri = &clipped[1];
assert_eq!(tri.name.as_deref(), Some("Triangle"));
let NodeKind::Path { path, .. } = &tri.kind else { panic!() };
let pts: Vec<_> = path.subpaths[0].anchors.iter().map(|a| (a.p.x, a.p.y)).collect();
assert_eq!(pts, [(10.0, 5.0), (60.0, 5.0), (35.0, 45.0)]);
assert!(path.subpaths[0].closed);
let [AppearanceItem::Fill(f), AppearanceItem::Stroke(k)] = tri.appearance.items.as_slice() else { panic!("{:?}", tri.appearance) };
assert_eq!(f.paint, vectorcraft_color::Paint::solid(vectorcraft_color::Color::Rgb { r: 1.0, g: 0.0, b: 0.0 }));
assert_eq!(k.width, 2.0);
let NodeKind::Layer { children, .. } = &d.layers[1].kind else { panic!() };
assert!(!children[0].visible, "hidden layers stay hidden");
}
}
/// A version-12 document holding an open curve with an 8 px stroke whose pressure curve (`LDeP`)
/// is made of `records`.
fn pressure_document(records: Vec<Vec<u8>>) -> Vec<u8> {
pressure_documents(records, 1)
}
/// [`pressure_document`] with `copies` curves, all linking the first one's fill and stroke.
fn pressure_documents(records: Vec<Vec<u8>>, copies: u32) -> Vec<u8> {
let mut style = Some((
solid(24, rgba(0.0, 0.0, 0.0, 1.0)),
F::Def(
27,
vec![tag(b"LDsc")],
vec![
(tag(b"LDeL"), F::Def(28, vec![tag(b"LSty")], vec![(tag(b"Wght"), F::F64(8.0))])),
(tag(b"LDSc"), F::Bool(false)),
(tag(b"LDeP"), F::Obj(tag(b"CrvD"), vec![(tag(b"Data"), F::Pos(vec![F::U32(0), F::Bool(false), F::Records(18, records)]))])),
],
),
));
let lines = (0..copies)
.map(|i| {
let (fill, line) = style.take().unwrap_or((F::Ref(24), F::Ref(27)));
F::Def(
if i == 0 { 20 } else { 1000 + i },
vec![tag(b"PCrv"), tag(b"VNod"), tag(b"Node")],
vec![
(
tag(b"Crvs"),
F::Obj(
tag(b"PCvD"),
vec![(
tag(b"Data"),
F::Pos(vec![
F::U8(0),
F::U32(1),
F::Bool(false),
F::Records(18, vec![node(0.0, 0.0, 1, 0), node(100.0, 0.0, 1, 0)]),
]),
)],
),
),
(tag(b"LIFl"), F::Shared(vec![fill])),
(tag(b"LILn"), F::Shared(vec![line])),
],
)
})
.collect();
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 200.0, 100.0])), (tag(b"Chld"), F::Shared(lines))]);
let doc = synth::stream(&[
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(72.0))])),
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))])),
]);
synth::container(&[("doc.dat", &doc, Method::Zstd)], Some(&png()))
}
/// The width profile of the only stroked path in `d`.
fn stroke_of(d: &vectorcraft_doc::Document) -> vectorcraft_doc::appearance::StrokeLayer {
let mut found = None;
d.walk(|n| {
if let Some(AppearanceItem::Stroke(k)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
found = Some(k.clone());
}
});
found.unwrap_or_else(|| panic!("no stroke in {:?}", d.layers))
}
#[test]
fn a_pressure_curve_becomes_the_strokes_width_profile() {
// Thin at the start, full width from half way: anchor (0, 0), handles (0.25, 0.5) and
// (0.5, 1), anchor (0.5, 1), then straight on to anchor (1, 1).
let curve = vec![node(0.0, 0.0, 4, 0), node(0.25, 0.5, 0, 1), node(0.5, 1.0, 0, 2), node(0.5, 1.0, 4, 0), node(1.0, 1.0, 4, 0)];
let r = load("pressure.af", &pressure_document(curve)).unwrap();
assert!(!r.preview_only);
assert!(r.warnings.iter().all(|w| !w.contains("pressure")), "{:?}", r.warnings);
let k = stroke_of(&r.doc);
assert_eq!(k.width, 8.0);
let profile = k.profile.unwrap_or_else(|| panic!("the pressure curve is read: {:?}", r.warnings));
assert_eq!(profile.points.first(), Some(&(0.0, 0.0, 0.0)));
assert_eq!(profile.points.last(), Some(&(1.0, 1.0, 1.0)));
assert!(profile.points.windows(2).all(|w| w[0].0 <= w[1].0), "positions go forward: {:?}", profile.points);
let (left, right) = profile.at(0.25);
assert!(left > 0.25 && left < 1.0 && left == right, "{left} {right}");
assert_eq!(profile.at(0.75), (1.0, 1.0));
// Widths past the full weight are held at it.
let huge = vec![node(0.0, 0.0, 4, 0), node(0.5, 1e300, 4, 0), node(1.0, 0.0, 4, 0)];
let profile = stroke_of(&load("huge.af", &pressure_document(huge)).unwrap().doc).profile.unwrap();
assert!(profile.points.iter().all(|p| (0.0..=1.0).contains(&p.1)), "{:?}", profile.points);
assert_eq!(profile.at(0.5), (1.0, 1.0));
// Each segment is sampled inside too: anchors at full width whose handles dip to nothing
// still narrow the stroke, with 256 segments.
let mut dips = vec![node(0.0, 1.0, 4, 0)];
for i in 1..=256 {
let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0);
dips.extend([node(a + (b - a) / 3.0, 0.0, 0, 1), node(b - (b - a) / 3.0, 0.0, 0, 2), node(b, 1.0, 4, 0)]);
}
let profile = stroke_of(&load("dips.af", &pressure_document(dips)).unwrap().doc).profile.unwrap();
assert!(profile.points.iter().any(|p| p.1 < 0.5), "{:?}", profile.points);
// A curve of more than 256 segments is left out, with a warning.
let long: Vec<Vec<u8>> = (0..=300).map(|i| node(f64::from(i) / 300.0, f64::from(i % 2), 4, 0)).collect();
let r = load("long.af", &pressure_document(long)).unwrap();
assert!(stroke_of(&r.doc).profile.is_none());
assert!(r.warnings.iter().any(|w| w.contains("pressure curve can't be read")), "{:?}", r.warnings);
// A pressure curve at full width all along leaves the stroke plain.
let flat = vec![node(0.0, 1.0, 4, 0), node(1.0, 1.0, 4, 0)];
let r = load("flat.af", &pressure_document(flat)).unwrap();
assert!(stroke_of(&r.doc).profile.is_none());
assert!(r.warnings.iter().all(|w| !w.contains("pressure")), "{:?}", r.warnings);
}
#[test]
fn a_dip_between_the_samples_of_a_long_pressure_curve_is_kept() {
// 256 segments at full width, except one whose handles sit at widths 0 and 2: its width is
// 1 at its middle and end but 0.71875 a quarter of the way along.
let mut curve = vec![node(0.0, 1.0, 4, 0)];
for i in 1..=256 {
let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0);
let (h1, h2) = if i == 100 { (0.0, 2.0) } else { (1.0, 1.0) };
curve.extend([node(a + (b - a) / 3.0, h1, 0, 1), node(b - (b - a) / 3.0, h2, 0, 2), node(b, 1.0, 4, 0)]);
}
let profile = stroke_of(&load("dip.af", &pressure_document(curve)).unwrap().doc).profile.unwrap_or_else(|| panic!("the dip is kept"));
let narrowest = profile.points.iter().map(|p| p.1).fold(1.0, f64::min);
assert!((narrowest - 0.71).abs() < 0.02, "{narrowest}");
}
#[test]
fn a_pressure_curve_just_under_full_width_is_kept() {
let curve = vec![node(0.0, 0.9995, 4, 0), node(1.0, 0.9995, 4, 0)];
let profile = stroke_of(&load("near.af", &pressure_document(curve)).unwrap().doc).profile.unwrap_or_else(|| panic!("the curve is kept"));
assert_eq!(profile.at(0.5), (0.9995, 0.9995));
}
#[test]
fn pressure_curves_stay_within_the_points_of_one_document() {
// One 256-segment curve linked from 2100 strokes: more than a million points in all.
let mut curve = vec![node(0.0, 1.0, 4, 0)];
for i in 1..=256 {
let (a, b) = (f64::from(i - 1) / 256.0, f64::from(i) / 256.0);
curve.extend([node(a + (b - a) / 3.0, 0.0, 0, 1), node(b - (b - a) / 3.0, 0.0, 0, 2), node(b, 1.0, 4, 0)]);
}
let r = load("many.af", &pressure_documents(curve, 2100)).unwrap();
let mut profiles = Vec::new();
r.doc.walk(|n| {
if let Some(AppearanceItem::Stroke(k)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
profiles.push(k.profile.as_ref().map_or(0, |p| p.points.len()));
}
});
assert_eq!(profiles.len(), 2100);
assert!(profiles.iter().sum::<usize>() <= 1 << 20, "{}", profiles.iter().sum::<usize>());
assert!(profiles.iter().filter(|n| **n > 0).count() > 1000);
assert!(profiles.contains(&0));
assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings);
// Curves at full width count too, so linking one from many strokes can't repeat its work
// without bound.
let flat: Vec<Vec<u8>> = (0..=768).map(|i| node(f64::from(i) / 768.0, 1.0, if i % 3 == 0 { 4 } else { 0 }, (i % 3) as u8)).collect();
let r = load("flat-many.af", &pressure_documents(flat, 2100)).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings);
// So do curves that can't be read: 768 segments, more than a curve may have.
let long: Vec<Vec<u8>> = (0..=768).map(|i| node(f64::from(i) / 768.0, f64::from(i % 2), 4, 0)).collect();
let r = load("long-many.af", &pressure_documents(long, 2100)).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("pressure curve can't be read")), "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("pressure points one document may hold")), "{:?}", r.warnings);
}
#[test]
fn native_documents_save_reopen_export_place_and_template() {
let bytes = document(Method::Zstd);
let mut s = Session::new();
open_bytes(&mut s, "native.af", &bytes, None).unwrap();
let doc = s.doc().unwrap().doc.clone();
let reopened = vectorcraft_format::load_file(&vectorcraft_format::save_file(&doc)).unwrap();
assert_eq!(reopened.doc.layers, doc.layers);
for format in ["svg", "pdf", "png"] {
assert!(s.execute("document.export", &json!({"format": format})).is_ok(), "{format}");
}
let p = json!({"name":"native.afdesign", "dataBase64":vectorcraft_format::base64_encode(&bytes)});
s.execute("file.new", &json!({"width":100,"height":100})).unwrap();
s.execute("file.place", &p).unwrap();
assert!(s.doc().unwrap().doc.layers.iter().any(|l| !l.children().unwrap().is_empty()));
s.execute("file.newFromTemplate", &p).unwrap();
assert!(s.doc().unwrap().path.is_none());
}
#[test]
fn a_damaged_native_document_falls_back_to_its_preview_with_the_reason() {
let mut bytes = document(Method::Zlib);
// Break the doc.dat CRC: the preview still opens, saying why.
bytes[80] ^= 0xFF;
let r = load("broken.af", &bytes).unwrap();
assert!(r.preview_only);
assert!(r.warnings[0].contains("could not be read (damaged Affinity file"), "{}", r.warnings[0]);
}
}