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:
commit
cc03a47b7d
|
|
@ -4350,6 +4350,7 @@ dependencies = [
|
|||
"vectorcraft-pdf",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-tools",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ pub trait Choice: Copy + 'static {
|
|||
const ALL: &'static [Self];
|
||||
/// The JSON ids, in display order.
|
||||
const IDS: &'static [&'static str];
|
||||
/// The labels shown in the Save PDF dialog, in display order.
|
||||
/// The labels shown in the PDF dialogs, in display order.
|
||||
const LABELS: &'static [&'static str];
|
||||
}
|
||||
|
||||
|
|
@ -20,19 +20,19 @@ pub trait Choice: Copy + 'static {
|
|||
macro_rules! choice {
|
||||
($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $v:ident = $id:literal, $label:literal;)+ } default $d:ident) => {
|
||||
$(#[$m])*
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum $name {
|
||||
$($(#[$vm])* #[serde(rename = $id)] $v,)+
|
||||
}
|
||||
|
||||
impl Choice for $name {
|
||||
impl $crate::Choice for $name {
|
||||
const ALL: &'static [Self] = &[$(Self::$v),+];
|
||||
const IDS: &'static [&'static str] = &[$($id),+];
|
||||
const LABELS: &'static [&'static str] = &[$($label),+];
|
||||
}
|
||||
|
||||
impl $name {
|
||||
/// The label shown in the Save PDF dialog.
|
||||
/// The label shown in the PDF dialogs.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
$(Self::$v => $label,)+
|
||||
|
|
@ -54,6 +54,7 @@ macro_rules! choice {
|
|||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use choice;
|
||||
|
||||
choice! {
|
||||
/// The PDF standard the file conforms to.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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);
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
//! (`.vectorcraft`, SVG, `document.inspect`).
|
||||
//! - [`raster`]: rendering helpers and image comparison with a perceptual tolerance.
|
||||
//! - [`geom`]: geometry assertions (approximate equality, curve sampling, Hausdorff distance).
|
||||
//! - [`pdf`]: hand-written PDF files (page boxes, colour spaces, encryption) for import tests.
|
||||
//!
|
||||
//! This crate may only be used as a dev-dependency (enforced by `cargo xtask layers`).
|
||||
// Test support only (a dev-dependency of every crate that uses it): a failed setup or assertion
|
||||
|
|
@ -20,6 +21,7 @@
|
|||
pub mod fixtures;
|
||||
pub mod geom;
|
||||
pub mod invariants;
|
||||
pub mod pdf;
|
||||
pub mod raster;
|
||||
pub mod strategies;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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");
|
||||
}
|
||||
}
|
||||
|
|
@ -26,3 +26,6 @@ image = { workspace = true }
|
|||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -145,10 +145,7 @@ fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Op
|
|||
}
|
||||
let doc = st.doc.clone();
|
||||
let r = doc.artboards.get(i)?.rect;
|
||||
let scale = 92.0 / r.width().max(r.height()).max(1.0);
|
||||
let img = app.canvas.renderer.render_region(&doc, r, scale, true);
|
||||
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
let tex = ctx.load_texture(format!("ab-thumb-{i}"), color, egui::TextureOptions::LINEAR);
|
||||
let tex = crate::widgets::region_texture(ctx, &mut app.canvas.renderer, &format!("ab-thumb-{i}"), &doc, r, 92.0);
|
||||
ctx.data_mut(|d| d.insert_temp(key, tex.clone()));
|
||||
Some(tex)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
//! The Import PDF dialog: opening or placing a multi-page or password-protected PDF (or `.ai`)
|
||||
//! asks which pages (a preview with page navigation), the box to crop to and the password.
|
||||
//!
|
||||
//! Fields: `mode` (`open` | `place`), `name`, `path?`, `pageCount`, `page` (the page shown, and the
|
||||
//! one placed; 1-based), `allPages` and `range` (open), `cropTo` and `password`. `__locked` is set
|
||||
//! until the password is accepted: OK then unlocks the file. `__place` keeps the other `file.place`
|
||||
//! params (Link, Template, `at`…). The file's bytes stay out of the fields, in
|
||||
//! [`crate::state::UiState::dialog_file`].
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_pdf::{Choice, CropTo, PdfError};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, io, widgets};
|
||||
|
||||
pub(super) const KIND: &str = "importPdf";
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |d| if d.str("mode") == "place" { "Place PDF" } else { "Import PDF" }.into(), body, confirm, ..DialogSpec::FORM };
|
||||
|
||||
/// The longest side of the page preview (pt).
|
||||
const PREVIEW: f32 = 200.0;
|
||||
const LABEL_WIDTH: f32 = 70.0;
|
||||
|
||||
/// The file the dialog reads.
|
||||
#[derive(Debug)]
|
||||
pub struct DialogFile {
|
||||
pub bytes: Vec<u8>,
|
||||
/// Unique per file the dialog opened (keys its preview cache).
|
||||
token: u64,
|
||||
}
|
||||
|
||||
/// Open `bytes` (or place them with the `file.place` params `place`) through the dialog when the
|
||||
/// file is a PDF (or `.ai`/`.ait`) with several pages or a password; false when there's nothing to
|
||||
/// ask (the caller goes on).
|
||||
pub fn offer(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, place: Option<&Value>) -> bool {
|
||||
if !fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) {
|
||||
return false;
|
||||
}
|
||||
let (pages, locked) = match vectorcraft_pdf::info(bytes, None) {
|
||||
Ok(i) if i.pages.len() > 1 => (i.pages.len(), false),
|
||||
Err(PdfError::NeedsPassword) => (0, true),
|
||||
_ => return false,
|
||||
};
|
||||
static TOKEN: AtomicU64 = AtomicU64::new(0);
|
||||
app.ui.dialog_file = Some(Arc::new(DialogFile { bytes: bytes.to_vec(), token: TOKEN.fetch_add(1, Ordering::Relaxed) }));
|
||||
let mut fields = json!({
|
||||
"mode": if place.is_some() { "place" } else { "open" },
|
||||
"name": name,
|
||||
"pageCount": pages,
|
||||
"page": 1,
|
||||
"allPages": true,
|
||||
"range": "",
|
||||
"cropTo": CropTo::default().id(),
|
||||
"password": "",
|
||||
"__locked": locked,
|
||||
});
|
||||
if let Some(p) = path {
|
||||
fields["path"] = json!(p);
|
||||
}
|
||||
if let Some(Value::Object(p)) = place {
|
||||
let rest: serde_json::Map<String, Value> = p
|
||||
.iter()
|
||||
.filter(|(k, _)| !matches!(k.as_str(), "path" | "name" | "dataBase64" | "page" | "crop" | "password"))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
fields["__place"] = Value::Object(rest);
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
true
|
||||
}
|
||||
|
||||
/// `file.place` with params `p` asks first (true) when they name a PDF (`.pdf`, `.ai`, `.ait`)
|
||||
/// with several pages or a password and no `page`.
|
||||
pub fn offer_place(app: &mut VectorcraftApp, p: &Value) -> bool {
|
||||
if p.get("page").is_some() {
|
||||
return false;
|
||||
}
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let (name, bytes, path) = match (s("path"), s("dataBase64")) {
|
||||
(Some(path), _) if matches!(fileio::extension(path).as_str(), "pdf" | "ai" | "ait") => {
|
||||
let Some(bytes) = app.services.read.as_ref().and_then(|r| r(path).ok()) else { return false };
|
||||
(path.to_string(), bytes, Some(path.to_string()))
|
||||
}
|
||||
(None, Some(b64)) => match vectorcraft_format::base64_decode(b64) {
|
||||
Some(bytes) => (s("name").unwrap_or("Untitled").to_string(), bytes, None),
|
||||
None => return false,
|
||||
},
|
||||
_ => return false,
|
||||
};
|
||||
offer(app, &name, &bytes, path, Some(p))
|
||||
}
|
||||
|
||||
fn page_count(d: &Dialog) -> usize {
|
||||
d.fields.get("pageCount").and_then(Value::as_u64).unwrap_or(0) as usize
|
||||
}
|
||||
|
||||
/// The page shown (1-based, within the file).
|
||||
fn page(d: &Dialog) -> usize {
|
||||
(d.f64("page", 1.0).max(1.0) as usize).min(page_count(d).max(1))
|
||||
}
|
||||
|
||||
fn crop(d: &Dialog) -> CropTo {
|
||||
CropTo::IDS.iter().position(|id| *id == d.str("cropTo")).and_then(|i| CropTo::ALL.get(i).copied()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn password(d: &Dialog) -> Option<String> {
|
||||
Some(d.str("password")).filter(|p| !p.is_empty())
|
||||
}
|
||||
|
||||
/// OK: unlock the file with the password, else open the pages or place the page.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let file = app.ui.dialog_file.clone().ok_or("the PDF is no longer loaded")?;
|
||||
let mut d = d.clone();
|
||||
if d.bool("__locked") {
|
||||
let info = vectorcraft_pdf::info(&file.bytes, password(&d).as_deref()).map_err(|e| e.to_string())?;
|
||||
d.fields.insert("__locked".into(), json!(false));
|
||||
d.fields.insert("pageCount".into(), json!(info.pages.len()));
|
||||
if info.pages.len() > 1 {
|
||||
// Unlocked: now pick the pages.
|
||||
app.ui.dialog = Some(d);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
}
|
||||
let name = d.str("name");
|
||||
let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string);
|
||||
let r = if d.str("mode") == "place" {
|
||||
let mut p = d.fields.get("__place").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
match &path {
|
||||
Some(path) => p["path"] = json!(path),
|
||||
None => {
|
||||
p["name"] = json!(name);
|
||||
p["dataBase64"] = json!(vectorcraft_format::base64_encode(&file.bytes));
|
||||
}
|
||||
}
|
||||
p["page"] = json!(page(&d));
|
||||
p["crop"] = json!(crop(&d).id());
|
||||
if let Some(pw) = password(&d) {
|
||||
p["password"] = json!(pw);
|
||||
}
|
||||
crate::place::run(app, &p).map(|_| ())
|
||||
} else {
|
||||
let mut p = json!({"cropTo": crop(&d).id(), "password": password(&d)});
|
||||
let range = d.str("range");
|
||||
if !d.bool("allPages") && !range.trim().is_empty() {
|
||||
p["pages"] = json!(range);
|
||||
}
|
||||
io::open_document(app, &name, &file.bytes, path, &p)
|
||||
};
|
||||
match r {
|
||||
Ok(()) => {
|
||||
app.ui.dialog = None;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Err(e) => {
|
||||
// Keep what was typed (the dialog stays open on errors such as a bad range).
|
||||
app.ui.dialog = Some(d);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if d.bool("__locked") {
|
||||
let name = d.str("name");
|
||||
let file = std::path::Path::new(&name).file_name().map_or(name.clone(), |f| f.to_string_lossy().into_owned());
|
||||
ui.label(egui::RichText::new(format!("“{file}” is protected by a password.")).color(t.text));
|
||||
ui.add_space(8.0);
|
||||
password_row(ui, d);
|
||||
return false;
|
||||
}
|
||||
let count = page_count(d);
|
||||
let mut current = page(d);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(PREVIEW, PREVIEW), egui::Sense::hover());
|
||||
ui.painter().rect_filled(r, 2.0, t.pasteboard);
|
||||
if let Some(tex) = preview(app, ui.ctx(), d, current) {
|
||||
let sz = tex.size_vec2();
|
||||
let ir = egui::Rect::from_center_size(r.center(), sz * (PREVIEW / sz.x.max(sz.y)));
|
||||
ui.painter().image(tex.id(), ir, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
ui.painter().rect_stroke(ir, 0.0, egui::Stroke::new(1.0, t.divider), egui::StrokeKind::Outside);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
// Page navigation: first, previous, the page number, next, last.
|
||||
ui.horizontal(|ui| {
|
||||
let nav = |ui: &mut egui::Ui, icon: &str, tip: &str, to: usize, enabled: bool| {
|
||||
widgets::icon_button_enabled(ui, icon, tip, false, enabled, 22.0).clicked().then_some(to)
|
||||
};
|
||||
let go = [
|
||||
nav(ui, "chevrons-left", "First Page", 1, current > 1),
|
||||
nav(ui, "chevron-left", "Previous Page", current.saturating_sub(1).max(1), current > 1),
|
||||
];
|
||||
if let Some(v) = widgets::plain_field(ui, "pdf-page", current as f64, "", 0, 40.0) {
|
||||
current = (v.max(1.0) as usize).min(count.max(1));
|
||||
}
|
||||
ui.label(egui::RichText::new(format!("of {count}")).color(t.text_dim));
|
||||
let go2 = [
|
||||
nav(ui, "chevron-right", "Next Page", (current + 1).min(count), current < count),
|
||||
nav(ui, "chevrons-right", "Last Page", count, current < count),
|
||||
];
|
||||
if let Some(to) = go.into_iter().chain(go2).flatten().next() {
|
||||
current = to;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
ui.vertical(|ui| {
|
||||
widgets::label_row(ui, "Crop To", LABEL_WIDTH, |ui| {
|
||||
if let Some(i) = widgets::dropdown(ui, "pdf-crop", crop(d).label(), CropTo::LABELS, 130.0)
|
||||
&& let Some(id) = CropTo::IDS.get(i)
|
||||
{
|
||||
d.fields.insert("cropTo".into(), json!(id));
|
||||
}
|
||||
});
|
||||
if d.str("mode") != "place" {
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("Pages").color(t.text));
|
||||
let all = d.bool("allPages");
|
||||
if widgets::radio(ui, "All", all, true) {
|
||||
d.fields.insert("allPages".into(), json!(true));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::radio(ui, "Range:", !all, true) {
|
||||
d.fields.insert("allPages".into(), json!(false));
|
||||
}
|
||||
if super::form::text_edit(ui, d, "range", 90.0).gained_focus() {
|
||||
d.fields.insert("allPages".into(), json!(false));
|
||||
}
|
||||
});
|
||||
widgets::dim_label(ui, "e.g. 1-3, 5");
|
||||
}
|
||||
});
|
||||
});
|
||||
d.fields.insert("page".into(), json!(current));
|
||||
false
|
||||
}
|
||||
|
||||
fn password_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
widgets::label_row(ui, "Password", LABEL_WIDTH, |ui| {
|
||||
let mut s = d.str("password");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).password(true).desired_width(180.0));
|
||||
if r.changed() {
|
||||
d.fields.insert("password".into(), json!(s));
|
||||
}
|
||||
if ui.memory(|m| m.focused().is_none()) {
|
||||
r.request_focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Page `page` (1-based) cropped as the dialog says, rendered once per file, page and box.
|
||||
fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog, page: usize) -> Option<egui::TextureHandle> {
|
||||
let file = app.ui.dialog_file.clone()?;
|
||||
let crop = crop(d);
|
||||
let key = egui::Id::new(("pdf-preview", file.token, page, crop.id()));
|
||||
if let Some(t) = ctx.data(|m| m.get_temp::<Option<egui::TextureHandle>>(key)) {
|
||||
return t;
|
||||
}
|
||||
let opts = fileio::LoadOptions { crop, password: password(d), ..Default::default() };
|
||||
// A page that can't be read shows an empty preview (and isn't read again).
|
||||
let tex = fileio::page_document(&file.bytes, page - 1, &opts).ok().and_then(|(doc, _)| {
|
||||
let r = doc.artboards.first()?.rect;
|
||||
let px = PREVIEW as f64 * ctx.pixels_per_point() as f64;
|
||||
Some(widgets::region_texture(ctx, &mut app.canvas.renderer, &format!("pdf-preview-{page}"), &doc, r, px))
|
||||
});
|
||||
ctx.data_mut(|m| m.insert_temp(key, tex.clone()));
|
||||
tex
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ pub mod flattener_presets;
|
|||
mod form;
|
||||
mod gradient_stop;
|
||||
pub mod graphic_style_options;
|
||||
pub mod import_pdf;
|
||||
pub mod new_color_group;
|
||||
mod new_document;
|
||||
pub mod new_swatch;
|
||||
|
|
@ -186,6 +187,7 @@ registry! {
|
|||
Place: [place::KIND] => place::SPEC,
|
||||
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions"] => png_options::SPEC,
|
||||
ExportAs: ["exportAs"] => export_as::SPEC,
|
||||
ImportPdf: [import_pdf::KIND] => import_pdf::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
@ -216,7 +218,10 @@ pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
|
|||
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
about::show(app, ctx);
|
||||
let Some(mut d) = app.ui.dialog.clone() else { return };
|
||||
let Some(mut d) = app.ui.dialog.clone() else {
|
||||
app.ui.dialog_file = None;
|
||||
return;
|
||||
};
|
||||
let spec = spec(&d.kind);
|
||||
if let Some(window) = spec.window {
|
||||
return window(app, ctx);
|
||||
|
|
@ -286,3 +291,5 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
#[cfg(test)]
|
||||
mod tests_import_pdf;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
//! The Import PDF dialog: opening and placing multi-page and password-protected PDFs.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
|
||||
use super::*;
|
||||
use crate::io;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// One headless frame of the dialog layer.
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, key: &str, v: serde_json::Value) {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v);
|
||||
}
|
||||
|
||||
fn field(app: &VectorcraftApp, key: &str) -> serde_json::Value {
|
||||
app.ui.dialog.as_ref().unwrap().fields[key].clone()
|
||||
}
|
||||
|
||||
/// Three pages, 100, 200 and 300 pt wide; the second has a 50 × 60 art box.
|
||||
fn three_pages() -> Vec<u8> {
|
||||
let mut second = PdfPage::new(200.0, 200.0, "0 0 1 rg 20 20 40 40 re f");
|
||||
second.art = Some([10.0, 10.0, 60.0, 70.0]);
|
||||
pdf(&[PdfPage::new(100.0, 100.0, "0 g 0 0 10 10 re f"), second, PdfPage::new(300.0, 100.0, "1 0 0 rg 0 0 30 30 re f")], None)
|
||||
}
|
||||
|
||||
fn artboard_widths(app: &VectorcraftApp) -> Vec<f64> {
|
||||
app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect.width()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_multi_page_pdf_asks_for_the_pages() {
|
||||
let mut app = app();
|
||||
io::open_bytes(&mut app, "three.pdf", &three_pages(), Some("/tmp/three.pdf".into())).unwrap();
|
||||
assert_eq!(app.session.documents().len(), 1, "nothing opened yet");
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.kind.as_str(), d.str("mode"), field(&app, "pageCount")), (import_pdf::KIND, "open".into(), json!(3)));
|
||||
assert_eq!(field(&app, "cropTo"), "crop");
|
||||
frame(&mut app);
|
||||
// Page navigation stays within the file.
|
||||
set(&mut app, "page", json!(9));
|
||||
frame(&mut app);
|
||||
assert_eq!(field(&app, "page"), 3);
|
||||
// A bad range keeps the dialog open with the message.
|
||||
set(&mut app, "allPages", json!(false));
|
||||
set(&mut app, "range", json!("2-7"));
|
||||
assert!(confirm(&mut app).unwrap_err().contains("1 to 3"));
|
||||
assert!(app.ui.dialog.is_some());
|
||||
set(&mut app, "range", json!("2-3"));
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(artboard_widths(&app), [200.0, 300.0]);
|
||||
assert_eq!(app.views.len(), 2, "views follow the documents");
|
||||
// The file is let go once no dialog is open.
|
||||
assert!(app.ui.dialog_file.is_some());
|
||||
frame(&mut app);
|
||||
assert!(app.ui.dialog_file.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_pages_and_a_crop_box() {
|
||||
let mut app = app();
|
||||
io::open_bytes(&mut app, "three.pdf", &three_pages(), None).unwrap();
|
||||
set(&mut app, "cropTo", json!("bounding"));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(artboard_widths(&app), [10.0, 40.0, 30.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_page_pdfs_open_directly() {
|
||||
let mut app = app();
|
||||
io::open_bytes(&mut app, "one.pdf", &pdf(&[PdfPage::new(80.0, 40.0, "")], None), None).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(artboard_widths(&app), [80.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_protected_pdf_asks_for_its_password() {
|
||||
let mut app = app();
|
||||
let bytes = pdf(&[PdfPage::new(120.0, 60.0, "1 0 0 rg 0 0 20 20 re f")], Some("pw"));
|
||||
io::open_bytes(&mut app, "locked.pdf", &bytes, None).unwrap();
|
||||
assert_eq!(field(&app, "__locked"), true);
|
||||
frame(&mut app);
|
||||
set(&mut app, "password", json!("nope"));
|
||||
assert!(confirm(&mut app).unwrap_err().contains("wrong"));
|
||||
assert_eq!(field(&app, "__locked"), true, "still locked");
|
||||
set(&mut app, "password", json!("pw"));
|
||||
// One page: unlocking opens it.
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(artboard_widths(&app), [120.0]);
|
||||
// Several pages: unlocking shows the pages first.
|
||||
let two = pdf(&[PdfPage::new(10.0, 10.0, ""), PdfPage::new(20.0, 10.0, "")], Some("pw"));
|
||||
io::open_bytes(&mut app, "two.pdf", &two, None).unwrap();
|
||||
set(&mut app, "password", json!("pw"));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!((field(&app, "__locked"), field(&app, "pageCount")), (json!(false), json!(2)));
|
||||
frame(&mut app);
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(artboard_widths(&app), [10.0, 20.0]);
|
||||
}
|
||||
|
||||
/// The placed object: its kind (clip group or not) and bounds.
|
||||
fn placed(app: &VectorcraftApp) -> (bool, vectorcraft_geom::Rect) {
|
||||
let st = app.session.active().unwrap();
|
||||
let n = st.doc.node(st.selection.objects[0]).unwrap();
|
||||
let NodeKind::Group { clip, .. } = n.kind else { panic!("{:?}", n.kind) };
|
||||
(clip, n.geometric_bounds().unwrap())
|
||||
}
|
||||
|
||||
fn place(app: &mut VectorcraftApp, bytes: &[u8], extra: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let mut p = json!({"name": "three.pdf", "dataBase64": vectorcraft_format::base64_encode(bytes)});
|
||||
if let (Some(o), serde_json::Value::Object(e)) = (p.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
crate::place::run(app, &p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placing_a_pdf_page_asks_for_the_page_and_crop_box() {
|
||||
let mut app = app();
|
||||
let bytes = three_pages();
|
||||
place(&mut app, &bytes, json!({"at": [100, 120], "link": false})).unwrap();
|
||||
assert_eq!((field(&app, "mode"), field(&app, "cropTo")), (json!("place"), json!("crop")));
|
||||
assert_eq!(field(&app, "__place"), json!({"at": [100, 120], "link": false}), "the other place params wait in the dialog");
|
||||
assert!(app.ui.dialog.as_ref().is_some_and(|d| (spec(&d.kind).heading)(d) == "Place PDF"));
|
||||
frame(&mut app);
|
||||
set(&mut app, "page", json!(2));
|
||||
set(&mut app, "cropTo", json!("art"));
|
||||
let clipboard = app.session.clipboard.len();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let (clip, b) = placed(&app);
|
||||
assert!(clip, "clipped to the art box");
|
||||
assert_eq!((b.width(), b.height()), (50.0, 60.0));
|
||||
assert_eq!(b.center(), vectorcraft_geom::Point::new(100.0, 120.0), "where `at` asked");
|
||||
assert_eq!(app.session.clipboard.len(), clipboard, "the clipboard is untouched");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// With a page, no dialog; Bounding Box places the art itself.
|
||||
place(&mut app, &bytes, json!({"page": 3, "crop": "bounding"})).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let (clip, b) = placed(&app);
|
||||
assert!(!clip);
|
||||
assert_eq!((b.width(), b.height()), (30.0, 30.0));
|
||||
assert!(place(&mut app, &bytes, json!({"page": 4})).is_err());
|
||||
// A one-page PDF places right away.
|
||||
place(&mut app, &pdf(&[PdfPage::new(80.0, 40.0, "0 g 0 0 10 10 re f")], None), json!({})).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(placed(&app).1.width(), 80.0);
|
||||
}
|
||||
|
|
@ -26,7 +26,16 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti
|
|||
let load = if swatches { crate::panels::swatches::load_library } else { crate::panels::graphic_styles::load_library };
|
||||
return load(app, p).map(|_| ());
|
||||
}
|
||||
let r = fileio::open_bytes(&mut app.session, name, bytes, path).map_err(|e| e.to_string())?;
|
||||
// A PDF with several pages or a password asks first (the Import PDF dialog).
|
||||
if crate::dialogs::import_pdf::offer(app, name, bytes, path.clone(), None) {
|
||||
return Ok(());
|
||||
}
|
||||
open_document(app, name, bytes, path, &Value::Null)
|
||||
}
|
||||
|
||||
/// Open a document through the engine loader with the `document.open` options in `p`.
|
||||
pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<(), String> {
|
||||
let r = fileio::open_bytes_with(&mut app.session, name, bytes, path, p).map_err(|e| e.to_string())?;
|
||||
app.sync_views();
|
||||
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
|
||||
app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default()));
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"file.place",
|
||||
"Place…",
|
||||
"Cmd+Shift+P",
|
||||
"{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view",
|
||||
"{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view (a PDF with several pages or a password and no page opens the Place PDF dialog first)",
|
||||
),
|
||||
("file.openRecent1", "Open Recent File 1", "", "{}"),
|
||||
("file.openRecent2", "Open Recent File 2", "", "{}"),
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
|||
open_dialog(app, files);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// A PDF with several pages or a password asks for the page, box and password first.
|
||||
if crate::dialogs::import_pdf::offer_place(app, p) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let mut p = p.clone();
|
||||
if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none())
|
||||
&& let Some(v) = app.view()
|
||||
|
|
|
|||
|
|
@ -266,6 +266,10 @@ pub struct UiState {
|
|||
/// File → Place: Link is on (the Place dialog remembers it).
|
||||
#[serde(default = "yes")]
|
||||
pub place_link: bool,
|
||||
/// The file the open dialog reads, kept out of its JSON fields (Import PDF); dropped when no
|
||||
/// dialog is open.
|
||||
#[serde(skip)]
|
||||
pub dialog_file: Option<std::sync::Arc<crate::dialogs::import_pdf::DialogFile>>,
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
|
|
@ -323,6 +327,7 @@ impl Default for UiState {
|
|||
color_guide_limit: String::new(),
|
||||
svg_options: Value::Null,
|
||||
place_link: true,
|
||||
dialog_file: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1247,3 +1247,19 @@ pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &st
|
|||
ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color })));
|
||||
resp.on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
/// `region` of `doc` rendered on white, its longest side `px` pixels, as a texture named `name`
|
||||
/// (artboard and page thumbnails).
|
||||
pub fn region_texture(
|
||||
ctx: &egui::Context,
|
||||
renderer: &mut vectorcraft_render::Renderer,
|
||||
name: &str,
|
||||
doc: &vectorcraft_doc::Document,
|
||||
region: vectorcraft_geom::Rect,
|
||||
px: f64,
|
||||
) -> egui::TextureHandle {
|
||||
let scale = px / region.width().max(region.height()).max(1.0);
|
||||
let img = renderer.render_region(doc, region, scale, true);
|
||||
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
ctx.load_texture(name, color, egui::TextureOptions::LINEAR)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
|
||||
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
|
||||
|
||||
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |
|
||||
|
||||
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
|
||||
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,
|
||||
`preview`), which previews on the canvas while open; `ui.dialog.confirm` applies it with `swatch.edit` as one undo
|
||||
|
|
|
|||
16
docs/mcp.md
16
docs/mcp.md
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue