mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 05:37:23 -06:00
PDF/.ai import: a .ai saved without PDF compatibility is placed and pasted from its editing data
Squashed from #2182 onto main.
This commit is contained in:
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load PDF into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64, page?: 1, password? (an encrypted PDF), editingData?: false (true: the data is a .ai or .ait file, read as document.open reads it; when document.open would use its editing data, every object of that data comes in, each layer's objects in stacking order and a hidden layer's objects hidden; else page `page`), center?: [x, y]} replace the clipboard with the objects of one PDF page, or of a .ai file's editing data (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
"{dataBase64, page?: 1, password? (an encrypted PDF), editingData?: false (true: the data is a .ai or .ait file, read as document.open reads it; when document.open would use its editing data, every object of that data comes in, each layer's objects in stacking order and a hidden layer's objects hidden; else page `page`), center?: [x, y]} replace the clipboard with the objects of one PDF page, or of a .ai file's editing data (with the images, patterns and swatches they use; a .ai saved without PDF compatibility: every object of its editing data, as document.open reads it, with or without editingData), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste",
|
||||
has_doc,
|
||||
import_pdf
|
||||
),
|
||||
@@ -510,7 +510,15 @@ fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return import_loaded(s, p, C, loaded);
|
||||
}
|
||||
let (mut doc, warnings) = fileio::page_document(&bytes, page - 1, &opts).map_err(|e| bad(C, e.to_string()))?;
|
||||
let (mut doc, warnings) = match fileio::page_document(&bytes, page - 1, &opts) {
|
||||
// For a .ai saved without PDF compatibility, the clipboard takes every object of its
|
||||
// editing data, as document.open reads it.
|
||||
Err(e) if fileio::is_placeholder(&e) => {
|
||||
let loaded = fileio::load_with("Clipboard.ai", &bytes, &opts).map_err(|e| bad(C, e.to_string()))?;
|
||||
return import_loaded(s, p, C, loaded);
|
||||
}
|
||||
read => read.map_err(|e| bad(C, e.to_string()))?,
|
||||
};
|
||||
doc.drop_edit_modes();
|
||||
let clip = Clipboard::from_document(&doc);
|
||||
if clip.is_empty() {
|
||||
|
||||
@@ -197,12 +197,38 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option<Result<(D
|
||||
)
|
||||
}
|
||||
|
||||
/// Is `e` the error of a file whose pages are only a placeholder
|
||||
/// ([`vectorcraft_pdf::PdfError::PlaceholderOnly`])?
|
||||
pub(crate) fn is_placeholder(e: &EngineError) -> bool {
|
||||
e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string()
|
||||
}
|
||||
|
||||
/// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one?
|
||||
fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool {
|
||||
let Some(range) = &opts.pages else { return true };
|
||||
vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len()))
|
||||
}
|
||||
|
||||
/// A page read from a PDF or `.ai` (`read`, as [`super::page_document`] reads it) → (document,
|
||||
/// notes, false); for a `.ai` saved without PDF compatibility (its page only a placeholder), the
|
||||
/// file `name` read as `document.open` reads it with `password`, from its editing copy alone →
|
||||
/// (document, notes, true).
|
||||
pub(crate) fn page_or_editing_data(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
password: Option<&str>,
|
||||
read: Result<(Document, Vec<String>)>,
|
||||
) -> Result<(Document, Vec<String>, bool)> {
|
||||
match read {
|
||||
Ok((doc, notes)) => Ok((doc, notes, false)),
|
||||
Err(e) if is_placeholder(&e) => {
|
||||
let l = load_with(name, bytes, &LoadOptions { password: password.map(str::to_string), ..Default::default() })?;
|
||||
Ok((l.doc, l.warnings, true))
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options).
|
||||
pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded> {
|
||||
let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) {
|
||||
@@ -256,7 +282,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
}
|
||||
// Saved without its PDF part: the editing copy is all the file has (`pages` that
|
||||
// name every page read it as the whole file).
|
||||
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
|
||||
Err(e) if is_placeholder(&e) => {
|
||||
let whole = LoadOptions { pages: None, ..opts.clone() };
|
||||
from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts })
|
||||
.unwrap_or(Err(e))
|
||||
|
||||
@@ -59,7 +59,9 @@ pub(crate) use export::{export_crop, isolated};
|
||||
pub(crate) use load::check_not_lossy_overwrite;
|
||||
use load::err;
|
||||
pub(crate) use load::import_svg;
|
||||
pub(crate) use load::is_placeholder;
|
||||
pub(crate) use load::native_file;
|
||||
pub(crate) use load::page_or_editing_data;
|
||||
pub(crate) use load::source;
|
||||
pub use load::{
|
||||
Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image,
|
||||
|
||||
@@ -346,6 +346,77 @@ fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() {
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
/// [`PLACEHOLDER`]'s `.ai` files as `dataBase64`: the sample's editing data, damaged editing data
|
||||
/// and none.
|
||||
fn placeholder_files() -> [String; 3] {
|
||||
[compressed(&ai::sample_data()), b"%AI12_CompressedData not zlib".to_vec(), vec![]]
|
||||
.map(|private| vectorcraft_format::base64_encode(&ai::ai(&private, PLACEHOLDER)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_saved_without_its_pdf_part_pastes_from_its_editing_data() {
|
||||
let [sample, damaged, plain] = placeholder_files();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// clipboard.importPdf (the PDF flavor's command) takes every object of both layers, as
|
||||
// document.open reads them.
|
||||
let r = s.execute("clipboard.importPdf", &json!({"dataBase64": sample})).unwrap();
|
||||
assert_eq!(r["count"], json!(7), "{r}");
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let art = s.doc().unwrap().doc.layers[0].children().unwrap().clone();
|
||||
assert_eq!(names(&art), ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""]);
|
||||
assert_eq!(art.iter().map(|n| n.visible).collect::<Vec<_>>(), [true, true, true, false, false, false, false], "the hidden layer's objects");
|
||||
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": damaged})).unwrap_err().to_string();
|
||||
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
|
||||
let e = s.execute("clipboard.importPdf", &json!({"dataBase64": plain})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() {
|
||||
let [sample, damaged, plain] = placeholder_files();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// The place takes the art of the layers that show on the artboard and clips it to the artboard.
|
||||
let r = s.execute("file.place", &json!({"name": "art.ai", "dataBase64": sample, "link": false})).unwrap();
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}");
|
||||
let placed = s.doc().unwrap().doc.layers[0].children().unwrap()[0].clone();
|
||||
let kids = placed.children().unwrap();
|
||||
assert_eq!(names(&kids[1..]), ["Pair", "Ring", "Window"], "after the clipping path");
|
||||
// With `crop: "bounding"` the size is the bounds of the art that shows. They run from the red
|
||||
// square's left edge to the window's right one; the pair's hidden square, stroked 2 pt wide,
|
||||
// is left out.
|
||||
let r = s.execute("file.place.info", &json!({"name": "art.ai", "dataBase64": sample, "crop": "bounding"})).unwrap();
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(170.0), Some(40.0)), "{r}");
|
||||
// The place takes the art of both layers that show.
|
||||
let square = |x: f64| format!("0 g\n{}f\n", ai::rect(x, 10.0, x + 40.0, 50.0));
|
||||
let two = ai::editing_data(200.0, 100.0, &[ai::layer("Back", &square(10.0)), ai::layer("Front", &square(110.0))].concat());
|
||||
let two = vectorcraft_format::base64_encode(&ai::ai(&compressed(two.as_bytes()), PLACEHOLDER));
|
||||
let r = s.execute("file.place.info", &json!({"name": "two.ai", "dataBase64": two, "crop": "bounding"})).unwrap();
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(140.0), Some(40.0)), "{r}");
|
||||
// The file has two 100-point artboards and one placeholder page. The second artboard lies 50
|
||||
// below the first. A square of 40 lies on the first and an oblong of 60 × 40 on the second.
|
||||
let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\
|
||||
%_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
|
||||
%_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\
|
||||
%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n";
|
||||
let art = format!("0 g\n{}f\n{}f\n", ai::rect(10.0, 10.0, 50.0, 50.0), ai::rect(10.0, -140.0, 70.0, -100.0));
|
||||
let paged = ai::editing_data(100.0, 100.0, &(boards.to_string() + &ai::layer("Art", &art)));
|
||||
let paged = vectorcraft_format::base64_encode(&ai::ai(&compressed(paged.as_bytes()), PLACEHOLDER));
|
||||
let mut size = |page: u64| {
|
||||
let r = s.execute("file.place.info", &json!({"name": "boards.ai", "dataBase64": paged, "page": page, "crop": "bounding"}));
|
||||
r.map(|r| (r["width"].as_f64(), r["height"].as_f64())).map_err(|e| e.to_string())
|
||||
};
|
||||
assert_eq!(size(1), Ok((Some(40.0), Some(40.0))));
|
||||
assert_eq!(size(2), Ok((Some(60.0), Some(40.0))));
|
||||
let third = size(3);
|
||||
assert!(third.as_ref().is_err_and(|e| e.contains("has 2 page(s)")), "{third:?}");
|
||||
let e = s.execute("file.place", &json!({"name": "damaged.ai", "dataBase64": damaged})).unwrap_err().to_string();
|
||||
assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}");
|
||||
let e = s.execute("file.place", &json!({"name": "plain.ai", "dataBase64": plain})).unwrap_err().to_string();
|
||||
assert!(e.contains("placeholder"), "{e}");
|
||||
}
|
||||
|
||||
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
|
||||
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), 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; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) 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 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), 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; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") 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
|
||||
),
|
||||
@@ -210,12 +210,28 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
|
||||
}
|
||||
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
|
||||
let (mut doc, warnings) = match format.id {
|
||||
let (mut doc, warnings, pdf) = match format.id {
|
||||
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's
|
||||
// bounds, below).
|
||||
// bounds, below). A .ai saved without PDF compatibility is read from its editing data, as
|
||||
// it opens, and placed as an EPS is: the art on artboard `page`.
|
||||
"pdf" | "ai" | "ait" if pdf => {
|
||||
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 mut read = fileio::page_document(&src.bytes, page - 1, &o);
|
||||
// Such a file can have more artboards than placeholder pages.
|
||||
if page > 1
|
||||
&& read.is_err()
|
||||
&& let Err(e) = fileio::page_document(&src.bytes, 0, &o)
|
||||
&& fileio::is_placeholder(&e)
|
||||
{
|
||||
read = Err(e);
|
||||
}
|
||||
let (mut doc, warnings, alone) =
|
||||
fileio::page_or_editing_data(src.name, &src.bytes, o.password.as_deref(), read).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
|
||||
if alone {
|
||||
// Linked images show their files, found from its folder.
|
||||
super::links::resolve(&mut doc, src.path, false);
|
||||
}
|
||||
(doc, warnings, !alone)
|
||||
}
|
||||
_ => {
|
||||
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
|
||||
@@ -233,7 +249,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
}
|
||||
// Linked images show their files, found from its folder.
|
||||
super::links::resolve(&mut l.doc, src.path, false);
|
||||
(l.doc, l.warnings)
|
||||
(l.doc, l.warnings, false)
|
||||
}
|
||||
};
|
||||
doc.drop_edit_modes();
|
||||
|
||||
Reference in New Issue
Block a user