mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
co-authored by
Claude Opus 5.5
parent
e8af1f6576
commit
e204341996
Generated
+1
@@ -4350,6 +4350,7 @@ dependencies = [
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-tools",
|
||||
]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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", "", "{}"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user