Merge pull request #75 from storytold/pr/m4-pdf-import

M4: PDF import pages, Crop To and passwords; CMYK, Gray and spot colours kept (M4.45–M4.47)
This commit is contained in:
BFlat 2026-10-05 09:51:03 +07:00 committed by GitHub
commit cc03a47b7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2295 additions and 87 deletions

1
Cargo.lock generated
View File

@ -4350,6 +4350,7 @@ dependencies = [
"vectorcraft-pdf",
"vectorcraft-render",
"vectorcraft-svg",
"vectorcraft-testkit",
"vectorcraft-text",
"vectorcraft-tools",
]

View File

@ -456,6 +456,19 @@ fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc()?.doc.color_mode == mode {
return ok();
}
s.edit("Document Color Mode", |d, _| {
set_color_mode(d, mode, convert);
Ok(())
})?;
ok()
}
/// Put `d` in colour `mode`; with `convert`, every colour of its art and swatches too.
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool) {
d.color_mode = mode;
if !convert {
return;
}
let f = move |c: vectorcraft_color::Color| match mode {
ColorMode::Cmyk => to_cmyk(c),
ColorMode::Rgb => {
@ -463,20 +476,13 @@ fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
vectorcraft_color::Color::rgb(r, g, b)
}
};
s.edit("Document Color Mode", |d, _| {
d.color_mode = mode;
if convert {
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers);
for sw in d.swatches_iter_mut() {
if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint {
*color = f(*color);
}
}
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers);
for sw in d.swatches_iter_mut() {
if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint {
*color = f(*color);
}
Ok(())
})?;
ok()
}
}
fn file_info(s: &mut Session, p: &Value) -> Result<Value> {

View File

@ -8,6 +8,7 @@ use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::Affine;
use super::super::*;
use super::pdfimport::LoadOptions;
use super::{Format, format, format_for_name, read_file};
use crate::EngineError;
@ -30,7 +31,7 @@ pub struct RasterImage {
pub ppi: Option<(f64, f64)>,
}
fn err(e: impl std::fmt::Display) -> EngineError {
pub(super) fn err(e: impl std::fmt::Display) -> EngineError {
EngineError::Other(e.to_string())
}
@ -74,6 +75,16 @@ 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> {
// PostScript (.ai saved in an older format or without PDF compatibility, .eps) says so, by
// any name.
if vectorcraft_pdf::is_postscript(bytes) {
return Err(err(format!("can't open `{}`: {}", file_name(name), vectorcraft_pdf::PdfError::PostScript)));
}
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,13 +113,16 @@ 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))),
};
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
super::super::docmenu::set_color_mode(&mut doc, mode, true);
}
// Imports are named after the file; a native document keeps its own title (the tab shows the
// file name once it has a path).
if format.id != "vectorcraft" || doc.title.is_empty() {
@ -120,7 +134,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 +175,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).

View File

@ -7,6 +7,7 @@
//! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`.
//! - `save`: `document.save`, `file.saveAsTemplate`.
//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`.
//! - `pdfimport`: the PDF pages, box and password `document.open` and Place read, `document.pdfInfo`.
//!
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
//! agents query `document.formats`.
@ -16,6 +17,7 @@ mod encode;
mod export;
mod load;
pub mod pdf;
mod pdfimport;
pub mod ppi;
mod save;
mod svg;
@ -24,8 +26,10 @@ use serde_json::{Value, json};
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
pub(crate) use encode::{anti_alias, background};
use load::err;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, open_bytes, raster_image};
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image};
pub use pdfimport::{LoadOptions, page_document};
pub use save::{save_encoding, save_format};
pub use svg::options_map as svg_options;
@ -39,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64} → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {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,10 @@ mod tests;
#[cfg(test)]
mod tests_pdf;
#[cfg(test)]
mod tests_pdfcolor;
#[cfg(test)]
mod tests_pdfimport;
#[cfg(test)]
mod tests_svg;
#[cfg(test)]
mod tests_svgedit;

View File

@ -0,0 +1,116 @@
//! 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::{ColorMode, Document};
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError};
use super::super::*;
use super::{err, source};
/// The `document.open` options besides the file. The PDF ones (pages, box, password) are ignored
/// by other formats.
#[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>,
/// The colour mode the document opens in, its colours converted as Document Color Mode does
/// (default: the file's; a PDF painted mostly in CMYK opens in CMYK).
pub color_mode: Option<ColorMode>,
}
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`), `password` and `colorMode` (`rgb` | `cmyk`).
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);
let color_mode = match str_param(p, "colorMode").map(str::to_ascii_lowercase).as_deref() {
None => None,
Some("rgb") => Some(ColorMode::Rgb),
Some("cmyk") => Some(ColorMode::Cmyk),
Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))),
};
Ok(Self { pages, crop, password, color_mode })
}
/// 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)
}

View File

@ -0,0 +1,64 @@
//! `document.open` keeps a PDF's CMYK, Gray and spot colours, and `colorMode` picks the mode.
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::ColorMode;
use super::*;
/// A CMYK document with a CMYK fill, a Gray fill and a 40 % tint of spot ink "Gold", as PDF.
fn cmyk_pdf() -> String {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 50, "colorMode": "cmyk"})).unwrap();
s.execute("swatch.new", &json!({"name": "Gold", "color": {"model": "cmyk", "c": 0.0, "m": 0.2, "y": 0.8, "k": 0.1}, "spot": true})).unwrap();
for (x, fill) in [
(0, json!({"color": {"model": "cmyk", "c": 0.6, "m": 0.1, "y": 0.0, "k": 0.2}})),
(20, json!({"color": {"model": "gray", "k": 0.3}})),
(40, json!({"swatch": "Gold", "tint": 40})),
] {
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("paint.setFill", &fill).unwrap();
}
let v = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap();
v["dataBase64"].as_str().unwrap().to_string()
}
fn fills(s: &Session) -> Vec<Paint> {
let d = &s.active().unwrap().doc;
d.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.fill().map(|f| f.paint.clone())).collect()
}
#[test]
fn a_cmyk_pdf_opens_as_a_cmyk_document_with_its_spot_swatch() {
let mut s = Session::new();
s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf()})).unwrap();
assert_eq!(s.execute("file.info", &json!({})).unwrap()["colorMode"], "cmyk");
let f = fills(&s);
assert!(matches!(f[0].color(), Some(Color::Cmyk { c, .. }) if (c - 0.6).abs() < 0.004), "{:?}", f[0]);
assert!(matches!(f[1].color(), Some(Color::Gray { k }) if (k - 0.3).abs() < 0.004), "{:?}", f[1]);
assert!(matches!(&f[2], Paint::Solid { swatch: Some(n), tint, .. } if n == "Gold" && (tint - 0.4).abs() < 0.004), "{:?}", f[2]);
let d = &s.active().unwrap().doc;
let gold = d.swatch("Gold").unwrap();
assert!(gold.spot && gold.global);
assert!(matches!(gold.paint.color(), Some(Color::Cmyk { m, y, .. }) if (m - 0.2).abs() < 0.004 && (y - 0.8).abs() < 0.004));
}
#[test]
fn color_mode_opens_the_file_in_the_mode_asked_for() {
let mut s = Session::new();
s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf(), "colorMode": "RGB"})).unwrap();
assert_eq!(s.active().unwrap().doc.color_mode, ColorMode::Rgb);
let f = fills(&s);
assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. }))), "converted as Document Color Mode does: {f:?}");
// Any format opens in the mode asked for.
let mut svg = Session::new();
svg.execute("file.new", &json!({"width": 20, "height": 20})).unwrap();
svg.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let text = svg.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
s.execute("document.open", &json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(text.as_bytes()), "colorMode": "cmyk"}))
.unwrap();
assert_eq!(s.active().unwrap().doc.color_mode, ColorMode::Cmyk);
assert!(fills(&s).iter().all(|p| !matches!(p.color(), Some(Color::Rgb { .. }))));
let e = s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf(), "colorMode": "lab"})).unwrap_err().to_string();
assert!(e.contains("rgb or cmyk"), "{e}");
}

View File

@ -0,0 +1,189 @@
//! `document.open` PDF options (pages, Crop To, password) and `document.pdfInfo`.
use serde_json::{Value, json};
use vectorcraft_testkit::pdf::{PdfPage, pdf};
use super::*;
fn b64(bytes: &[u8]) -> String {
vectorcraft_format::base64_encode(bytes)
}
/// Three pages of widths 100, 200 and 300 pt, the second with every box set.
fn three_pages() -> Vec<u8> {
let mut second = PdfPage::new(200.0, 400.0, "0 0 1 rg 20 30 40 50 re f");
second.crop = Some([10.0, 10.0, 190.0, 390.0]);
second.bleed = Some([5.0, 5.0, 195.0, 395.0]);
second.trim = Some([15.0, 15.0, 185.0, 385.0]);
second.art = Some([20.0, 20.0, 120.0, 220.0]);
pdf(&[PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), second, PdfPage::new(300.0, 100.0, "")], None)
}
fn open(s: &mut Session, bytes: &[u8], extra: Value) -> crate::Result<Value> {
let mut p = json!({"name": "pages.pdf", "dataBase64": b64(bytes)});
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
o.extend(e);
}
s.execute("document.open", &p)
}
fn sizes(s: &Session) -> Vec<(f64, f64)> {
s.active().unwrap().doc.artboards.iter().map(|a| (a.rect.width(), a.rect.height())).collect()
}
#[test]
fn document_open_imports_the_pages_named() {
let bytes = three_pages();
let mut s = Session::new();
open(&mut s, &bytes, json!({"pages": "2-3"})).unwrap();
assert_eq!(sizes(&s), [(180.0, 380.0), (300.0, 100.0)]);
let layers: Vec<String> = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(layers, ["Page 2", "Page 3"]);
open(&mut s, &bytes, json!({"page": 1})).unwrap();
assert_eq!(sizes(&s), [(100.0, 100.0)]);
open(&mut s, &bytes, json!({"pages": "all"})).unwrap();
assert_eq!(sizes(&s).len(), 3);
let e = open(&mut s, &bytes, json!({"pages": "4"})).unwrap_err().to_string();
assert!(e.contains("pages") && e.contains("1 to 3"), "{e}");
assert!(open(&mut s, &bytes, json!({"pages": true})).is_err());
}
#[test]
fn document_open_crops_each_page_to_the_box_asked_for() {
let bytes = three_pages();
let mut s = Session::new();
for (crop, size) in [
("media", (200.0, 400.0)),
("crop", (180.0, 380.0)),
("bleed", (190.0, 390.0)),
("trim", (170.0, 370.0)),
("art", (100.0, 200.0)),
("bounding", (40.0, 50.0)),
] {
open(&mut s, &bytes, json!({"page": 2, "cropTo": crop})).unwrap();
assert_eq!(sizes(&s), [size], "{crop}");
}
// `crop` is an alias (the file.place name); case doesn't matter.
open(&mut s, &bytes, json!({"page": 2, "crop": "Art"})).unwrap();
assert_eq!(sizes(&s), [(100.0, 200.0)]);
let e = open(&mut s, &bytes, json!({"cropTo": "page"})).unwrap_err().to_string();
assert!(e.contains("bounding, art, crop, trim, bleed, media"), "{e}");
}
#[test]
fn an_encrypted_pdf_needs_its_password() {
let bytes = pdf(&[PdfPage::new(100.0, 50.0, "1 0 0 rg 0 0 20 20 re f"), PdfPage::new(100.0, 50.0, "")], Some("open sesame"));
let mut s = Session::new();
let e = open(&mut s, &bytes, json!({})).unwrap_err().to_string();
assert!(e.contains("password-protected"), "{e}");
let e = open(&mut s, &bytes, json!({"password": "nope"})).unwrap_err().to_string();
assert!(e.contains("password is wrong"), "{e}");
assert!(s.active().is_none(), "nothing opened");
let r = open(&mut s, &bytes, json!({"password": "open sesame", "pages": "1"})).unwrap();
assert_eq!(r["format"], "pdf");
assert_eq!(sizes(&s), [(100.0, 50.0)]);
assert_eq!(s.active().unwrap().doc.node_count(), 2, "the layer and its decrypted rectangle");
}
#[test]
fn pdf_info_lists_pages_boxes_and_a_thumbnail() {
let bytes = three_pages();
let mut s = Session::new();
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "thumbnail": 2, "thumbnailSize": 100, "cropTo": "art"})).unwrap();
assert_eq!(v["pages"], 3);
assert_eq!(v["needsPassword"], false);
let p2 = &v["pageInfo"][1];
assert_eq!((p2["width"].as_f64(), p2["height"].as_f64()), (Some(180.0), Some(380.0)));
assert_eq!(p2["boxes"]["trim"], json!([15.0, 15.0, 185.0, 385.0]));
assert_eq!(p2["boxes"]["media"], json!([0.0, 0.0, 200.0, 400.0]));
let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap();
assert_eq!((img.width(), img.height()), (50, 100), "the art box at 100 px on its longest side");
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "thumbnail": 4})).is_err());
// Locked: no pages until the password is given.
let locked = pdf(&[PdfPage::new(10.0, 10.0, "")], Some("pw"));
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&locked)})).unwrap();
assert_eq!((v["pages"].as_u64(), v["needsPassword"].as_bool(), v["wrongPassword"].as_bool()), (Some(0), Some(true), Some(false)));
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&locked), "password": "x"})).unwrap();
assert_eq!(v["wrongPassword"], true);
let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&locked), "password": "pw"})).unwrap();
assert_eq!((v["pages"].as_u64(), v["needsPassword"].as_bool()), (Some(1), Some(false)));
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(b"not a pdf")})).is_err());
assert!(s.execute("document.pdfInfo", &json!({})).is_err());
}
#[test]
fn file_place_crops_a_pdf_page_to_any_box_and_takes_its_password() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let place = |s: &mut Session, bytes: &[u8], extra: Value| {
let mut p = json!({"name": "pages.pdf", "dataBase64": b64(bytes)});
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
o.extend(e);
}
s.execute("file.place", &p)
};
let bytes = three_pages();
for (crop, size) in
[("trim", (170.0, 370.0)), ("art", (100.0, 200.0)), ("media", (200.0, 400.0)), ("crop", (180.0, 380.0)), ("bounding", (40.0, 50.0))]
{
let r = place(&mut s, &bytes, json!({"page": 2, "crop": crop})).unwrap();
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(size.0), Some(size.1)), "{crop}");
}
let info = s.execute("file.place.info", &json!({"name": "pages.pdf", "dataBase64": b64(&bytes), "page": 2, "crop": "bleed"})).unwrap();
assert_eq!((info["width"].as_f64(), info["height"].as_f64()), (Some(190.0), Some(390.0)));
let locked = pdf(&[PdfPage::new(60.0, 30.0, "0 g 0 0 10 10 re f")], Some("pw"));
assert!(place(&mut s, &locked, json!({})).unwrap_err().to_string().contains("password-protected"));
assert_eq!(place(&mut s, &locked, json!({"password": "pw"})).unwrap()["width"].as_f64(), Some(60.0));
}
#[test]
fn load_options_read_pages_crop_and_password() {
let o = LoadOptions::from_params("x", &json!({"page": 3, "crop": "trim", "password": "p"})).unwrap();
assert_eq!(o, LoadOptions { pages: Some("3".into()), crop: vectorcraft_pdf::CropTo::Trim, password: Some("p".into()), ..Default::default() });
// `pages` wins over `page`; an empty password is none.
let o = LoadOptions::from_params("x", &json!({"pages": "1-2", "page": 3, "password": ""})).unwrap();
assert_eq!((o.pages.as_deref(), o.password), (Some("1-2"), None));
assert_eq!(LoadOptions::from_params("x", &Value::Null).unwrap(), LoadOptions::default());
}
#[test]
fn a_postscript_ai_file_says_it_cannot_be_opened_yet() {
let ps = b"%!PS-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 moveto 100 100 lineto stroke\nshowpage\n%%EOF\n";
let mut s = Session::new();
for name in ["legacy.ai", "art.eps", "named.pdf", "template.ait"] {
let e = s.execute("document.open", &json!({"name": name, "dataBase64": b64(ps)})).unwrap_err().to_string();
assert!(e.contains(name) && e.contains("PostScript") && e.contains("save it as PDF"), "{name}: {e}");
}
// An EPS with a binary header too, and the PDF info of either.
let mut binary = vec![0xC5, 0xD0, 0xD3, 0xC6];
binary.extend_from_slice(&[0; 28]);
binary.extend_from_slice(ps);
assert!(s.execute("document.open", &json!({"name": "x.eps", "dataBase64": b64(&binary)})).unwrap_err().to_string().contains("PostScript"));
assert!(s.execute("document.pdfInfo", &json!({"dataBase64": b64(ps)})).unwrap_err().to_string().contains("PostScript"));
assert!(s.active().is_none());
}
/// A page showing only text, with an editor's private data when `private`.
fn text_page(private: bool, also: &str) -> PdfPage {
PdfPage {
resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(),
entries: if private { "/PieceInfo << /Editor << /Private << /AIPrivateData1 7 /RoundTripVersion 24 >> >> >>".into() } else { String::new() },
..PdfPage::new(300.0, 100.0, &format!("BT /F1 12 Tf 10 50 Td (Saved without PDF content) Tj ET {also}"))
}
}
#[test]
fn a_placeholder_only_pdf_part_is_detected() {
let mut s = Session::new();
let e = open(&mut s, &pdf(&[text_page(true, "")], None), json!({})).unwrap_err().to_string();
assert!(e.contains("placeholder page") && e.contains("PDF compatibility"), "{e}");
assert!(s.active().is_none());
// Text without private data, and private data with art, are ordinary files.
open(&mut s, &pdf(&[text_page(false, "")], None), json!({})).unwrap();
open(&mut s, &pdf(&[text_page(true, "0 g 0 0 10 10 re f")], None), json!({})).unwrap();
// One real page among the pages picked is enough.
let two = pdf(&[text_page(true, ""), text_page(true, "0 g 0 0 10 10 re f")], None);
assert!(open(&mut s, &two, json!({"pages": "1"})).is_err());
open(&mut s, &two, json!({})).unwrap();
}

View File

@ -6,7 +6,8 @@
//! `link`.
//! - An SVG becomes one group of its art.
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
//! (`crop: "crop"`) or bounded by its art (`crop: "bounding"`).
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
//! (`crop: "bounding"`).
//!
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
@ -21,6 +22,7 @@ use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeId, NodeKind, Scaling};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_pdf::CropTo;
use super::fileio::{self, Format, RasterImage};
use super::*;
@ -40,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
has_doc,
place
),
@ -49,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Placed File Info",
[],
None,
"{path | name+dataBase64, page?, crop?, thumbnail?: px} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}",
"{path | name+dataBase64, page?, crop?, password?, thumbnail?: px} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}",
always,
info
),
@ -116,11 +118,15 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
None => 1,
Some(v) => v.as_u64().filter(|n| (1..=100_000).contains(n)).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))? as usize,
};
let crop = match str_param(p, "crop").unwrap_or("crop") {
"crop" => true,
"bounding" => false,
other => return Err(bad(cmd, format!("crop `{other}`: use \"crop\" or \"bounding\""))),
};
let opts = fileio::LoadOptions::from_params(cmd, p)?;
let pdf = matches!(format.id, "pdf" | "ai" | "ait");
if !pdf && !matches!(opts.crop, CropTo::Crop | CropTo::Bounding) {
return Err(bad(
cmd,
format!("crop `{}`: only PDF pages have art, trim, bleed and media boxes; use \"crop\" or \"bounding\"", opts.crop.id()),
));
}
let crop = opts.crop != CropTo::Bounding;
let path = src.path.map(str::to_string);
if format.raster {
let img = fileio::raster_image(&src.bytes)?;
@ -129,11 +135,11 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], path });
}
let (mut doc, warnings) = match format.id {
// Only the pages up to the one placed are read.
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's
// bounds, below).
"pdf" | "ai" | "ait" => {
let opts = vectorcraft_pdf::ImportOptions { max_pages: Some(page), ..Default::default() };
let r = vectorcraft_pdf::import_with_report(&src.bytes, &opts).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
(r.document, r.warnings)
let o = fileio::LoadOptions { crop: if crop { opts.crop } else { CropTo::Crop }, ..opts };
fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))?
}
_ => {
let l = fileio::load(src.name, &src.bytes)?;
@ -145,14 +151,16 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
let (nodes, clip) = if matches!(format.id, "svg" | "svgz") {
(art_of(layers.iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten())), None)
} else {
// The page placed is a PDF import's only one.
let index = if pdf { 0 } else { page - 1 };
let n = doc.artboards.len();
let board = doc.artboards.get(page - 1).map(|a| a.rect).ok_or_else(|| {
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| {
let what = if format.id == "vectorcraft" { "artboard" } else { "page" };
bad(cmd, format!("page {page}: `{name}` has {n} {what}(s)"))
})?;
// A PDF import has one layer per page; a native document's art is whatever lies on the
// artboard.
let pages: Vec<&Arc<Node>> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(page - 1).into_iter().collect() };
let pages: Vec<&Arc<Node>> = if format.id == "vectorcraft" { layers.iter().collect() } else { layers.get(index).into_iter().collect() };
let on_board = |c: &&Arc<Node>| c.visual_bounds().is_some_and(|b| b.intersect(board).area() > 0.0 || board.contains(b.origin()));
let nodes = art_of(pages.into_iter().filter(|l| placeable(l)).flat_map(|l| l.children().into_iter().flatten()).filter(on_board));
(nodes, crop.then_some(board))

View File

@ -273,6 +273,8 @@ fn arb_pdf_op() -> impl Strategy<Value = String> {
Just("/Im0".to_string()),
Just("/GS0".to_string()),
Just("/DeviceRGB".to_string()),
Just("/CS0".to_string()),
Just("/DeviceCMYK".to_string()),
Just("/Pattern".to_string()),
Just("(Hi)".to_string()),
Just("<00ff>".to_string()),
@ -290,6 +292,12 @@ fn arb_resources() -> impl Strategy<Value = String> {
"<< /Shading << /Sh0 << /ShadingType 3 /ColorSpace /DeviceRGB /Coords [0 0 0 0 0 -5] /Function << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [] /N -1 >> >> >> >>".to_string(),
"<< /XObject << /Im0 << /Type /XObject /Subtype /Image /Width 4294967295 /Height 0 /BitsPerComponent 8 /ColorSpace /DeviceRGB /Length 3 >> >> >>".to_string(),
"<< /Pattern << /P0 << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0 0] /XStep 0 /YStep -0 /Resources << >> >> >> >>".to_string(),
// Ink colour spaces with junk names, alternates and tint transforms (spot swatch import).
"<< /ColorSpace << /CS0 [/Separation /All /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [1 1 1 1] /N 1 >>] >> >>".to_string(),
"<< /ColorSpace << /CS0 [/Separation /#00 /Lab << /FunctionType 2 /Domain [0 1] /C0 [1e308] /C1 [NaN 5] /N -1 >>] >> >>".to_string(),
"<< /ColorSpace << /CS0 [/DeviceN [] /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>] >> >>".to_string(),
"<< /ColorSpace << /CS0 [/DeviceN [/A /None /Cyan /All] [/ICCBased << /N 4 >>] << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>] >> >>".to_string(),
"<< /Shading << /Sh0 << /ShadingType 2 /ColorSpace [/Separation /Ink /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>] /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> >>".to_string(),
])
}
@ -328,6 +336,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.

View File

@ -10,17 +10,20 @@ 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};
use vectorcraft_doc::{
Appearance, AppearanceItem, Artboard, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind,
StrokeLayer,
Appearance, AppearanceItem, Artboard, ColorMode, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId,
NodeKind, StrokeLayer,
};
use vectorcraft_geom::{FillRule, PathData};
use crate::{ImportOptions, ImportReport, PdfError};
use crate::import_color::{Colors, Native};
use crate::{CropTo, ImportOptions, ImportReport, PdfError};
/// The name of the paths text imports as.
const TEXT_OUTLINES: &str = "<Text Outlines>";
/// Import a PDF (or PDF-compatible `.ai`) with default options.
pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
@ -29,12 +32,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();
@ -58,14 +58,32 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
doc.title = "Imported PDF".into();
doc.artboards.clear();
doc.layers.clear();
let mut b = Builder::new(doc.peek_next_id());
let taken = doc.swatches_iter().map(|s| s.name.clone()).chain(doc.swatch_groups.iter().map(|g| g.name.clone())).collect();
let mut b = Builder::new(doc.peek_next_id(), Colors::new(&pdf, taken));
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;
// Every page only a placeholder (text) over private data: the file's art isn't in its PDF part.
let mut placeholder = true;
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();
placeholder &= crate::pages::has_private_data(page) && only_text(&children);
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,21 +91,27 @@ 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;
}
doc.layers.push(Arc::new(layer));
}
if placeholder {
return Err(PdfError::PlaceholderOnly);
}
for (k, blob) in b.images.drain() {
doc.images.insert(k, blob);
}
// A file painted mostly in CMYK opens as a CMYK document (with CMYK default swatches).
if b.colors.cmyk_document() {
doc.color_mode = ColorMode::Cmyk;
(doc.swatches, doc.swatch_groups) = vectorcraft_color::default_swatches(ColorMode::Cmyk.model());
}
doc.swatches.extend(b.colors.swatches());
if b.colors.mixed {
b.warn("colours mixing several inks (DeviceN) were imported as RGB");
}
doc.fix_next_id();
let mut warnings = b.warnings;
@ -101,6 +125,23 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
Ok(ImportReport { document: doc, warnings })
}
/// Is this art text and nothing else (in groups and clips), with some text?
fn only_text(nodes: &[Arc<Node>]) -> bool {
fn walk(nodes: &[Arc<Node>], text: &mut bool) -> bool {
nodes.iter().all(|n| match &n.kind {
NodeKind::Path { clipping: true, .. } => true,
NodeKind::Path { .. } if n.name.as_deref() == Some(TEXT_OUTLINES) => {
*text = true;
true
}
NodeKind::Group { children, .. } => walk(children, text),
_ => false,
})
}
let mut text = false;
walk(nodes, &mut text) && text
}
/// Decoded RGBA pixels, width, height and hayro's scale factors.
type Decoded = (Vec<u8>, u32, u32, (f32, f32));
@ -131,7 +172,8 @@ struct GlyphRun {
scale: f64,
}
struct Builder {
struct Builder<'p> {
colors: Colors<'p>,
next: u64,
stack: Vec<Frame>,
blend: BlendMode,
@ -199,9 +241,10 @@ fn round3(v: f32) -> f32 {
(v * 1000.0).round() / 1000.0
}
impl Builder {
fn new(next: u64) -> Self {
impl<'p> Builder<'p> {
fn new(next: u64, colors: Colors<'p>) -> Self {
Self {
colors,
next,
stack: vec![],
blend: BlendMode::Normal,
@ -317,7 +360,7 @@ impl Builder {
}
let id = self.id();
let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap);
n.name = Some("<Text Outlines>".into());
n.name = Some(TEXT_OUTLINES.into());
self.push_node(n);
}
@ -327,10 +370,11 @@ impl Builder {
match p {
hayro_interpret::Paint::Color(c) => {
let [r, g, b, a] = c.to_rgba().components();
(Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), a)
let paint = self.colors.solid(c).map_or_else(|| Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), Native::paint);
(paint, a)
}
hayro_interpret::Paint::Pattern(pat) => match pat.as_ref() {
Pattern::Shading(sp) => match shading_gradient(sp) {
Pattern::Shading(sp) => match shading_gradient(sp, &mut self.colors) {
Some(g) => (Paint::Gradient(Box::new(g)), sp.opacity),
None => {
self.warn("mesh/function shadings are imported as a flat colour");
@ -353,7 +397,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)
@ -363,17 +408,19 @@ impl Builder {
}
}
/// Axial/radial shading → gradient paint (stops sampled from the shading function).
fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option<GradientPaint> {
/// Axial/radial shading → gradient paint (stops sampled from the shading function, in the
/// shading's own colour model).
fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern, colors: &mut Colors<'_>) -> Option<GradientPaint> {
let ShadingType::RadialAxial { coords, domain, function, axial, .. } = sp.shading.shading_type.as_ref() else {
return None;
};
let m = sp.matrix;
let cs = &sp.shading.color_space;
let at = |t: f32| function.eval(&smallvec::smallvec![domain[0] + (domain[1] - domain[0]) * t]);
let space = colors.shading_space(cs, at(0.0)?.len());
// RGB to decide which samples are stops; the stops keep the shading's model.
let sample = |t: f32| -> Option<(Color, f32)> {
let x = domain[0] + (domain[1] - domain[0]) * t;
let v = function.eval(&smallvec::smallvec![x])?;
let [r, g, b, a] = cs.to_rgba(&v, 1.0, false).components();
let [r, g, b, a] = cs.to_rgba(&at(t)?, 1.0, false).components();
Some((Color::rgb(round3(r), round3(g), round3(b)), a))
};
// Sample densely, then drop samples that linear interpolation reproduces.
@ -395,9 +442,13 @@ fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option<Gra
}
}
keep.push(N);
let stop_at = |t: f32, offset: f32| -> GradientStop {
let (color, _) = sample(t).unwrap_or((Color::BLACK, 1.0));
GradientStop::new(offset, color)
let mut stop_at = |t: f32, offset: f32| -> GradientStop {
let (rgb, _) = sample(t).unwrap_or((Color::BLACK, 1.0));
match at(t).and_then(|v| colors.native(space, &v)) {
Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) },
Some(Native { color, link: None }) => GradientStop::new(offset, color),
None => GradientStop::new(offset, rgb),
}
};
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
let (kind, geom, stops) = if *axial {
@ -438,7 +489,7 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec<u8> {
}
}
impl<'a> Device<'a> for Builder {
impl<'a> Device<'a> for Builder<'_> {
fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
if mask.is_some() {
self.warn("soft masks are not imported (content drawn unmasked)");

View File

@ -0,0 +1,419 @@
//! PDF colours in their own models: DeviceCMYK and CMYK ICC colours stay CMYK, DeviceGray is
//! Gray, and Separation and DeviceN inks become spot swatches at a tint (for solid paints and
//! shading stops alike). Everything else is RGB, as the interpreter converts it.
//!
//! hayro-interpret keeps a colour's space and components private: their `Debug` form is the only
//! public view, so [`Colors`] reads that (`tests_import_color.rs` pins the format). Ink names
//! aren't in it at all: they come from the file's Separation and DeviceN arrays, found by their
//! tint transform.
use std::fmt::{self, Debug, Write as _};
use hayro_interpret::Function;
use hayro_interpret::color::ColorSpace;
use hayro_syntax::Pdf;
use hayro_syntax::object::{Array, MaybeRef, Name, Object, Stream};
use smallvec::SmallVec;
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_color::{Color, Paint, Swatch};
/// The longest `Debug` text read: a colour whose space is longer (a big sampled tint transform)
/// imports as RGB.
const DEBUG_LIMIT: usize = 64 * 1024;
/// The deepest nesting searched for colour spaces, and the most ink spaces kept.
const MAX_DEPTH: u32 = 32;
const MAX_SPACES: usize = 4096;
/// DeviceN (and Separation) colorants that are process plates, in CMYK channel order.
const PROCESS: [&str; 4] = ["Cyan", "Magenta", "Yellow", "Black"];
fn round3(v: f32) -> f32 {
(v * 1000.0).round() / 1000.0
}
/// A colour in its own model; an ink links to its spot swatch at a tint.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Native {
pub color: Color,
pub link: Option<(String, f32)>,
}
impl Native {
fn plain(color: Color) -> Self {
Self { color, link: None }
}
pub fn paint(self) -> Paint {
match self.link {
Some((name, tint)) => Paint::Solid { color: self.color, swatch: Some(name), tint },
None => Paint::solid(self.color),
}
}
}
/// What a Separation or DeviceN colorant is.
#[derive(Clone, Copy, Debug)]
enum Colorant {
/// A spot ink (index into `Colors::inks`).
Ink(usize),
/// A process plate (CMYK channel).
Process(usize),
/// Every plate (Registration).
All,
/// Paints nothing.
None,
/// An ink whose colour can't be told (its alternate space isn't one we model).
Unknown,
}
/// A Separation or DeviceN colour space of the file.
struct InkSpace {
/// `Debug` text of its tint transform: how a colour's space is matched to it.
key: String,
device_n: bool,
names: Vec<String>,
colorants: Vec<Colorant>,
}
struct Ink {
/// The colorant name in the file.
source: String,
/// The swatch name (the source name, made unique against the document's swatches).
name: String,
/// The colour at 100 %.
color: Color,
used: bool,
}
/// How a colour space's components map.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Space {
Gray,
Rgb,
Cmyk,
/// A Separation or DeviceN space (index into `Colors::spaces`).
Inks(usize),
/// Anything else: RGB through the interpreter.
Other,
}
/// The alternate space of an ink, for its colour at 100 %.
#[derive(Clone, Copy)]
enum Alt {
Gray,
Rgb,
Cmyk,
Lab,
}
/// The import's colour mapping, with the inks found and the colour models used.
pub(crate) struct Colors<'p> {
pdf: &'p Pdf,
/// Swatch names the document already has.
taken: Vec<String>,
scanned: bool,
spaces: Vec<InkSpace>,
inks: Vec<Ink>,
/// Process colours painted in CMYK and in RGB (or anything converted to it).
cmyk: usize,
rgb: usize,
/// A DeviceN colour mixed several inks (imported as RGB).
pub mixed: bool,
}
/// `v`'s `Debug` text, or `None` past [`DEBUG_LIMIT`] (formatting stops there).
fn debug_text(v: &impl Debug) -> Option<String> {
struct Capped(String);
impl fmt::Write for Capped {
fn write_str(&mut self, s: &str) -> fmt::Result {
if self.0.len() + s.len() > DEBUG_LIMIT {
return Err(fmt::Error);
}
self.0.push_str(s);
Ok(())
}
}
let mut w = Capped(String::new());
write!(w, "{v:?}").ok()?;
Some(w.0)
}
/// A colour's `Debug` text (`Color { color_space: …, components: [..], opacity: .. }`) split into
/// its space's text and its components.
pub(crate) fn split_color(t: &str) -> Option<(&str, Vec<f32>)> {
let start = t.find("color_space: ")? + "color_space: ".len();
let at = t.rfind(", components: [")?;
let space = t.get(start..at)?;
let rest = t.get(at + ", components: [".len()..)?;
let list = rest.get(..rest.find(']')?)?;
let comps = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::parse::<f32>).collect::<Result<Vec<_>, _>>().ok()?;
comps.iter().all(|c| c.is_finite()).then_some((space, comps))
}
impl<'p> Colors<'p> {
pub fn new(pdf: &'p Pdf, taken: Vec<String>) -> Self {
Self { pdf, taken, scanned: false, spaces: vec![], inks: vec![], cmyk: 0, rgb: 0, mixed: false }
}
/// A solid colour in its own model; `None`: use its RGB.
pub fn solid(&mut self, c: &hayro_interpret::color::Color) -> Option<Native> {
let mapped = debug_text(c).and_then(|t| {
let (space, comps) = split_color(&t)?;
let space = self.space_of(space, comps.len());
Some((space, comps))
});
let (space, comps) = mapped.unwrap_or((Space::Other, vec![]));
self.count(space);
self.native(space, &comps)
}
/// The space shading colours are in, for [`Colors::native`]; `n`: its component count.
pub fn shading_space(&mut self, cs: &ColorSpace, n: usize) -> Space {
let space = debug_text(cs).map_or(Space::Other, |t| self.space_of(&t, n));
self.count(space);
space
}
/// Note a colour painted in `space` (the document's colour mode follows the majority).
fn count(&mut self, space: Space) {
match space {
Space::Cmyk => self.cmyk += 1,
Space::Rgb | Space::Other => self.rgb += 1,
Space::Gray | Space::Inks(_) => {}
}
}
/// The document opens in CMYK when more process colours were CMYK than RGB.
pub fn cmyk_document(&self) -> bool {
self.cmyk > self.rgb
}
/// Spot swatches for the inks used.
pub fn swatches(&self) -> impl Iterator<Item = Swatch> + '_ {
self.inks.iter().filter(|i| i.used).map(|i| Swatch { name: i.name.clone(), paint: Paint::solid(i.color), global: true, spot: true })
}
/// The space a `ColorSpace(…)` `Debug` text names, with `n` components.
pub(crate) fn space_of(&mut self, text: &str, n: usize) -> Space {
let inner = text.strip_prefix("ColorSpace(").unwrap_or(text);
let kind = inner.split(|c: char| !c.is_ascii_alphanumeric()).next().unwrap_or_default();
match (kind, n) {
("DeviceGray" | "ICCBased", 1) => Space::Gray,
("DeviceRgb", 3) => Space::Rgb,
("DeviceCmyk" | "ICCBased", 4) => Space::Cmyk,
("Separation", 1) | ("DeviceN", _) => {
self.scan();
let device_n = kind == "DeviceN";
let mut found =
self.spaces.iter().enumerate().filter(|(_, s)| s.device_n == device_n && s.colorants.len() == n && inner.contains(&s.key));
match found.next() {
// Spaces of other inks with the same alternate and tint transform look the
// same here: which inks this colour uses can't be told.
Some((i, first)) if found.all(|(_, s)| s.names == first.names) => Space::Inks(i),
_ => Space::Other,
}
}
_ => Space::Other,
}
}
/// Components `comps` of `space` in their own model; `None`: use their RGB.
pub fn native(&mut self, space: Space, comps: &[f32]) -> Option<Native> {
let c = |i: usize| round3(comps.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0));
match space {
Space::Gray => Some(Native::plain(Color::gray(round3(1.0 - c(0))))),
Space::Cmyk => Some(Native::plain(Color::cmyk(c(0), c(1), c(2), c(3)))),
Space::Rgb | Space::Other => None,
Space::Inks(i) => self.inks_color(i, comps),
}
}
/// A Separation or DeviceN colour: process plates make a CMYK colour; one ink (or every
/// plate) at a tint links to its swatch; several inks together can't, and import as RGB.
fn inks_color(&mut self, space: usize, comps: &[f32]) -> Option<Native> {
let colorants = &self.spaces.get(space)?.colorants;
let mut cmyk = [0.0f32; 4];
let mut ink: Option<(Colorant, f32)> = None;
let mut first_ink = None;
let mut several = false;
for (c, t) in colorants.iter().zip(comps) {
let t = round3(t.clamp(0.0, 1.0));
match *c {
Colorant::Unknown => return None,
Colorant::None => {}
Colorant::Process(ch) => {
if let Some(v) = cmyk.get_mut(ch) {
*v = v.max(t);
}
}
Colorant::Ink(_) | Colorant::All => {
first_ink.get_or_insert(*c);
if t > 0.0 {
several |= ink.is_some();
ink = Some((*c, t));
}
}
}
}
let process = cmyk.iter().any(|v| *v > 0.0);
let (c, t) = match (ink, first_ink) {
(Some(_), _) if several || process => {
self.mixed = true;
return None;
}
(Some(it), _) => it,
// No ink is painting: 0 % of the first one (a tint of paper), unless the plates are
// all process ones.
(None, Some(c)) if !process => (c, 0.0),
_ => return Some(Native::plain(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]))),
};
match c {
Colorant::Ink(k) => {
let ink = self.inks.get_mut(k)?;
ink.used = true;
Some(Native { color: ink.color.tinted(t), link: Some((ink.name.clone(), t)) })
}
_ => Some(Native { color: Color::cmyk(1.0, 1.0, 1.0, 1.0).tinted(t), link: Some((REGISTRATION.to_string(), t)) }),
}
}
/// Find the file's Separation and DeviceN colour spaces (once, when a colour first needs them).
fn scan(&mut self) {
if self.scanned {
return;
}
self.scanned = true;
let pdf = self.pdf;
for o in pdf.objects() {
self.visit(o, 0);
}
}
fn visit(&mut self, o: Object<'_>, depth: u32) {
if depth > MAX_DEPTH || self.spaces.len() >= MAX_SPACES {
return;
}
match o {
Object::Array(a) => {
self.ink_space(&a);
for item in a.raw_iter().filter_map(direct) {
self.visit(item, depth + 1);
}
}
Object::Dict(d) => {
for (_, v) in d.entries() {
if let Some(v) = direct(v) {
self.visit(v, depth + 1);
}
}
}
Object::Stream(s) => self.visit(Object::Dict(s.dict().clone()), depth + 1),
_ => {}
}
}
/// Record `a` when it is a `[/Separation name alt tint]` or `[/DeviceN [names] alt tint …]` space.
fn ink_space(&mut self, a: &Array<'_>) -> Option<()> {
let mut it = a.flex_iter();
let device_n = match it.next::<Name<'_>>()?.as_str() {
"Separation" => false,
"DeviceN" => true,
_ => return None,
};
let names: Vec<String> = if device_n {
it.next::<Array<'_>>()?.iter::<Name<'_>>().map(|n| n.as_str().to_string()).collect()
} else {
vec![it.next::<Name<'_>>()?.as_str().to_string()]
};
let alt = alt_model(&it.next::<Object<'_>>()?);
let tint = Function::new(&it.next::<Object<'_>>()?)?;
let key = debug_text(&tint)?;
if names.is_empty() || self.spaces.iter().any(|s| s.key == key && s.device_n == device_n && s.names == names) {
return None;
}
let colorants = names
.iter()
.enumerate()
.map(|(i, name)| match name.as_str() {
"None" => Colorant::None,
"All" => Colorant::All,
n => match PROCESS.iter().position(|p| *p == n) {
Some(ch) => Colorant::Process(ch),
None => {
// The ink alone at 100 %, through the tint transform.
let input: SmallVec<[f32; 4]> = (0..names.len()).map(|j| if i == j { 1.0 } else { 0.0 }).collect();
match alt.zip(tint.eval(input)).and_then(|(alt, v)| alt_color(alt, &v)) {
Some(color) => Colorant::Ink(self.ink(n, color)),
None => Colorant::Unknown,
}
}
},
})
.collect();
self.spaces.push(InkSpace { key, device_n, names, colorants });
Some(())
}
/// The ink named `source` (the first definition wins), its swatch name unique in the document.
fn ink(&mut self, source: &str, color: Color) -> usize {
if let Some(i) = self.inks.iter().position(|k| k.source == source) {
return i;
}
let mut name = source.to_string();
let mut n = 2;
while self.taken.contains(&name) {
name = format!("{source} {n}");
n += 1;
}
self.inks.push(Ink { source: source.to_string(), name, color, used: false });
self.inks.len() - 1
}
}
/// An object written in place (references are visited as objects of their own).
fn direct(m: MaybeRef<Object<'_>>) -> Option<Object<'_>> {
match m {
MaybeRef::NotRef(o) => Some(o),
MaybeRef::Ref(_) => None,
}
}
fn alt_model(o: &Object<'_>) -> Option<Alt> {
match o {
Object::Name(n) => match n.as_str() {
"DeviceGray" | "G" => Some(Alt::Gray),
"DeviceRGB" | "RGB" => Some(Alt::Rgb),
"DeviceCMYK" | "CMYK" => Some(Alt::Cmyk),
_ => None,
},
Object::Array(a) => {
let mut it = a.flex_iter();
match it.next::<Name<'_>>()?.as_str() {
"ICCBased" => match it.next::<Stream<'_>>()?.dict().get::<u8>(b"N")? {
1 => Some(Alt::Gray),
3 => Some(Alt::Rgb),
4 => Some(Alt::Cmyk),
_ => None,
},
"Lab" => Some(Alt::Lab),
"CalRGB" => Some(Alt::Rgb),
"CalGray" => Some(Alt::Gray),
"CalCMYK" => Some(Alt::Cmyk),
_ => None,
}
}
_ => None,
}
}
/// An alternate-space colour (the tint transform's output) as a VectorCraft colour.
fn alt_color(alt: Alt, v: &[f32]) -> Option<Color> {
let c = |i: usize| v.get(i).copied().filter(|x| x.is_finite());
let u = |i: usize| c(i).map(|x| round3(x.clamp(0.0, 1.0)));
Some(match alt {
Alt::Gray => Color::gray(round3(1.0 - u(0)?)),
Alt::Rgb => Color::rgb(u(0)?, u(1)?, u(2)?),
Alt::Cmyk => Color::cmyk(u(0)?, u(1)?, u(2)?, u(3)?),
Alt::Lab => Color::lab(round3(c(0)?.clamp(0.0, 100.0)), round3(c(1)?.clamp(-128.0, 127.0)), round3(c(2)?.clamp(-128.0, 127.0))),
})
}

View File

@ -11,16 +11,22 @@
//! - [`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. Colours keep their model: CMYK, Gray, and spot
//! inks (Separation, DeviceN) as spot swatches at a tint; a file painted mostly in CMYK opens as a
//! CMYK document. [`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 import_color;
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, is_postscript};
pub use settings::*;
use vectorcraft_doc::Document;
@ -49,18 +55,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 +118,20 @@ 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),
#[error(
"this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG"
)]
PostScript,
#[error(
"the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG"
)]
PlaceholderOnly,
}
#[cfg(test)]
@ -108,6 +149,10 @@ mod tests_focal;
#[cfg(test)]
mod tests_fx;
#[cfg(test)]
mod tests_import_color;
#[cfg(test)]
mod tests_import_options;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stroke;

127
crates/pdf/src/pages.rs Normal file
View File

@ -0,0 +1,127 @@
//! 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::object::Dict;
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>,
}
/// Is this a PostScript file (plain, or an EPS with a binary header) rather than a PDF?
pub fn is_postscript(bytes: &[u8]) -> bool {
bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6])
}
/// Read a PDF, decrypting it with `password` (none: the empty user password).
pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result<Pdf, PdfError> {
if is_postscript(bytes) {
return Err(PdfError::PostScript);
}
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) })
}
/// Does `page` carry an editor's private data (`PieceInfo` keys starting with `AIPrivateData`)?
/// A file saved without PDF compatibility keeps its art there and shows a placeholder page.
pub(crate) fn has_private_data(page: &Page<'_>) -> bool {
fn walk(d: &Dict<'_>, depth: u32) -> bool {
depth < 4 && d.keys().any(|k| k.starts_with(b"AIPrivateData") || d.get::<Dict<'_>>(k.as_ref()).is_some_and(|sub| walk(&sub, depth + 1)))
}
page.raw().get::<Dict<'_>>(b"PieceInfo").is_some_and(|d| walk(&d, 0))
}

View File

@ -12,7 +12,7 @@ pub trait Choice: Copy + 'static {
const ALL: &'static [Self];
/// The JSON ids, in display order.
const IDS: &'static [&'static str];
/// The labels shown in the Save PDF dialog, in display order.
/// The labels shown in the PDF dialogs, in display order.
const LABELS: &'static [&'static str];
}
@ -20,19 +20,19 @@ pub trait Choice: Copy + 'static {
macro_rules! choice {
($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $v:ident = $id:literal, $label:literal;)+ } default $d:ident) => {
$(#[$m])*
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum $name {
$($(#[$vm])* #[serde(rename = $id)] $v,)+
}
impl Choice for $name {
impl $crate::Choice for $name {
const ALL: &'static [Self] = &[$(Self::$v),+];
const IDS: &'static [&'static str] = &[$($id),+];
const LABELS: &'static [&'static str] = &[$($label),+];
}
impl $name {
/// The label shown in the Save PDF dialog.
/// The label shown in the PDF dialogs.
pub fn label(self) -> &'static str {
match self {
$(Self::$v => $label,)+
@ -54,6 +54,7 @@ macro_rules! choice {
}
};
}
pub(crate) use choice;
choice! {
/// The PDF standard the file conforms to.

View File

@ -0,0 +1,217 @@
//! Imported colours keep their model: CMYK, Gray, spot inks (Separation, DeviceN) at a tint, in
//! solid paints and shading stops; a CMYK file opens as a CMYK document.
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint, Swatch};
use vectorcraft_doc::{Appearance, AppearanceItem, ColorMode, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with};
use crate::import_color::{Colors, Space};
use crate::*;
const Q: f32 = 1.0 / 255.0 + 1e-4;
fn add(d: &mut Document, p: Paint) {
let x = d.node_count() as f64 * 12.0;
let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(p, Paint::None, 0.0));
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
}
/// The fills of the imported page, in paint order.
fn fills(d: &Document) -> Vec<Paint> {
let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!() };
children
.iter()
.filter_map(|n| n.appearance.items.iter().find_map(|i| if let AppearanceItem::Fill(f) = i { Some(f.paint.clone()) } else { None }))
.collect()
}
fn near(a: Color, b: Color) -> bool {
match (a, b) {
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => [c - c2, m - m2, y - y2, k - k2].iter().all(|v| v.abs() <= Q),
(Color::Gray { k }, Color::Gray { k: k2 }) => (k - k2).abs() <= Q,
(Color::Lab { l, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => [l - l2, a - a2, b - b2].iter().all(|v| v.abs() <= 0.01),
(Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => [r - r2, g - g2, b - b2].iter().all(|v| v.abs() <= Q),
_ => false,
}
}
fn solid(p: &Paint) -> (Color, Option<&str>, f32) {
match p {
Paint::Solid { color, swatch, tint } => (*color, swatch.as_deref(), *tint),
other => panic!("{other:?}"),
}
}
fn spot(name: &str, c: Color) -> Swatch {
Swatch { name: name.into(), paint: Paint::solid(c), global: true, spot: true }
}
#[test]
fn a_cmyk_document_with_spot_colours_round_trips() {
let mut d = Document::new_with_mode(200.0, 50.0, ColorMode::Cmyk);
d.swatches.push(spot("Ink A", Color::cmyk(0.1, 0.9, 0.2, 0.0)));
d.swatches.push(spot("Lab Ink", Color::lab(55.0, 60.0, 40.0)));
add(&mut d, Paint::solid(Color::cmyk(0.1, 0.2, 0.3, 0.4)));
add(&mut d, Paint::solid(Color::gray(0.25)));
for (name, tint) in [("Ink A", 0.4), ("Lab Ink", 0.6), (REGISTRATION, 0.5)] {
let p = d.tint_paint(name, tint).unwrap();
add(&mut d, p);
}
let bytes = export(&d, &PdfOptions::default()).unwrap();
let r = import_with_report(&bytes, &ImportOptions::default()).unwrap();
let back = r.document;
assert_eq!(back.color_mode, ColorMode::Cmyk, "a CMYK file opens in CMYK");
assert!(back.swatches.iter().filter_map(|s| s.paint.color()).any(|c| matches!(c, Color::Cmyk { .. })), "CMYK default swatches");
let f = fills(&back);
assert_eq!(f.len(), 5);
assert!(near(solid(&f[0]).0, Color::cmyk(0.1, 0.2, 0.3, 0.4)), "{:?}", f[0]);
assert!(near(solid(&f[1]).0, Color::gray(0.25)), "{:?}", f[1]);
// Spot inks link to new spot swatches at their tint.
for (p, name, tint) in [(&f[2], "Ink A", 0.4), (&f[3], "Lab Ink", 0.6)] {
let (color, link, t) = solid(p);
assert_eq!(link, Some(name));
assert!((t - tint).abs() <= Q, "{name}: tint {t}");
let sw = back.swatch(name).unwrap();
assert!(sw.spot && sw.global, "{name} is a spot swatch");
assert!(near(color, sw.paint.color().unwrap().tinted(t)), "{name}: {color:?}");
}
assert!(near(back.swatch("Ink A").unwrap().paint.color().unwrap(), Color::cmyk(0.1, 0.9, 0.2, 0.0)));
assert!(near(back.swatch("Lab Ink").unwrap().paint.color().unwrap(), Color::lab(55.0, 60.0, 40.0)), "a Lab alternate keeps Lab");
// Registration (every plate) links to the built-in swatch, which isn't added.
let (_, link, t) = solid(&f[4]);
assert_eq!((link, (t * 100.0).round()), (Some(REGISTRATION), 50.0));
assert_eq!(back.swatches.iter().filter(|s| s.spot).count(), 2);
assert!(r.warnings.iter().all(|w| !w.contains("inks")), "{:?}", r.warnings);
}
#[test]
fn gray_imports_as_gray_and_rgb_documents_stay_rgb() {
let mut d = Document::new(100.0, 50.0);
add(&mut d, Paint::solid(Color::gray(0.8)));
add(&mut d, Paint::solid(Color::rgb(0.2, 0.4, 0.6)));
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
assert_eq!(back.color_mode, ColorMode::Rgb);
let f = fills(&back);
assert!(near(solid(&f[0]).0, Color::gray(0.8)), "{:?}", f[0]);
assert!(near(solid(&f[1]).0, Color::rgb(0.2, 0.4, 0.6)), "{:?}", f[1]);
assert!(back.swatches.iter().all(|s| !s.spot));
}
#[test]
fn shading_stops_keep_cmyk_and_spot_tints() {
let mut d = Document::new_with_mode(100.0, 50.0, ColorMode::Cmyk);
d.swatches.push(spot("Ink A", Color::cmyk(0.0, 0.5, 1.0, 0.0)));
let grad = |stops: Vec<GradientStop>| Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
add(&mut d, grad(vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 1.0, 0.2))]));
let ink = d.global_color("Ink A").unwrap();
let linked = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink A".into()), tint, ..GradientStop::new(offset, ink.tinted(tint)) };
add(&mut d, grad(vec![linked(0.0, 0.0), linked(1.0, 1.0)]));
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
let f = fills(&back);
let stops = |p: &Paint| match p {
Paint::Gradient(g) => g.gradient.stops.clone(),
other => panic!("{other:?}"),
};
let s = stops(&f[0]);
assert!(near(s[0].color, Color::cmyk(1.0, 0.0, 0.0, 0.0)) && near(s.last().unwrap().color, Color::cmyk(0.0, 0.0, 1.0, 0.2)), "{s:?}");
assert!(s.iter().all(|s| matches!(s.color, Color::Cmyk { .. }) && s.swatch.is_none()));
let s = stops(&f[1]);
assert!(s.iter().all(|s| s.swatch.as_deref() == Some("Ink A")), "{s:?}");
assert!(s[0].tint.abs() <= Q && (s.last().unwrap().tint - 1.0).abs() <= Q, "{s:?}");
assert!(near(s.last().unwrap().color, Color::cmyk(0.0, 0.5, 1.0, 0.0)));
assert!(back.swatch("Ink A").is_some_and(|w| w.spot));
}
/// A page painting with colour spaces `CS0`…: `fills` are `(space, components)`.
fn page_with(spaces: &str, fills: &[(&str, &str)]) -> PdfPage {
let content: String = fills.iter().enumerate().map(|(i, (cs, c))| format!("/{cs} cs {c} scn {} 0 10 10 re f\n", i * 12)).collect();
PdfPage { resources: format!("/ColorSpace << {spaces} >>"), ..PdfPage::new(200.0, 50.0, &content) }
}
#[test]
fn device_n_inks_become_spot_swatches_and_process_plates_cmyk() {
// Gold and Silver → CMYK (0, 0, gold, silver), through a calculator function.
let f = first_extra(1);
let func = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 16 >>\nstream\n{ 0 0 4 2 roll }\nendstream";
// Cyan and Magenta → CMYK (cyan, magenta, 0, 0).
let process = "<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 9 >>\nstream\n{ 0 0 }\nendstream";
let spaces = format!(
"/CS0 [/DeviceN [/Gold /Silver] /DeviceCMYK {f} 0 R] /CS1 [/DeviceN [/Cyan /Magenta] /DeviceCMYK {} 0 R] /CS2 [/Separation /Black /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>]",
f + 1
);
let page = page_with(&spaces, &[("CS0", "0.3 0"), ("CS0", "0 1"), ("CS0", "0.5 0.5"), ("CS1", "0.2 0.7"), ("CS2", "0.4"), ("CS0", "0 0")]);
let r = import_with_report(&pdf_with(&[page], &[func, process], None), &ImportOptions::default()).unwrap();
let d = &r.document;
let f = fills(d);
assert_eq!(f.len(), 6);
assert_eq!(solid(&f[0]).1, Some("Gold"));
assert!((solid(&f[0]).2 - 0.3).abs() < 1e-6);
assert_eq!((solid(&f[1]).1, solid(&f[1]).2), (Some("Silver"), 1.0));
assert!(near(d.swatch("Gold").unwrap().paint.color().unwrap(), Color::cmyk(0.0, 0.0, 1.0, 0.0)));
assert!(near(d.swatch("Silver").unwrap().paint.color().unwrap(), Color::cmyk(0.0, 0.0, 0.0, 1.0)));
// Two inks at once can't link to one swatch: RGB, with a warning.
assert!(matches!(solid(&f[2]), (Color::Rgb { .. }, None, _)));
assert!(r.warnings.iter().any(|w| w.contains("several inks")), "{:?}", r.warnings);
// Process plates are CMYK, also as a Separation.
assert!(near(solid(&f[3]).0, Color::cmyk(0.2, 0.7, 0.0, 0.0)), "{:?}", f[3]);
assert!(near(solid(&f[4]).0, Color::cmyk(0.0, 0.0, 0.0, 0.4)), "{:?}", f[4]);
// No ink painting: 0 % of the first.
assert_eq!((solid(&f[5]).1, solid(&f[5]).2), (Some("Gold"), 0.0));
assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 2);
}
#[test]
fn inks_that_cannot_be_told_apart_import_as_rgb() {
// Two spaces of other inks with the same alternate and tint transform.
let tint = "/DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>";
let spaces = format!("/CS0 [/Separation /Tealish {tint}] /CS1 [/Separation /Rubyish {tint}] /CS2 [/Separation /Tealish {tint}]");
let d = import(&pdf(&[page_with(&spaces, &[("CS0", "1")])], None)).unwrap();
assert!(matches!(solid(&fills(&d)[0]), (Color::Rgb { .. }, None, _)));
// The same ink defined twice is still that ink.
let spaces = format!("/CS0 [/Separation /Tealish {tint}] /CS2 [/Separation /Tealish {tint}]");
let d = import(&pdf(&[page_with(&spaces, &[("CS2", "0.5")])], None)).unwrap();
assert_eq!(solid(&fills(&d)[0]).1, Some("Tealish"));
}
#[test]
fn ink_names_never_clash_with_the_document_swatches() {
let taken = Document::new(1.0, 1.0).swatches.iter().find(|s| !s.paint.is_none() && s.name != REGISTRATION).unwrap().name.clone();
let spaces = format!(
"/CS0 [/Separation /{} /DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>]",
taken.replace(' ', "#20").replace('=', "#3D")
);
let d = import(&pdf(&[page_with(&spaces, &[("CS0", "1")])], None)).unwrap();
let f = fills(&d);
let (color, link, _) = solid(&f[0]);
let renamed = format!("{taken} 2");
assert_eq!(link, Some(renamed.as_str()));
assert!(near(color, Color::rgb(1.0, 0.0, 0.0)));
assert!(d.swatch(&renamed).unwrap().spot);
}
#[test]
fn colour_spaces_are_read_from_the_interpreters_debug_form() {
let pdf = hayro_syntax::Pdf::new(pdf(&[PdfPage::new(10.0, 10.0, "")], None)).unwrap();
let mut c = Colors::new(&pdf, vec![]);
for (text, n, want) in [
("ColorSpace(DeviceCmyk)", 4, Space::Cmyk),
("ColorSpace(DeviceGray)", 1, Space::Gray),
("ColorSpace(DeviceRgb)", 3, Space::Rgb),
("ColorSpace(ICCBased(ICCColor {..}))", 4, Space::Cmyk),
("ColorSpace(ICCBased(ICCColor {..}))", 1, Space::Gray),
("ColorSpace(ICCBased(ICCColor {..}))", 3, Space::Other),
("ColorSpace(Lab(Lab { range: [] }))", 3, Space::Other),
("ColorSpace(Separation(Separation { tint_transform: Function(Type2(..)) }))", 1, Space::Other),
("ColorSpace(DeviceCmyk)", 3, Space::Other),
] {
assert_eq!(c.space_of(text, n), want, "{text}");
}
let t = "Color { color_space: ColorSpace(DeviceCmyk), components: [0.101960786, 0.2, 0.3, 0.4], opacity: 1.0 }";
let (space, comps) = crate::import_color::split_color(t).unwrap();
assert_eq!((space, comps), ("ColorSpace(DeviceCmyk)", vec![0.101960786, 0.2, 0.3, 0.4]));
assert_eq!(crate::import_color::split_color("Color { color_space: X, components: [NaN], opacity: 1.0 }"), None);
}

View File

@ -0,0 +1,158 @@
//! 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:?}");
}
}
#[test]
fn postscript_is_refused_as_such() {
let ps = b"%!PS\n0 0 moveto 10 10 lineto stroke\nshowpage\n";
assert_eq!(import(ps).unwrap_err(), PdfError::PostScript);
assert_eq!(info(ps, None).unwrap_err(), PdfError::PostScript);
assert!(is_postscript(&[0xC5, 0xD0, 0xD3, 0xC6, 0, 0]) && !is_postscript(b"%PDF-1.7"));
}

View File

@ -9,6 +9,7 @@
//! (`.vectorcraft`, SVG, `document.inspect`).
//! - [`raster`]: rendering helpers and image comparison with a perceptual tolerance.
//! - [`geom`]: geometry assertions (approximate equality, curve sampling, Hausdorff distance).
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
//!
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
// Test support only (a dev-dependency of every crate that uses it): a failed setup or assertion
@ -20,6 +21,7 @@
pub mod fixtures;
pub mod geom;
pub mod invariants;
pub mod pdf;
pub mod raster;
pub mod strategies;

206
crates/testkit/src/pdf.rs Normal file
View File

@ -0,0 +1,206 @@
//! 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,
/// More entries of the page dictionary (`/PieceInfo << … >>`).
pub entries: 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> {
pdf_with(pages, &[], password)
}
/// The object number of the first `extra` object of [`pdf_with`] for a file of `pages` pages.
pub fn first_extra(pages: usize) -> usize {
3 + 2 * pages
}
/// [`pdf`] with `extra` objects (written as given, not encrypted: streams such as functions and
/// ICC profiles that resources refer to by number, from [`first_extra`] on).
pub fn pdf_with(pages: &[PdfPage], extra: &[&str], 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);
}
if !p.entries.is_empty() {
let _ = write!(d, " {}", p.entries);
}
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);
}
objects.extend(extra.iter().map(|o| o.as_bytes().to_vec()));
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");
}
}

View File

@ -26,3 +26,6 @@ image = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
vectorcraft-testkit = { workspace = true }

View File

@ -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)
}

View File

@ -0,0 +1,276 @@
//! The Import PDF dialog: opening or placing a multi-page or password-protected PDF (or `.ai`)
//! asks which pages (a preview with page navigation), the box to crop to and the password.
//!
//! Fields: `mode` (`open` | `place`), `name`, `path?`, `pageCount`, `page` (the page shown, and the
//! one placed; 1-based), `allPages` and `range` (open), `cropTo` and `password`. `__locked` is set
//! until the password is accepted: OK then unlocks the file. `__place` keeps the other `file.place`
//! params (Link, Template, `at`…). The file's bytes stay out of the fields, in
//! [`crate::state::UiState::dialog_file`].
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_pdf::{Choice, CropTo, PdfError};
use super::DialogSpec;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, io, widgets};
pub(super) const KIND: &str = "importPdf";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |d| if d.str("mode") == "place" { "Place PDF" } else { "Import PDF" }.into(), body, confirm, ..DialogSpec::FORM };
/// The longest side of the page preview (pt).
const PREVIEW: f32 = 200.0;
const LABEL_WIDTH: f32 = 70.0;
/// The file the dialog reads.
#[derive(Debug)]
pub struct DialogFile {
pub bytes: Vec<u8>,
/// Unique per file the dialog opened (keys its preview cache).
token: u64,
}
/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the
/// file is a PDF (or `.ai`/`.ait`) with several pages or a password; false when there's nothing to
/// ask (the caller goes on).
pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, place: Option<&Value>) -> bool {
if !fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) {
return false;
}
let (pages, locked) = match vectorcraft_pdf::info(bytes, None) {
Ok(i) if i.pages.len() > 1 => (i.pages.len(), false),
Err(PdfError::NeedsPassword) => (0, true),
_ => return false,
};
static TOKEN: AtomicU64 = AtomicU64::new(0);
app.ui.dialog_file = Some(Arc::new(DialogFile { bytes: bytes.to_vec(), token: TOKEN.fetch_add(1, Ordering::Relaxed) }));
let mut fields = json!({
"mode": if place.is_some() { "place" } else { "open" },
"name": name,
"pageCount": pages,
"page": 1,
"allPages": true,
"range": "",
"cropTo": CropTo::default().id(),
"password": "",
"__locked": locked,
});
if let Some(p) = path {
fields["path"] = json!(p);
}
if let Some(Value::Object(p)) = place {
let rest: serde_json::Map<String, Value> = p
.iter()
.filter(|(k, _)| !matches!(k.as_str(), "path" | "name" | "dataBase64" | "page" | "crop" | "password"))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
fields["__place"] = Value::Object(rest);
}
app.ui.dialog = Some(Dialog::new(KIND, fields));
true
}
/// `file.place` with params `p` asks first (true) when they name a PDF (`.pdf`, `.ai`, `.ait`)
/// with several pages or a password and no `page`.
pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool {
if p.get("page").is_some() {
return false;
}
let s = |k: &str| p.get(k).and_then(Value::as_str);
let (name, bytes, path) = match (s("path"), s("dataBase64")) {
(Some(path), _) if matches!(fileio::extension(path).as_str(), "pdf" | "ai" | "ait") => {
let Some(bytes) = app.services.read.as_ref().and_then(|r| r(path).ok()) else { return false };
(path.to_string(), bytes, Some(path.to_string()))
}
(None, Some(b64)) => match vectorcraft_format::base64_decode(b64) {
Some(bytes) => (s("name").unwrap_or("Untitled").to_string(), bytes, None),
None => return false,
},
_ => return false,
};
offer(app, &name, &bytes, path, Some(p))
}
fn page_count(d: &Dialog) -> usize {
d.fields.get("pageCount").and_then(Value::as_u64).unwrap_or(0) as usize
}
/// The page shown (1-based, within the file).
fn page(d: &Dialog) -> usize {
(d.f64("page", 1.0).max(1.0) as usize).min(page_count(d).max(1))
}
fn crop(d: &Dialog) -> CropTo {
CropTo::IDS.iter().position(|id| *id == d.str("cropTo")).and_then(|i| CropTo::ALL.get(i).copied()).unwrap_or_default()
}
fn password(d: &Dialog) -> Option<String> {
Some(d.str("password")).filter(|p| !p.is_empty())
}
/// OK: unlock the file with the password, else open the pages or place the page.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let file = app.ui.dialog_file.clone().ok_or("the PDF is no longer loaded")?;
let mut d = d.clone();
if d.bool("__locked") {
let info = vectorcraft_pdf::info(&file.bytes, password(&d).as_deref()).map_err(|e| e.to_string())?;
d.fields.insert("__locked".into(), json!(false));
d.fields.insert("pageCount".into(), json!(info.pages.len()));
if info.pages.len() > 1 {
// Unlocked: now pick the pages.
app.ui.dialog = Some(d);
return Ok(Value::Null);
}
}
let name = d.str("name");
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
let r = if d.str("mode") == "place" {
let mut p = d.fields.get("__place").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
match &path {
Some(path) => p["path"] = json!(path),
None => {
p["name"] = json!(name);
p["dataBase64"] = json!(vectorcraft_format::base64_encode(&file.bytes));
}
}
p["page"] = json!(page(&d));
p["crop"] = json!(crop(&d).id());
if let Some(pw) = password(&d) {
p["password"] = json!(pw);
}
crate::place::run(app, &p).map(|_| ())
} else {
let mut p = json!({"cropTo": crop(&d).id(), "password": password(&d)});
let range = d.str("range");
if !d.bool("allPages") && !range.trim().is_empty() {
p["pages"] = json!(range);
}
io::open_document(app, &name, &file.bytes, path, &p)
};
match r {
Ok(()) => {
app.ui.dialog = None;
Ok(Value::Null)
}
Err(e) => {
// Keep what was typed (the dialog stays open on errors such as a bad range).
app.ui.dialog = Some(d);
Err(e)
}
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
if d.bool("__locked") {
let name = d.str("name");
let file = std::path::Path::new(&name).file_name().map_or(name.clone(), |f| f.to_string_lossy().into_owned());
ui.label(egui::RichText::new(format!("“{file}” is protected by a password.")).color(t.text));
ui.add_space(8.0);
password_row(ui, d);
return false;
}
let count = page_count(d);
let mut current = page(d);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
let (r, _) = ui.allocate_exact_size(egui::vec2(PREVIEW, PREVIEW), egui::Sense::hover());
ui.painter().rect_filled(r, 2.0, t.pasteboard);
if let Some(tex) = preview(app, ui.ctx(), d, current) {
let sz = tex.size_vec2();
let ir = egui::Rect::from_center_size(r.center(), sz * (PREVIEW / sz.x.max(sz.y)));
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
ui.painter().rect_stroke(ir, 0.0, egui::Stroke::new(1.0, t.divider), egui::StrokeKind::Outside);
}
ui.add_space(6.0);
// Page navigation: first, previous, the page number, next, last.
ui.horizontal(|ui| {
let nav = |ui: &mut egui::Ui, icon: &str, tip: &str, to: usize, enabled: bool| {
widgets::icon_button_enabled(ui, icon, tip, false, enabled, 22.0).clicked().then_some(to)
};
let go = [
nav(ui, "chevrons-left", "First Page", 1, current > 1),
nav(ui, "chevron-left", "Previous Page", current.saturating_sub(1).max(1), current > 1),
];
if let Some(v) = widgets::plain_field(ui, "pdf-page", current as f64, "", 0, 40.0) {
current = (v.max(1.0) as usize).min(count.max(1));
}
ui.label(egui::RichText::new(format!("of {count}")).color(t.text_dim));
let go2 = [
nav(ui, "chevron-right", "Next Page", (current + 1).min(count), current < count),
nav(ui, "chevrons-right", "Last Page", count, current < count),
];
if let Some(to) = go.into_iter().chain(go2).flatten().next() {
current = to;
}
});
});
ui.add_space(14.0);
ui.vertical(|ui| {
widgets::label_row(ui, "Crop To", LABEL_WIDTH, |ui| {
if let Some(i) = widgets::dropdown(ui, "pdf-crop", crop(d).label(), CropTo::LABELS, 130.0)
&& let Some(id) = CropTo::IDS.get(i)
{
d.fields.insert("cropTo".into(), json!(id));
}
});
if d.str("mode") != "place" {
ui.add_space(10.0);
ui.label(egui::RichText::new("Pages").color(t.text));
let all = d.bool("allPages");
if widgets::radio(ui, "All", all, true) {
d.fields.insert("allPages".into(), json!(true));
}
ui.horizontal(|ui| {
if widgets::radio(ui, "Range:", !all, true) {
d.fields.insert("allPages".into(), json!(false));
}
if super::form::text_edit(ui, d, "range", 90.0).gained_focus() {
d.fields.insert("allPages".into(), json!(false));
}
});
widgets::dim_label(ui, "e.g. 1-3, 5");
}
});
});
d.fields.insert("page".into(), json!(current));
false
}
fn password_row(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::label_row(ui, "Password", LABEL_WIDTH, |ui| {
let mut s = d.str("password");
let r = ui.add(egui::TextEdit::singleline(&mut s).password(true).desired_width(180.0));
if r.changed() {
d.fields.insert("password".into(), json!(s));
}
if ui.memory(|m| m.focused().is_none()) {
r.request_focus();
}
});
}
/// Page `page` (1-based) cropped as the dialog says, rendered once per file, page and box.
fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog, page: usize) -> Option<egui::TextureHandle> {
let file = app.ui.dialog_file.clone()?;
let crop = crop(d);
let key = egui::Id::new(("pdf-preview", file.token, page, crop.id()));
if let Some(t) = ctx.data(|m| m.get_temp::<Option<egui::TextureHandle>>(key)) {
return t;
}
let opts = fileio::LoadOptions { crop, password: password(d), ..Default::default() };
// A page that can't be read shows an empty preview (and isn't read again).
let tex = fileio::page_document(&file.bytes, page - 1, &opts).ok().and_then(|(doc, _)| {
let r = doc.artboards.first()?.rect;
let px = PREVIEW as f64 * ctx.pixels_per_point() as f64;
Some(widgets::region_texture(ctx, &mut app.canvas.renderer, &format!("pdf-preview-{page}"), &doc, r, px))
});
ctx.data_mut(|m| m.insert_temp(key, tex.clone()));
tex
}

View File

@ -25,6 +25,7 @@ pub mod flattener_presets;
mod form;
mod gradient_stop;
pub mod graphic_style_options;
pub mod import_pdf;
pub mod new_color_group;
mod new_document;
pub mod new_swatch;
@ -186,6 +187,7 @@ registry! {
Place: [place::KIND] => place::SPEC,
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions"] => png_options::SPEC,
ExportAs: ["exportAs"] => export_as::SPEC,
ImportPdf: [import_pdf::KIND] => import_pdf::SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
@ -216,7 +218,10 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
about::show(app, ctx);
let Some(mut d) = app.ui.dialog.clone() else { return };
let Some(mut d) = app.ui.dialog.clone() else {
app.ui.dialog_file = None;
return;
};
let spec = spec(&d.kind);
if let Some(window) = spec.window {
return window(app, ctx);
@ -286,3 +291,5 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
mod tests;
#[cfg(test)]
mod tests_export;
#[cfg(test)]
mod tests_import_pdf;

View File

@ -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);
}

View File

@ -26,7 +26,16 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library };
return load(app, p).map(|_| ());
}
let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?;
// A PDF with several pages or a password asks first (the Import PDF dialog).
if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) {
return Ok(());
}
open_document(app, name, bytes, path, &Value::Null)
}
/// Open a document through the engine loader with the `document.open` options in `p`.
pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<(), String> {
let r = fileio::open_bytes_with(&mut app.session, name, bytes, path, p).map_err(|e| e.to_string())?;
app.sync_views();
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default()));

View File

@ -62,7 +62,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.place",
"Place…",
"Cmd+Shift+P",
"{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view",
"{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)",
),
("file.openRecent1", "Open Recent File 1", "", "{}"),
("file.openRecent2", "Open Recent File 2", "", "{}"),

View File

@ -81,6 +81,10 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
open_dialog(app, files);
return Ok(Value::Null);
}
// A PDF with several pages or a password asks for the page, box and password first.
if crate::dialogs::import_pdf::offer_place(app, p) {
return Ok(Value::Null);
}
let mut p = p.clone();
if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none())
&& let Some(v) = app.view()

View File

@ -266,6 +266,10 @@ pub struct UiState {
/// File → Place: Link is on (the Place dialog remembers it).
#[serde(default = "yes")]
pub place_link: bool,
/// The file the open dialog reads, kept out of its JSON fields (Import PDF); dropped when no
/// dialog is open.
#[serde(skip)]
pub dialog_file: Option<std::sync::Arc<crate::dialogs::import_pdf::DialogFile>>,
}
impl UiState {
@ -323,6 +327,7 @@ impl Default for UiState {
color_guide_limit: String::new(),
svg_options: Value::Null,
place_link: true,
dialog_file: None,
}
}
}

View File

@ -1247,3 +1247,19 @@ pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &st
ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color })));
resp.on_hover_text(tip).clicked()
}
/// `region` of `doc` rendered on white, its longest side `px` pixels, as a texture named `name`
/// (artboard and page thumbnails).
pub fn region_texture(
ctx: &egui::Context,
renderer: &mut vectorcraft_render::Renderer,
name: &str,
doc: &vectorcraft_doc::Document,
region: vectorcraft_geom::Rect,
px: f64,
) -> egui::TextureHandle {
let scale = px / region.width().max(region.height()).max(1.0);
let img = renderer.render_region(doc, region, scale, true);
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
ctx.load_texture(name, color, egui::TextureOptions::LINEAR)
}

View File

@ -26,6 +26,8 @@
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,
`preview`), which previews on the canvas while open; `ui.dialog.confirm` applies it with `swatch.edit` as one undo

View File

@ -185,6 +185,22 @@ 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. Imported colours keep their model:
DeviceCMYK and CMYK ICC colours stay CMYK (a file painted mostly in CMYK opens as a CMYK document), DeviceGray is Gray,
and Separation and DeviceN inks become spot swatches the art links to at its tint (gradient stops too).
`colorMode: "rgb" | "cmyk"` opens any file in that mode instead, its colours converted as `file.documentColorMode` does:
```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"}}}
```
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) can't be opened yet and
say so, as does an `.ai` whose PDF part is only a placeholder page.
Drive a tool like a mouse:
```json