Files
vectorcraft/crates/engine/src/cmd/fileio/tests_aiimport.rs
T

797 lines
46 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.
//! Illustrator EPS, `.ai` and PDF files open from the editing data they carry: layers, groups,
//! names, hidden objects, artboards, colours and images as they were; what the reader doesn't read
//! yet opens as before, with a warning saying why.
use std::io::Write as _;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_testkit::ai;
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
use super::*;
fn open(s: &mut Session, name: &str, bytes: &[u8], params: Value) -> Result<Value> {
let mut p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)});
if let (Some(p), Some(extra)) = (p.as_object_mut(), params.as_object()) {
p.extend(extra.clone());
}
s.execute("document.open", &p)
}
/// The `.ai` private data of `data`, compressed as older `.ai` files compress it.
fn compressed(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
[&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat()
}
fn names(nodes: &[Arc<Node>]) -> Vec<String> {
nodes.iter().map(|n| n.name.clone().unwrap_or_default()).collect()
}
/// The sample's structure, as [`ai::sample_data`] builds it.
fn check_sample(s: &Session) {
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art", "Hidden"]);
assert_eq!(doc.artboards.len(), 1);
assert_eq!(doc.artboards[0].rect, vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0));
let art = doc.layers[0].children().unwrap();
assert_eq!(names(art), ["Pair", "Ring", "Window"]);
let pair = art[0].children().unwrap();
assert_eq!(names(pair), ["Red", ""]);
assert!(pair[0].visible && !pair[1].visible, "the hidden member stays hidden");
assert!(matches!(art[1].kind, NodeKind::Compound { .. }));
assert!(matches!(&art[2].kind, NodeKind::Group { clip: true, children } if matches!(children[0].kind, NodeKind::Path { clipping: true, .. })));
let hidden = &doc.layers[1];
assert!(!hidden.visible);
let kids = hidden.children().unwrap();
assert_eq!(names(kids), ["Shaded", "", "Sub", ""]);
assert!(matches!(kids[0].appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Gradient(_))));
assert_eq!((kids[1].opacity, kids[1].blend), (0.5, vectorcraft_color::BlendMode::Screen));
assert!(kids[2].locked && matches!(kids[2].kind, NodeKind::Layer { .. }));
let NodeKind::Image(im) = &kids[3].kind else { panic!("an image") };
assert_eq!((im.width, im.height), (2, 1));
}
#[test]
fn an_illustrator_eps_opens_with_its_layers() {
let mut s = Session::new();
let r = open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
assert_eq!((&r["format"], &r["warnings"]), (&json!("eps"), &json!([])), "{r}");
check_sample(&s);
// Saved as EPS and opened again, it comes back the same.
let eps = s.execute("document.exportEps", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(eps["dataBase64"].as_str().unwrap()).unwrap();
let r = open(&mut s, "again.eps", &bytes, json!({})).unwrap();
assert_eq!(r["restored"], json!(true), "{r}");
check_sample(&s);
}
#[test]
fn an_illustrator_ai_file_opens_from_its_editing_data() {
let mut s = Session::new();
let file = ai::ai(&compressed(&ai::sample_data()), ai::page_pdf());
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
assert_eq!(r["format"], json!("ai"), "{r}");
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("weren't read")), "{r}");
check_sample(&s);
// Pages picked: the PDF's.
open(&mut s, "art.ai", &file, json!({"pages": "1"})).unwrap();
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
}
/// A 1-bit RGB image opens from the editing data in the colors its PDF part shows.
#[test]
fn a_one_bit_rgb_image_opens_from_the_editing_data_in_its_colors() {
// 3 × 1: red, green, blue (100 010 001), as hex samples in both parts.
let raster = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n[ 60 0 0 60 10 90 ] 3 1 0 Xh\n\
[ 60 0 0 60 10 90 ] 0 0 3 1 3 1 1 3 0 0 0 0 4 4 0 0\n%%BeginData: 6\rXI\n8880\n%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n";
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", raster));
let page = "q 180 0 0 60 10 30 cm BI /W 3 /H 1 /CS /RGB /BPC 1 /F /AHx ID 8880> EI Q";
let file = ai::ai(&compressed(data.as_bytes()), page);
let mut s = Session::new();
let r = open(&mut s, "bits.ai", &file, json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art"], "{r}");
let mut found = vec![];
doc.visit_images(|_, im| {
let img = image::load_from_memory(&doc.images[&im.key].bytes).unwrap().to_rgb8();
found.push(img.pixels().map(|p| p.0).collect::<Vec<_>>());
});
assert_eq!(found, [[[255, 0, 0], [0, 255, 0], [0, 0, 255]]], "{r}");
}
/// A page of a square and a line of type, as a `.ai`'s PDF part draws them.
const SQUARE_AND_TYPE: &str = "0 g 10 10 40 40 re f BT /F1 12 Tf 100 50 Td (Hi) Tj ET";
/// `file` loaded with `clipboard.importPdf` (`editingData` as given) and pasted → the command's
/// reply and the objects the paste selected, in stacking order.
fn paste(s: &mut Session, file: &[u8], editing_data: bool) -> (Value, Vec<Node>) {
let p = json!({"dataBase64": vectorcraft_format::base64_encode(file), "editingData": editing_data});
let r = s.execute("clipboard.importPdf", &p).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let st = s.doc().unwrap();
(r, st.selection.in_paint_order(&st.doc).into_iter().filter_map(|id| st.doc.node(id).cloned()).collect())
}
/// Is any of `nodes` (or an object inside one) type?
fn has_type(nodes: &[Node]) -> bool {
let mut found = false;
nodes.iter().for_each(|n| n.walk(&mut |c| found |= matches!(c.kind, NodeKind::Text(_))));
found
}
/// A `.ai` copied in a file manager pastes the objects of its editing data, as `document.open`
/// reads it: both layers' objects in their stacking order, into the active layer, with their names
/// and hidden members, and its type as type. Without `editingData`, as for a PDF, its page pastes:
/// the shown layer's art without names.
#[test]
fn a_copied_ai_file_pastes_from_its_editing_data() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let file = ai::ai(&compressed(&ai::sample_data()), ai::page_pdf());
let (r, pasted) = paste(&mut s, &file, true);
assert_eq!(r["count"], 7, "{r}");
let pasted_names: Vec<String> = pasted.iter().map(|n| n.name.clone().unwrap_or_default()).collect();
assert_eq!(pasted_names, ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""]);
let pair = pasted[0].children().unwrap();
assert!(pair[0].visible && !pair[1].visible, "the hidden member stays hidden");
assert!(matches!(pasted[5].kind, NodeKind::Layer { .. }) && pasted[5].locked, "the sublayer comes as it is");
assert_eq!(pasted.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, true, true, false, false, false, false], "the hidden layer's objects");
let layers = &s.doc().unwrap().doc.layers;
assert_eq!(layers.len(), 1, "no layer is added");
assert!(pasted.iter().all(|n| layers[0].children().unwrap().iter().any(|c| c.id == n.id)), "into the active layer");
// The same editing data in an EPS: the same objects, the hidden layer's objects hidden.
let eps = ai::eps(&ai::sample_data(), ai::page_ps());
s.execute("clipboard.importEps", &json!({"dataBase64": vectorcraft_format::base64_encode(&eps)})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let st = s.doc().unwrap();
let shown = |nodes: Vec<&Node>| nodes.iter().map(|n| (n.name.clone().unwrap_or_default(), n.visible)).collect::<Vec<_>>();
assert_eq!(shown(st.selection.in_paint_order(&st.doc).into_iter().filter_map(|id| st.doc.node(id)).collect()), shown(pasted.iter().collect()));
let (r, page) = paste(&mut s, &file, false);
assert!(page.iter().all(|n| n.name.is_none()), "{r}");
// A page of text only, or only a placeholder (saved without PDF compatibility): the editing
// data's objects all the same.
for (page, boxed) in [(TEXT_ART, "/ArtBox [0 0 200 100]"), (PLACEHOLDER, "")] {
let (r, pasted) = paste(&mut s, &ai::ai_with(&compressed(&ai::sample_data()), page, boxed), true);
assert_eq!(pasted.first().and_then(|n| n.name.as_deref()), Some("Pair"), "{page}: {r}");
assert_eq!(r["count"], 7, "{page}: {r}");
}
// Type: a text object from the editing data, outlines from the page.
let data = ai::editing_data(
200.0,
100.0,
&ai::layer("Art", &format!("0 g\n{}f\n/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n", ai::rect(10.0, 10.0, 50.0, 50.0))),
);
let typed = ai::ai(&compressed(data.as_bytes()), SQUARE_AND_TYPE);
// (The page's type comes in as type too now that PDF import keeps it, so only this side is told apart.)
let (r, pasted) = paste(&mut s, &typed, true);
assert!(has_type(&pasted), "{r}: {pasted:?}");
// A `.ai` VectorCraft wrote: the document it carries, its hidden square too.
let mut other = Session::new();
other.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
for x in [10, 100] {
other.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 40, "height": 40})).unwrap();
}
other.execute("object.hide", &json!({})).unwrap();
let saved = other.execute("document.export", &json!({"format": "ai"})).unwrap();
let saved = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
let (r, pasted) = paste(&mut s, &saved, true);
assert_eq!(pasted.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, false], "{r}");
let (r, page) = paste(&mut s, &saved, false);
assert!(page.iter().all(|n| n.visible), "{r}");
}
/// A PDF saved with the editing data opens from it, as a `.ai` does (#868).
#[test]
fn a_pdf_saved_with_the_editing_data_opens_from_it() {
let mut s = Session::new();
let file = ai::pdf(&compressed(&ai::sample_data()), ai::page_pdf());
let r = open(&mut s, "art.pdf", &file, json!({})).unwrap();
assert_eq!((&r["format"], &r["warnings"]), (&json!("pdf"), &json!([])), "{r}");
check_sample(&s);
// Without it: the PDF part.
let r = open(&mut s, "art.pdf", &file, json!({"editingData": false})).unwrap();
let layers = &s.doc().unwrap().doc.layers;
assert_eq!(layers.len(), 1, "{r}");
assert_ne!(names(layers), ["Art"]);
}
/// Art crossing the page's edge opens whole from a PDF's editing data, without the note that the
/// PDF's art was clipped to the page; without the editing data it is clipped, with the note.
#[test]
fn a_pdfs_art_past_the_page_opens_whole_from_its_editing_data() {
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n", ai::rect(150.0, 20.0, 250.0, 80.0))));
let file = ai::pdf(data.as_bytes(), "0 g 150 20 100 60 re f");
let mut s = Session::new();
let r = open(&mut s, "past.pdf", &file, json!({})).unwrap();
assert_eq!(r["warnings"], json!([]), "{r}");
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), ["Art"]);
assert!(doc.art_bounds().is_some_and(|b| b.x1 > 200.5), "{:?}", doc.art_bounds());
let r = open(&mut s, "past.pdf", &file, json!({"editingData": false})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE]), "{r}");
assert!(s.doc().unwrap().doc.art_bounds().is_some_and(|b| b.x1 < 200.5));
}
/// A `.ai` or a PDF whose editing data can't be decompressed opens from its PDF part with a note
/// that its layers weren't read, and an export to its own path is refused unless it is given
/// `acknowledgeLoss`.
#[test]
fn a_file_whose_editing_data_cant_be_decompressed_is_not_written_over_unasked() {
let d = std::env::temp_dir().join(format!("vc-aiundecoded-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let damaged = b"%AI12_CompressedDataxx";
for (name, file) in [("art.ai", ai::ai(damaged, ai::page_pdf())), ("art.pdf", ai::pdf(damaged, ai::page_pdf()))] {
let path = d.join(name);
std::fs::write(&path, &file).unwrap();
let path = path.to_string_lossy().to_string();
let mut s = Session::new();
let r = s.execute("document.open", &json!({"path": path})).unwrap();
assert!(r["warnings"].to_string().contains("layers weren't read from its editing data (its zlib compression is damaged)"), "{name}: {r}");
let e = s.execute("document.export", &json!({"path": path})).unwrap_err().to_string();
assert!(e.contains("left things out") && e.contains("layers weren't read"), "{name}: {e}");
assert_eq!(std::fs::read(&path).unwrap(), file, "{name}");
s.execute("document.export", &json!({"path": path, "acknowledgeLoss": true})).unwrap();
assert_ne!(std::fs::read(&path).unwrap(), file, "{name}");
}
std::fs::remove_dir_all(d).ok();
}
/// Editing data that can't be read leaves the file as its PDF part reads, with that part's notes
/// and then a note saying why its layers weren't read: a `.ai`'s art outside its artboards is
/// missing, and a PDF's art past the page is clipped.
#[test]
fn editing_data_that_cant_be_read_keeps_the_pdf_parts_notes() {
let damaged = b"%AI12_CompressedData not zlib";
let unread = "the file's layers weren't read from its editing data (its zlib compression is damaged): they come from its PDF part";
let mut s = Session::new();
let r = open(&mut s, "damaged.ai", &ai::ai(damaged, ai::page_pdf()), json!({})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::OFF_ARTBOARD_NOTE, unread]), "{r}");
let r = open(&mut s, "damaged.pdf", &ai::pdf(damaged, "0 g 150 20 100 60 re f"), json!({})).unwrap();
assert_eq!(r["warnings"], json!([vectorcraft_pdf::PAST_PAGE_NOTE, unread]), "{r}");
}
/// A copied `.ai` whose editing data can't be read, or isn't there, pastes its page as a PDF
/// does: the same objects as without `editingData`.
#[test]
fn a_copied_ai_file_whose_editing_data_cant_be_read_pastes_its_page() {
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
for (what, private) in
[("damaged", b"%AI12_CompressedData not zlib".to_vec()), ("unread layers", compressed(symbols.as_bytes())), ("none", vec![])]
{
let file = ai::ai(&private, SQUARE_AND_TYPE);
let (r, pasted) = paste(&mut s, &file, true);
let (want, page) = paste(&mut s, &file, false);
assert_eq!((&r["count"], &r["warnings"]), (&want["count"], &want["warnings"]), "{what}");
let kinds = |nodes: &[Node]| nodes.iter().map(|n| std::mem::discriminant(&n.kind)).collect::<Vec<_>>();
assert_eq!(kinds(&pasted), kinds(&page), "{what}");
assert!(!pasted.is_empty(), "{what}: {r}");
}
// Editing data that reads and draws as the page does: its objects, not the page's.
let (r, _) = paste(&mut s, &ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), true);
assert_eq!(r["count"], 7, "{r}");
}
#[test]
fn what_the_reader_doesnt_read_opens_as_before_with_a_warning() {
let symbols = ai::editing_data(200.0, 100.0, &ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n"));
let mut s = Session::new();
let r = open(&mut s, "symbols.eps", &ai::eps(symbols.as_bytes(), ai::page_ps()), json!({})).unwrap();
assert!(r["warnings"].to_string().contains("layers weren't read") && r["warnings"].to_string().contains("symbols"), "{r}");
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Layer 1"]);
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
assert!(r["warnings"].to_string().contains("symbols") && r["warnings"].to_string().contains("PDF part"), "{r}");
}
/// A `.ai` whose document data has a variable library opens with a note that its variables and
/// data sets weren't read, whether its layers are read or it comes in as its PDF part.
#[test]
fn an_ai_file_with_a_variable_library_says_its_variables_werent_read() {
const NOTE: &str = "the file's variables and data sets weren't read (variables: 2, data sets: 3): the Variables panel doesn't list them";
let library = ai::variable_library(&["Name", "Number"], &["One", "Two", "Three"]);
let square = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0));
let notes =
|r: &Value| r["warnings"].as_array().unwrap().iter().filter(|w| w.as_str().unwrap().contains("variables")).cloned().collect::<Vec<_>>();
let mut s = Session::new();
let layered = ai::editing_data(200.0, 100.0, &format!("{library}{}", ai::layer("Art", &square)));
let file = ai::ai(&compressed(layered.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
let r = open(&mut s, "variables.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(notes(&r), [json!(NOTE)], "{r}");
let r = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(r["variables"], json!([]), "{r}");
// Its layers can't be read (a symbol): it comes in as its PDF part, with the note.
let symbols = ai::editing_data(200.0, 100.0, &format!("{library}{}", ai::layer("Art", "/SymbolInstance :\n(Dot) /SymbolRef ,\n;\n")));
let r = open(&mut s, "symbols.ai", &ai::ai(&compressed(symbols.as_bytes()), ai::page_pdf()), json!({})).unwrap();
assert!(r["warnings"].to_string().contains("PDF part"), "{r}");
assert_eq!(notes(&r), [json!(NOTE)], "{r}");
// Without a library, no note.
let plain = ai::editing_data(200.0, 100.0, &ai::layer("Art", &square));
let r = open(&mut s, "plain.ai", &ai::ai(&compressed(plain.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
assert!(notes(&r).is_empty(), "{r}");
}
/// A page showing only a placeholder line, as a `.ai` saved without PDF compatibility has.
const PLACEHOLDER: &str = "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET";
#[test]
fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
let mut s = Session::new();
let r = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({})).unwrap();
assert_eq!(r["format"], json!("ai"), "{r}");
check_sample(&s);
// Its editing data damaged: it says so (the placeholder isn't the art).
let e = open(&mut s, "damaged.ai", &ai::ai(b"%AI12_CompressedData not zlib", PLACEHOLDER), json!({})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
// Without editing data, the placeholder still isn't opened as the art.
let e = open(&mut s, "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
#[test]
fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
let file = ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER);
for pages in [json!("1"), json!(1), json!("1-")] {
let mut s = Session::new();
open(&mut s, "art.ai", &file, json!({"pages": pages})).unwrap();
check_sample(&s);
}
// Without editing data, the placeholder still isn't opened as the art.
let e = open(&mut Session::new(), "plain.ai", &ai::ai(b"", PLACEHOLDER), json!({"pages": "1"})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
/// [`PLACEHOLDER`]'s `.ai` files as `dataBase64`: the sample's editing data, damaged editing data
/// and none.
fn placeholder_files() -> [String; 3] {
[compressed(&ai::sample_data()), b"%AI12_CompressedData not zlib".to_vec(), vec![]]
.map(|private| vectorcraft_format::base64_encode(&ai::ai(&private, PLACEHOLDER)))
}
#[test]
fn an_ai_file_saved_without_its_pdf_part_pastes_from_its_editing_data() {
let [sample, damaged, plain] = placeholder_files();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// clipboard.importPdf (the PDF flavor's command) takes every object of both layers, as
// document.open reads them.
let r = s.execute("clipboard.importPdf", &json!({"dataBase64": sample})).unwrap();
assert_eq!(r["count"], json!(7), "{r}");
s.execute("edit.paste", &json!({})).unwrap();
let art = s.doc().unwrap().doc.layers[0].children().unwrap().clone();
assert_eq!(names(&art), ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""]);
assert_eq!(art.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, true, true, false, false, false, false], "the hidden layer's objects");
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": damaged})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": plain})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
#[test]
fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() {
let [sample, damaged, plain] = placeholder_files();
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
// The place takes the art of the layers that show on the artboard and clips it to the artboard.
let r = s.execute("file.place", &json!({"name": "art.ai", "dataBase64": sample, "link": false})).unwrap();
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}");
let placed = s.doc().unwrap().doc.layers[0].children().unwrap()[0].clone();
let kids = placed.children().unwrap();
assert_eq!(names(&kids[1..]), ["Pair", "Ring", "Window"], "after the clipping path");
// With `crop: "bounding"` the size is the bounds of the art that shows. They run from the red
// square's left edge to the window's right one; the pair's hidden square, stroked 2 pt wide,
// is left out.
let r = s.execute("file.place.info", &json!({"name": "art.ai", "dataBase64": sample, "crop": "bounding"})).unwrap();
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(170.0), Some(40.0)), "{r}");
// The place takes the art of both layers that show.
let square = |x: f64| format!("0 g\n{}f\n", ai::rect(x, 10.0, x + 40.0, 50.0));
let two = ai::editing_data(200.0, 100.0, &[ai::layer("Back", &square(10.0)), ai::layer("Front", &square(110.0))].concat());
let two = vectorcraft_format::base64_encode(&ai::ai(&compressed(two.as_bytes()), PLACEHOLDER));
let r = s.execute("file.place.info", &json!({"name": "two.ai", "dataBase64": two, "crop": "bounding"})).unwrap();
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(140.0), Some(40.0)), "{r}");
// The file has two 100-point artboards and one placeholder page. The second artboard lies 50
// below the first. A square of 40 lies on the first and an oblong of 60 × 40 on the second.
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
let art = format!("0 g\n{}f\n{}f\n", ai::rect(10.0, 10.0, 50.0, 50.0), ai::rect(10.0, -140.0, 70.0, -100.0));
let paged = ai::editing_data(100.0, 100.0, &(boards.to_string() + &ai::layer("Art", &art)));
let paged = vectorcraft_format::base64_encode(&ai::ai(&compressed(paged.as_bytes()), PLACEHOLDER));
let mut size = |page: u64| {
let r = s.execute("file.place.info", &json!({"name": "boards.ai", "dataBase64": paged, "page": page, "crop": "bounding"}));
r.map(|r| (r["width"].as_f64(), r["height"].as_f64())).map_err(|e| e.to_string())
};
assert_eq!(size(1), Ok((Some(40.0), Some(40.0))));
assert_eq!(size(2), Ok((Some(60.0), Some(40.0))));
let third = size(3);
assert!(third.as_ref().is_err_and(|e| e.contains("has 2 page(s)")), "{third:?}");
let e = s.execute("file.place", &json!({"name": "damaged.ai", "dataBase64": damaged})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
let e = s.execute("file.place", &json!({"name": "plain.ai", "dataBase64": plain})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
/// The kinds of what the first layer holds: type (or its outlines, without the font) and others.
fn text_and_others(s: &Session) -> (usize, usize) {
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
let text = art.iter().filter(|n| matches!(n.kind, NodeKind::Text(_)) || n.name.as_deref() == Some("<Text Outlines>")).count();
(text, art.len() - text)
}
#[test]
fn an_ai_file_whose_pdf_part_is_only_text_opens_from_it_when_its_editing_data_cant_be_read() {
// #1387. The page states where its art is (an art, trim or bleed box), which the notice
// page of a file saved without its PDF part has no art to do.
let unreadable = b"%AI24_ZStandard_Data\0\0\0\0\0\0\0\0";
for boxed in ["/ArtBox [0 0 200 100]", "/TrimBox [0 0 200 100]", "/BleedBox [0 0 200 100]"] {
let mut s = Session::new();
let r = open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({})).unwrap();
// Then a note on the font, where the machine lacks it.
assert_eq!(r["warnings"][0], json!(vectorcraft_pdf::OFF_ARTBOARD_NOTE), "{boxed}: {r}");
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
// The PDF part alone, as asked.
open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({"editingData": false})).unwrap();
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
}
// The same page without a box is the notice: the file still says why it can't open.
let mut s = Session::new();
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("can't be read"), "{e}");
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({"editingData": false})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
// Placed, it is read as its page too.
let r = s.execute("file.new", &json!({})).and_then(|_| {
let data = vectorcraft_format::base64_encode(&ai::ai_with(unreadable, TEXT_ART, "/ArtBox [0 0 200 100]"));
s.execute("file.place", &json!({"name": "text.ai", "dataBase64": data, "link": false}))
});
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn an_ai_file_whose_pdf_part_is_only_text_still_opens_from_editing_data_that_reads() {
let mut s = Session::new();
open(&mut s, "art.ai", &ai::ai_with(&compressed(&ai::sample_data()), TEXT_ART, "/ArtBox [0 0 200 100]"), json!({})).unwrap();
check_sample(&s);
}
#[test]
fn editing_data_false_opens_only_what_the_page_prints() {
let mut s = Session::new();
for (name, file) in [("art.eps", ai::eps(&ai::sample_data(), ai::page_ps())), ("art.ai", ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()))]
{
let r = open(&mut s, name, &file, json!({"editingData": false})).unwrap();
let layers = &s.doc().unwrap().doc.layers;
assert_eq!(layers.len(), 1, "{name}: {r}");
assert_ne!(names(layers), ["Art"], "{name}: the page's layer");
}
let e = open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({"editingData": "no"})).unwrap_err().to_string();
assert!(e.contains("editingData must be true, false or \"always\""), "{e}");
// `always`: the editing data, as by default (whether it draws as the page does is the reader's).
open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), json!({"editingData": "always"})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
// A file that is only its editing data can't open without it.
let e = open(&mut s, "art.ai", &ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER), json!({"editingData": false})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
}
/// A CMYK image opens with the same ink amounts from a `.ai` file's editing data as from its PDF
/// part.
#[test]
fn a_cmyk_image_keeps_its_inks_from_the_editing_data_as_from_the_pdf_part() {
// 3 × 1: cyan, 100% black, then a mix, as hex samples in both parts.
let inks = [255u8, 0, 0, 0, 0, 0, 0, 255, 12, 34, 56, 78];
let hex: String = inks.iter().map(|b| format!("{b:02x}")).collect();
let raster = format!(
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceCMYK XN\n[ 10 0 0 10 10 60 ] 3 1 0 Xh\n\
[ 10 0 0 10 10 60 ] 0 0 3 1 3 1 8 4 0 0 0 0 4 4 0 0\n%%BeginData: {}\rXI\n{hex}\n%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n",
hex.len() + 2
);
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &raster));
let page = format!("q 30 0 0 10 10 50 cm BI /W 3 /H 1 /CS /CMYK /BPC 8 /F /AHx ID {hex}> EI Q");
let file = ai::ai(&compressed(data.as_bytes()), &page);
let opened = |s: &Session| {
let doc = &s.doc().unwrap().doc;
let mut found = vec![];
doc.visit_images(|_, im| found.push(doc.images[&im.key].cmyk().map(|i| i.data)));
found
};
let mut s = Session::new();
let r = open(&mut s, "cmyk.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(opened(&s), [Some(inks.to_vec())], "from the editing data");
let r = open(&mut s, "cmyk.ai", &file, json!({"editingData": false})).unwrap();
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(opened(&s), [Some(inks.to_vec())], "from the PDF part");
}
#[test]
fn fit_to_artwork_bounds_leaves_out_the_hidden_art_and_guides_the_editing_data_has() {
// A shown square; a hidden one and a long guide far from it, which the page doesn't draw.
let art =
format!("0 g\n{}f\n1 Xw\n{}f\n0 Xw\n-500 20 m\n700 20 L\n(N) *\n", ai::rect(10.0, 10.0, 50.0, 50.0), ai::rect(150.0, 60.0, 190.0, 95.0));
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &art));
let mut s = Session::new();
let r = open(&mut s, "fit.eps", &ai::eps(data.as_bytes(), "0 setgray 10 10 40 40 rectfill\n"), json!({})).unwrap();
let layer = &s.doc().unwrap().doc.layers[0];
let kids = layer.children().unwrap();
assert_eq!(kids.len(), 3, "{r}");
assert!(!kids[1].visible && matches!(kids[2].kind, NodeKind::Path { guide: true, .. }));
s.execute("artboard.fitToArt", &json!({})).unwrap();
// The square, 50 to 90 down the 100-point page.
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(10.0, 50.0, 50.0, 90.0));
}
#[test]
fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part() {
let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n";
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 g\n{}f\n{text}", ai::rect(10.0, 10.0, 50.0, 50.0))));
let file = ai::ai(&compressed(data.as_bytes()), "0 g 10 10 40 40 re f BT /F1 12 Tf 100 50 Td (Hi) Tj ET");
let mut s = Session::new();
// As type, the page's type goes into the text object of the layers.
let r = open(&mut s, "type.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
// As outlines, only the PDF part has them.
let r = open(&mut s, "type.ai", &file, json!({"textAs": "outlines"})).unwrap();
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
}
/// `data` as ASCII85, up to the `~>`.
fn ascii85(data: &[u8]) -> String {
let mut out = String::new();
for chunk in data.chunks(4) {
let mut x = chunk.iter().enumerate().fold(0u32, |v, (i, b)| v | u32::from(*b) << (24 - 8 * i));
let mut d = [0u8; 5];
for c in d.iter_mut().rev() {
*c = (x % 85) as u8 + b'!';
x /= 85;
}
out.extend(d.iter().take(chunk.len() + 1).map(|c| char::from(*c)));
}
out.push_str("~>");
out
}
/// Page content drawing one glyph and then `rest` in 10 pt on the baseline at `y`, from x = 14, in
/// rendering mode `mode` (0 fills the type, 1 strokes it). The glyph is code 0x41 of the font `F2`,
/// standing for the "fi" of the story, and `F2` isn't installed and its `ToUnicode` map gives that
/// glyph U+FFFD, so that the PDF part keeps it as outlines; `rest` is in Helvetica (`F1`).
fn half_outlined_line(y: f64, rest: &str, mode: u8) -> String {
format!("{mode} Tr BT /F2 10 Tf 14 {y} Td (A) Tj ET BT /F1 10 Tf 19 {y} Td ({rest}) Tj ET ")
}
/// A `.ai` whose page draws a gray square and `type_content` (see [`half_outlined_line`]), and whose
/// layer `Art` has the square and text objects: the first of story 0, which `story` gives in the
/// text document (a line starting at (14, 65)), then `more` of a story the text document doesn't
/// have. Without `story` the file has no text document.
fn half_outlined_ai(story: Option<&str>, more: usize, type_content: &str) -> Vec<u8> {
let text = |story: u32| format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n;\n");
let texts: String = std::iter::once(text(0)).chain((0..more).map(|_| text(99))).collect();
let mut body =
format!("%AI3_TemplateBox: 100 50 100 50\n{}", ai::layer("Art", &format!("0.5 g\n{}f\n{texts}", ai::rect(120.0, 20.0, 160.0, 60.0))));
if let Some(story) = story {
// The story's one line starts at the canvas center (8191.5, the template box's center) moved
// to (14, 65): its y runs down the page.
let n = story.chars().count();
let document = format!(
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\
/1 << /1 [ << /0 << /0 ({story}\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {} >> ] >> \
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 10.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 0.0 0.0 1.0 ] >> >> >> >> >> /1 {} >> ] >> >> \
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8105.5 8176.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
<< /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 {} >> >> ] >> ] >> ] >> ] >> ] >> ] >> >> ] /2 << /1 12.0 >> >>\n",
n + 1,
n + 1,
n + 1
);
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
body.push_str(&format!("%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n", lines.join("\n")));
}
let data = ai::editing_data(200.0, 100.0, &body);
let content = format!("0.5 g 120 20 40 40 re f 0.2 g 0.2 G {type_content}");
let page = PdfPage {
resources: format!(
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> \
/F2 << /Type /Font /Subtype /Type1 /BaseFont /VectorCraftTestUnnamed /ToUnicode {} 0 R >> >>",
first_extra(1) + 1
),
entries: format!("/PieceInfo << /Illustrator << /Private << /AIPrivateData1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
..PdfPage::new(200.0, 100.0, &content)
};
let cmap = "begincmap\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n1 beginbfchar\n<41> <FFFD>\nendbfchar\nendcmap\n";
pdf_with(
&[page],
&[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()), &format!("<< /Length {} >>\nstream\n{cmap}endstream", cmap.len())],
None,
)
}
fn outlines_notes(r: &Value) -> Vec<String> {
let notes = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str);
notes.filter(|w| vectorcraft_pdf::is_outlines_note(w)).map(String::from).collect()
}
/// The notes the PDF part of [`half_outlined_ai`] reads with: its glyph of U+FFFD, kept as outlines.
fn pdf_part_outlines_notes() -> [String; 2] {
[format!("text in Vector Craft Test Unnamed {}", vectorcraft_pdf::UNNAMED_CHARACTERS_NOTE), vectorcraft_pdf::OUTLINED_TEXT_NOTE.into()]
}
/// The note on text objects that hold the page's type without some of its characters, for `n` of
/// them.
fn without_outlines_note(n: usize) -> Value {
json!(format!("{n} text objects hold their type as the page draws it, without the characters the page has only as outlines"))
}
/// The texts in the first layer.
fn texts(s: &Session) -> Vec<String> {
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect()
}
/// The note that glyphs were kept as outlines is dropped when the type comes from the editing data's
/// text document, and stays with the PDF part alone.
#[test]
fn type_from_the_text_document_opens_without_the_pdf_parts_note_on_outlines() {
let file = half_outlined_ai(Some("first draft"), 0, &half_outlined_line(65.0, "rst draft", 0));
let mut s = Session::new();
let r = open(&mut s, "draft.ai", &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(texts(&s), ["first draft"], "{r}");
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
let r = open(&mut s, "draft.ai", &file, json!({"editingData": false})).unwrap();
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
// A second text object that takes the page's type, away from the glyph: no note on it.
let page = half_outlined_line(65.0, "rst draft", 0) + "BT /F1 10 Tf 14 30 Td (notes) Tj ET";
let r = open(&mut s, "notes.ai", &half_outlined_ai(Some("first draft"), 1, &page), json!({})).unwrap();
assert_eq!(texts(&s), ["first draft", "notes"], "{r}");
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
}
/// A text object that takes the page's type lacks the glyphs next to it that the page kept as
/// outlines: a note counts such objects, in place of the PDF part's notes. Stroked glyphs that
/// come in with the stroked type keep the PDF part's notes.
#[test]
fn type_from_the_page_opens_with_a_note_on_the_characters_it_has_only_as_outlines() {
let mut s = Session::new();
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 0)), json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(texts(&s), ["rst draft"], "{r}");
assert!(!vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
assert_eq!(outlines_notes(&r), Vec::<String>::new(), "{r}");
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
// Stroked: the glyph comes in with the type.
let r = open(&mut s, "draft.ai", &half_outlined_ai(None, 0, &half_outlined_line(65.0, "rst draft", 1)), json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert!(vectorcraft_pdf::holds_outlines(&s.doc().unwrap().doc), "{r}");
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
assert!(!r["warnings"].to_string().contains("without the characters"), "{r}");
// A stroked line whose glyph comes in, and a filled one whose glyph doesn't.
let page = half_outlined_line(65.0, "rst draft", 1) + &half_outlined_line(30.0, "nd more", 0);
let r = open(&mut s, "both.ai", &half_outlined_ai(None, 1, &page), json!({})).unwrap();
assert_eq!(texts(&s), ["rst draft", "nd more"], "{r}");
assert_eq!(outlines_notes(&r), pdf_part_outlines_notes(), "{r}");
assert!(r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
}
/// A `.ai` saved in units of 10 points: its PDF part's page is 200 × 100 with `/UserUnit 10`, and
/// its editing data has the artboard and a red rectangle in the same units. It opens at 2000 × 1000
/// pt, from its editing data and from its PDF part alone, with the rectangle ten times as large,
/// and `document.pdfInfo` gives the page in points (#1482).
#[test]
fn an_ai_file_with_a_user_unit_opens_at_its_size_in_points() {
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(20.0, 20.0, 60.0, 50.0))));
let file = ai::ai_with(&compressed(data.as_bytes()), "1 0 0 rg 20 20 40 30 re f", "/UserUnit 10");
let near = |r: vectorcraft_geom::Rect, want: [f64; 4]| [r.x0, r.y0, r.x1, r.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01);
let mut s = Session::new();
for (params, layers) in [(json!({}), ["Art"]), (json!({"editingData": false}), ["Page 1"])] {
let r = open(&mut s, "large.ai", &file, params.clone()).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), layers, "{params}: {r}");
assert!(near(doc.artboards[0].rect, [0.0, 0.0, 2000.0, 1000.0]), "{params}: {:?}", doc.artboards[0].rect);
let art = doc.art_bounds().unwrap();
assert!(near(art, [200.0, 500.0, 600.0, 800.0]), "{params}: {art:?}");
}
let v = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
let page = &v["pageInfo"][0];
assert_eq!((page["width"].as_f64(), page["height"].as_f64()), (Some(2000.0), Some(1000.0)), "{v}");
assert_eq!(page["boxes"]["media"], json!([0.0, 0.0, 2000.0, 1000.0]), "{v}");
}
/// A `.ai` of a 200 × 100 pt artboard with a 10 mm bleed: its PDF part's page is the artboard
/// (its trim box) grown by the bleed, and a red square reaches 20 pt into the bleed, on the page
/// as in the editing data. `key`: `AIPrivateData`, or `AIPDFPrivateData` for a PDF saved with its
/// editing data.
fn bled_ai(bleed: f64, key: &str) -> Vec<u8> {
let art = format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(-20.0, -20.0, 60.0, 50.0));
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &art));
let (w, h) = (200.0 + 2.0 * bleed, 100.0 + 2.0 * bleed);
let page = PdfPage {
bleed: Some([0.0, 0.0, w, h]),
trim: Some([bleed, bleed, 200.0 + bleed, 100.0 + bleed]),
entries: format!("/PieceInfo << /Illustrator << /Private << /{key}1 {} 0 R /NumBlock 1 >> >> >>", first_extra(1)),
..PdfPage::new(w, h, &format!("1 0 0 rg {0} {0} 80 70 re f", bleed - 20.0))
};
pdf_with(&[page], &[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len())], None)
}
#[test]
fn a_file_with_a_bleed_opens_on_its_artboard_with_the_bleed_round_it() {
// A `.ai`, and a PDF saved with its editing data (#1345), which has it under other keys.
for (name, key) in [("bled.ai", "AIPrivateData"), ("bled.pdf", "AIPDFPrivateData")] {
bled_file_opens_on_its_artboard(name, key);
}
}
fn bled_file_opens_on_its_artboard(name: &str, key: &str) {
let bleed = 10.0 * 72.0 / 25.4;
let file = bled_ai(bleed, key);
let mut s = Session::new();
// From the editing data, and from the PDF part alone.
for params in [json!({}), json!({"editingData": false})] {
let r = open(&mut s, name, &file, params.clone()).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.artboards.len(), 1, "{name} {params}: {r}");
let ab = doc.artboards[0].rect;
assert!((ab.width() - 200.0).abs() < 0.01 && (ab.height() - 100.0).abs() < 0.01, "{name} {params}: {ab:?} {r}");
assert!(doc.setup.bleed.iter().all(|b| (b - bleed).abs() < 0.01), "{name} {params}: {:?}", doc.setup.bleed);
// The square reaches into the bleed, whole.
let art = doc.art_bounds().unwrap();
let want = [ab.x0 - 20.0, ab.y0 + 50.0, ab.x0 + 60.0, ab.y0 + 120.0];
assert!([art.x0, art.y0, art.x1, art.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{name} {params}: {art:?}");
assert!(doc.setup.bleed_rect(ab).inflate(0.01, 0.01).contains_rect(art), "{name} {params}: {art:?}");
}
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Page 1"], "{name}: editingData: false reads the PDF part");
open(&mut s, name, &file, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{name}: the editing data's layers are read");
}
/// The rows that open in the Layers panel (layers, sublayers and groups), and which of them are
/// open.
fn open_rows(s: &Session) -> (Vec<String>, Vec<String>) {
let st = s.doc().unwrap();
let (mut rows, mut open) = (vec![], vec![]);
st.doc.walk(|n| {
if n.children().is_some() {
rows.push(n.name.clone().unwrap_or_default());
if st.layers_open.contains(n.id) {
open.push(n.name.clone().unwrap_or_default());
}
}
});
(rows, open)
}
/// #1475: a `.ai` another app wrote keeps no open rows VectorCraft reads, so it opens with every
/// row closed, its top-level layers as its groups; a `.ai` VectorCraft wrote, and other formats,
/// open as before (with their top-level layers open unless the file says otherwise).
#[test]
fn an_illustrator_ai_file_opens_with_its_layers_closed() {
let mut s = Session::new();
for file in [ai::ai(&compressed(&ai::sample_data()), ai::page_pdf()), ai::eps(&ai::sample_data(), ai::page_ps())] {
let r = open(&mut s, "art.ai", &file, json!({})).unwrap();
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(false)), "{r}");
let (rows, open) = open_rows(&s);
assert!(rows.contains(&"Art".into()) && rows.contains(&"Pair".into()) && rows.contains(&"Sub".into()), "{rows:?}");
assert_eq!(open, Vec::<String>::new(), "{r}");
}
// The same art as an EPS: its top-level layers open.
open(&mut s, "art.eps", &ai::eps(&ai::sample_data(), ai::page_ps()), json!({})).unwrap();
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
// Saved as .ai by VectorCraft, it opens with the rows it was saved with.
let saved = s.execute("document.save", &json!({"format": "ai"})).unwrap();
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
let r = open(&mut s, "again.ai", &bytes, json!({})).unwrap();
assert_eq!(r["restored"], json!(true), "{r}");
assert_eq!(open_rows(&s).1, ["Art", "Hidden"]);
}