mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 09:33:36 +00:00
The editing copy of a .ai or .ait file that VectorCraft saves or exports now has `%_<n> /Int (CropAreaActive) ,` after the ArtboardArray in its recorded part, and marks that artboard as the selected one. The app records its view's active artboard before each command (DocState::active_artboard; an index past the last artboard is the last, as in the Artboards panel); without a view, the artboard the document opened on (Document::opened_artboard) is written, else the first. A .ai file read from its editing copy opens on that artboard. Before, the copy named no active artboard and always marked the first as selected. Docs: docs/mcp.md, docs/file-format-parity.md, the editingData option text.
1765 lines
105 KiB
Rust
1765 lines
105 KiB
Rust
//! 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, NodeId, 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"]);
|
||
}
|
||
|
||
/// Opening a `.ai` or a PDF whose PDF part reads, or a `.ai` saved without PDF compatibility,
|
||
/// reads and checks the file once, on any thread, for the document it may carry, its pages, its
|
||
/// editing data and its File Info, with pages picked or not.
|
||
#[test]
|
||
fn an_ai_or_pdf_file_is_read_once_when_it_opens() {
|
||
let ai_file = ai::ai(&compressed(&ai::sample_data()), ai::page_pdf());
|
||
let pdf_file = pdf_with(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &[], None);
|
||
let placeholder = ai::ai(&compressed(&ai::sample_data()), PLACEHOLDER);
|
||
for (name, file) in [("art.ai", &ai_file), ("art.pdf", &pdf_file), ("placeholder.ai", &placeholder)] {
|
||
for opts in [LoadOptions::default(), LoadOptions { pages: Some("1".into()), ..Default::default() }] {
|
||
let before = vectorcraft_pdf::reads_of(file);
|
||
let loaded = load_with(name, file, &opts).unwrap();
|
||
assert_eq!(vectorcraft_pdf::reads_of(file) - before, 1, "{name}, pages {:?}", opts.pages);
|
||
let editing_data = name == "placeholder.ai" || (name == "art.ai" && opts.pages.is_none());
|
||
assert_eq!(loaded.editing_data, editing_data, "{name}, pages {:?}", opts.pages);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_ai_file_opens_with_its_artboard_options_raster_settings_and_author() {
|
||
let document = "%_/Document :\n%_/Dictionary :\n%_/Dictionary :\n%_150 /Int (dpi.) ,\n%_1 /Bool (mask) ,\n%_; (AI Auto Rasterize) ,\n\
|
||
%_/Array :\n%_/Dictionary :\n%_3 /Int (DisplayMark) ,\n%_1.33 /Real (PAR) ,\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n\
|
||
%_200 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_(Board) /UnicodeString (Name) ,\n%_; ,\n%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
|
||
// The sample (it holds binary samples) with `insert` before the first `at`.
|
||
let before = |data: Vec<u8>, at: &[u8], insert: &str| {
|
||
let i = data.windows(at.len()).position(|w| w == at).unwrap();
|
||
[&data[..i], insert.as_bytes(), &data[i..]].concat()
|
||
};
|
||
let data = before(before(ai::sample_data(), b"%%EndComments", "%%For: (Ann Lee) (Studio)\n"), b"%AI5_BeginLayer", document);
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "art.ai", &ai::ai(&data, ai::page_pdf()), json!({})).unwrap();
|
||
check_sample(&s);
|
||
let d = &s.doc().unwrap().doc;
|
||
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("weren't read")), "{r}");
|
||
let b = &d.artboards[0];
|
||
assert_eq!((b.name.as_str(), b.show_center_mark, b.show_cross_hairs, b.show_video_safe_areas), ("Board", true, true, false));
|
||
assert_eq!(b.video_ruler_pixel_aspect_ratio, 1.33);
|
||
assert_eq!((d.raster_effects_ppi, d.raster_effects.clipping_mask), (150.0, true));
|
||
assert_eq!(d.metadata.author, "Ann Lee");
|
||
}
|
||
|
||
#[test]
|
||
fn an_ai_file_opens_with_its_active_artboard() {
|
||
let board = |x: i32, name: &str| {
|
||
format!(
|
||
"%_/Dictionary :\n%_{x} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_({name}) /UnicodeString (Name) ,\n%_; ,\n",
|
||
x + 200
|
||
)
|
||
};
|
||
let document = format!(
|
||
"%_/Document :\n%_/Dictionary :\n%_/Array :\n{}{}%_; (ArtboardArray) ,\n%_1 /Int (CropAreaActive) ,\n%_; /NotRecorded ,\n%_;\n",
|
||
board(0, "One"),
|
||
board(300, "Two")
|
||
);
|
||
let data = ai::sample_data();
|
||
let at = data.windows(15).position(|w| w == b"%AI5_BeginLayer").unwrap();
|
||
let data = [&data[..at], document.as_bytes(), &data[at..]].concat();
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "art.ai", &ai::ai(&compressed(&data), PLACEHOLDER), json!({})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
let names: Vec<&str> = d.artboards.iter().map(|a| a.name.as_str()).collect();
|
||
assert_eq!(names, ["One", "Two"], "{r}");
|
||
assert_eq!(d.opened_artboard, Some(d.artboards[1].id), "{r}");
|
||
}
|
||
|
||
/// A `.ai` save names the view's active artboard in its editing copy, else the one the document
|
||
/// opened on, else the first; the file read from that copy opens on it.
|
||
#[test]
|
||
fn an_ai_save_names_its_active_artboard() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap();
|
||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||
let ids: Vec<u32> = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect();
|
||
let opts = LoadOptions { native: false, ..Default::default() };
|
||
let active = |s: &mut Session, format: &str| {
|
||
let cmd = if format == "ait" { "file.saveAsTemplate" } else { "file.saveCopy" };
|
||
let saved = s.execute(cmd, &json!({"format": format})).unwrap();
|
||
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
|
||
let l = load::load_with(&format!("a.{format}"), &bytes, &opts).unwrap();
|
||
assert!(l.editing_data && !l.restored, "{:?}", l.warnings);
|
||
assert_eq!(l.doc.artboards.len(), 3);
|
||
l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0)
|
||
};
|
||
assert_eq!(active(&mut s, "ai"), 0);
|
||
// The view's.
|
||
s.doc_mut().unwrap().active_artboard = Some(ids[2]);
|
||
assert_eq!((active(&mut s, "ai"), active(&mut s, "ait")), (2, 2));
|
||
// The one the document opened on, when the view names none or an artboard it doesn't have.
|
||
Arc::make_mut(&mut s.doc_mut().unwrap().doc).opened_artboard = Some(ids[1]);
|
||
for view in [None, Some(99)] {
|
||
s.doc_mut().unwrap().active_artboard = view;
|
||
assert_eq!(active(&mut s, "ai"), 1, "{view:?}");
|
||
}
|
||
// The view's first artboard over the one the document opened on.
|
||
s.doc_mut().unwrap().active_artboard = Some(ids[0]);
|
||
assert_eq!(active(&mut s, "ai"), 0);
|
||
assert_eq!(s.doc().unwrap().doc.opened_artboard, Some(ids[1]), "the document is unchanged");
|
||
}
|
||
|
||
/// A `.ai` export (`document.serialize`, `document.export`, with or without `selectedOnly`) names
|
||
/// the view's active artboard in its editing copy, as a save does.
|
||
#[test]
|
||
fn an_ai_export_names_its_active_artboard() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 60, "height": 40, "artboards": 3})).unwrap();
|
||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||
let ids: Vec<u32> = s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect();
|
||
s.doc_mut().unwrap().active_artboard = Some(ids[2]);
|
||
let opts = LoadOptions { native: false, ..Default::default() };
|
||
for (cmd, selected) in [("document.serialize", false), ("document.serialize", true), ("document.export", false)] {
|
||
let out = s.execute(cmd, &json!({"format": "ai", "selectedOnly": selected})).unwrap();
|
||
let bytes = vectorcraft_format::base64_decode(out["dataBase64"].as_str().unwrap()).unwrap();
|
||
let l = load::load_with("a.ai", &bytes, &opts).unwrap();
|
||
assert!(l.editing_data && !l.restored, "{cmd}: {:?}", l.warnings);
|
||
let active = l.doc.opened_artboard.and_then(|id| l.doc.artboards.iter().position(|a| a.id == id));
|
||
assert_eq!(active, Some(2), "{cmd} {selected}");
|
||
}
|
||
assert_eq!(s.doc().unwrap().doc.opened_artboard, None, "the document is unchanged");
|
||
}
|
||
|
||
#[test]
|
||
fn an_ai_unread_group_keeps_its_layers_and_live_neighbor() {
|
||
let live = format!("0 0 1 0 k\n{}f", ai::rect(10.0, 10.0, 14.0, 14.0));
|
||
let unread = format!("u\n0 0 1 0 k\n{}f\nZZ\nU\n{}", ai::rect(50.0, 10.0, 54.0, 14.0), ai::named("Unread"));
|
||
let data = ai::editing_data(200.0, 100.0, &(ai::layer("Live", &live) + &ai::layer("Mixed", &unread)));
|
||
let page = "0 0 1 0 k 10 10 4 4 re f 1 0 0 0 k 50 10 4 4 re f";
|
||
let mut session = Session::new();
|
||
let result = open(&mut session, "unread.ai", &ai::ai(&compressed(data.as_bytes()), page), json!({})).unwrap();
|
||
let doc = &session.doc().unwrap().doc;
|
||
assert_eq!(names(&doc.layers), ["Live", "Mixed"], "{result}");
|
||
assert!(matches!(doc.layers[0].children().unwrap()[0].kind, NodeKind::Path { .. }), "{result}");
|
||
assert!(matches!(doc.layers[1].children().unwrap()[0].kind, NodeKind::Group { .. }), "{result}");
|
||
assert_eq!(doc.layers[1].children().unwrap()[0].name.as_deref(), Some("Unread"), "{result}");
|
||
let replacement = &doc.layers[1].children().unwrap()[0].children().unwrap()[0];
|
||
assert!(
|
||
matches!(replacement.appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill))
|
||
if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0))),
|
||
"{result}"
|
||
);
|
||
assert!(result["warnings"].to_string().contains("`ZZ` (1)"), "{result}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_differing_object_keeps_the_layers_with_a_note() {
|
||
let square = |cmyk: &str, x: f64| format!("{cmyk} k\n{}f\n", ai::rect(x, 40.0, x + 16.0, 56.0));
|
||
let front = square("0 0 0 1", 90.0) + &square("0 0 1 0", 160.0);
|
||
let data = ai::editing_data(200.0, 100.0, &(ai::layer("Back", &square("0 0 1 0", 20.0)) + &ai::layer("Front", &front)));
|
||
let page = "0 0 1 0 k 20 40 16 16 re f 1 0 0 0 k 90 40 16 16 re f 0 0 1 0 k 160 40 16 16 re f";
|
||
let mut session = Session::new();
|
||
let result = open(&mut session, "differs.ai", &ai::ai(&compressed(data.as_bytes()), page), json!({})).unwrap();
|
||
let doc = &session.doc().unwrap().doc;
|
||
assert_eq!(names(&doc.layers), ["Back", "Front"], "{result}");
|
||
let front = doc.layers[1].children().unwrap();
|
||
assert!(matches!(front[0].kind, NodeKind::Group { .. }) && matches!(front[1].kind, NodeKind::Path { .. }), "{result}");
|
||
let replacement = &front[0].children().unwrap()[0];
|
||
assert!(
|
||
matches!(replacement.appearance.items.first(), Some(vectorcraft_doc::AppearanceItem::Fill(fill))
|
||
if fill.paint == vectorcraft_color::Paint::solid(vectorcraft_color::Color::cmyk(1.0, 0.0, 0.0, 0.0))),
|
||
"{result}"
|
||
);
|
||
assert!(
|
||
result["warnings"].to_string().contains("objects that drew differently from the file's own page came in as its art: Front (1)"),
|
||
"{result}"
|
||
);
|
||
// An export to the file's own path is refused unless it is given `acknowledgeLoss`.
|
||
let d = std::env::temp_dir().join(format!("vc-aidiffers-{}", std::process::id()));
|
||
std::fs::create_dir_all(&d).unwrap();
|
||
let path = d.join("differs.ai");
|
||
let file = ai::ai(&compressed(data.as_bytes()), page);
|
||
std::fs::write(&path, &file).unwrap();
|
||
let path = path.to_string_lossy().to_string();
|
||
let mut s = Session::new();
|
||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||
let e = s.execute("document.export", &json!({"path": path})).unwrap_err().to_string();
|
||
assert!(e.contains("left things out") && e.contains("objects that drew differently"), "{e}");
|
||
assert_eq!(std::fs::read(&path).unwrap(), file);
|
||
s.execute("document.export", &json!({"path": path, "acknowledgeLoss": true})).unwrap();
|
||
assert_ne!(std::fs::read(&path).unwrap(), file);
|
||
std::fs::remove_dir_all(d).ok();
|
||
}
|
||
|
||
/// A palette of three swatches and a color group `Inks` holding a fourth.
|
||
const INKS_PALETTE: &str = "%AI5_BeginPalette\n0 0 Pb\n0 0 0 0 1 1 1 Xa\n(White)\nPc\n0.04 0.3 0 0 81.96 21 -8 (Pink ink) 0 2 Xx\n(Pink ink)\nPc\n\
|
||
0.06 0.98 1 0 (Red ink) 0 x\n(Red ink)\nPc\n1 (Inks) 1 Pg\n0.02 0.1 1 0 (Yellow ink) 0 x\n(Yellow ink)\nPc\nPB\n%AI5_EndPalette\n";
|
||
|
||
/// A `.ai` file opens with the swatches its palette holds, spot colours the art doesn't use and colour
|
||
/// groups included, in place of the default swatches; art painted with Registration (`XZ`) still
|
||
/// opens from the editing data.
|
||
#[test]
|
||
fn an_ai_file_opens_with_the_swatches_of_its_palette() {
|
||
// The square the page draws, in the red ink, after a stroke colour of Registration.
|
||
let art = format!("0 0 0 0 0 0 0 ([Registration]) 0 1 XZ\n0.06 0.98 1 0 (Red ink) 0 x\n{}f\n", ai::rect(10.0, 50.0, 40.0, 90.0));
|
||
let data = ai::editing_data(200.0, 100.0, &format!("{INKS_PALETTE}{}", ai::layer("Art", &art)));
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "inks.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
|
||
assert_eq!(r["format"], json!("ai"), "{r}");
|
||
let doc = &s.doc().unwrap().doc;
|
||
assert_eq!(names(&doc.layers), ["Art"], "opened from the editing data: {r}");
|
||
let swatches: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect();
|
||
assert_eq!(swatches, ["[None]", "White", "Pink ink", "Red ink"]);
|
||
let pink = doc.swatch("Pink ink").unwrap();
|
||
assert!(pink.spot && pink.global);
|
||
assert_eq!(pink.paint.color(), Some(vectorcraft_color::Color::lab(81.96, 21.0, -8.0)), "a Lab ink keeps its Lab values");
|
||
let groups: Vec<&str> = doc.swatch_groups.iter().map(|g| g.name.as_str()).collect();
|
||
assert_eq!(groups, ["Inks"]);
|
||
assert!(doc.swatch("Yellow ink").is_some_and(|s| s.spot), "an ink the art doesn't use");
|
||
let red = &doc.layers[0].children().unwrap()[0];
|
||
assert!(
|
||
matches!(red.appearance.fill().map(|f| &f.paint), Some(vectorcraft_color::Paint::Solid { swatch: Some(n), .. }) if n == "Red ink"),
|
||
"{:?}",
|
||
red.appearance.fill()
|
||
);
|
||
}
|
||
|
||
/// A `.ai` opens with the graphic styles of its style list, in the list's order, and an object read
|
||
/// as itself with a listed style is linked to it.
|
||
#[test]
|
||
fn an_ai_file_opens_with_the_graphic_styles_of_its_style_list() {
|
||
let style = |name: &str, xa: &str| {
|
||
format!(
|
||
"/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||
/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n\
|
||
/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n;\n /Part ,\n;\n"
|
||
)
|
||
};
|
||
let styles = format!(
|
||
"%AI9_BeginArtStyles\n{}{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(Red)\n(Blue)\n%AI9_EndArtStyleList\n",
|
||
style("Blue", "0 0 0 0 0 0 1 Xa\n"),
|
||
style("Red", "0 0 0 0 1 0 0 Xa\n")
|
||
);
|
||
let hidden = ai::rect(10.0, 50.0, 40.0, 90.0).replace('\n', "\n%_");
|
||
let art = format!("0 Ae\nu\n0 0 0 0 1 0 0 Xa\n{}f\nU\n%_{hidden}f\n1 (Red) XW\n", ai::rect(10.0, 50.0, 40.0, 90.0));
|
||
let data = ai::editing_data(200.0, 100.0, &format!("{styles}{}", ai::layer("Art", &art)));
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "styles.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
|
||
let doc = &s.doc().unwrap().doc;
|
||
assert_eq!(names(&doc.layers), ["Art"], "opened from the editing data: {r}");
|
||
let styles: Vec<(&str, u32)> = doc.graphic_styles.iter().map(|g| (g.name.as_str(), g.id)).collect();
|
||
assert_eq!(styles, [("Red", 1), ("Blue", 2)]);
|
||
let square = &doc.layers[0].children().unwrap()[0];
|
||
assert_eq!(square.extras().graphic_style, Some(1), "{square:#?}");
|
||
}
|
||
|
||
/// A `.ai` whose editing data has a palette and no layers, as a swatch library is saved, opens as its
|
||
/// PDF part with the swatches and color groups of its palette in place of the default swatches.
|
||
#[test]
|
||
fn an_ai_file_without_layers_opens_with_the_swatches_of_its_palette() {
|
||
let data = ai::editing_data(200.0, 100.0, INKS_PALETTE);
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "inks.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
|
||
assert_eq!(r["format"], json!("ai"), "{r}");
|
||
let doc = &s.doc().unwrap().doc;
|
||
let swatches: Vec<&str> = doc.swatches.iter().map(|s| s.name.as_str()).collect();
|
||
assert_eq!(swatches, ["[None]", "White", "Pink ink", "Red ink"], "{r}");
|
||
let groups: Vec<&str> = doc.swatch_groups.iter().map(|g| g.name.as_str()).collect();
|
||
assert_eq!(groups, ["Inks"]);
|
||
assert!(doc.swatch("Yellow ink").is_some_and(|s| s.spot));
|
||
}
|
||
|
||
/// Other Library… on a `.ai` swatch library (a palette and no layers) gives the library the swatches
|
||
/// of its palette, a gradient swatch among them, not the default swatches.
|
||
#[test]
|
||
fn a_swatch_library_in_a_ai_file_without_layers_loads_its_palette() {
|
||
let gradient =
|
||
"%AI5_BeginGradient: (Sky)\n(Sky) 0 2 Bd\n[\n%_0 0 0 0 1 1 1 2 1 6 50 0 Bs\n%_0 0 0 1 0 0 0 2 0.5 6 50 100 Bs\nBD\n%AI5_EndGradient\n";
|
||
let palette = INKS_PALETTE.replace("0 0 Pb\n", "0 0 Pb\nBb\n2 (Sky) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB\n(Sky)\nPc\n");
|
||
let data = ai::editing_data(200.0, 100.0, &format!("{gradient}{palette}"));
|
||
let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
|
||
let mut s = Session::new();
|
||
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "inks.ai"});
|
||
let r = s.execute("swatch.library.load", &p).unwrap();
|
||
assert_eq!(r["count"], json!(5), "{r}");
|
||
let lib = s.execute("swatch.library.get", &json!({"library": r["library"]})).unwrap();
|
||
let swatches: Vec<&str> = lib["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect();
|
||
assert_eq!(swatches, ["Sky", "White", "Pink ink", "Red ink", "Yellow ink"], "{lib}");
|
||
assert_eq!(lib["groups"][0]["name"], json!("Inks"), "{lib}");
|
||
let doc = s.execute("document.open", &json!({"name": "inks.ai", "dataBase64": p["dataBase64"]}));
|
||
assert!(doc.is_ok(), "{doc:?}");
|
||
let sky = s.doc().unwrap().doc.swatch("Sky").map(|w| w.paint.clone());
|
||
assert!(matches!(sky, Some(vectorcraft_color::Paint::Gradient(_))), "{sky:?}");
|
||
}
|
||
|
||
/// Other Library… on a `.ai` graphic style library (art styles, their list and no layers) gives the
|
||
/// library the styles of its list, in the list's order, not the default styles; the file opens with
|
||
/// them too.
|
||
#[test]
|
||
fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() {
|
||
let style = |name: &str, xa: &str| {
|
||
format!(
|
||
"/KnownStyle :\n({name}) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\
|
||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n"
|
||
)
|
||
};
|
||
let styles = format!(
|
||
"%AI9_BeginArtStyles\n{}{}{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n%AI9_EndArtStyleList\n",
|
||
style("Blue", "0 0 0 0 0 0 1 Xa\n"),
|
||
style("Green", "0 0 0 0 0 1 0 Xa\n"),
|
||
style("Red", "0 0 0 0 1 0 0 Xa\n")
|
||
);
|
||
let data = ai::editing_data(200.0, 100.0, &styles);
|
||
let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
|
||
let mut s = Session::new();
|
||
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fills.ai"});
|
||
let r = s.execute("graphicStyle.loadLibrary", &p).unwrap();
|
||
assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(3)), "{r}");
|
||
let lib = s.execute("graphicStyle.library", &json!({"library": r["library"]})).unwrap();
|
||
let names: Vec<&str> = lib["styles"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect();
|
||
assert_eq!(names, ["Red", "Green", "Blue"], "{lib}");
|
||
let r = open(&mut s, "fills.ai", &file, json!({})).unwrap();
|
||
let doc = &s.doc().unwrap().doc;
|
||
let styles: Vec<(&str, u32)> = doc.graphic_styles.iter().map(|g| (g.name.as_str(), g.id)).collect();
|
||
assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3)], "{r}");
|
||
}
|
||
|
||
/// 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 stroke of width 0 (`0 w`) in the editing data is the thinnest line the device draws: the
|
||
/// opened `.ai` shows a hairline where the same path without its stroke shows nothing.
|
||
#[test]
|
||
fn a_zero_width_stroke_in_the_editing_data_draws_a_hairline() {
|
||
let drawn = |art: &str| {
|
||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", art));
|
||
let file = ai::ai(&compressed(data.as_bytes()), "");
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "zero.ai", &file, json!({})).unwrap();
|
||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||
let v = s.execute("document.export", &json!({"format": "png", "background": "transparent"})).unwrap();
|
||
let png = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||
image::load_from_memory(&png).unwrap().to_rgba8().pixels().filter(|p| p.0[3] > 0).count()
|
||
};
|
||
assert!(drawn("0 1 1 0 1 0 0 XA\n0 w\n20 20 m\n180 80 L\nS\n") > 0, "the hairline draws");
|
||
assert_eq!(drawn("0 1 1 0 1 0 0 XA\n0 w\n20 20 m\n180 80 L\nN\n"), 0, "the unstroked path draws nothing");
|
||
}
|
||
|
||
/// 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}");
|
||
}
|
||
|
||
/// Decode parameters the import refuses, for the page's content stream (#4366).
|
||
const REFUSED_CONTENT: &str = "/DecodeParms << /Predictor 12 /Columns 0 >>";
|
||
|
||
/// A `.ai` and a PDF saved with the editing data, carrying `private`, whose page's content stream
|
||
/// the import refuses.
|
||
fn refused_files(private: &[u8], page: &str) -> [(&'static str, Vec<u8>); 2] {
|
||
[("art.ai", ai::ai_with_content(private, page, REFUSED_CONTENT)), ("art.pdf", ai::pdf_with_content(private, page, REFUSED_CONTENT))]
|
||
}
|
||
|
||
/// The note of a file opened from its editing data because its PDF part fails with `e`.
|
||
fn unread_pdf_part(e: &str) -> String {
|
||
format!("the PDF part couldn't be read ({e}): the file opened from its editing data")
|
||
}
|
||
|
||
/// #4366: a `.ai`, or a PDF saved with the editing data, whose PDF part can't be read opens from
|
||
/// its editing data, with a first note saying why. Pages picked, layers off or the editing data
|
||
/// off keep the error, and so does editing data that can't be read.
|
||
#[test]
|
||
fn a_file_whose_pdf_part_cant_be_read_opens_from_its_editing_data() {
|
||
let mut s = Session::new();
|
||
for (name, file) in refused_files(&compressed(&ai::sample_data()), ai::page_pdf()) {
|
||
let e = open(&mut s, name, &file, json!({"editingData": false})).unwrap_err().to_string();
|
||
assert!(e.contains("a page's content in it (object 4) has decode parameters that can't be applied"), "{name}: {e}");
|
||
let r = open(&mut s, name, &file, json!({})).unwrap();
|
||
assert_eq!(r["format"], json!(&name[4..]), "{name}: {r}");
|
||
assert_eq!(r["warnings"], json!([unread_pdf_part(&e)]), "{name}: {r}");
|
||
check_sample(&s);
|
||
assert!(load(name, &file).unwrap().editing_data, "{name}");
|
||
for p in [json!({"pages": "1"}), json!({"layers": false})] {
|
||
assert_eq!(open(&mut s, name, &file, p.clone()).unwrap_err().to_string(), e, "{name}: {p}");
|
||
}
|
||
}
|
||
for (name, file) in refused_files(b"%AI12_CompressedData not zlib", ai::page_pdf()) {
|
||
let plain = open(&mut s, name, &file, json!({"editingData": false})).unwrap_err().to_string();
|
||
assert_eq!(open(&mut s, name, &file, json!({})).unwrap_err().to_string(), plain, "{name}");
|
||
}
|
||
}
|
||
|
||
/// A file saved in units of 10 points (`/UserUnit 10`, as in
|
||
/// `an_ai_file_with_a_user_unit_opens_at_its_size_in_points`) whose PDF part can't be read opens
|
||
/// from its editing data at its size in points.
|
||
#[test]
|
||
fn a_file_whose_pdf_part_cant_be_read_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_entries_and_content(&compressed(data.as_bytes()), "1 0 0 rg 20 20 40 30 re f", "/UserUnit 10", REFUSED_CONTENT);
|
||
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();
|
||
let r = open(&mut s, "large.ai", &file, json!({})).unwrap();
|
||
assert!(r["warnings"][0].as_str().unwrap().starts_with("the PDF part couldn't be read ("), "{r}");
|
||
let doc = &s.doc().unwrap().doc;
|
||
assert!(near(doc.artboards[0].rect, [0.0, 0.0, 2000.0, 1000.0]), "{:?}", doc.artboards[0].rect);
|
||
let art = doc.art_bounds().unwrap();
|
||
assert!(near(art, [200.0, 500.0, 600.0, 800.0]), "{art:?}");
|
||
// On a page turned a quarter, the same.
|
||
let file = ai::ai_with_entries_and_content(&compressed(data.as_bytes()), "", "/UserUnit 10 /Rotate 90", REFUSED_CONTENT);
|
||
open(&mut s, "turned.ai", &file, json!({})).unwrap();
|
||
assert!(near(s.doc().unwrap().doc.artboards[0].rect, [0.0, 0.0, 2000.0, 1000.0]), "{:?}", s.doc().unwrap().doc.artboards[0].rect);
|
||
// A page cropped to the art: its box has the art's size in those units.
|
||
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 cropped = ai::editing_data_in([20.0, 20.0, 60.0, 50.0], [0.0, 0.0, 200.0, 100.0], &ai::layer("Art", &art));
|
||
let file = ai::ai_with_entries_and_content(&compressed(cropped.as_bytes()), "", "/UserUnit 10 /TrimBox [20 20 60 50]", REFUSED_CONTENT);
|
||
open(&mut s, "cropped.ai", &file, json!({})).unwrap();
|
||
let doc = &s.doc().unwrap().doc;
|
||
assert!(near(doc.artboards[0].rect, [0.0, 0.0, 2000.0, 1000.0]), "{:?}", doc.artboards[0].rect);
|
||
assert!(near(doc.art_bounds().unwrap(), [200.0, 500.0, 600.0, 800.0]), "{:?}", doc.art_bounds());
|
||
// Saved in points, it opens as it is.
|
||
let file = ai::ai_with_entries_and_content(&compressed(data.as_bytes()), "", "", REFUSED_CONTENT);
|
||
open(&mut s, "points.ai", &file, json!({})).unwrap();
|
||
assert!(near(s.doc().unwrap().doc.artboards[0].rect, [0.0, 0.0, 200.0, 100.0]));
|
||
}
|
||
|
||
/// Type asked for as outlines in a file whose PDF part can't be read: the editing data's type,
|
||
/// outlined, as for a file saved without its PDF part.
|
||
#[test]
|
||
fn a_file_whose_pdf_part_cant_be_read_outlines_its_type_when_asked() {
|
||
let [(name, file), _] = refused_files(&typed_data(), "");
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, name, &file, json!({})).unwrap();
|
||
assert_eq!(texts_and_paths(&s).0, 1, "by default the editing data's type is type: {r}");
|
||
let r = open(&mut s, name, &file, json!({"textAs": "outlines"})).unwrap();
|
||
let (texts, paths) = texts_and_paths(&s);
|
||
assert_eq!(texts, 0, "no type left: {r}");
|
||
assert!(paths > 0, "the outlines are there: {r}");
|
||
}
|
||
|
||
/// An image too large to read from the editing data of a file whose PDF part can't be read is
|
||
/// left out, and both notes say so.
|
||
#[test]
|
||
fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() {
|
||
let data = String::from_utf8_lossy(&ai::sample_data()).replace("] 0 0 2 1 2 1 8 3", "] 0 0 6000 6000 6000 6000 8 3");
|
||
let [(name, file), _] = refused_files(&compressed(data.as_bytes()), ai::page_pdf());
|
||
let mut s = Session::new();
|
||
let e = open(&mut s, name, &file, json!({"editingData": false})).unwrap_err().to_string();
|
||
let r = open(&mut s, name, &file, json!({})).unwrap();
|
||
assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image too large to read from the editing data was left out"]), "{r}");
|
||
}
|
||
|
||
/// A copied file whose PDF part can't be read pastes the objects of its editing data when asked
|
||
/// for them, with the note; without `editingData` its page can't be read.
|
||
#[test]
|
||
fn a_copied_file_whose_pdf_part_cant_be_read_pastes_from_its_editing_data() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
for (name, file) in refused_files(&compressed(&ai::sample_data()), ai::page_pdf()) {
|
||
let (r, pasted) = paste(&mut s, &file, true);
|
||
assert_eq!(r["count"], 7, "{name}: {r}");
|
||
let pasted: Vec<String> = pasted.iter().map(|n| n.name.clone().unwrap_or_default()).collect();
|
||
assert_eq!(pasted, ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""], "{name}");
|
||
assert!(r["warnings"][0].as_str().unwrap().starts_with("the PDF part couldn't be read (cannot read PDF: "), "{name}: {r}");
|
||
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file)});
|
||
let e = s.execute("clipboard.importPdf", &p).unwrap_err().to_string();
|
||
assert!(e.contains("decode parameters"), "{name}: {e}");
|
||
}
|
||
}
|
||
|
||
/// A file whose PDF part can't be read places its first artboard from its editing data, with the
|
||
/// note; any other page keeps the error.
|
||
#[test]
|
||
fn a_file_whose_pdf_part_cant_be_read_places_from_its_editing_data() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
for (name, file) in refused_files(&compressed(&ai::sample_data()), ai::page_pdf()) {
|
||
let data = vectorcraft_format::base64_encode(&file);
|
||
let r = s.execute("file.place", &json!({"name": name, "dataBase64": data, "link": false})).unwrap();
|
||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{name}: {r}");
|
||
assert!(r["warnings"][0].as_str().unwrap().starts_with("the PDF part couldn't be read ("), "{name}: {r}");
|
||
let placed = s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().clone();
|
||
assert_eq!(names(&placed.children().unwrap()[1..]), ["Pair", "Ring", "Window"], "{name}: after the clipping path");
|
||
for off in [json!({"editingData": false}), json!({"layers": false})] {
|
||
let mut p = json!({"name": name, "dataBase64": data, "link": false});
|
||
p.as_object_mut().unwrap().extend(off.as_object().unwrap().clone());
|
||
let e = s.execute("file.place", &p).unwrap_err().to_string();
|
||
assert!(e.contains("decode parameters"), "{name}: {off}: {e}");
|
||
}
|
||
}
|
||
let [(name, file), _] = refused_files(&typed_data(), "");
|
||
let data = vectorcraft_format::base64_encode(&file);
|
||
let r = s.execute("file.place", &json!({"name": name, "dataBase64": data, "link": false, "textAs": "outlines", "crop": "bounding"})).unwrap();
|
||
assert!(r["warnings"][0].as_str().unwrap().starts_with("the PDF part couldn't be read ("), "{r}");
|
||
let placed = s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().clone();
|
||
let mut texts = 0;
|
||
placed.walk(&mut |n| texts += usize::from(matches!(n.kind, NodeKind::Text(_))));
|
||
assert_eq!(texts, 0, "placed with its type outlined: {r}");
|
||
// Two artboards: the second can't be placed.
|
||
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 [(name, file), _] = refused_files(&compressed(paged.as_bytes()), ai::page_pdf());
|
||
let data = vectorcraft_format::base64_encode(&file);
|
||
let size = |s: &mut Session, page: u64| {
|
||
let r = s.execute("file.place.info", &json!({"name": name, "dataBase64": data, "page": page, "crop": "bounding"}));
|
||
r.map(|r| (r["width"].as_f64(), r["height"].as_f64())).map_err(|e| e.to_string())
|
||
};
|
||
assert_eq!(size(&mut s, 1), Ok((Some(40.0), Some(40.0))));
|
||
let second = size(&mut s, 2);
|
||
assert!(second.as_ref().is_err_and(|e| e.contains("decode parameters")), "{second:?}");
|
||
}
|
||
|
||
/// 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 its variables, data sets, active
|
||
/// data set and lock, whether its layers are read or it comes in as its PDF part: `variable.list`
|
||
/// and `dataset.list` report them. A variable of unknown kind can't be bound. What of the library
|
||
/// can't be read is noted. With its layers read, the object a variable is bound to comes in bound,
|
||
/// and selecting a data set changes it; the bindings to objects the file doesn't have are noted.
|
||
#[test]
|
||
fn an_ai_file_opens_its_variable_library() {
|
||
let library = ai::variable_library_data(&ai::sample_variable_sets("variableAndTypes", &[]));
|
||
let square = format!("0 1 1 0 1 0 0 Xa\n{}f\n{}", ai::rect(10.0, 50.0, 40.0, 90.0), ai::named("path_x5F_1"));
|
||
let notes = |r: &Value| {
|
||
r["warnings"].as_array().map_or(vec![], |w| w.iter().filter(|w| w.as_str().unwrap().contains("variable")).cloned().collect::<Vec<_>>())
|
||
};
|
||
let skipped = |n: usize| {
|
||
json!(format!("the file's variable bindings weren't all read (bindings skipped: {n}): those objects aren't bound to their variables"))
|
||
};
|
||
let check = |s: &mut Session, bound: usize| {
|
||
let r = s.execute("variable.list", &json!({})).unwrap();
|
||
assert_eq!(
|
||
r,
|
||
json!({"variables": [
|
||
{"name": "v_text", "kind": "text", "bindings": 0},
|
||
{"name": "v_visible", "kind": "visibility", "bindings": bound},
|
||
{"name": "v_file", "kind": "image", "bindings": 0},
|
||
{"name": "v_graph", "kind": "graph", "bindings": 0},
|
||
{"name": "v_unbound", "kind": "unknown", "bindings": 0},
|
||
], "locked": true})
|
||
);
|
||
let r = s.execute("dataset.list", &json!({})).unwrap();
|
||
assert_eq!(r["active"], "ds_two", "{r}");
|
||
let names: Vec<&str> = r["datasets"].as_array().unwrap().iter().map(|d| d["name"].as_str().unwrap()).collect();
|
||
assert_eq!(names, ["ds_one", "ds_two", "ds_three"]);
|
||
assert_eq!(
|
||
r["datasets"][1]["values"],
|
||
json!({"v_text": {"text": "line one\nline two & < é"}, "v_visible": {"visible": false}, "v_file": {"image": ai::SAMPLE_MISSING_FILE}})
|
||
);
|
||
};
|
||
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}");
|
||
// The library's text and image variables are bound to objects the file doesn't have.
|
||
assert_eq!(notes(&r), [skipped(2)], "{r}");
|
||
check(&mut s, 1);
|
||
let id = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id;
|
||
assert_eq!(s.doc().unwrap().doc.variables.objects_of("v_visible"), [id]);
|
||
s.execute("dataset.select", &json!({"name": "ds_two"})).unwrap();
|
||
assert!(!s.doc().unwrap().doc.node(id).unwrap().visible, "ds_two hides the square");
|
||
s.execute("dataset.select", &json!({"name": "ds_one"})).unwrap();
|
||
assert!(s.doc().unwrap().doc.node(id).unwrap().visible, "ds_one shows it");
|
||
let e = s.execute("variable.bind", &json!({"variable": "v_unbound", "ids": [id.0]})).unwrap_err();
|
||
assert!(e.to_string().contains("a variable of unknown kind can't be bound to objects"), "{e}");
|
||
// Its layers can't be read (a symbol): it comes in as its PDF part, with the library and none
|
||
// of its bindings.
|
||
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), [skipped(3)], "{r}");
|
||
check(&mut s, 0);
|
||
// A graph value isn't read: the note says how many entries were left out.
|
||
let graph = ai::xml_element("v_graph", &[], &[ai::xml_text(ai::XmlValue::Str("a,b"))]);
|
||
let partly = ai::variable_library_data(&ai::sample_variable_sets("none", &[graph]));
|
||
let layered = ai::editing_data(200.0, 100.0, &format!("{partly}{}", ai::layer("Art", &square)));
|
||
let r = open(&mut s, "graph.ai", &ai::ai(&compressed(layered.as_bytes()), "1 0 0 rg 10 50 30 40 re f"), json!({})).unwrap();
|
||
assert_eq!(
|
||
notes(&r),
|
||
[json!("the file's variable library was partly read (entries skipped: 1): the Variables panel lists the rest"), skipped(2)],
|
||
"{r}"
|
||
);
|
||
// Without a library, no note and no variables.
|
||
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}");
|
||
assert_eq!(s.execute("variable.list", &json!({})).unwrap()["variables"], json!([]));
|
||
}
|
||
|
||
/// An Objects on Path group in a `.ai` file opens as one: its record names the group's children by
|
||
/// their ids in the opened document.
|
||
#[test]
|
||
fn an_ai_file_opens_its_objects_on_path_group_live() {
|
||
let ids = ["9b1c0e7a-0000-4000-8000-000000000001", "9b1c0e7a-0000-4000-8000-000000000002", "9b1c0e7a-0000-4000-8000-000000000003"];
|
||
let options = ai::on_path_options(2, 4, std::f64::consts::FRAC_PI_2, "");
|
||
let record = ai::on_path_record("Adobe Align On Path", &ids, &options); // brand-ok: the record's name in the files
|
||
let group = format!(
|
||
"0 Ae\nu\n/ConstraintGroup :\n128 /GroupType ,\n;\n0 0 1 0 0 0 1 XA\n10 50 m\n190 50 L\nS\n{}0 1 1 0 1 0 0 Xa\n{}f\n{}{}f\n{}U\n",
|
||
ai::on_path_member(ids[0], None, 0.0),
|
||
ai::rect(5.0, 45.0, 15.0, 55.0),
|
||
ai::on_path_member(ids[1], Some(0), 0.0),
|
||
ai::rect(185.0, 45.0, 195.0, 55.0),
|
||
ai::on_path_member(ids[2], Some(1), 0.0),
|
||
);
|
||
let data = ai::editing_data(200.0, 100.0, &format!("{record}{}", ai::layer("Art", &group)));
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "on-path.ai", &ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 5 45 10 10 re f 185 45 10 10 re f"), json!({})).unwrap();
|
||
assert_eq!(r["format"], json!("ai"), "{r}");
|
||
let doc = &s.doc().unwrap().doc;
|
||
let g = &doc.layers[0].children().unwrap()[0];
|
||
let rec = g.extras().objects_on_path.as_deref().expect("an Objects on Path group");
|
||
let kids: Vec<NodeId> = g.children().unwrap().iter().map(|c| c.id).collect();
|
||
assert!(kids.contains(&rec.path) && rec.members.iter().all(|m| kids.contains(&m.id)), "{rec:?} {kids:?}");
|
||
assert_eq!((rec.members.len(), rec.pivot, rec.rotate.round()), (2, 4, 90.0));
|
||
}
|
||
|
||
/// 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 pages")), "{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 an_ai_file_whose_pdf_part_is_cropped_to_its_art_opens_from_its_layers() {
|
||
// A 400 × 200 pt artboard with two red bars across (100, 50)–(300, 150). The PDF part's page
|
||
// is that art's box, 200 × 100 pt (the editing data's bounding box), not the artboard.
|
||
let red = "0 1 1 0 1 0 0 Xa\n";
|
||
let art = ai::layer("Art", &format!("{red}{}f\n{red}{}f\n", ai::rect(100.0, 50.0, 180.0, 150.0), ai::rect(220.0, 50.0, 300.0, 150.0)));
|
||
let data = ai::editing_data_in([100.0, 50.0, 300.0, 150.0], [0.0, 0.0, 400.0, 200.0], &art);
|
||
let page = "1 0 0 rg 0 0 80 100 re f 120 0 80 100 re f";
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "cropped.ai", &ai::ai(&compressed(data.as_bytes()), page), json!({})).unwrap();
|
||
assert!(!r["warnings"].to_string().contains("weren't read"), "{r}");
|
||
let doc = &s.doc().unwrap().doc;
|
||
assert_eq!(names(&doc.layers), ["Art"]);
|
||
assert_eq!(doc.artboards.len(), 1);
|
||
assert_eq!(doc.artboards[0].rect, vectorcraft_geom::Rect::new(0.0, 0.0, 400.0, 200.0), "the file's artboard, not its page");
|
||
let bars = doc.layers[0].children().unwrap();
|
||
assert_eq!(bars.len(), 2);
|
||
let b = bars[0].geometric_bounds().unwrap();
|
||
assert!((b.x0 - 100.0).abs() < 0.01 && (b.y0 - 50.0).abs() < 0.01, "where the artboard has it: {b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn an_ai_file_whose_pdf_part_draws_nothing_opens_from_its_editing_data() {
|
||
// A PDF-compatible `.ai` whose PDF part is a page with nothing on it (no notice text either),
|
||
// with or without the boxes that say where its art is: its editing data is the art.
|
||
for boxed in ["", "/ArtBox [0 0 200 100] /TrimBox [0 0 200 100]"] {
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "blank.ai", &ai::ai_with(&compressed(&ai::sample_data()), "", boxed), json!({})).unwrap();
|
||
assert_eq!(r["warnings"], json!([]), "{boxed:?}: {r}");
|
||
check_sample(&s);
|
||
// The PDF part alone, as asked: its empty page.
|
||
open(&mut s, "blank.ai", &ai::ai_with(&compressed(&ai::sample_data()), "", boxed), json!({"editingData": false})).unwrap();
|
||
let layers = &s.doc().unwrap().doc.layers;
|
||
assert!(layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty())), "{boxed:?}");
|
||
}
|
||
// Editing data that can't be read leaves the empty page, as before.
|
||
let mut s = Session::new();
|
||
open(&mut s, "blank.ai", &ai::ai(b"%AI24_ZStandard_Data\0\0\0\0\0\0\0\0", ""), json!({})).unwrap();
|
||
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]);
|
||
}
|
||
|
||
/// A page that draws nothing only because what it holds couldn't be read (an image that can't be
|
||
/// decoded) isn't an empty page: its notes stay, and the editing data isn't taken in its place
|
||
/// unchecked.
|
||
#[test]
|
||
fn a_page_whose_content_was_skipped_is_not_taken_for_an_empty_one() {
|
||
let broken = "q 100 0 0 50 0 0 cm BI /W 2 /H 2 /CS /RGB /BPC 8 /F /DCT ID notajpeg EI Q";
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "broken.ai", &ai::ai(&compressed(&ai::sample_data()), broken), json!({})).unwrap();
|
||
let warnings = r["warnings"].to_string();
|
||
assert_ne!(r["warnings"], json!([]), "the skipped image is told: {r}");
|
||
assert!(!warnings.contains("saved without PDF compatibility"), "{r}");
|
||
}
|
||
|
||
/// Editing data with one layer, "Art", holding one left-aligned story, "Hi", 14 pt Helvetica,
|
||
/// anchored at the canvas centre (the template box's centre), as the editing data's text document
|
||
/// gives it; compressed.
|
||
fn typed_data() -> Vec<u8> {
|
||
let story = "<< /0 << /0 (Hi\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 3 >> ] >> \
|
||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 3 >> ] >> >> \
|
||
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8191.5 8191.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
|
||
<< /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 3 >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>";
|
||
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 [ {story} ] /2 << /1 12.0 >> >>\n"
|
||
);
|
||
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||
let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n2 /TextAntialiasing ,\n;\n";
|
||
let data = format!(
|
||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
|
||
%AI5_NumLayers: 1\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
|
||
ai::layer("Art", text),
|
||
lines.join("\n")
|
||
);
|
||
compressed(data.as_bytes())
|
||
}
|
||
|
||
/// How many text objects and how many paths the open document holds.
|
||
fn texts_and_paths(s: &Session) -> (usize, usize) {
|
||
let (mut texts, mut paths) = (0, 0);
|
||
s.doc().unwrap().doc.walk(|c| {
|
||
texts += usize::from(matches!(c.kind, NodeKind::Text(_)));
|
||
paths += usize::from(matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
|
||
});
|
||
(texts, paths)
|
||
}
|
||
|
||
/// Type asked for as outlines on a page that draws nothing: the editing data's type, outlined (the
|
||
/// page has none to give).
|
||
#[test]
|
||
fn an_empty_page_with_type_as_outlines_outlines_the_editing_datas_type() {
|
||
let file = ai::ai(&typed_data(), "");
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "typed.ai", &file, json!({})).unwrap();
|
||
assert_eq!(texts_and_paths(&s).0, 1, "by default the editing data's type is type: {r}");
|
||
let r = open(&mut s, "typed.ai", &file, json!({"textAs": "outlines"})).unwrap();
|
||
let (texts, paths) = texts_and_paths(&s);
|
||
assert_eq!(texts, 0, "no type left: {r}");
|
||
assert!(paths > 0, "the outlines are there: {r}");
|
||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers");
|
||
}
|
||
|
||
/// A story of a `.ai` file's text document with no character or paragraph runs is set in
|
||
/// `[Normal Character Style]` and `[Normal Paragraph Style]`; the base attributes give only what
|
||
/// those styles leave out.
|
||
#[test]
|
||
fn an_ai_story_without_runs_takes_the_normal_styles() {
|
||
let story = "<< /0 << /0 (Hi\r) >> \
|
||
/1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8191.5 8191.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \
|
||
<< /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 3 >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>";
|
||
let char_sheets = "/5 << /0 [ << /0 << /0 ([Normal Character Style]) /6 << /0 0 /1 9.0 \
|
||
/53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 0.0 0.0 1.0 ] >> >> >> >> >> ] >>";
|
||
let para_sheets = "/6 << /0 [ << /0 << /0 ([Normal Paragraph Style]) /5 << /1 6.0 /9 false >> >> >> ] >>";
|
||
let document = format!(
|
||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> {char_sheets} {para_sheets} \
|
||
/8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n\
|
||
/1 << /1 [ {story} ] /2 << /1 12.0 /53 << /99 /SimplePaint /0 << /0 0 /1 [ 1.0 1.0 ] >> >> >> /3 << /1 2.0 /9 true >> >>\n"
|
||
);
|
||
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||
let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n2 /TextAntialiasing ,\n;\n";
|
||
let data = format!(
|
||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
|
||
%AI5_NumLayers: 1\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
|
||
ai::layer("Art", text),
|
||
lines.join("\n")
|
||
);
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "plain.ai", &ai::ai(&compressed(data.as_bytes()), ""), json!({})).unwrap();
|
||
let mut texts = vec![];
|
||
s.doc().unwrap().doc.walk(|c| {
|
||
if let NodeKind::Text(t) = &c.kind {
|
||
texts.push((**t).clone());
|
||
}
|
||
});
|
||
let [t] = texts.as_slice() else { panic!("one text object: {r}") };
|
||
assert_eq!(t.plain_text(), "Hi");
|
||
let style = &t.runs[0].style;
|
||
assert_eq!(style.size, 9.0, "{r}");
|
||
assert_eq!(style.fill, vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)));
|
||
assert_eq!((t.para.first_line_indent, t.para.hyphenate), (6.0, false));
|
||
}
|
||
|
||
/// Type asked for as outlines in a `.ai` saved without PDF compatibility (its page only a
|
||
/// placeholder): the editing data's type, outlined, as for an empty page.
|
||
#[test]
|
||
fn a_file_saved_without_its_pdf_part_with_type_as_outlines_outlines_its_type() {
|
||
let file = ai::ai(&typed_data(), PLACEHOLDER);
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "typed.ai", &file, json!({})).unwrap();
|
||
assert_eq!(texts_and_paths(&s).0, 1, "by default the editing data's type is type: {r}");
|
||
let r = open(&mut s, "typed.ai", &file, json!({"textAs": "outlines"})).unwrap();
|
||
let (texts, paths) = texts_and_paths(&s);
|
||
assert_eq!(texts, 0, "no type left: {r}");
|
||
assert!(paths > 0, "the outlines are there: {r}");
|
||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers");
|
||
}
|
||
|
||
/// Type of the editing data outlined keeps its transparency, opacity mask and effects, and is
|
||
/// shown and locked as the type was.
|
||
#[test]
|
||
fn outlined_type_of_the_editing_data_keeps_its_transparency_and_effects() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||
let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||
let mut doc = s.doc().unwrap().doc.as_ref().clone();
|
||
let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true };
|
||
{
|
||
let t = doc.node_mut(id).unwrap();
|
||
t.opacity = 0.5;
|
||
t.blend = vectorcraft_color::BlendMode::Multiply;
|
||
t.isolate = true;
|
||
t.locked = true;
|
||
t.name = Some("Title".into());
|
||
t.appearance.effects = vec![shadow.clone()];
|
||
}
|
||
let opts = LoadOptions { text_as: vectorcraft_pdf::TextAs::Outlines, ..Default::default() };
|
||
let (doc, _) = load::outlined_if_asked(doc, vec![], &opts);
|
||
let mut outlines = vec![];
|
||
doc.walk(|n| {
|
||
if n.extras().type_outlines {
|
||
outlines.push(n.clone());
|
||
}
|
||
});
|
||
let [o] = &outlines[..] else { panic!("one group of outlines: {outlines:?}") };
|
||
assert_eq!((o.opacity, o.blend, o.isolate), (0.5, vectorcraft_color::BlendMode::Multiply, true));
|
||
assert_eq!(o.appearance.effects, [shadow]);
|
||
assert!(o.locked && o.visible);
|
||
assert_eq!(o.name.as_deref(), Some("Title"));
|
||
}
|
||
|
||
/// Threaded type of the editing data, its story all in its first frame as the editing data gives
|
||
/// it, outlined: each frame's outlines show the text that flows into it.
|
||
#[test]
|
||
fn outlined_threaded_type_of_the_editing_data_shows_the_text_of_every_frame() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||
let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(4);
|
||
let a =
|
||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
|
||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||
s.execute("text.thread.create", &json!({})).unwrap();
|
||
// As the editing data gives it: the first frame holds the story, the second nothing yet.
|
||
let mut doc = s.doc().unwrap().doc.as_ref().clone();
|
||
let runs = |id: u64| match &doc.node(NodeId(id)).unwrap().kind {
|
||
NodeKind::Text(t) => t.runs.clone(),
|
||
_ => panic!("area type"),
|
||
};
|
||
let (shown, flowed) = (runs(a), runs(b));
|
||
let empty: Vec<_> = flowed.iter().take(1).map(|r| vectorcraft_doc::TextRun { text: String::new(), ..r.clone() }).collect();
|
||
for (id, put) in [(a, [shown, flowed].concat()), (b, empty)] {
|
||
if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind {
|
||
t.runs = put;
|
||
t.cached_bounds = None;
|
||
t.cached_baselines.clear();
|
||
}
|
||
}
|
||
let opts = LoadOptions { text_as: vectorcraft_pdf::TextAs::Outlines, ..Default::default() };
|
||
let (doc, _) = load::outlined_if_asked(doc, vec![], &opts);
|
||
let mut right = 0;
|
||
doc.walk(|n| {
|
||
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && n.geometric_bounds().is_some_and(|r| r.x0 >= 190.0) {
|
||
right += 1;
|
||
}
|
||
});
|
||
assert!(right > 0, "the second frame's text is outlined where it flows");
|
||
assert!(doc.text_threads.is_empty(), "outlined frames are in no thread: {:?}", doc.text_threads);
|
||
}
|
||
|
||
#[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> {
|
||
half_outlined_ai_on(story, more, type_content, "", |page| page)
|
||
}
|
||
|
||
/// [`half_outlined_ai`] with the editing data's comments `head` before its art, whose page is
|
||
/// `page_of` the 200 × 100 pt page it draws on (other boxes, more entries).
|
||
fn half_outlined_ai_on(story: Option<&str>, more: usize, type_content: &str, head: &str, page_of: impl FnOnce(PdfPage) -> PdfPage) -> 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!("{head}%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_of(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()
|
||
}
|
||
|
||
/// A `.ai` saved without its PDF part whose layer `Art` holds a story in two area type frames
|
||
/// (art x 10 to 90 and 110 to 190, y 10 to 90), then a band across the first frame (y 30 to 70),
|
||
/// made a wrap object with no offset when `wrap` is set.
|
||
fn wrapped_thread_ai(wrap: bool) -> Vec<u8> {
|
||
ai::ai(&wrapped_thread_data(wrap.then_some(0.0)), PLACEHOLDER)
|
||
}
|
||
|
||
/// The editing data of [`wrapped_thread_ai`], compressed, with the band a wrap object of `offset`
|
||
/// when one is given.
|
||
fn wrapped_thread_data(offset: Option<f64>) -> Vec<u8> {
|
||
// The template box is centred at (100, 50), the canvas centre 8191.5: y runs down the canvas.
|
||
let canvas = |x: f64, y: f64| (x + 8091.5, 8241.5 - y);
|
||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||
let corners = [canvas(x0, y1), canvas(x0, y0), canvas(x1, y0), canvas(x1, y1), canvas(x0, y1)];
|
||
let segments: Vec<String> =
|
||
corners.windows(2).map(|w| format!("{} {} {} {} {} {} {} {}", w[0].0, w[0].1, w[0].0, w[0].1, w[1].0, w[1].1, w[1].0, w[1].1)).collect();
|
||
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", segments.join(" "))
|
||
};
|
||
let story = "Words keep flowing from the first frame into the second one, line after line, until the story ends here. ".repeat(3);
|
||
let n = story.chars().count() + 1;
|
||
let document = format!(
|
||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} {} ] >> >>\n\
|
||
/1 << /1 [ << /0 << /0 ({story}\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {n} >> ] >> \
|
||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 10.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 0 >> << /0 1 >> ] >> >> ] /2 << /1 12.0 >> >>\n",
|
||
rect(10.0, 10.0, 90.0, 90.0),
|
||
rect(110.0, 10.0, 190.0, 90.0)
|
||
);
|
||
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||
let frame = |f: u32| format!("/AI11Text :\n0 /FreeUndo ,\n{f} /FrameIndex ,\n0 /StoryIndex ,\n;\n");
|
||
let dict = offset
|
||
.map(|o| format!("%_/ArtDictionary :\n%_{o} /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n"))
|
||
.unwrap_or_default();
|
||
let art = format!("{}{}0.5 g\n{}f\n{dict}", frame(0), frame(1), ai::rect(10.0, 30.0, 90.0, 70.0));
|
||
let body = format!(
|
||
"%AI3_TemplateBox: 100 50 100 50\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n",
|
||
ai::layer("Art", &art),
|
||
lines.join("\n")
|
||
);
|
||
compressed(ai::editing_data(200.0, 100.0, &body).as_bytes())
|
||
}
|
||
|
||
/// The wrap offset of the first frame of `doc`'s threaded type, as its text resolves it.
|
||
fn first_frame_wrap_offset(doc: &vectorcraft_doc::Document) -> f64 {
|
||
match &doc.node(doc.text_threads[0][0]).unwrap().kind {
|
||
NodeKind::Text(t) => t.wrap[0].wrap.offset,
|
||
_ => panic!("area type"),
|
||
}
|
||
}
|
||
|
||
/// A `.ai` in units of 10 points whose threaded area type flows around a wrap object of a 2 unit
|
||
/// offset opens with the type flowing round it 20 pt off, its PDF part read or not, and when its
|
||
/// PDF part can't be read. The default graphic styles of a file without its own aren't scaled.
|
||
#[test]
|
||
fn wrapped_type_in_units_of_ten_points_flows_round_the_wrap_at_its_size_in_points() {
|
||
let private = wrapped_thread_data(Some(2.0));
|
||
let page = "0.5 g 10 30 80 40 re f";
|
||
let refused = ai::ai_with_entries_and_content(&private, page, "/UserUnit 10", REFUSED_CONTENT);
|
||
// On the trim boxes the PDF part is imported, and the refused stream stops it.
|
||
let trim = LoadOptions { crop: vectorcraft_pdf::CropTo::Trim, ..Default::default() };
|
||
let files = [(ai::ai_with(&private, page, "/UserUnit 10"), LoadOptions::default()), (refused, trim)];
|
||
for (file, opts) in &files {
|
||
let loaded = load_with("thread.ai", file, opts).unwrap();
|
||
assert!(loaded.editing_data, "{:?}", loaded.warnings);
|
||
assert!((first_frame_wrap_offset(&loaded.doc) - 20.0).abs() < 0.01, "{:?}", loaded.warnings);
|
||
}
|
||
let loaded = load_with("thread.ai", &files[1].0, &files[1].1).unwrap();
|
||
assert!(loaded.warnings.iter().any(|w| w.starts_with("the PDF part couldn't be read")), "{:?}", loaded.warnings);
|
||
// The file has no graphic styles of its own: the default ones aren't scaled either way.
|
||
let read = load_with("thread.ai", &files[0].0, &files[0].1).unwrap();
|
||
assert_eq!(loaded.doc.graphic_styles, read.doc.graphic_styles);
|
||
}
|
||
|
||
/// Threaded area type below a wrap object flows around it when the file opens: the text the wrap
|
||
/// object pushes out of the first frame goes on in the second.
|
||
#[test]
|
||
fn wrap_threaded_area_after_open() {
|
||
let second = |wrap: bool| {
|
||
let mut s = Session::new();
|
||
let r = open(&mut s, "thread.ai", &wrapped_thread_ai(wrap), json!({})).unwrap();
|
||
let doc = &s.doc().unwrap().doc;
|
||
assert_eq!(doc.text_threads.len(), 1, "{r}");
|
||
let text = |id: NodeId| match &doc.node(id).unwrap().kind {
|
||
NodeKind::Text(t) => t.clone(),
|
||
_ => panic!("area type"),
|
||
};
|
||
let (first, second) = (text(doc.text_threads[0][0]), text(doc.text_threads[0][1]));
|
||
assert_eq!(first.wrap.len(), usize::from(wrap), "{r}");
|
||
second.plain_text().chars().count()
|
||
};
|
||
let (plain, wrapped) = (second(false), second(true));
|
||
assert!(wrapped > plain, "the second frame holds more of the story: {plain} → {wrapped}");
|
||
}
|
||
|
||
/// 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 `.ai` whose text document makes all of its type, with nothing unread, opens from its layers
|
||
/// without its PDF part being imported: a page that draws more than the layers isn't compared with
|
||
/// them. On its trim box it takes the pages' bleed, and pages in units of 10 points draw it ten
|
||
/// times as large, as when its PDF part is imported. With type the text document can't make,
|
||
/// editing data on a large canvas, its type asked for as outlines, another box or pages picked, the
|
||
/// PDF part is imported as before.
|
||
#[test]
|
||
fn type_all_from_the_text_document_opens_without_importing_the_pdf_part() {
|
||
let line = half_outlined_line(65.0, "rst draft", 0);
|
||
// The page draws a red square the layers don't have.
|
||
let differs = half_outlined_ai(Some("first draft"), 0, &format!("{line} 1 0 0 rg 20 20 30 30 re f"));
|
||
let mut s = Session::new();
|
||
let before = vectorcraft_pdf::imports_of(&differs);
|
||
let loaded = load_with("draft.ai", &differs, &LoadOptions::default()).unwrap();
|
||
assert_eq!(vectorcraft_pdf::imports_of(&differs), before, "{:?}", loaded.warnings);
|
||
assert!(loaded.editing_data, "{:?}", loaded.warnings);
|
||
assert!(!loaded.warnings.iter().any(|w| w.contains("differently")), "{:?}", loaded.warnings);
|
||
let r = open(&mut s, "draft.ai", &differs, json!({})).unwrap();
|
||
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
|
||
assert_eq!(texts(&s), ["first draft"], "{r}");
|
||
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2, "the square and the type, without the page's red square: {r}");
|
||
// A 10 pt bleed round the trim box, the art drawn in the trim box.
|
||
let bled = half_outlined_ai_on(Some("first draft"), 0, &line, "", |p| PdfPage {
|
||
media: [0.0, 0.0, 220.0, 120.0],
|
||
bleed: Some([0.0, 0.0, 220.0, 120.0]),
|
||
trim: Some([10.0, 10.0, 210.0, 110.0]),
|
||
content: format!("1 0 0 1 10 10 cm {}", p.content),
|
||
..p
|
||
});
|
||
let before = vectorcraft_pdf::imports_of(&bled);
|
||
let loaded = load_with("bled.ai", &bled, &LoadOptions::default()).unwrap();
|
||
assert_eq!(vectorcraft_pdf::imports_of(&bled), before);
|
||
assert_eq!(loaded.doc.setup.bleed, [10.0; 4]);
|
||
// Pages of 10 pt units.
|
||
let large = half_outlined_ai_on(Some("first draft"), 0, &line, "", |p| PdfPage { entries: format!("{} /UserUnit 10", p.entries), ..p });
|
||
let before = vectorcraft_pdf::imports_of(&large);
|
||
let loaded = load_with("large.ai", &large, &LoadOptions::default()).unwrap();
|
||
assert_eq!(vectorcraft_pdf::imports_of(&large), before);
|
||
let board = loaded.doc.artboards[0].rect;
|
||
assert!((board.width() - 2000.0).abs() < 0.01 && (board.height() - 1000.0).abs() < 0.01, "{board:?}");
|
||
// Opened on the trim boxes with the PDF part imported and compared, as before, both files have
|
||
// the same artboards and bleed.
|
||
for file in [&bled, &large] {
|
||
let alone = load_with("draft.ai", file, &LoadOptions::default()).unwrap();
|
||
let compared = load_with("draft.ai", file, &LoadOptions { crop: vectorcraft_pdf::CropTo::Trim, ..Default::default() }).unwrap();
|
||
assert!(compared.editing_data, "{:?}", compared.warnings);
|
||
let boards = |d: &vectorcraft_doc::Document| d.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||
assert_eq!(boards(&alone.doc), boards(&compared.doc));
|
||
assert_eq!(alone.doc.setup.bleed, compared.doc.setup.bleed);
|
||
assert_eq!(names(&alone.doc.layers), names(&compared.doc.layers));
|
||
// The file has no graphic styles of its own: the default ones aren't scaled.
|
||
assert_eq!(alone.doc.graphic_styles, compared.doc.graphic_styles);
|
||
}
|
||
// The PDF part is imported, once.
|
||
let unmade = half_outlined_ai(Some("first draft"), 1, &line);
|
||
// Editing data on a large canvas reads at its own scale when the file has no PDF part.
|
||
let canvas = half_outlined_ai_on(Some("first draft"), 0, &line, "%AI24_LargeCanvasScale: 2\n", |p| p);
|
||
let cases = [
|
||
(&unmade, LoadOptions::default()),
|
||
(&canvas, LoadOptions::default()),
|
||
(&differs, LoadOptions { text_as: vectorcraft_pdf::TextAs::Outlines, ..Default::default() }),
|
||
(&differs, LoadOptions { crop: vectorcraft_pdf::CropTo::Trim, ..Default::default() }),
|
||
(&differs, LoadOptions { pages: Some("1".into()), ..Default::default() }),
|
||
];
|
||
for (file, opts) in cases {
|
||
let before = vectorcraft_pdf::imports_of(file);
|
||
load_with("draft.ai", file, &opts).unwrap();
|
||
assert_eq!(vectorcraft_pdf::imports_of(file) - before, 1, "{opts:?}");
|
||
}
|
||
}
|
||
|
||
/// 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"]);
|
||
}
|