M4.45: PDF import and place options — pages, Crop To and password

Opening or placing a PDF or .ai with several pages or a password shows the
Import PDF / Place PDF dialog: a page preview with page navigation, Pages
(All or a range) when opening, Crop To (Bounding Box, Art, Crop, Trim, Bleed,
Media) and a password prompt for encrypted files.

- vectorcraft-pdf: ImportOptions.pages / crop / password, CropTo, info()
  (pages, sizes, rotation, boxes); NeedsPassword / WrongPassword / BadPage
  errors; images keyed by content so placed pages merge safely.
- document.open takes pages, page, cropTo (or crop) and password;
  document.pdfInfo (query) lists pages and boxes, reports needsPassword and
  renders a page thumbnail.
- file.place and file.place.info read only the page placed and take every
  PDF box as crop (art, trim, bleed, media besides crop and bounding) and a
  password; the UI's File > Place asks with the dialog when no page is given,
  keeping Link, Template and the other place params.
- testkit::pdf writes hand-made PDFs with page boxes and RC4 encryption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 09:37:11 +07:00
co-authored by Claude Opus 5.5
parent e8af1f6576
commit e204341996
26 changed files with 1381 additions and 56 deletions
Generated
+1
View File
@@ -4350,6 +4350,7 @@ dependencies = [
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"vectorcraft-tools",
]
+18 -6
View File
@@ -8,6 +8,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use super::super::*;
use super::pdfimport::LoadOptions;
use super::{Format, format, format_for_name, read_file};
use crate::EngineError;
@@ -30,7 +31,7 @@ pub struct RasterImage {
pub ppi: Option<(f64, f64)>,
}
fn err(e: impl std::fmt::Display) -> EngineError {
pub(super) fn err(e: impl std::fmt::Display) -> EngineError {
EngineError::Other(e.to_string())
}
@@ -74,6 +75,11 @@ pub const EDITING_DAMAGED: &str = "this SVG's editing data can't be read: it ope
/// Read a file of any readable format (`name`: its file name or path, for the extension and title).
pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
load_with(name, bytes, &LoadOptions::default())
}
/// [`load`] with `document.open` options (the PDF pages, box and password).
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?;
let title = file_name(name);
let mut warnings = vec![];
@@ -102,9 +108,9 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
}
}
"pdf" | "ai" | "ait" => {
let r = vectorcraft_pdf::import_with_report(bytes, &vectorcraft_pdf::ImportOptions::default()).map_err(err)?;
warnings = r.warnings;
r.document
let (d, w) = super::pdfimport::import(bytes, opts)?;
warnings = w;
d
}
_ if format.raster => raster_doc(&title, bytes)?,
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
@@ -120,7 +126,13 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
/// Open a file's bytes as the new active document (what `document.open` does) →
/// `{index, title, format, warnings}`. `path` is kept for Save only for a native, non-template file.
pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value> {
let Loaded { mut doc, format, warnings } = load(name, bytes)?;
open_bytes_with(s, name, bytes, path, &Value::Null)
}
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
let opts = LoadOptions::from_params("document.open", p)?;
let Loaded { mut doc, format, warnings } = load_with(name, bytes, &opts)?;
// A template (saved by Save as Template, or an .ait file) opens as a new untitled document.
let template = doc.template || format.id == "ait";
if template {
@@ -155,7 +167,7 @@ pub(crate) fn source<'a>(p: &'a Value, cmd: &str) -> Result<Source<'a>> {
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
let src = source(p, "document.open")?;
open_bytes(s, src.name, &src.bytes, src.path.map(str::to_string))
open_bytes_with(s, src.name, &src.bytes, src.path.map(str::to_string), p)
}
/// Decode an image's header (and, for formats stored as PNG, its pixels).
+17 -2
View File
@@ -7,6 +7,7 @@
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`.
//! - `save`: `document.save`, `file.saveAsTemplate`.
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`.
//!
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
//! agents query `document.formats`.
@@ -16,6 +17,7 @@ mod encode;
mod export;
mod load;
pub mod pdf;
mod pdfimport;
pub mod ppi;
mod save;
mod svg;
@@ -24,8 +26,10 @@ use serde_json::{Value, json};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
pub(crate) use encode::{anti_alias, background};
use load::err;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, open_bytes, raster_image};
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image};
pub use pdfimport::{LoadOptions, page_document};
pub use save::{save_encoding, save_format};
pub use svg::options_map as svg_options;
@@ -39,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64} → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo) → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document",
always,
load::open
),
@@ -107,6 +111,15 @@ pub fn specs() -> Vec<CommandSpec> {
always,
formats
),
cmd!(
query "document.pdfInfo",
"PDF Info",
[],
None,
"{path} or {name?, dataBase64}, password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box) → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; an encrypted PDF without its password → {pages: 0, needsPassword: true}",
always,
pdfimport::pdf_info
),
]
}
@@ -455,6 +468,8 @@ mod tests;
#[cfg(test)]
mod tests_pdf;
#[cfg(test)]
mod tests_pdfimport;
#[cfg(test)]
mod tests_svg;
#[cfg(test)]
mod tests_svgedit;
+106
View File
@@ -0,0 +1,106 @@
//! Opening and placing PDF (and PDF-compatible `.ai`/`.ait`) files: the options `document.open`
//! and `file.place` read ([`LoadOptions`]), the pages they import and `document.pdfInfo` (pages,
//! boxes, a thumbnail) for the Import PDF dialog.
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError};
use super::super::*;
use super::{err, source};
/// The `document.open` options besides the file. PDF only for now; other formats ignore them.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LoadOptions {
/// 1-based pages to import, such as `"2-3, 5"`; `None` = every page.
pub pages: Option<String>,
/// The box each page's artboard (or placed frame) gets.
pub crop: CropTo,
/// The password of an encrypted PDF.
pub password: Option<String>,
}
impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`) and `password`.
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
None => None,
Some(Value::String(s)) if s.trim().eq_ignore_ascii_case("all") => None,
Some(Value::String(s)) => Some(s.clone()),
Some(Value::Number(n)) => Some(n.to_string()),
Some(v) => return Err(bad(cmd, format!("pages must be a range such as \"2-3\" or a page number, not {v}"))),
};
let crop = match str_param(p, "cropTo").or_else(|| str_param(p, "crop")) {
None => CropTo::default(),
Some(c) => CropTo::ALL
.iter()
.zip(CropTo::IDS)
.find(|(_, id)| id.eq_ignore_ascii_case(c))
.map(|(c, _)| *c)
.ok_or_else(|| bad(cmd, format!("cropTo must be one of {}", CropTo::IDS.join(", "))))?,
};
let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string);
Ok(Self { pages, crop, password })
}
/// The PDF import options: `pages` resolved against the file's page count.
fn import_options(&self, bytes: &[u8]) -> Result<ImportOptions> {
let pages = match &self.pages {
None => None,
Some(range) => {
let count = vectorcraft_pdf::info(bytes, self.password.as_deref()).map_err(err)?.pages.len();
Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?)
}
};
Ok(ImportOptions { pages, crop: self.crop, password: self.password.clone(), ..Default::default() })
}
}
/// Import the pages `o` pick → the document and import notes.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?;
Ok((r.document, r.warnings))
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and the
/// import notes.
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let opts = ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), ..Default::default() };
vectorcraft_pdf::import_with_report(bytes, &opts).map(|r| (r.document, r.warnings)).map_err(err)
}
const INFO: &str = "document.pdfInfo";
/// `document.pdfInfo`.
pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result<Value> {
let bytes = source(p, INFO)?.bytes;
let o = LoadOptions::from_params(INFO, p)?;
let info = match vectorcraft_pdf::info(&bytes, o.password.as_deref()) {
Ok(i) => i,
Err(e @ (PdfError::NeedsPassword | PdfError::WrongPassword)) => {
return Ok(json!({"pages": 0, "needsPassword": true, "wrongPassword": e == PdfError::WrongPassword, "pageInfo": []}));
}
Err(e) => return Err(err(e)),
};
let r4 = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
let pages: Vec<Value> = info
.pages
.iter()
.map(|pg| {
let boxes: serde_json::Map<String, Value> = pg.boxes.iter().map(|(c, r)| (c.id().to_string(), r4(*r))).collect();
json!({"width": pg.width, "height": pg.height, "rotation": pg.rotation, "boxes": boxes})
})
.collect();
let mut out = json!({"pages": pages.len(), "needsPassword": false, "pageInfo": pages});
if let Some(n) = p.get("thumbnail").and_then(Value::as_u64) {
let page = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).filter(|i| *i < info.pages.len());
let page = page.ok_or_else(|| bad(INFO, format!("thumbnail: no page {n} (the PDF has {})", info.pages.len())))?;
let (doc, _) = page_document(&bytes, page, &o)?;
let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0);
let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?;
let png = super::encode(&doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?;
out["thumbnail"] = json!(vectorcraft_format::base64_encode(&png));
}
Ok(out)
}
@@ -0,0 +1,148 @@
//! `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()) });
// `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());
}
+22 -14
View File
@@ -6,7 +6,8 @@
//! `link`.
//! - An SVG becomes one group of its art.
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
//! (`crop: "crop"`) or bounded by its art (`crop: "bounding"`).
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
//! (`crop: "bounding"`).
//!
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
@@ -21,6 +22,7 @@ use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_pdf::CropTo;
use super::fileio::{self, Format, RasterImage};
use super::*;
@@ -40,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
has_doc,
place
),
@@ -49,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Placed File Info",
[],
None,
"{path | name+dataBase64, page?, crop?, thumbnail?: px} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}",
"{path | name+dataBase64, page?, crop?, password?, thumbnail?: px} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}",
always,
info
),
@@ -116,11 +118,15 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
None => 1,
Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))? as usize,
};
let crop = match str_param(p, "crop").unwrap_or("crop") {
"crop" => true,
"bounding" => false,
other => return Err(bad(cmd, format!("crop `{other}`: use \"crop\" or \"bounding\""))),
};
let opts = fileio::LoadOptions::from_params(cmd, p)?;
let pdf = matches!(format.id, "pdf" | "ai" | "ait");
if !pdf && !matches!(opts.crop, CropTo::Crop | CropTo::Bounding) {
return Err(bad(
cmd,
format!("crop `{}`: only PDF pages have art, trim, bleed and media boxes; use \"crop\" or \"bounding\"", opts.crop.id()),
));
}
let crop = opts.crop != CropTo::Bounding;
let path = src.path.map(str::to_string);
if format.raster {
let img = fileio::raster_image(&src.bytes)?;
@@ -129,11 +135,11 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], path });
}
let (mut doc, warnings) = match format.id {
// Only the pages up to the one placed are read.
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's
// bounds, below).
"pdf" | "ai" | "ait" => {
let opts = vectorcraft_pdf::ImportOptions { max_pages: Some(page), ..Default::default() };
let r = vectorcraft_pdf::import_with_report(&src.bytes, &opts).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
(r.document, r.warnings)
let o = fileio::LoadOptions { crop: if crop { opts.crop } else { CropTo::Crop }, ..opts };
fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))?
}
_ => {
let l = fileio::load(src.name, &src.bytes)?;
@@ -145,14 +151,16 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
let (nodes, clip) = if matches!(format.id, "svg" | "svgz") {
(art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), None)
} else {
// The page placed is a PDF import's only one.
let index = if pdf { 0 } else { page - 1 };
let n = doc.artboards.len();
let board = doc.artboards.get(page - 1).map(|a| a.rect).ok_or_else(|| {
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| {
let what = if format.id == "vectorcraft" { "artboard" } else { "page" };
bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)"))
})?;
// A PDF import has one layer per page; a native document's art is whatever lies on the
// artboard.
let pages: Vec<&Arc<Node>> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(page - 1).into_iter().collect() };
let pages: Vec<&Arc<Node>> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(index).into_iter().collect() };
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board));
(nodes, crop.then_some(board))
+52
View File
@@ -328,6 +328,58 @@ proptest! {
}
}
/// A page box with junk in it: inverted, empty, huge, NaN or outside the media box.
fn arb_box() -> impl Strategy<Value = Option<[f64; 4]>> {
prop_oneof![
Just(None),
Just(Some([10.0, 10.0, 90.0, 90.0])),
Just(Some([90.0, 90.0, 10.0, 10.0])),
Just(Some([0.0, 0.0, 0.0, 0.0])),
Just(Some([-1e308, -1e308, 1e308, 1e308])),
Just(Some([f64::NAN, 0.0, 50.0, f64::INFINITY])),
Just(Some([200.0, 200.0, 300.0, 300.0])),
]
}
proptest! {
#![proptest_config(config())]
/// Every page box, rotation, page pick, password and Crop To, through `document.open` and
/// `document.pdfInfo` (with a thumbnail).
#[test]
fn pdf_import_options_never_panic(
boxes in prop::collection::vec(arb_box(), 5),
rotate in prop::sample::select(vec![0, 90, 180, 270, 45, -90, 1_000_000_000]),
encrypt in any::<bool>(),
password in prop::sample::select(vec!["", "pw", "wrong", "\u{0}\u{ff}"]),
pages in prop::sample::select(vec!["1", "2", "1-2", "2-1", "0", "-", "1-", "all", "1,1,2", "99999999999999999999"]),
crop in prop::sample::select(vec!["bounding", "art", "crop", "trim", "bleed", "media", "nope"]),
) {
use vectorcraft_testkit::pdf::{PdfPage, pdf};
let page = PdfPage {
media: boxes[0].unwrap_or([0.0, 0.0, 100.0, 100.0]),
crop: boxes[1],
bleed: boxes[2],
trim: boxes[3],
art: boxes[4],
rotate,
..PdfPage::new(0.0, 0.0, "0 0 1 rg 20 20 50 50 re f")
};
let bytes = pdf(&[page.clone(), page], encrypt.then_some("pw"));
let b64 = vectorcraft_format::base64_encode(&bytes);
let r = catch_quiet(|| {
let mut s = vectorcraft_engine::Session::new();
let p = json!({"name": "x.pdf", "dataBase64": b64, "pages": pages, "cropTo": crop, "password": password, "thumbnail": 1, "thumbnailSize": 64});
let _ = s.execute("document.pdfInfo", &p);
if s.execute("document.open", &p).is_ok() {
let d = (*s.doc().unwrap().doc).clone();
survive("pdf options", || Some(d)).unwrap();
}
});
r.map_err(|msg| TestCaseError::fail(format!("{pages} {crop} {rotate}: panicked: {msg}")))?;
}
}
// ---------- library files ----------
/// The `data` of a library file `cmd` writes for the rich document.
+25 -20
View File
@@ -10,7 +10,6 @@ use hayro_interpret::{
ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, PathDrawMode,
SoftMask, StrokeProps, interpret_page,
};
use hayro_syntax::Pdf;
use hayro_syntax::object::Name;
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
@@ -20,7 +19,7 @@ use vectorcraft_doc::{
};
use vectorcraft_geom::{FillRule, PathData};
use crate::{ImportOptions, ImportReport, PdfError};
use crate::{CropTo, ImportOptions, ImportReport, PdfError};
/// Import a PDF (or PDF-compatible `.ai`) with default options.
pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
@@ -29,12 +28,9 @@ pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
let pdf = Pdf::new(bytes.to_vec()).map_err(|e| PdfError::Parse(format!("{e:?}")))?;
let pdf = crate::pages::open(bytes, opts.password.as_deref())?;
let pages = pdf.pages();
let n = opts.max_pages.map_or(pages.len(), |m| m.min(pages.len()));
if n == 0 {
return Err(PdfError::NoPages);
}
let picked = crate::pages::picked(opts, pages.len())?;
let sink: Arc<Mutex<Vec<String>>> = Arc::default();
let sink2 = sink.clone();
@@ -61,11 +57,25 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
let mut b = Builder::new(doc.peek_next_id());
let cache = InterpreterCache::new();
let mut x = 0.0;
for (i, page) in pages.iter().take(n).enumerate() {
let (w, h) = page.render_dimensions();
let (w, h) = (w as f64, h as f64);
let ab = Rect::new(x, 0.0, x + w, h);
x += w + opts.artboard_gap;
for (i, &number) in picked.iter().enumerate() {
let Some(page) = pages.get(number) else { continue };
// The chosen box sits at (x, 0); the page draws round it.
let (init, frame) = crate::pages::frame(page, opts.crop);
let mut ab = Rect::new(x, 0.0, x + frame.width(), frame.height());
let xf = Affine::translate((x - frame.x0, -frame.y0)) * init;
let mut ctx = Context::new(xf, ab, &cache, pdf.xref(), settings.clone());
b.page = ab;
b.begin_page();
interpret_page(page, &mut ctx, &mut b);
let children = b.end_page();
let mut right = ab.x1;
if opts.crop == CropTo::Bounding
&& let Some(art) = vectorcraft_doc::live::nodes_bounds(&children)
{
right = right.max(art.x1);
ab = art;
}
x = right + opts.artboard_gap;
doc.artboards.push(Artboard {
id: i as u32 + 1,
name: format!("Artboard {}", i + 1),
@@ -73,13 +83,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
show_center_mark: false,
show_cross_hairs: false,
});
let init = Affine::translate((ab.x0, ab.y0)) * Affine::new(page.initial_transform(true).as_coeffs());
let mut ctx = Context::new(init, ab, &cache, pdf.xref(), settings.clone());
b.page = ab;
b.begin_page();
interpret_page(page, &mut ctx, &mut b);
let children = b.end_page();
let mut layer = Node::layer(b.id(), &format!("Page {}", i + 1), LayerColor::Preset((i % 27) as u8));
let mut layer = Node::layer(b.id(), &format!("Page {}", number + 1), LayerColor::Preset((i % 27) as u8));
if let NodeKind::Layer { children: c, .. } = &mut layer.kind {
*c = children;
}
@@ -353,7 +357,8 @@ impl Builder {
self.warn("an image could not be decoded and was skipped");
return;
};
let k = format!("pdf-image-{}", self.image_keys.len() + 1);
// Content keys: placing this page into another document merges its images safely.
let k = blob.content_key();
self.images.insert(k.clone(), blob);
self.image_keys.insert(key, (k.clone(), w, h));
(k, w, h)
+35 -3
View File
@@ -11,16 +11,19 @@
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
//! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups,
//! transparency groups, axial/radial shadings → gradients, images (JPEG passthrough, others
//! re-encoded as PNG) and text as glyph outlines.
//! re-encoded as PNG) and text as glyph outlines. [`ImportOptions`] pick the pages, the box each
//! artboard gets ([`CropTo`]) and the password; [`info`] lists the pages and their boxes.
#![forbid(unsafe_code)]
mod export;
mod import;
mod lab_spot;
mod pages;
mod settings;
pub use export::{export, export_with_report};
pub use import::{import, import_with_report};
pub use pages::{PageInfo, PdfInfo, info};
pub use settings::*;
use vectorcraft_doc::Document;
@@ -49,18 +52,39 @@ impl PdfOptions {
/// Import options.
#[derive(Clone, Debug)]
pub struct ImportOptions {
/// Import at most this many pages (from the first); `None` = all pages.
/// Import at most this many pages (of those picked); `None` = all of them.
pub max_pages: Option<usize>,
/// Horizontal gap in points between the artboards created for consecutive pages.
pub artboard_gap: f64,
/// 0-based pages to import, in this order; `None` = every page.
pub pages: Option<Vec<usize>>,
/// The page box each artboard gets.
pub crop: CropTo,
/// The (user) password of an encrypted PDF.
pub password: Option<String>,
}
impl Default for ImportOptions {
fn default() -> Self {
Self { max_pages: None, artboard_gap: 36.0 }
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None }
}
}
settings::choice! {
/// The page box an imported or placed page is cropped to: its artboard (or placed frame).
CropTo {
/// The bounds of the page's art.
Bounding = "bounding", "Bounding Box";
Art = "art", "Art";
/// The visible page area (what viewers show).
Crop = "crop", "Crop";
Trim = "trim", "Trim";
Bleed = "bleed", "Bleed";
/// The whole sheet.
Media = "media", "Media";
} default Crop
}
/// Result of an import with non-fatal warnings (unsupported features, skipped content).
#[derive(Clone, Debug)]
pub struct ImportReport {
@@ -91,6 +115,12 @@ pub enum PdfError {
NoPages,
#[error("invalid PDF setting: {0}")]
BadSetting(String),
#[error("the PDF is password-protected: give its password")]
NeedsPassword,
#[error("the PDF password is wrong")]
WrongPassword,
#[error("page {0} does not exist (the PDF has {1})")]
BadPage(usize, usize),
}
#[cfg(test)]
@@ -108,6 +138,8 @@ mod tests_focal;
#[cfg(test)]
mod tests_fx;
#[cfg(test)]
mod tests_import_options;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stroke;
+109
View File
@@ -0,0 +1,109 @@
//! The pages of a PDF to import: opening with a password, the pages picked, the box each page
//! is cropped to ([`CropTo`]) and [`info`].
use hayro_syntax::page::Page;
use hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
use kurbo::{Affine, Rect};
use crate::{Choice, CropTo, ImportOptions, PdfError};
/// What `info` tells about one page.
#[derive(Clone, Debug, PartialEq)]
pub struct PageInfo {
/// The page's size in points as viewers show it (its crop box, rotated).
pub width: f64,
pub height: f64,
/// The page's rotation in degrees (0, 90, 180 or 270).
pub rotation: u16,
/// Every box but Bounding, in PDF user space (y up), clipped to the media box. A missing
/// bleed, trim or art box is the crop box.
pub boxes: Vec<(CropTo, Rect)>,
}
/// The pages of a PDF ([`info`]).
#[derive(Clone, Debug, PartialEq)]
pub struct PdfInfo {
pub pages: Vec<PageInfo>,
}
/// Read a PDF, decrypting it with `password` (none: the empty user password).
pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result<Pdf, PdfError> {
let password = password.unwrap_or_default();
Pdf::new_with_password(bytes.to_vec(), password).map_err(|e| match e {
LoadPdfError::Decryption(DecryptionError::PasswordProtected) if password.is_empty() => PdfError::NeedsPassword,
LoadPdfError::Decryption(DecryptionError::PasswordProtected) => PdfError::WrongPassword,
e => PdfError::Parse(format!("{e:?}")),
})
}
/// The pages and their boxes. An encrypted PDF needs its `password`
/// ([`PdfError::NeedsPassword`] / [`PdfError::WrongPassword`]).
pub fn info(bytes: &[u8], password: Option<&str>) -> Result<PdfInfo, PdfError> {
let pdf = open(bytes, password)?;
let pages = pdf
.pages()
.iter()
.map(|page| {
let (w, h) = page.render_dimensions();
let rotation = match page.rotation() {
hayro_syntax::page::Rotation::None => 0,
hayro_syntax::page::Rotation::Horizontal => 90,
hayro_syntax::page::Rotation::Flipped => 180,
hayro_syntax::page::Rotation::FlippedHorizontal => 270,
};
let boxes = CropTo::ALL.iter().filter(|c| **c != CropTo::Bounding).map(|c| (*c, page_box(page, *c))).collect();
PageInfo { width: w as f64, height: h as f64, rotation, boxes }
})
.collect();
Ok(PdfInfo { pages })
}
/// The 0-based pages `opts` pick out of `count`.
pub(crate) fn picked(opts: &ImportOptions, count: usize) -> Result<Vec<usize>, PdfError> {
let mut v = match &opts.pages {
Some(p) => {
if let Some(bad) = p.iter().find(|i| **i >= count) {
return Err(PdfError::BadPage(bad.saturating_add(1), count));
}
p.clone()
}
None => (0..count).collect(),
};
if let Some(m) = opts.max_pages {
v.truncate(m);
}
if v.is_empty() {
return Err(PdfError::NoPages);
}
Ok(v)
}
fn rect(r: hayro_syntax::object::Rect) -> Rect {
Rect::new(r.x0, r.y0, r.x1, r.y1).abs()
}
/// Box `which` of `page` in PDF user space, clipped to the media box (Bounding: the crop box,
/// which the art's bounds replace after import). A missing or empty box is the crop box.
pub(crate) fn page_box(page: &Page<'_>, which: CropTo) -> Rect {
let media = rect(page.media_box());
let crop = rect(page.intersected_crop_box());
let key: &[u8] = match which {
CropTo::Bounding | CropTo::Crop => return crop,
CropTo::Media if media.area() > 0.0 => return media,
CropTo::Media => return crop,
CropTo::Bleed => b"BleedBox",
CropTo::Trim => b"TrimBox",
CropTo::Art => b"ArtBox",
};
page.raw().get::<hayro_syntax::object::Rect>(key).map(|r| rect(r).intersect(media)).filter(|r| r.area() > 0.0).unwrap_or(crop)
}
/// Where `page` draws on its artboard: the page transform (PDF user space → y-down page space
/// with the crop box at the origin, rotation applied) and `which` box in that space (a page with
/// no area gets the size viewers give it).
pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) {
let init = Affine::new(page.initial_transform(true).as_coeffs());
let b = init.transform_rect_bbox(page_box(page, which));
let (w, h) = page.render_dimensions();
(init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) })
}
+5 -4
View File
@@ -12,7 +12,7 @@ pub trait Choice: Copy + 'static {
const ALL: &'static [Self];
/// The JSON ids, in display order.
const IDS: &'static [&'static str];
/// The labels shown in the Save PDF dialog, in display order.
/// The labels shown in the PDF dialogs, in display order.
const LABELS: &'static [&'static str];
}
@@ -20,19 +20,19 @@ pub trait Choice: Copy + 'static {
macro_rules! choice {
($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $v:ident = $id:literal, $label:literal;)+ } default $d:ident) => {
$(#[$m])*
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum $name {
$($(#[$vm])* #[serde(rename = $id)] $v,)+
}
impl Choice for $name {
impl $crate::Choice for $name {
const ALL: &'static [Self] = &[$(Self::$v),+];
const IDS: &'static [&'static str] = &[$($id),+];
const LABELS: &'static [&'static str] = &[$($label),+];
}
impl $name {
/// The label shown in the Save PDF dialog.
/// The label shown in the PDF dialogs.
pub fn label(self) -> &'static str {
match self {
$(Self::$v => $label,)+
@@ -54,6 +54,7 @@ macro_rules! choice {
}
};
}
pub(crate) use choice;
choice! {
/// The PDF standard the file conforms to.
+150
View File
@@ -0,0 +1,150 @@
//! 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);
}
#[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 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:?}");
}
}
+2
View File
@@ -9,6 +9,7 @@
//! (`.vectorcraft`, SVG, `document.inspect`).
//! - [`raster`]: rendering helpers and image comparison with a perceptual tolerance.
//! - [`geom`]: geometry assertions (approximate equality, curve sampling, Hausdorff distance).
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
//!
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
// Test support only (a dev-dependency of every crate that uses it): a failed setup or assertion
@@ -20,6 +21,7 @@
pub mod fixtures;
pub mod geom;
pub mod invariants;
pub mod pdf;
pub mod raster;
pub mod strategies;
+189
View File
@@ -0,0 +1,189 @@
//! Hand-written PDF files for import tests: pages with given boxes, rotation, resources and
//! content, optionally encrypted with a user password (the standard security handler, revision 2:
//! 40-bit RC4), which the PDF writer can't produce.
use std::fmt::Write as _;
/// One page: its boxes as `[x0, y0, x1, y1]` in PDF user space.
#[derive(Clone, Debug, Default)]
pub struct PdfPage {
pub media: [f64; 4],
pub crop: Option<[f64; 4]>,
pub bleed: Option<[f64; 4]>,
pub trim: Option<[f64; 4]>,
pub art: Option<[f64; 4]>,
pub rotate: i32,
/// The entries of the page's resource dictionary (`/ColorSpace << … >>`).
pub resources: String,
/// The content stream (operators, uncompressed).
pub content: String,
}
impl PdfPage {
/// A `w` × `h` pt page drawing `content`.
pub fn new(w: f64, h: f64, content: &str) -> Self {
Self { media: [0.0, 0.0, w, h], content: content.into(), ..Default::default() }
}
}
/// Padding of passwords (PDF 1.7, 7.6.3.3).
const PAD: [u8; 32] = [
0x28, 0xBF, 0x4E, 0x5E, 0x4E, 0x75, 0x8A, 0x41, 0x64, 0x00, 0x4E, 0x56, 0xFF, 0xFA, 0x01, 0x08, 0x2E, 0x2E, 0x00, 0xB6, 0xD0, 0x68, 0x3E, 0x80,
0x2F, 0x0C, 0xA9, 0xFE, 0x64, 0x53, 0x69, 0x7A,
];
fn padded(pw: &str) -> Vec<u8> {
let mut v: Vec<u8> = pw.bytes().take(32).collect();
v.extend_from_slice(&PAD[..32 - v.len()]);
v
}
fn hex(b: &[u8]) -> String {
b.iter().fold(String::new(), |mut s, x| {
let _ = write!(s, "{x:02X}");
s
})
}
fn rect(r: [f64; 4]) -> String {
format!("[{} {} {} {}]", r[0], r[1], r[2], r[3])
}
/// A PDF of `pages`; with a `password` it is encrypted and opens only with it (its owner password
/// is the same followed by `-owner`).
pub fn pdf(pages: &[PdfPage], password: Option<&str>) -> Vec<u8> {
let id = md5(b"vectorcraft test file");
// (O, U, file key) of the standard security handler, revision 2, permissions -4.
let crypt = password.map(|pw| {
let owner_key = md5(&padded(&format!("{pw}-owner")));
let o = rc4(&owner_key[..5], &padded(pw));
let mut k = padded(pw);
k.extend_from_slice(&o);
k.extend_from_slice(&(-4i32).to_le_bytes());
k.extend_from_slice(&id);
let key = md5(&k)[..5].to_vec();
let u = rc4(&key, &PAD);
(o, u, key)
});
let mut objects: Vec<Vec<u8>> = vec![];
let kids: Vec<String> = (0..pages.len()).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
objects.push(b"<< /Type /Catalog /Pages 2 0 R >>".to_vec());
objects.push(format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids.join(" "), pages.len()).into_bytes());
for (i, p) in pages.iter().enumerate() {
let mut d = format!("<< /Type /Page /Parent 2 0 R /MediaBox {} /Contents {} 0 R /Resources << {} >>", rect(p.media), 4 + 2 * i, p.resources);
for (k, r) in [("CropBox", p.crop), ("BleedBox", p.bleed), ("TrimBox", p.trim), ("ArtBox", p.art)] {
if let Some(r) = r {
let _ = write!(d, " /{k} {}", rect(r));
}
}
if p.rotate != 0 {
let _ = write!(d, " /Rotate {}", p.rotate);
}
d.push_str(" >>");
objects.push(d.into_bytes());
let num = objects.len() + 1;
let data = match &crypt {
Some((_, _, key)) => {
let mut k = key.clone();
k.extend_from_slice(&(num as u32).to_le_bytes()[..3]);
k.extend_from_slice(&[0, 0]);
rc4(&md5(&k)[..10], p.content.as_bytes())
}
None => p.content.as_bytes().to_vec(),
};
let mut s = format!("<< /Length {} >>\nstream\n", data.len()).into_bytes();
s.extend_from_slice(&data);
s.extend_from_slice(b"\nendstream");
objects.push(s);
}
if let Some((o, u, _)) = &crypt {
objects.push(format!("<< /Filter /Standard /V 1 /R 2 /O <{}> /U <{}> /P -4 >>", hex(o), hex(u)).into_bytes());
}
let mut out = b"%PDF-1.4\n".to_vec();
let mut offsets = vec![];
for (i, o) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend(format!("{} 0 obj\n", i + 1).bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes());
for off in offsets {
out.extend(format!("{off:010} 00000 n \n").bytes());
}
let encrypt = if crypt.is_some() { format!(" /Encrypt {} 0 R", objects.len()) } else { String::new() };
let (size, id) = (objects.len() + 1, hex(&id));
out.extend(format!("trailer\n<< /Size {size} /Root 1 0 R /ID [<{id}> <{id}>]{encrypt} >>\nstartxref\n{xref}\n%%EOF\n").bytes());
out
}
/// RC4 of `data` with `key`.
fn rc4(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut s: Vec<u8> = (0..=255).collect();
let mut j = 0u8;
for i in 0..256 {
j = j.wrapping_add(s[i]).wrapping_add(key[i % key.len()]);
s.swap(i, j as usize);
}
let (mut i, mut j) = (0u8, 0u8);
data.iter()
.map(|b| {
i = i.wrapping_add(1);
j = j.wrapping_add(s[i as usize]);
s.swap(i as usize, j as usize);
b ^ s[s[i as usize].wrapping_add(s[j as usize]) as usize]
})
.collect()
}
/// MD5 of `data` (RFC 1321).
fn md5(data: &[u8]) -> [u8; 16] {
const SHIFT: [u32; 16] = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
let k: Vec<u32> = (0..64).map(|i| ((i as f64 + 1.0).sin().abs() * 4_294_967_296.0) as u32).collect();
let mut h = [0x6745_2301u32, 0xefcd_ab89, 0x98ba_dcfe, 0x1032_5476];
let mut msg = data.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&((data.len() as u64).wrapping_mul(8)).to_le_bytes());
for chunk in msg.chunks(64) {
let m: Vec<u32> = chunk.chunks(4).map(|w| u32::from_le_bytes([w[0], w[1], w[2], w[3]])).collect();
let [mut a, mut b, mut c, mut d] = h;
for i in 0..64 {
let (f, g) = match i / 16 {
0 => ((b & c) | (!b & d), i),
1 => ((d & b) | (!d & c), (5 * i + 1) % 16),
2 => (b ^ c ^ d, (3 * i + 5) % 16),
_ => (c ^ (b | !d), (7 * i) % 16),
};
let f = f.wrapping_add(a).wrapping_add(k[i]).wrapping_add(m[g]);
a = d;
d = c;
c = b;
b = b.wrapping_add(f.rotate_left(SHIFT[(i / 16) * 4 + i % 4]));
}
for (x, y) in h.iter_mut().zip([a, b, c, d]) {
*x = x.wrapping_add(y);
}
}
let mut out = [0u8; 16];
for (o, w) in out.chunks_mut(4).zip(h) {
o.copy_from_slice(&w.to_le_bytes());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn md5_and_rc4_match_their_reference_vectors() {
assert_eq!(hex(&md5(b"")), "D41D8CD98F00B204E9800998ECF8427E");
assert_eq!(hex(&md5(b"abc")), "900150983CD24FB0D6963F7D28E17F72");
assert_eq!(hex(&md5(&[b'a'; 100])), "36A92CC94A9E0FA21F625F8BFB007ADF");
assert_eq!(hex(&rc4(b"Key", b"Plaintext")), "BBF316E8D940AF0AD3");
}
}
+3
View File
@@ -26,3 +26,6 @@ image = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }
@@ -145,10 +145,7 @@ fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Op
}
let doc = st.doc.clone();
let r = doc.artboards.get(i)?.rect;
let scale = 92.0 / r.width().max(r.height()).max(1.0);
let img = app.canvas.renderer.render_region(&doc, r, scale, true);
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ctx.load_texture(format!("ab-thumb-{i}"), color, egui::TextureOptions::LINEAR);
let tex = crate::widgets::region_texture(ctx, &mut app.canvas.renderer, &format!("ab-thumb-{i}"), &doc, r, 92.0);
ctx.data_mut(|d| d.insert_temp(key, tex.clone()));
Some(tex)
}
+276
View File
@@ -0,0 +1,276 @@
//! The Import PDF dialog: opening or placing a multi-page or password-protected PDF (or `.ai`)
//! asks which pages (a preview with page navigation), the box to crop to and the password.
//!
//! Fields: `mode` (`open` | `place`), `name`, `path?`, `pageCount`, `page` (the page shown, and the
//! one placed; 1-based), `allPages` and `range` (open), `cropTo` and `password`. `__locked` is set
//! until the password is accepted: OK then unlocks the file. `__place` keeps the other `file.place`
//! params (Link, Template, `at`…). The file's bytes stay out of the fields, in
//! [`crate::state::UiState::dialog_file`].
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_pdf::{Choice, CropTo, PdfError};
use super::DialogSpec;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
pub(super) const KIND: &str = "importPdf";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |d| if d.str("mode") == "place" { "Place PDF" } else { "Import PDF" }.into(), body, confirm, ..DialogSpec::FORM };
/// The longest side of the page preview (pt).
const PREVIEW: f32 = 200.0;
const LABEL_WIDTH: f32 = 70.0;
/// The file the dialog reads.
#[derive(Debug)]
pub struct DialogFile {
pub bytes: Vec<u8>,
/// Unique per file the dialog opened (keys its preview cache).
token: u64,
}
/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the
/// file is a PDF (or `.ai`/`.ait`) with several pages or a password; false when there's nothing to
/// ask (the caller goes on).
pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, place: Option<&Value>) -> bool {
if !fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) {
return false;
}
let (pages, locked) = match vectorcraft_pdf::info(bytes, None) {
Ok(i) if i.pages.len() > 1 => (i.pages.len(), false),
Err(PdfError::NeedsPassword) => (0, true),
_ => return false,
};
static TOKEN: AtomicU64 = AtomicU64::new(0);
app.ui.dialog_file = Some(Arc::new(DialogFile { bytes: bytes.to_vec(), token: TOKEN.fetch_add(1, Ordering::Relaxed) }));
let mut fields = json!({
"mode": if place.is_some() { "place" } else { "open" },
"name": name,
"pageCount": pages,
"page": 1,
"allPages": true,
"range": "",
"cropTo": CropTo::default().id(),
"password": "",
"__locked": locked,
});
if let Some(p) = path {
fields["path"] = json!(p);
}
if let Some(Value::Object(p)) = place {
let rest: serde_json::Map<String, Value> = p
.iter()
.filter(|(k, _)| !matches!(k.as_str(), "path" | "name" | "dataBase64" | "page" | "crop" | "password"))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
fields["__place"] = Value::Object(rest);
}
app.ui.dialog = Some(Dialog::new(KIND, fields));
true
}
/// `file.place` with params `p` asks first (true) when they name a PDF (`.pdf`, `.ai`, `.ait`)
/// with several pages or a password and no `page`.
pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool {
if p.get("page").is_some() {
return false;
}
let s = |k: &str| p.get(k).and_then(Value::as_str);
let (name, bytes, path) = match (s("path"), s("dataBase64")) {
(Some(path), _) if matches!(fileio::extension(path).as_str(), "pdf" | "ai" | "ait") => {
let Some(bytes) = app.services.read.as_ref().and_then(|r| r(path).ok()) else { return false };
(path.to_string(), bytes, Some(path.to_string()))
}
(None, Some(b64)) => match vectorcraft_format::base64_decode(b64) {
Some(bytes) => (s("name").unwrap_or("Untitled").to_string(), bytes, None),
None => return false,
},
_ => return false,
};
offer(app, &name, &bytes, path, Some(p))
}
fn page_count(d: &Dialog) -> usize {
d.fields.get("pageCount").and_then(Value::as_u64).unwrap_or(0) as usize
}
/// The page shown (1-based, within the file).
fn page(d: &Dialog) -> usize {
(d.f64("page", 1.0).max(1.0) as usize).min(page_count(d).max(1))
}
fn crop(d: &Dialog) -> CropTo {
CropTo::IDS.iter().position(|id| *id == d.str("cropTo")).and_then(|i| CropTo::ALL.get(i).copied()).unwrap_or_default()
}
fn password(d: &Dialog) -> Option<String> {
Some(d.str("password")).filter(|p| !p.is_empty())
}
/// OK: unlock the file with the password, else open the pages or place the page.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let file = app.ui.dialog_file.clone().ok_or("the PDF is no longer loaded")?;
let mut d = d.clone();
if d.bool("__locked") {
let info = vectorcraft_pdf::info(&file.bytes, password(&d).as_deref()).map_err(|e| e.to_string())?;
d.fields.insert("__locked".into(), json!(false));
d.fields.insert("pageCount".into(), json!(info.pages.len()));
if info.pages.len() > 1 {
// Unlocked: now pick the pages.
app.ui.dialog = Some(d);
return Ok(Value::Null);
}
}
let name = d.str("name");
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
let r = if d.str("mode") == "place" {
let mut p = d.fields.get("__place").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
match &path {
Some(path) => p["path"] = json!(path),
None => {
p["name"] = json!(name);
p["dataBase64"] = json!(vectorcraft_format::base64_encode(&file.bytes));
}
}
p["page"] = json!(page(&d));
p["crop"] = json!(crop(&d).id());
if let Some(pw) = password(&d) {
p["password"] = json!(pw);
}
crate::place::run(app, &p).map(|_| ())
} else {
let mut p = json!({"cropTo": crop(&d).id(), "password": password(&d)});
let range = d.str("range");
if !d.bool("allPages") && !range.trim().is_empty() {
p["pages"] = json!(range);
}
io::open_document(app, &name, &file.bytes, path, &p)
};
match r {
Ok(()) => {
app.ui.dialog = None;
Ok(Value::Null)
}
Err(e) => {
// Keep what was typed (the dialog stays open on errors such as a bad range).
app.ui.dialog = Some(d);
Err(e)
}
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
if d.bool("__locked") {
let name = d.str("name");
let file = std::path::Path::new(&name).file_name().map_or(name.clone(), |f| f.to_string_lossy().into_owned());
ui.label(egui::RichText::new(format!("“{file}” is protected by a password.")).color(t.text));
ui.add_space(8.0);
password_row(ui, d);
return false;
}
let count = page_count(d);
let mut current = page(d);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(PREVIEW, PREVIEW), egui::Sense::hover());
ui.painter().rect_filled(r, 2.0, t.pasteboard);
if let Some(tex) = preview(app, ui.ctx(), d, current) {
let sz = tex.size_vec2();
let ir = egui::Rect::from_center_size(r.center(), sz * (PREVIEW / sz.x.max(sz.y)));
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
ui.painter().rect_stroke(ir, 0.0, egui::Stroke::new(1.0, t.divider), egui::StrokeKind::Outside);
}
ui.add_space(6.0);
// Page navigation: first, previous, the page number, next, last.
ui.horizontal(|ui| {
let nav = |ui: &mut egui::Ui, icon: &str, tip: &str, to: usize, enabled: bool| {
widgets::icon_button_enabled(ui, icon, tip, false, enabled, 22.0).clicked().then_some(to)
};
let go = [
nav(ui, "chevrons-left", "First Page", 1, current > 1),
nav(ui, "chevron-left", "Previous Page", current.saturating_sub(1).max(1), current > 1),
];
if let Some(v) = widgets::plain_field(ui, "pdf-page", current as f64, "", 0, 40.0) {
current = (v.max(1.0) as usize).min(count.max(1));
}
ui.label(egui::RichText::new(format!("of {count}")).color(t.text_dim));
let go2 = [
nav(ui, "chevron-right", "Next Page", (current + 1).min(count), current < count),
nav(ui, "chevrons-right", "Last Page", count, current < count),
];
if let Some(to) = go.into_iter().chain(go2).flatten().next() {
current = to;
}
});
});
ui.add_space(14.0);
ui.vertical(|ui| {
widgets::label_row(ui, "Crop To", LABEL_WIDTH, |ui| {
if let Some(i) = widgets::dropdown(ui, "pdf-crop", crop(d).label(), CropTo::LABELS, 130.0)
&& let Some(id) = CropTo::IDS.get(i)
{
d.fields.insert("cropTo".into(), json!(id));
}
});
if d.str("mode") != "place" {
ui.add_space(10.0);
ui.label(egui::RichText::new("Pages").color(t.text));
let all = d.bool("allPages");
if widgets::radio(ui, "All", all, true) {
d.fields.insert("allPages".into(), json!(true));
}
ui.horizontal(|ui| {
if widgets::radio(ui, "Range:", !all, true) {
d.fields.insert("allPages".into(), json!(false));
}
if super::form::text_edit(ui, d, "range", 90.0).gained_focus() {
d.fields.insert("allPages".into(), json!(false));
}
});
widgets::dim_label(ui, "e.g. 1-3, 5");
}
});
});
d.fields.insert("page".into(), json!(current));
false
}
fn password_row(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::label_row(ui, "Password", LABEL_WIDTH, |ui| {
let mut s = d.str("password");
let r = ui.add(egui::TextEdit::singleline(&mut s).password(true).desired_width(180.0));
if r.changed() {
d.fields.insert("password".into(), json!(s));
}
if ui.memory(|m| m.focused().is_none()) {
r.request_focus();
}
});
}
/// Page `page` (1-based) cropped as the dialog says, rendered once per file, page and box.
fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog, page: usize) -> Option<egui::TextureHandle> {
let file = app.ui.dialog_file.clone()?;
let crop = crop(d);
let key = egui::Id::new(("pdf-preview", file.token, page, crop.id()));
if let Some(t) = ctx.data(|m| m.get_temp::<Option<egui::TextureHandle>>(key)) {
return t;
}
let opts = fileio::LoadOptions { crop, password: password(d), ..Default::default() };
// A page that can't be read shows an empty preview (and isn't read again).
let tex = fileio::page_document(&file.bytes, page - 1, &opts).ok().and_then(|(doc, _)| {
let r = doc.artboards.first()?.rect;
let px = PREVIEW as f64 * ctx.pixels_per_point() as f64;
Some(widgets::region_texture(ctx, &mut app.canvas.renderer, &format!("pdf-preview-{page}"), &doc, r, px))
});
ctx.data_mut(|m| m.insert_temp(key, tex.clone()));
tex
}
+8 -1
View File
@@ -25,6 +25,7 @@ pub mod flattener_presets;
mod form;
mod gradient_stop;
pub mod graphic_style_options;
pub mod import_pdf;
pub mod new_color_group;
mod new_document;
pub mod new_swatch;
@@ -186,6 +187,7 @@ registry! {
Place: [place::KIND] => place::SPEC,
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions"] => png_options::SPEC,
ExportAs: ["exportAs"] => export_as::SPEC,
ImportPdf: [import_pdf::KIND] => import_pdf::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
@@ -216,7 +218,10 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
about::show(app, ctx);
let Some(mut d) = app.ui.dialog.clone() else { return };
let Some(mut d) = app.ui.dialog.clone() else {
app.ui.dialog_file = None;
return;
};
let spec = spec(&d.kind);
if let Some(window) = spec.window {
return window(app, ctx);
@@ -286,3 +291,5 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
mod tests;
#[cfg(test)]
mod tests_export;
#[cfg(test)]
mod tests_import_pdf;
@@ -0,0 +1,166 @@
//! The Import PDF dialog: opening and placing multi-page and password-protected PDFs.
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_engine::Session;
use vectorcraft_testkit::pdf::{PdfPage, pdf};
use super::*;
use crate::io;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
app
}
/// One headless frame of the dialog layer.
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
theme::apply(&ctx, Default::default());
let mut out = ctx.run_ui(Default::default(), |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
fn set(app: &mut VectorcraftApp, key: &str, v: serde_json::Value) {
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v);
}
fn field(app: &VectorcraftApp, key: &str) -> serde_json::Value {
app.ui.dialog.as_ref().unwrap().fields[key].clone()
}
/// Three pages, 100, 200 and 300 pt wide; the second has a 50 × 60 art box.
fn three_pages() -> Vec<u8> {
let mut second = PdfPage::new(200.0, 200.0, "0 0 1 rg 20 20 40 40 re f");
second.art = Some([10.0, 10.0, 60.0, 70.0]);
pdf(&[PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), second, PdfPage::new(300.0, 100.0, "1 0 0 rg 0 0 30 30 re f")], None)
}
fn artboard_widths(app: &VectorcraftApp) -> Vec<f64> {
app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect.width()).collect()
}
#[test]
fn opening_a_multi_page_pdf_asks_for_the_pages() {
let mut app = app();
io::open_bytes(&mut app, "three.pdf", &three_pages(), Some("/tmp/three.pdf".into())).unwrap();
assert_eq!(app.session.documents().len(), 1, "nothing opened yet");
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.str("mode"), field(&app, "pageCount")), (import_pdf::KIND, "open".into(), json!(3)));
assert_eq!(field(&app, "cropTo"), "crop");
frame(&mut app);
// Page navigation stays within the file.
set(&mut app, "page", json!(9));
frame(&mut app);
assert_eq!(field(&app, "page"), 3);
// A bad range keeps the dialog open with the message.
set(&mut app, "allPages", json!(false));
set(&mut app, "range", json!("2-7"));
assert!(confirm(&mut app).unwrap_err().contains("1 to 3"));
assert!(app.ui.dialog.is_some());
set(&mut app, "range", json!("2-3"));
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.documents().len(), 2);
assert_eq!(artboard_widths(&app), [200.0, 300.0]);
assert_eq!(app.views.len(), 2, "views follow the documents");
// The file is let go once no dialog is open.
assert!(app.ui.dialog_file.is_some());
frame(&mut app);
assert!(app.ui.dialog_file.is_none());
}
#[test]
fn all_pages_and_a_crop_box() {
let mut app = app();
io::open_bytes(&mut app, "three.pdf", &three_pages(), None).unwrap();
set(&mut app, "cropTo", json!("bounding"));
confirm(&mut app).unwrap();
assert_eq!(artboard_widths(&app), [10.0, 40.0, 30.0]);
}
#[test]
fn single_page_pdfs_open_directly() {
let mut app = app();
io::open_bytes(&mut app, "one.pdf", &pdf(&[PdfPage::new(80.0, 40.0, "")], None), None).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(artboard_widths(&app), [80.0]);
}
#[test]
fn a_password_protected_pdf_asks_for_its_password() {
let mut app = app();
let bytes = pdf(&[PdfPage::new(120.0, 60.0, "1 0 0 rg 0 0 20 20 re f")], Some("pw"));
io::open_bytes(&mut app, "locked.pdf", &bytes, None).unwrap();
assert_eq!(field(&app, "__locked"), true);
frame(&mut app);
set(&mut app, "password", json!("nope"));
assert!(confirm(&mut app).unwrap_err().contains("wrong"));
assert_eq!(field(&app, "__locked"), true, "still locked");
set(&mut app, "password", json!("pw"));
// One page: unlocking opens it.
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(artboard_widths(&app), [120.0]);
// Several pages: unlocking shows the pages first.
let two = pdf(&[PdfPage::new(10.0, 10.0, ""), PdfPage::new(20.0, 10.0, "")], Some("pw"));
io::open_bytes(&mut app, "two.pdf", &two, None).unwrap();
set(&mut app, "password", json!("pw"));
confirm(&mut app).unwrap();
assert_eq!((field(&app, "__locked"), field(&app, "pageCount")), (json!(false), json!(2)));
frame(&mut app);
confirm(&mut app).unwrap();
assert_eq!(artboard_widths(&app), [10.0, 20.0]);
}
/// The placed object: its kind (clip group or not) and bounds.
fn placed(app: &VectorcraftApp) -> (bool, vectorcraft_geom::Rect) {
let st = app.session.active().unwrap();
let n = st.doc.node(st.selection.objects[0]).unwrap();
let NodeKind::Group { clip, .. } = n.kind else { panic!("{:?}", n.kind) };
(clip, n.geometric_bounds().unwrap())
}
fn place(app: &mut VectorcraftApp, bytes: &[u8], extra: serde_json::Value) -> Result<serde_json::Value, String> {
let mut p = json!({"name": "three.pdf", "dataBase64": vectorcraft_format::base64_encode(bytes)});
if let (Some(o), serde_json::Value::Object(e)) = (p.as_object_mut(), extra) {
o.extend(e);
}
crate::place::run(app, &p)
}
#[test]
fn placing_a_pdf_page_asks_for_the_page_and_crop_box() {
let mut app = app();
let bytes = three_pages();
place(&mut app, &bytes, json!({"at": [100, 120], "link": false})).unwrap();
assert_eq!((field(&app, "mode"), field(&app, "cropTo")), (json!("place"), json!("crop")));
assert_eq!(field(&app, "__place"), json!({"at": [100, 120], "link": false}), "the other place params wait in the dialog");
assert!(app.ui.dialog.as_ref().is_some_and(|d| (spec(&d.kind).heading)(d) == "Place PDF"));
frame(&mut app);
set(&mut app, "page", json!(2));
set(&mut app, "cropTo", json!("art"));
let clipboard = app.session.clipboard.len();
let undo = app.session.doc().unwrap().history.undo.len();
confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let (clip, b) = placed(&app);
assert!(clip, "clipped to the art box");
assert_eq!((b.width(), b.height()), (50.0, 60.0));
assert_eq!(b.center(), vectorcraft_geom::Point::new(100.0, 120.0), "where `at` asked");
assert_eq!(app.session.clipboard.len(), clipboard, "the clipboard is untouched");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// With a page, no dialog; Bounding Box places the art itself.
place(&mut app, &bytes, json!({"page": 3, "crop": "bounding"})).unwrap();
assert!(app.ui.dialog.is_none());
let (clip, b) = placed(&app);
assert!(!clip);
assert_eq!((b.width(), b.height()), (30.0, 30.0));
assert!(place(&mut app, &bytes, json!({"page": 4})).is_err());
// A one-page PDF places right away.
place(&mut app, &pdf(&[PdfPage::new(80.0, 40.0, "0 g 0 0 10 10 re f")], None), json!({})).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(placed(&app).1.width(), 80.0);
}
+10 -1
View File
@@ -26,7 +26,16 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library };
return load(app, p).map(|_| ());
}
let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?;
// A PDF with several pages or a password asks first (the Import PDF dialog).
if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) {
return Ok(());
}
open_document(app, name, bytes, path, &Value::Null)
}
/// Open a document through the engine loader with the `document.open` options in `p`.
pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<(), String> {
let r = fileio::open_bytes_with(&mut app.session, name, bytes, path, p).map_err(|e| e.to_string())?;
app.sync_views();
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default()));
+1 -1
View File
@@ -62,7 +62,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.place",
"Place…",
"Cmd+Shift+P",
"{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view",
"{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)",
),
("file.openRecent1", "Open Recent File 1", "", "{}"),
("file.openRecent2", "Open Recent File 2", "", "{}"),
+4
View File
@@ -81,6 +81,10 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
open_dialog(app, files);
return Ok(Value::Null);
}
// A PDF with several pages or a password asks for the page, box and password first.
if crate::dialogs::import_pdf::offer_place(app, p) {
return Ok(Value::Null);
}
let mut p = p.clone();
if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none())
&& let Some(v) = app.view()
+5
View File
@@ -266,6 +266,10 @@ pub struct UiState {
/// File → Place: Link is on (the Place dialog remembers it).
#[serde(default = "yes")]
pub place_link: bool,
/// The file the open dialog reads, kept out of its JSON fields (Import PDF); dropped when no
/// dialog is open.
#[serde(skip)]
pub dialog_file: Option<std::sync::Arc<crate::dialogs::import_pdf::DialogFile>>,
}
impl UiState {
@@ -323,6 +327,7 @@ impl Default for UiState {
color_guide_limit: String::new(),
svg_options: Value::Null,
place_link: true,
dialog_file: None,
}
}
}
+16
View File
@@ -1247,3 +1247,19 @@ pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &st
ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color })));
resp.on_hover_text(tip).clicked()
}
/// `region` of `doc` rendered on white, its longest side `px` pixels, as a texture named `name`
/// (artboard and page thumbnails).
pub fn region_texture(
ctx: &egui::Context,
renderer: &mut vectorcraft_render::Renderer,
name: &str,
doc: &vectorcraft_doc::Document,
region: vectorcraft_geom::Rect,
px: f64,
) -> egui::TextureHandle {
let scale = px / region.width().max(region.height()).max(1.0);
let img = renderer.render_region(doc, region, scale, true);
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
ctx.load_texture(name, color, egui::TextureOptions::LINEAR)
}
+2
View File
@@ -26,6 +26,8 @@
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,
`preview`), which previews on the canvas while open; `ui.dialog.confirm` applies it with `swatch.edit` as one undo
+10
View File
@@ -185,6 +185,16 @@ documents every field). `document.exportPdf` and `export` (format `pdf`, the opt
options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused.
`document.pdfSettings` lists the options that differ from the defaults and the warnings without writing a file.
Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based),
`cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets)
and `password` for an encrypted file. `document.pdfInfo` reads a file without opening it: the page count, each page's
size and boxes, `needsPassword`, and with `thumbnail: n` a PNG of page n:
```json
{"name":"run_command","arguments":{"command":"document.pdfInfo","params":{"path":"/tmp/brochure.pdf","thumbnail":2,"cropTo":"trim"}}}
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/brochure.pdf","pages":"2-3","cropTo":"trim"}}}
```
Drive a tool like a mouse:
```json