mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
document.open and file.place read .eps files and PostScript by any name (PostScript .ai opens as artwork, .ait as a new untitled document). An EPS file VectorCraft wrote restores the document it carries (restored: true); other files run through a small clean-room PostScript interpreter (paths, colours and spot inks, gsave/grestore, concat, line state, bind def, procedures, loops and dictionaries, clips, shadings, images through the decoding filters, type as point type), and fall back to their TIFF preview with a warning when the program can't be read. Operation, nesting, memory and path budgets keep hostile files from hanging or exhausting memory. The clipping-group nesting the metafile reader used moves to vectorcraft_doc::clipnest, shared by both readers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
9.8 KiB
Rust
192 lines
9.8 KiB
Rust
//! `document.open` PDF options (pages, Crop To, password) and `document.pdfInfo`.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
|
|
|
use super::*;
|
|
|
|
fn b64(bytes: &[u8]) -> String {
|
|
vectorcraft_format::base64_encode(bytes)
|
|
}
|
|
|
|
/// Three pages of widths 100, 200 and 300 pt, the second with every box set.
|
|
fn three_pages() -> Vec<u8> {
|
|
let mut second = PdfPage::new(200.0, 400.0, "0 0 1 rg 20 30 40 50 re f");
|
|
second.crop = Some([10.0, 10.0, 190.0, 390.0]);
|
|
second.bleed = Some([5.0, 5.0, 195.0, 395.0]);
|
|
second.trim = Some([15.0, 15.0, 185.0, 385.0]);
|
|
second.art = Some([20.0, 20.0, 120.0, 220.0]);
|
|
pdf(&[PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), second, PdfPage::new(300.0, 100.0, "")], None)
|
|
}
|
|
|
|
fn open(s: &mut Session, bytes: &[u8], extra: Value) -> crate::Result<Value> {
|
|
let mut p = json!({"name": "pages.pdf", "dataBase64": b64(bytes)});
|
|
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
|
|
o.extend(e);
|
|
}
|
|
s.execute("document.open", &p)
|
|
}
|
|
|
|
fn sizes(s: &Session) -> Vec<(f64, f64)> {
|
|
s.active().unwrap().doc.artboards.iter().map(|a| (a.rect.width(), a.rect.height())).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn document_open_imports_the_pages_named() {
|
|
let bytes = three_pages();
|
|
let mut s = Session::new();
|
|
open(&mut s, &bytes, json!({"pages": "2-3"})).unwrap();
|
|
assert_eq!(sizes(&s), [(180.0, 380.0), (300.0, 100.0)]);
|
|
let layers: Vec<String> = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
|
|
assert_eq!(layers, ["Page 2", "Page 3"]);
|
|
open(&mut s, &bytes, json!({"page": 1})).unwrap();
|
|
assert_eq!(sizes(&s), [(100.0, 100.0)]);
|
|
open(&mut s, &bytes, json!({"pages": "all"})).unwrap();
|
|
assert_eq!(sizes(&s).len(), 3);
|
|
let e = open(&mut s, &bytes, json!({"pages": "4"})).unwrap_err().to_string();
|
|
assert!(e.contains("pages") && e.contains("1 to 3"), "{e}");
|
|
assert!(open(&mut s, &bytes, json!({"pages": true})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn document_open_crops_each_page_to_the_box_asked_for() {
|
|
let bytes = three_pages();
|
|
let mut s = Session::new();
|
|
for (crop, size) in [
|
|
("media", (200.0, 400.0)),
|
|
("crop", (180.0, 380.0)),
|
|
("bleed", (190.0, 390.0)),
|
|
("trim", (170.0, 370.0)),
|
|
("art", (100.0, 200.0)),
|
|
("bounding", (40.0, 50.0)),
|
|
] {
|
|
open(&mut s, &bytes, json!({"page": 2, "cropTo": crop})).unwrap();
|
|
assert_eq!(sizes(&s), [size], "{crop}");
|
|
}
|
|
// `crop` is an alias (the file.place name); case doesn't matter.
|
|
open(&mut s, &bytes, json!({"page": 2, "crop": "Art"})).unwrap();
|
|
assert_eq!(sizes(&s), [(100.0, 200.0)]);
|
|
let e = open(&mut s, &bytes, json!({"cropTo": "page"})).unwrap_err().to_string();
|
|
assert!(e.contains("bounding, art, crop, trim, bleed, media"), "{e}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_encrypted_pdf_needs_its_password() {
|
|
let bytes = pdf(&[PdfPage::new(100.0, 50.0, "1 0 0 rg 0 0 20 20 re f"), PdfPage::new(100.0, 50.0, "")], Some("open sesame"));
|
|
let mut s = Session::new();
|
|
let e = open(&mut s, &bytes, json!({})).unwrap_err().to_string();
|
|
assert!(e.contains("password-protected"), "{e}");
|
|
let e = open(&mut s, &bytes, json!({"password": "nope"})).unwrap_err().to_string();
|
|
assert!(e.contains("password is wrong"), "{e}");
|
|
assert!(s.active().is_none(), "nothing opened");
|
|
let r = open(&mut s, &bytes, json!({"password": "open sesame", "pages": "1"})).unwrap();
|
|
assert_eq!(r["format"], "pdf");
|
|
assert_eq!(sizes(&s), [(100.0, 50.0)]);
|
|
assert_eq!(s.active().unwrap().doc.node_count(), 2, "the layer and its decrypted rectangle");
|
|
}
|
|
|
|
#[test]
|
|
fn pdf_info_lists_pages_boxes_and_a_thumbnail() {
|
|
let bytes = three_pages();
|
|
let mut s = Session::new();
|
|
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "thumbnail": 2, "thumbnailSize": 100, "cropTo": "art"})).unwrap();
|
|
assert_eq!(v["pages"], 3);
|
|
assert_eq!(v["needsPassword"], false);
|
|
let p2 = &v["pageInfo"][1];
|
|
assert_eq!((p2["width"].as_f64(), p2["height"].as_f64()), (Some(180.0), Some(380.0)));
|
|
assert_eq!(p2["boxes"]["trim"], json!([15.0, 15.0, 185.0, 385.0]));
|
|
assert_eq!(p2["boxes"]["media"], json!([0.0, 0.0, 200.0, 400.0]));
|
|
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
|
|
let img = image::load_from_memory(&png).unwrap();
|
|
assert_eq!((img.width(), img.height()), (50, 100), "the art box at 100 px on its longest side");
|
|
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "thumbnail": 4})).is_err());
|
|
// Locked: no pages until the password is given.
|
|
let locked = pdf(&[PdfPage::new(10.0, 10.0, "")], Some("pw"));
|
|
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&locked)})).unwrap();
|
|
assert_eq!((v["pages"].as_u64(), v["needsPassword"].as_bool(), v["wrongPassword"].as_bool()), (Some(0), Some(true), Some(false)));
|
|
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&locked), "password": "x"})).unwrap();
|
|
assert_eq!(v["wrongPassword"], true);
|
|
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&locked), "password": "pw"})).unwrap();
|
|
assert_eq!((v["pages"].as_u64(), v["needsPassword"].as_bool()), (Some(1), Some(false)));
|
|
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(b"not a pdf")})).is_err());
|
|
assert!(s.execute("document.pdfInfo", &json!({})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn file_place_crops_a_pdf_page_to_any_box_and_takes_its_password() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
|
let place = |s: &mut Session, bytes: &[u8], extra: Value| {
|
|
let mut p = json!({"name": "pages.pdf", "dataBase64": b64(bytes)});
|
|
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
|
|
o.extend(e);
|
|
}
|
|
s.execute("file.place", &p)
|
|
};
|
|
let bytes = three_pages();
|
|
for (crop, size) in
|
|
[("trim", (170.0, 370.0)), ("art", (100.0, 200.0)), ("media", (200.0, 400.0)), ("crop", (180.0, 380.0)), ("bounding", (40.0, 50.0))]
|
|
{
|
|
let r = place(&mut s, &bytes, json!({"page": 2, "crop": crop})).unwrap();
|
|
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(size.0), Some(size.1)), "{crop}");
|
|
}
|
|
let info = s.execute("file.place.info", &json!({"name": "pages.pdf", "dataBase64": b64(&bytes), "page": 2, "crop": "bleed"})).unwrap();
|
|
assert_eq!((info["width"].as_f64(), info["height"].as_f64()), (Some(190.0), Some(390.0)));
|
|
let locked = pdf(&[PdfPage::new(60.0, 30.0, "0 g 0 0 10 10 re f")], Some("pw"));
|
|
assert!(place(&mut s, &locked, json!({})).unwrap_err().to_string().contains("password-protected"));
|
|
assert_eq!(place(&mut s, &locked, json!({"password": "pw"})).unwrap()["width"].as_f64(), Some(60.0));
|
|
}
|
|
|
|
#[test]
|
|
fn load_options_read_pages_crop_and_password() {
|
|
let o = LoadOptions::from_params("x", &json!({"page": 3, "crop": "trim", "password": "p"})).unwrap();
|
|
assert_eq!(o, LoadOptions { pages: Some("3".into()), crop: vectorcraft_pdf::CropTo::Trim, password: Some("p".into()), ..Default::default() });
|
|
// `pages` wins over `page`; an empty password is none.
|
|
let o = LoadOptions::from_params("x", &json!({"pages": "1-2", "page": 3, "password": ""})).unwrap();
|
|
assert_eq!((o.pages.as_deref(), o.password), (Some("1-2"), None));
|
|
assert_eq!(LoadOptions::from_params("x", &Value::Null).unwrap(), LoadOptions::default());
|
|
}
|
|
|
|
#[test]
|
|
fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
|
|
// PostScript opens through the EPS reader now (tests_epsimport.rs): a program it can't read,
|
|
// with no preview to fall back on, still says so.
|
|
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 moveto 100 100 frobnicate stroke\nshowpage\n%%EOF\n";
|
|
let mut s = Session::new();
|
|
for name in ["legacy.ai", "art.eps", "named.pdf", "template.ait"] {
|
|
let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string();
|
|
assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}");
|
|
}
|
|
// An EPS with a binary header too, and the PDF info of either.
|
|
let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6];
|
|
binary.extend_from_slice(&[0; 28]);
|
|
binary.extend_from_slice(ps);
|
|
assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
|
|
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript"));
|
|
assert!(s.active().is_none());
|
|
}
|
|
|
|
/// A page showing only text, with an editor's private data when `private`.
|
|
fn text_page(private: bool, also: &str) -> PdfPage {
|
|
PdfPage {
|
|
resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(),
|
|
entries: if private { "/PieceInfo << /Editor << /Private << /AIPrivateData1 7 /RoundTripVersion 24 >> >> >>".into() } else { String::new() },
|
|
..PdfPage::new(300.0, 100.0, &format!("BT /F1 12 Tf 10 50 Td (Saved without PDF content) Tj ET {also}"))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_placeholder_only_pdf_part_is_detected() {
|
|
let mut s = Session::new();
|
|
let e = open(&mut s, &pdf(&[text_page(true, "")], None), json!({})).unwrap_err().to_string();
|
|
assert!(e.contains("placeholder page") && e.contains("PDF compatibility"), "{e}");
|
|
assert!(s.active().is_none());
|
|
// Text without private data, and private data with art, are ordinary files.
|
|
open(&mut s, &pdf(&[text_page(false, "")], None), json!({})).unwrap();
|
|
open(&mut s, &pdf(&[text_page(true, "0 g 0 0 10 10 re f")], None), json!({})).unwrap();
|
|
// One real page among the pages picked is enough.
|
|
let two = pdf(&[text_page(true, ""), text_page(true, "0 g 0 0 10 10 re f")], None);
|
|
assert!(open(&mut s, &two, json!({"pages": "1"})).is_err());
|
|
open(&mut s, &two, json!({})).unwrap();
|
|
}
|