A PDF-compatible .ai with a bleed writes its PDF part's page as the
artboard grown by the bleed: the crop box takes the bleed in, the trim
box is the artboard and the bleed box the bleed. The importer made the
crop box the artboard, so the bleed opened as more white page with no
bleed guide; and since the editing data's artboard no longer matched
that page, its layers weren't read either ("its editing data is for a
different page").
Opening a PDF-compatible .ai with the crop box (the default) now takes
its trim boxes as the artboards. Any import on the trim boxes (Crop To
› Trim, for a PDF too) sets the document's bleed to how far the bleed
boxes reach past them on each side (ISO 32000-1, 14.11.2), kept to
0–72 pt, and the document read from the editing data keeps it. Art in
the bleed shows outside the artboard, within the bleed guide. Plain
PDFs still open on their crop box by default, and Place is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
10 KiB
Rust
211 lines
10 KiB
Rust
//! Import options: the pages picked, the box each artboard gets (Crop To), passwords and `info`.
|
||
|
||
use vectorcraft_doc::{Document, NodeKind};
|
||
use vectorcraft_geom::Rect;
|
||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||
|
||
use crate::*;
|
||
|
||
/// A 600 × 800 page with every box set and a 200 × 100 rectangle at (120, 150) (PDF space).
|
||
fn boxed_page() -> PdfPage {
|
||
PdfPage {
|
||
crop: Some([50.0, 50.0, 550.0, 750.0]),
|
||
bleed: Some([40.0, 40.0, 560.0, 760.0]),
|
||
trim: Some([60.0, 60.0, 540.0, 740.0]),
|
||
art: Some([100.0, 100.0, 300.0, 400.0]),
|
||
..PdfPage::new(600.0, 800.0, "0 0 1 rg 120 150 200 100 re f")
|
||
}
|
||
}
|
||
|
||
fn open(bytes: &[u8], opts: ImportOptions) -> Document {
|
||
import_with_report(bytes, &opts).unwrap().document
|
||
}
|
||
|
||
fn art(d: &Document) -> Rect {
|
||
d.art_bounds().unwrap()
|
||
}
|
||
|
||
fn close(a: Rect, b: Rect) -> bool {
|
||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 1e-6)
|
||
}
|
||
|
||
#[test]
|
||
fn each_crop_box_sizes_the_artboard_and_places_the_art_in_it() {
|
||
let bytes = pdf(&[boxed_page()], None);
|
||
// (box, artboard size, the rectangle's top-left on the artboard).
|
||
let cases = [
|
||
(CropTo::Media, (600.0, 800.0), (120.0, 550.0)),
|
||
(CropTo::Crop, (500.0, 700.0), (70.0, 500.0)),
|
||
(CropTo::Bleed, (520.0, 720.0), (80.0, 510.0)),
|
||
(CropTo::Trim, (480.0, 680.0), (60.0, 490.0)),
|
||
(CropTo::Art, (200.0, 300.0), (20.0, 150.0)),
|
||
(CropTo::Bounding, (200.0, 100.0), (0.0, 0.0)),
|
||
];
|
||
for (crop, (w, h), (x, y)) in cases {
|
||
let d = open(&bytes, ImportOptions { crop, ..Default::default() });
|
||
let ab = d.artboards[0].rect;
|
||
assert!((ab.width() - w).abs() < 1e-6 && (ab.height() - h).abs() < 1e-6, "{crop:?}: {ab:?}");
|
||
let r = art(&d);
|
||
assert!(close(r, Rect::new(ab.x0 + x, ab.y0 + y, ab.x0 + x + 200.0, ab.y0 + y + 100.0)), "{crop:?}: art {r:?} on {ab:?}");
|
||
}
|
||
// The default is the crop box, as before.
|
||
assert_eq!(ImportOptions::default().crop, CropTo::Crop);
|
||
}
|
||
|
||
/// 10 mm in points.
|
||
const BLEED: f64 = 10.0 * 72.0 / 25.4;
|
||
|
||
/// A 300 × 200 sheet with a 200 × 100 trim box, the bleed box 10 mm round it, and a red rectangle
|
||
/// reaching 20 pt past the trim box's left and bottom edges into the bleed.
|
||
fn bled_page() -> PdfPage {
|
||
PdfPage {
|
||
bleed: Some([50.0 - BLEED, 50.0 - BLEED, 250.0 + BLEED, 150.0 + BLEED]),
|
||
trim: Some([50.0, 50.0, 250.0, 150.0]),
|
||
..PdfPage::new(300.0, 200.0, "1 0 0 rg 30 30 100 60 re f")
|
||
}
|
||
}
|
||
|
||
/// The same within the precision of a box read from a file (32-bit floats).
|
||
fn near(a: [f64; 4], b: [f64; 4]) -> bool {
|
||
a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
|
||
}
|
||
|
||
#[test]
|
||
fn trim_box_artboards_take_the_bleed_box_round_them_as_the_document_bleed() {
|
||
let bytes = pdf(&[bled_page()], None);
|
||
let trim = ImportOptions { crop: CropTo::Trim, ..Default::default() };
|
||
let d = open(&bytes, trim.clone());
|
||
let ab = d.artboards[0].rect;
|
||
assert!(close(ab, Rect::new(0.0, 0.0, 200.0, 100.0)), "{ab:?}");
|
||
assert!(near(d.setup.bleed, [BLEED; 4]), "{:?}", d.setup.bleed);
|
||
// The rectangle comes in whole, reaching into the bleed (y down: the bottom bleed is below).
|
||
assert!(close(art(&d), Rect::new(-20.0, 60.0, 80.0, 120.0)), "{:?}", art(&d));
|
||
assert!(d.setup.bleed_rect(ab).contains_rect(art(&d)));
|
||
// Each side as the page shows it: PDF space is y up, and a rotated page turns its bleed.
|
||
let uneven = PdfPage { bleed: Some([49.0, 48.0, 253.0, 154.0]), ..bled_page() };
|
||
let turned = PdfPage { rotate: 90, ..uneven.clone() };
|
||
assert!(near(open(&pdf(&[uneven], None), trim.clone()).setup.bleed, [4.0, 2.0, 1.0, 3.0]));
|
||
assert!(near(open(&pdf(&[turned], None), trim).setup.bleed, [1.0, 3.0, 2.0, 4.0]));
|
||
// On the crop box (the sheet here) there is no bleed round the artboard.
|
||
let d = open(&bytes, ImportOptions::default());
|
||
assert_eq!((d.artboards[0].rect.width(), d.setup.bleed), (300.0, [0.0; 4]));
|
||
}
|
||
|
||
#[test]
|
||
fn missing_boxes_fall_back_to_the_crop_box_and_boxes_are_clipped_to_the_media_box() {
|
||
let page = PdfPage { crop: Some([10.0, 10.0, 290.0, 390.0]), art: Some([-50.0, 0.0, 100.0, 100.0]), ..PdfPage::new(300.0, 400.0, "") };
|
||
let bytes = pdf(&[page], None);
|
||
for crop in [CropTo::Bleed, CropTo::Trim] {
|
||
let ab = open(&bytes, ImportOptions { crop, ..Default::default() }).artboards[0].rect;
|
||
assert_eq!((ab.width(), ab.height()), (280.0, 380.0), "{crop:?}");
|
||
}
|
||
let ab = open(&bytes, ImportOptions { crop: CropTo::Art, ..Default::default() }).artboards[0].rect;
|
||
assert_eq!((ab.width(), ab.height()), (100.0, 100.0));
|
||
// A page without art keeps its crop box for Bounding.
|
||
let ab = open(&bytes, ImportOptions { crop: CropTo::Bounding, ..Default::default() }).artboards[0].rect;
|
||
assert_eq!((ab.width(), ab.height()), (280.0, 380.0));
|
||
}
|
||
|
||
#[test]
|
||
fn a_rotated_page_rotates_its_boxes() {
|
||
let page = PdfPage { rotate: 90, ..boxed_page() };
|
||
let d = open(&pdf(&[page], None), ImportOptions { crop: CropTo::Art, ..Default::default() });
|
||
let ab = d.artboards[0].rect;
|
||
assert_eq!((ab.width(), ab.height()), (300.0, 200.0));
|
||
// The rectangle turns with the page (clockwise): PDF (x, y) shows at (y − 100, x − 100) on
|
||
// the art box's artboard.
|
||
assert!(close(art(&d), Rect::new(50.0, 20.0, 150.0, 220.0)), "{:?}", art(&d));
|
||
}
|
||
|
||
#[test]
|
||
fn pages_pick_which_pages_import_and_in_what_order() {
|
||
let pages: Vec<PdfPage> = (1..=4).map(|i| PdfPage::new(100.0 * i as f64, 100.0, "0 g 0 0 10 10 re f")).collect();
|
||
let bytes = pdf(&pages, None);
|
||
let d = open(&bytes, ImportOptions { pages: Some(vec![1, 2]), ..Default::default() });
|
||
let widths: Vec<f64> = d.artboards.iter().map(|a| a.rect.width()).collect();
|
||
assert_eq!(widths, [200.0, 300.0]);
|
||
let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_deref().unwrap_or_default()).collect();
|
||
assert_eq!(names, ["Page 2", "Page 3"]);
|
||
assert_eq!(d.artboards.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Artboard 1", "Artboard 2"]);
|
||
// Artboards sit side by side with the gap between them.
|
||
assert_eq!(d.artboards[1].rect.x0, d.artboards[0].rect.x1 + 36.0);
|
||
let d = open(&bytes, ImportOptions { pages: Some(vec![3, 0]), max_pages: Some(1), ..Default::default() });
|
||
assert_eq!(d.artboards.len(), 1);
|
||
assert_eq!(d.artboards[0].rect.width(), 400.0);
|
||
assert_eq!(import_with_report(&bytes, &ImportOptions { pages: Some(vec![4]), ..Default::default() }).unwrap_err(), PdfError::BadPage(5, 4));
|
||
assert_eq!(import_with_report(&bytes, &ImportOptions { pages: Some(vec![]), ..Default::default() }).unwrap_err(), PdfError::NoPages);
|
||
}
|
||
|
||
#[test]
|
||
fn bounding_boxes_of_several_pages_do_not_overlap() {
|
||
let page = |x: f64| PdfPage::new(300.0, 300.0, &format!("0 g {x} 10 50 50 re f"));
|
||
let d = open(&pdf(&[page(240.0), page(0.0)], None), ImportOptions { crop: CropTo::Bounding, ..Default::default() });
|
||
let (a, b) = (d.artboards[0].rect, d.artboards[1].rect);
|
||
assert_eq!((a.width(), b.width()), (50.0, 50.0));
|
||
assert!(b.x0 >= a.x1 + 36.0, "{a:?} {b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn art_of_neighbouring_artboards_off_a_page_is_left_to_its_own_page() {
|
||
// Page 2 also draws page 1's square, off to its left (outside its box), as files with several
|
||
// artboards are written: only page 1 keeps it, so it doesn't come back twice.
|
||
let pages = [PdfPage::new(100.0, 100.0, "0 g 10 10 20 20 re f"), PdfPage::new(100.0, 100.0, "0 g -90 10 20 20 re f 1 0 0 rg 50 10 20 20 re f")];
|
||
let d = open(&pdf(&pages, None), ImportOptions::default());
|
||
let count = |i: usize| d.layers[i].children().map_or(0, |c| c.len());
|
||
assert_eq!((count(0), count(1)), (1, 1));
|
||
// A single page keeps its art off the page (the pasteboard).
|
||
let d = open(&pdf(&pages[1..], None), ImportOptions::default());
|
||
assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn an_encrypted_pdf_opens_with_its_password_only() {
|
||
let bytes = pdf(&[PdfPage::new(200.0, 100.0, "1 0 0 rg 10 10 50 50 re f")], Some("secret"));
|
||
assert_eq!(import_with_report(&bytes, &ImportOptions::default()).unwrap_err(), PdfError::NeedsPassword);
|
||
let wrong = ImportOptions { password: Some("guess".into()), ..Default::default() };
|
||
assert_eq!(import_with_report(&bytes, &wrong).unwrap_err(), PdfError::WrongPassword);
|
||
assert_eq!(info(&bytes, None).unwrap_err(), PdfError::NeedsPassword);
|
||
let d = open(&bytes, ImportOptions { password: Some("secret".into()), ..Default::default() });
|
||
let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!() };
|
||
assert_eq!(children.len(), 1, "the decrypted page has its rectangle");
|
||
assert!(close(art(&d), Rect::new(10.0, 40.0, 60.0, 90.0)), "{:?}", art(&d));
|
||
// A file that isn't encrypted ignores a password.
|
||
let plain = pdf(&[PdfPage::new(200.0, 100.0, "")], None);
|
||
assert!(import_with_report(&plain, &ImportOptions { password: Some("x".into()), ..Default::default() }).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn info_lists_pages_with_their_size_rotation_and_boxes() {
|
||
let bytes = pdf(&[boxed_page(), PdfPage { rotate: 270, ..PdfPage::new(100.0, 50.0, "") }], None);
|
||
let i = info(&bytes, None).unwrap();
|
||
assert_eq!(i.pages.len(), 2);
|
||
let p = &i.pages[0];
|
||
assert_eq!((p.width, p.height, p.rotation), (500.0, 700.0, 0));
|
||
let b = |c: CropTo| p.boxes.iter().find(|(k, _)| *k == c).map(|(_, r)| *r).unwrap();
|
||
assert_eq!(b(CropTo::Media), Rect::new(0.0, 0.0, 600.0, 800.0));
|
||
assert_eq!(b(CropTo::Trim), Rect::new(60.0, 60.0, 540.0, 740.0));
|
||
assert!(p.boxes.iter().all(|(k, _)| *k != CropTo::Bounding));
|
||
assert_eq!((i.pages[1].width, i.pages[1].height, i.pages[1].rotation), (50.0, 100.0, 270));
|
||
assert!(matches!(info(b"not a pdf", None), Err(PdfError::Parse(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn a_page_without_area_gets_the_size_viewers_give_it() {
|
||
let bytes = pdf(&[PdfPage { media: [0.0, 0.0, 0.0, 0.0], ..PdfPage::new(0.0, 0.0, "") }], None);
|
||
let i = info(&bytes, None).unwrap();
|
||
let (w, h) = (i.pages[0].width, i.pages[0].height);
|
||
assert!(w > 0.0 && h > 0.0);
|
||
for crop in [CropTo::Crop, CropTo::Media, CropTo::Art] {
|
||
let ab = open(&bytes, ImportOptions { crop, ..Default::default() }).artboards[0].rect;
|
||
assert_eq!((ab.width(), ab.height()), (w, h), "{crop:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn postscript_is_refused_as_such() {
|
||
let ps = b"%!PS\n0 0 moveto 10 10 lineto stroke\nshowpage\n";
|
||
assert_eq!(import(ps).unwrap_err(), PdfError::PostScript);
|
||
assert_eq!(info(ps, None).unwrap_err(), PdfError::PostScript);
|
||
assert!(is_postscript(&[0xC5, 0xD0, 0xD3, 0xC6, 0, 0]) && !is_postscript(b"%PDF-1.7"));
|
||
}
|