mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 12:24:19 +00:00
Opening a .psd or .psb used to give one merged image. document.open now reads the layer records and builds the document from them. The PSD import options page (https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/photoshop-import-options.html) says the option that converts layers to objects keeps as much of the layer structure as it can, merges the layers of a clipping mask into one layer and discards hidden layers that require merging, and that Import Hidden Layers brings hidden layers in. document.open does the same, with hiddenLayers: true for the hidden layers; layers: false keeps the merged image. VectorCraft's own rules: - Each layer becomes a layer holding its pixels as an image, with its name, visibility, opacity and blend mode. A group becomes a layer of sublayers; a Pass Through group is not isolated. - Clipped layers show where the layer below has pixels. That layer's fill opacity fades only its own pixels. Layers clipped to an adjustment layer, or to a fill that stores no pixels and isn't drawn, show over the whole canvas. - A layer mask becomes the layer's opacity mask, its default color and invert flag applied; a disabled mask is kept, switched off. - Fill opacity multiplies the layer's opacity. - Layers and groups in Dissolve, Linear Burn, Linear Dodge, Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide, Darker Color and Lighter Color are merged with the layers below, whether or not they hold transparent pixels; Color Dodge, Color Burn and Difference keep their mode. A merged Dissolve layer's pixels are opaque or transparent, in a pattern of each layer's own. - A solid color fill layer that stores no pixels and has no vector mask comes in as its color (RGB, CMYK, Grayscale, Lab or HSB). - A layer name stored only as a Pascal string, without its Unicode name, is read as Mac Roman. - Adjustment layers, fill layers that aren't drawn, layer effects that are switched on, knockouts and vector masks are not applied; the warnings count them. - A merge whose canvases need more memory than allowed leaves the layer alone, in the Normal mode or unclipped, with a warning. This changes what opening a layered PSD does. Batch Trace Folder passes layers: false, so a PSD is still traced as its merged image. A file whose layers can't be read, or whose layers and merges need more memory than allowed, opens as the merged image with a warning. Lengths, counts, nesting, decoded pixels and the canvases merges make are bounded. Zipped 32-bit data with prediction is now read, in layers and in the merged image. import_fuzz gains a case that mutates the PSD and PSB samples and opens them with their layers. The PSD reader's new error messages have rows in every catalog. Docs: docs/mcp.md, docs/target-app-parity.md, docs/file-format-parity.md, README.
273 lines
13 KiB
Rust
273 lines
13 KiB
Rust
//! 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; one page for a PostScript file) for the Import PDF dialog.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{ColorMode, Document};
|
|
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
|
|
|
|
use super::super::colormgmt::Grays;
|
|
use super::super::{Result, Session, bad, check_numbers, number_or, parse_range, text_param};
|
|
use super::{err, source};
|
|
|
|
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text,
|
|
/// layers) and the DXF ones are ignored by other formats.
|
|
#[derive(Clone, Debug, 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>,
|
|
/// How that conversion separates RGB greys (`grays`).
|
|
pub grays: Grays,
|
|
/// What a PDF's text becomes.
|
|
pub text_as: TextAs,
|
|
/// A PDF's optional content groups become layers (else one layer per page, without the art
|
|
/// that is off), and a PSD or PSB file's layers become layers (else its merged image opens).
|
|
pub layers: bool,
|
|
/// A PSD or PSB file's hidden layers come in, hidden (`hiddenLayers`; else they are left out).
|
|
pub hidden_layers: bool,
|
|
/// How a DXF drawing comes in (the `dxf` param, see [`super::dxfimport`]).
|
|
pub dxf: vectorcraft_cad::ImportOptions,
|
|
/// An Illustrator EPS or `.ai` that carries the editing copy of its art opens from it (its
|
|
/// layers, hidden objects and the art outside its artboards); else only what its page or PDF
|
|
/// part prints comes in.
|
|
pub editing_data: bool,
|
|
/// A `.ai` opens from its editing copy even where that draws differently from its PDF part
|
|
/// (which can be older than it), with a warning, instead of from the PDF part.
|
|
pub trust_editing_data: bool,
|
|
/// The encoding names in a `.ai` or EPS's editing data are read in when they aren't UTF-8
|
|
/// (`legacyNameEncoding`).
|
|
pub legacy_names: vectorcraft_eps::NameEncoding,
|
|
/// A PDF or `.ai` saved with Preserve Editing opens from the VectorCraft document it carries;
|
|
/// off, it opens as another app would open it, from the editing copy of its art or its pages.
|
|
pub native: bool,
|
|
}
|
|
|
|
impl Default for LoadOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
pages: None,
|
|
crop: CropTo::default(),
|
|
password: None,
|
|
color_mode: None,
|
|
grays: Grays::default(),
|
|
text_as: TextAs::default(),
|
|
layers: true,
|
|
hidden_layers: false,
|
|
dxf: Default::default(),
|
|
editing_data: true,
|
|
trust_editing_data: false,
|
|
legacy_names: vectorcraft_eps::NameEncoding::default(),
|
|
native: true,
|
|
}
|
|
}
|
|
}
|
|
|
|
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`, `colorMode` (`rgb` | `cmyk`) with `grays`,
|
|
/// `textAs` (`text` | `outlines`), `layers` (true | false), `hiddenLayers` (true | false),
|
|
/// `editingData` (true | false | `always`),
|
|
/// `legacyNameEncoding` (one of [`vectorcraft_eps::NameEncoding::LABELS`]) and `dxf` (the DXF
|
|
/// import options).
|
|
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_to, crop_alias) = (text_param(p, "cropTo", cmd)?, text_param(p, "crop", cmd)?);
|
|
let crop = choice(cmd, "cropTo", crop_to.or(crop_alias))?;
|
|
let password = text_param(p, "password", cmd)?.filter(|s| !s.is_empty()).map(str::to_string);
|
|
let color_mode = match text_param(p, "colorMode", cmd)?.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}`"))),
|
|
};
|
|
let grays = Grays::param(cmd, p)?;
|
|
let text_as = choice(cmd, "textAs", text_param(p, "textAs", cmd)?)?;
|
|
let layers = match p.get("layers").filter(|v| !v.is_null()) {
|
|
None => true,
|
|
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
|
|
};
|
|
let hidden_layers = match p.get("hiddenLayers").filter(|v| !v.is_null()) {
|
|
None => false,
|
|
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "hiddenLayers must be true or false"))?,
|
|
};
|
|
let (editing_data, trust_editing_data) = match p.get("editingData").filter(|v| !v.is_null()) {
|
|
None => (true, false),
|
|
Some(Value::String(s)) if s.eq_ignore_ascii_case("always") => (true, true),
|
|
Some(v) => (v.as_bool().ok_or_else(|| bad(cmd, "editingData must be true, false or \"always\""))?, false),
|
|
};
|
|
let legacy_names = match p.get("legacyNameEncoding").filter(|v| !v.is_null()) {
|
|
None => vectorcraft_eps::NameEncoding::default(),
|
|
Some(v) => v
|
|
.as_str()
|
|
.and_then(vectorcraft_eps::NameEncoding::from_label)
|
|
.ok_or_else(|| bad(cmd, format!("legacyNameEncoding must be one of {}", vectorcraft_eps::NameEncoding::LABELS.join(", "))))?,
|
|
};
|
|
let dxf = super::dxfimport::options(cmd, p.get("dxf"))?;
|
|
Ok(Self {
|
|
pages,
|
|
crop,
|
|
password,
|
|
color_mode,
|
|
grays,
|
|
text_as,
|
|
layers,
|
|
hidden_layers,
|
|
dxf,
|
|
editing_data,
|
|
trust_editing_data,
|
|
legacy_names,
|
|
native: true,
|
|
})
|
|
}
|
|
|
|
/// Does the document read only part of the file, or read it differently (a page range, another
|
|
/// box, a password, without its hidden layers, without the VectorCraft document it carries)?
|
|
/// Writing it back would lose the rest, so Save doesn't (it asks for a name).
|
|
pub fn is_partial(&self) -> bool {
|
|
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers || !self.native
|
|
}
|
|
|
|
/// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible
|
|
/// `.ai`, or a PDF saved with its editing data, asked for its crop box opens on its trim boxes,
|
|
/// its editor's artboards, with the bleed round them (its crop box takes the bleed in).
|
|
fn import_options(&self, opened: &vectorcraft_pdf::Opened, editor_document: bool) -> Result<ImportOptions> {
|
|
let pages = match &self.pages {
|
|
None => None,
|
|
Some(range) => Some(parse_range(range, opened.page_count()).map_err(|e| err(format!("pages: {e}")))?),
|
|
};
|
|
let is_ai = self.crop == CropTo::Crop && opened.has_illustrator_data();
|
|
Ok(ImportOptions {
|
|
pages,
|
|
crop: if is_ai { CropTo::Trim } else { self.crop },
|
|
password: self.password.clone(),
|
|
text_as: self.text_as,
|
|
layers: self.layers,
|
|
uncalibrated_rgb: editor_document,
|
|
..Default::default()
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The option of `T` named `id` (any case), for param `key`; not given: the default.
|
|
fn choice<T: Choice + Default>(cmd: &str, key: &str, id: Option<&str>) -> Result<T> {
|
|
let Some(id) = id else { return Ok(T::default()) };
|
|
T::ALL
|
|
.iter()
|
|
.zip(T::IDS)
|
|
.find(|(_, i)| i.eq_ignore_ascii_case(id))
|
|
.map(|(t, _)| *t)
|
|
.ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", "))))
|
|
}
|
|
|
|
/// Import the pages `o` pick of `opened`, the PDF `bytes` already read
|
|
/// ([`vectorcraft_pdf::open_pdf`]) → the document, import notes and what else the import tells.
|
|
/// `editor_document`: a `.ai` file.
|
|
pub(super) fn import(
|
|
bytes: &[u8],
|
|
opened: &vectorcraft_pdf::Opened,
|
|
o: &LoadOptions,
|
|
editor_document: bool,
|
|
) -> Result<vectorcraft_pdf::ImportReport> {
|
|
let options = o.import_options(opened, editor_document)?;
|
|
vectorcraft_pdf::import_opened(bytes, opened, &options).map_err(err)
|
|
}
|
|
|
|
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
|
|
/// layer of what it shows, with the same text choice as Open, and the import notes.
|
|
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
|
|
vectorcraft_pdf::import_with_report(bytes, &page_options(page, o)).map(|r| (r.document, r.warnings)).map_err(err)
|
|
}
|
|
|
|
/// [`page_document`] of `opened`, the PDF `bytes` already read.
|
|
fn opened_page_document(bytes: &[u8], opened: &vectorcraft_pdf::Opened, page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
|
|
vectorcraft_pdf::import_opened(bytes, opened, &page_options(page, o)).map(|r| (r.document, r.warnings)).map_err(err)
|
|
}
|
|
|
|
/// The import options of [`page_document`].
|
|
fn page_options(page: usize, o: &LoadOptions) -> ImportOptions {
|
|
ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), text_as: o.text_as, layers: false, ..Default::default() }
|
|
}
|
|
|
|
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)?;
|
|
check_numbers(p, &["thumbnailSize"], INFO)?;
|
|
// Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them.
|
|
if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) {
|
|
return postscript_info(&bytes, p);
|
|
}
|
|
// The file is read once, for its pages and the thumbnail.
|
|
let opened = match vectorcraft_pdf::open_pdf(&bytes, o.password.as_deref()) {
|
|
Ok(opened) => opened,
|
|
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 info = opened.info();
|
|
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(page) = thumbnail_page(p, info.pages.len(), "PDF")? {
|
|
let (doc, _) = opened_page_document(&bytes, &opened, page, &o)?;
|
|
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The page (0-based) `document.pdfInfo`'s `thumbnail` names, from 1 to `count`; `None` when it is
|
|
/// absent or null. Any other value (0, a negative or fractional number, a string) is an error that
|
|
/// names the pages the `what` has.
|
|
fn thumbnail_page(p: &Value, count: usize, what: &str) -> Result<Option<usize>> {
|
|
let Some(v) = p.get("thumbnail").filter(|v| !v.is_null()) else { return Ok(None) };
|
|
let page = v.as_u64().and_then(|n| usize::try_from(n).ok()).and_then(|n| n.checked_sub(1)).filter(|i| *i < count);
|
|
page.map(Some).ok_or_else(|| bad(INFO, format!("thumbnail: no page {v} (the {what} has {count})")))
|
|
}
|
|
|
|
/// `document.pdfInfo` of a PostScript file (an EPS, a `.ps` file, a `.ai` in PostScript form): one
|
|
/// page, the page [`vectorcraft_eps::page`] reads, with no PDF boxes. Its thumbnail is the file as
|
|
/// the EPS reader reads it without any editing data it carries: what the page prints, or the
|
|
/// file's preview when its PostScript can't be read.
|
|
fn postscript_info(bytes: &[u8], p: &Value) -> Result<Value> {
|
|
let page = vectorcraft_eps::page(bytes).map_err(err)?;
|
|
let info = json!({"width": page.width(), "height": page.height(), "rotation": 0, "boxes": {}});
|
|
let mut out = json!({"pages": 1, "needsPassword": false, "pageInfo": [info]});
|
|
if thumbnail_page(p, 1, "file")?.is_some() {
|
|
let doc = vectorcraft_eps::import_with(bytes, false).map_err(err)?.document;
|
|
out["thumbnail"] = json!(thumbnail(&doc, p)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer
|
|
/// side, base64.
|
|
fn thumbnail(doc: &Document, p: &Value) -> Result<String> {
|
|
let size = number_or(p, "thumbnailSize", 160.0, INFO)?.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)}))?;
|
|
Ok(vectorcraft_format::base64_encode(&png))
|
|
}
|