mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Open and Place read Affinity documents natively (.af from Affinity 3, .afdesign/.afphoto/.afpub from Affinity 1 and 2): layers and sublayers, groups, artboards with their names and backgrounds, Publisher pages side by side, curves with live corners, parametric shapes, compound shapes, clipping by nesting, vector and pixel masks, solid and gradient fills, strokes, fill layers, artistic and frame text, placed images (their original JPEG/PNG), pixel layers, embedded documents as their cached picture, opacity, visibility, locking and blend modes, at the document's resolution in points. Unsupported content (effects, adjustments, brushes, master pages, special shapes, Affinity-only blend modes...) is listed in the import warning, one line per kind. A file whose native data can't be read opens as its embedded preview with the reason, and that preview cannot be placed or used as a template or library. Save never writes back over the Affinity file and there is no Affinity export. The document semantics were fitted to the thumbnails Affinity embeds in 176 public documents. `cargo xtask corpus --affinity` fetches 22 public CC0/MIT/Apache-2.0 documents at pinned commits, sha256-verified; the new engine test renders each and compares it with Affinity's own thumbnail (0-4.4 of 255 mean difference, per-file ceilings). import_fuzz mutates a synthetic native document's stream and container through open, render and export; the fuzz crate gains a stream target. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
86 lines
4.4 KiB
Rust
86 lines
4.4 KiB
Rust
//! File → Place of a text file: Text Import Options, and the text set as area type.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{NodeKind, TextKind};
|
|
use vectorcraft_geom::Rect;
|
|
|
|
use super::*;
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn place_text(s: &mut Session, text: &[u8], extra: Value) -> Value {
|
|
let mut p = json!({"name": "notes.txt", "dataBase64": vectorcraft_format::base64_encode(text)});
|
|
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
|
|
o.extend(e);
|
|
}
|
|
s.execute("file.place", &p).unwrap()
|
|
}
|
|
|
|
/// The placed type's text and frame bounds.
|
|
fn placed(s: &Session, r: &Value) -> (String, Rect) {
|
|
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
|
|
let NodeKind::Text(t) = &n.kind else { panic!("not type: {:?}", n.kind) };
|
|
assert!(matches!(t.kind, TextKind::Area { .. }), "area type");
|
|
(t.plain_text(), n.geometric_bounds().unwrap())
|
|
}
|
|
|
|
fn close(a: Rect, b: Rect) -> bool {
|
|
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6)
|
|
}
|
|
|
|
#[test]
|
|
fn crlf_with_returns_removed_places_one_paragraph_per_block_as_area_type() {
|
|
let mut s = session();
|
|
let text = b"Dear reader,\r\nthis is the first\r\nparagraph.\r\n\r\nAnd here\r\nthe second.\r\n";
|
|
let opts = json!({"text": {"removeLineReturns": true, "removeParagraphReturns": true, "replaceSpaces": 3}});
|
|
let r = place_text(&mut s, text, opts);
|
|
assert_eq!((r["format"].as_str(), r["name"].as_str(), r["linked"].as_bool()), (Some("txt"), Some("notes.txt"), Some(false)));
|
|
let (body, frame) = placed(&s, &r);
|
|
assert_eq!(body, "Dear reader, this is the first paragraph.\nAnd\there the second.");
|
|
assert!(close(frame, Rect::new(36.0, 36.0, 364.0, 264.0)), "the artboard less a 36 pt margin: {frame:?}");
|
|
let st = s.doc().unwrap();
|
|
assert_eq!((st.history.undo.len(), st.selection.objects.len()), (1, 1), "one undo step, selected");
|
|
// Without options each line is a paragraph; `rect` sets the frame.
|
|
let r = place_text(&mut s, text, json!({"rect": [10, 20, 100, 50]}));
|
|
let (body, frame) = placed(&s, &r);
|
|
assert_eq!(body, "Dear reader,\nthis is the first\nparagraph.\n\nAnd here\nthe second.");
|
|
assert!(close(frame, Rect::new(10.0, 20.0, 110.0, 70.0)), "{frame:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn text_files_report_and_queue_with_their_options() {
|
|
let mut s = session();
|
|
let file = json!({"name": "a.txt", "dataBase64": vectorcraft_format::base64_encode(b"caf\xe9")});
|
|
let info = s.execute("file.place.info", &file).unwrap();
|
|
assert_eq!((info["format"].as_str(), info["width"].as_f64(), info["height"].as_f64()), (Some("txt"), Some(468.0), Some(648.0)));
|
|
// Not UTF-8: read as Windows-1252, or Mac Roman for a Mac file.
|
|
let r = place_text(&mut s, b"caf\x8e", json!({"text": {"characterSet": "ansi", "platform": "mac"}}));
|
|
assert_eq!(placed(&s, &r).0, "café");
|
|
let r = s.execute("file.place.queue", &json!({"files": [file], "text": {"characterSet": "ansi"}})).unwrap();
|
|
assert_eq!((r["count"].as_u64(), r["files"][0]["format"].as_str()), (Some(1), Some("txt")));
|
|
assert_eq!(s.tool_options()["name"], "a.txt");
|
|
let bad = json!({"name": "a.txt", "dataBase64": "YQ==", "text": {"platform": "beos"}});
|
|
assert!(s.execute("file.place", &bad).is_err());
|
|
let blank = json!({"name": "blank.txt", "dataBase64": vectorcraft_format::base64_encode(b" \r\n\t\r\n")});
|
|
assert!(s.execute("file.place", &blank).is_err(), "no text to place");
|
|
// Replace: the frame takes the replaced object's bounds.
|
|
s.execute("shape.rectangle", &json!({"x": 50, "y": 60, "width": 80, "height": 40})).unwrap();
|
|
let r = place_text(&mut s, b"Swap", json!({"replace": true}));
|
|
let (body, frame) = placed(&s, &r);
|
|
assert_eq!(body, "Swap");
|
|
assert!(close(frame, Rect::new(50.0, 60.0, 130.0, 100.0)), "{frame:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn place_reads_what_open_reads_and_text_files() {
|
|
use cmd::fileio::{OPEN_EXTS, PLACE_EXTS, TEXT_EXTS};
|
|
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS].concat());
|
|
let filters: Vec<_> = cmd::fileio::place_filters().collect();
|
|
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("Text", TEXT_EXTS))));
|
|
assert!(!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt")), "Open doesn't read text files");
|
|
}
|