Merge pull request #3496 from dotCooCoo/feat/place-links-pdf-and-ai

Place: Link keeps a placed PDF or .ai linked to its file
This commit is contained in:
BFlat
2026-10-22 22:09:29 +07:00
committed by GitHub
37 changed files with 766 additions and 146 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS};
pub use orient::OrientedBox;
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use perspective::PerspectiveAttachment;
pub use placed_document::PlacedDocument;
pub use placed_document::{PageBox, PlacedDocument};
pub use profiles::ColorProfiles;
pub use puppet::{PuppetPin, PuppetPins};
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
+2 -2
View File
@@ -523,8 +523,8 @@ pub enum NodeKind {
Mesh(GradientMesh),
/// Live Repeat (radial / grid / mirror) of source art.
Repeat(RepeatSpec),
/// A placed document: an artboard of another VectorCraft file, linked and locked (see
/// [`crate::placed_document`]).
/// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai`
/// file, linked and locked (see [`crate::placed_document`]).
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
/// Object › Intertwine: the objects keep their own stacking order, except where one of them
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
+59 -10
View File
@@ -1,4 +1,5 @@
//! Placed documents: another VectorCraft file placed linked ([`PlacedDocument`]).
//! Placed documents: another VectorCraft file, or a page of a PDF or `.ai` file, placed linked
//! ([`PlacedDocument`]).
//!
//! The object keeps the link to the file and the file's bytes, stored like image data in
//! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the
@@ -6,8 +7,9 @@
//! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting
//! expand it like a live object ([`crate::live::expanded`]).
//!
//! The file is read by the native format's loader, which lives above this crate and registers
//! itself ([`set_loader`]); the art read is kept in a small cache by content. Before exporting,
//! The file is read by the engine's loader (native documents, PDF and `.ai` pages), which lives
//! above this crate and registers itself ([`set_loader`]); the art read is kept in a small cache
//! by content. Before exporting,
//! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses
//! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy
//! of the document, so they never meet the user's or show in panels. Placed documents inside a
@@ -25,16 +27,21 @@ use vectorcraft_color::Paint;
use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol};
/// A placed document: an artboard of another VectorCraft file, shown as one locked object.
/// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` file,
/// shown as one locked object.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlacedDocument {
/// The file (its `page` is the artboard shown, 1-based).
/// The file (its `page` is the artboard or page shown, 1-based).
pub link: LinkInfo,
/// Key into [`Document::images`]: the file's bytes (see [`key_for`]).
pub key: String,
/// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub bounding: bool,
/// The box of a PDF or `.ai` page shown when not `bounding` (File › Place's `crop`); a
/// VectorCraft document shows its artboard whatever this says.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub page_box: PageBox,
/// The artboard's size (or the art's) at 100% (pt).
pub width: f64,
pub height: f64,
@@ -45,11 +52,43 @@ pub struct PlacedDocument {
pub placement: PlacementOptions,
}
/// The box of a PDF page a placed PDF or `.ai` file shows (File › Place's `crop`, other than
/// `bounding`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PageBox {
/// The visible page area.
#[default]
Crop,
Art,
Trim,
Bleed,
Media,
}
impl PageBox {
/// The `crop` param value.
pub fn id(self) -> &'static str {
match self {
PageBox::Crop => "crop",
PageBox::Art => "art",
PageBox::Trim => "trim",
PageBox::Bleed => "bleed",
PageBox::Media => "media",
}
}
}
/// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing
/// artboard `page` (`bounding`: its art's bounds): objects showing the same thing share a blob and
/// its preview.
pub fn key_for(hash: &str, page: u32, bounding: bool) -> String {
format!("{hash}-{page}{}", if bounding { "b" } else { "" })
/// artboard or page `page` (`bounding`: its art's bounds; else the page's box `page_box`):
/// objects showing the same thing share a blob and its preview.
pub fn key_for(hash: &str, page: u32, bounding: bool, page_box: PageBox) -> String {
let shown = match (bounding, page_box) {
(true, _) => "b",
(false, PageBox::Crop) => "",
(false, b) => b.id(),
};
format!("{hash}-{page}{shown}")
}
impl PlacedDocument {
@@ -576,6 +615,7 @@ mod tests {
link: LinkInfo::new("/art/logo.vectorcraft"),
key: key.into(),
bounding: false,
page_box: PageBox::Crop,
width: 80.0,
height: 40.0,
xf: Affine::translate((10.0, 10.0)),
@@ -616,9 +656,18 @@ mod tests {
let v = serde_json::to_value(&n).unwrap();
assert_eq!(v["kind"]["type"], "placeddocument");
assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft");
// The box of a PDF page is written only when it isn't the crop box.
assert!(v["kind"].get("page_box").is_none(), "{v}");
let back: Node = serde_json::from_value(v).unwrap();
assert_eq!(back, n);
assert_eq!(key_for("img00", 2, true), "img00-2b");
assert_eq!(key_for("img00", 2, true, PageBox::Crop), "img00-2b");
assert_eq!(key_for("img00", 2, false, PageBox::Crop), "img00-2");
assert_eq!(key_for("img00", 2, false, PageBox::Trim), "img00-2trim");
let mut trim = placed("doc1-1trim");
trim.page_box = PageBox::Trim;
let v = serde_json::to_value(&trim).unwrap();
assert_eq!(v["page_box"], "trim");
assert_eq!(serde_json::from_value::<PlacedDocument>(v).unwrap(), trim);
}
#[test]
+9
View File
@@ -101,6 +101,12 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String {
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
art_fonts(d, &[])
}
/// The fonts (family, style, version) of the type in the layers and symbols of `d` and in `art`
/// (art read from a file, its resources in `d`).
pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet<UsedFont> {
let mut fonts = BTreeSet::new();
let mut add = |n: &Node| {
if let NodeKind::Text(t) = &n.kind {
@@ -111,6 +117,9 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
for s in &d.symbols {
s.art.walk(&mut add);
}
for n in art {
n.walk(&mut add);
}
fonts
}
+33 -26
View File
@@ -17,16 +17,16 @@
//! (`links.embed`) and Unembed writes an embedded image to a file it then links to
//! (`links.unembed`).
//!
//! Placed documents ([`PlacedDocument`], VectorCraft documents placed with Link) link to their
//! files the same way: checking, updating, relinking and finding them on open work alike, and they
//! are listed. Embed (Break Link) turns one into an editable copy of its art.
//! Placed documents ([`PlacedDocument`], VectorCraft documents and PDF or `.ai` pages placed with
//! Link) link to their files the same way: checking, updating, relinking and finding them on open
//! work alike, and they are listed. Embed (Break Link) turns one into an editable copy of its art.
use std::collections::BTreeMap;
use std::path::{Component, Path};
use serde_json::{Value, json};
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacedDocument, PlacementOptions};
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_geom::{Affine, Rect, shapes};
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Relink",
[],
None,
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
has_doc,
relink
),
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Link Info",
[],
None,
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size",
"{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size",
has_doc,
info
),
@@ -148,21 +148,25 @@ impl Status {
}
/// The image objects that show one linked file as one content (same path, same image), or the
/// placed documents that show one artboard of it.
/// placed documents that show one artboard or page of it.
struct Group {
link: LinkInfo,
key: String,
/// The objects in the layers (images in symbols and patterns share the content, not an id).
ids: Vec<NodeId>,
/// Placed documents: whether the art's bounds (not the artboard) are shown.
document: Option<bool>,
/// Placed documents: what they show.
document: Option<Shown>,
}
/// The linked object `n` as (link, content key, placed document's bounding).
fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<bool>)> {
/// What a placed document shows: whether the art's bounds (not the artboard or page) are shown,
/// and a PDF page's box.
type Shown = (bool, PageBox);
/// The linked object `n` as (link, content key, what a placed document shows).
fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<Shown>)> {
match &n.kind {
NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)),
NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some(p.bounding))),
NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some((p.bounding, p.page_box)))),
_ => None,
}
}
@@ -177,7 +181,7 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
let mut out: Vec<Group> = vec![];
let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new();
let mut add = |link: &LinkInfo, key: &str, document: Option<bool>, id: Option<NodeId>| {
let mut add = |link: &LinkInfo, key: &str, document: Option<Shown>, id: Option<NodeId>| {
let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| {
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document });
out.len() - 1
@@ -199,7 +203,7 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
add(l, &im.key, None, None);
}
});
d.visit_placed(|_, p| add(&p.link, &p.key, Some(p.bounding), None));
d.visit_placed(|_, p| add(&p.link, &p.key, Some((p.bounding, p.page_box)), None));
}
out
}
@@ -355,16 +359,17 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
/// A linked file read for the objects that show it.
enum Read {
Image(FileRead),
/// A VectorCraft document read for placed documents: the link to it and what they show.
/// A VectorCraft document or a PDF or `.ai` file read for placed documents: the link to it and
/// what they show.
Document(LinkInfo, super::place::document::Source),
}
/// The file at `f` read for group `g`: its image, or the artboard `g` shows.
/// The file at `f` read for group `g`: its image, or the artboard or page `g` shows.
fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result<Read> {
let Some(bounding) = g.document else { return read_image(f, &g.link).map(Read::Image) };
let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link).map(Read::Image) };
let bytes = f.read()?;
let page = g.link.page.unwrap_or(1);
let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, cmd)?;
let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, page_box, cmd)?;
Ok(Read::Document(f.link(&bytes, &g.link), src))
}
@@ -834,13 +839,13 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let (mut copied, mut kept) = (vec![], vec![]);
for (ids, path) in files {
let bytes = read_file(&path)?;
// Images show the file; placed documents each show their artboard of it.
// Images show the file; placed documents each show their artboard or page of it.
let mut images = vec![];
let mut documents: Vec<((u32, bool), Vec<NodeId>)> = vec![];
let mut documents: Vec<((u32, bool, PageBox), Vec<NodeId>)> = vec![];
for id in ids {
match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::PlacedDocument(pl)) => {
let k = (pl.page(), pl.bounding);
let k = (pl.page(), pl.bounding, pl.page_box);
match documents.iter_mut().find(|(e, _)| *e == k) {
Some((_, ids)) => ids.push(id),
None => documents.push((k, vec![id])),
@@ -852,8 +857,8 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
// Read before it is copied: a file that can't be shown isn't copied.
let img = if images.is_empty() { None } else { Some(fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) };
let mut sources = vec![];
for ((page, bounding), ids) in documents {
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?));
for ((page, bounding, page_box), ids) in documents {
sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?));
}
let to = match &copy_to {
Some(dir) => {
@@ -997,10 +1002,12 @@ fn format_label(d: &Document, im: &ImageObject) -> &'static str {
.map_or("Image", |f| f.label)
}
/// A placed document's `links.list` fields: `format`, `document: true` and its artboard's size
/// (`pageWidth`, `pageHeight`, pt) instead of pixels.
/// A placed document's `links.list` fields: `format` (`VectorCraft`, or the PDF or `.ai` file's
/// format), `document: true` and its artboard's or page's size (`pageWidth`, `pageHeight`, pt)
/// instead of pixels.
fn document_row(p: &PlacedDocument) -> Value {
json!({ "format": "VectorCraft", "document": true, "pageWidth": p.width, "pageHeight": p.height })
let pdf = fileio::format_for_name(p.link.name()).filter(|f| matches!(f.id, "pdf" | "ai" | "ait"));
json!({ "format": pdf.map_or("VectorCraft", |f| f.label), "document": true, "pageWidth": p.width, "pageHeight": p.height })
}
/// Sort rank of a `links.list` status: missing, modified, ok, embedded.
+80 -12
View File
@@ -4,7 +4,7 @@
//! report). The packaged document links to the copies (Relink); the open document doesn't change.
//! Without a folder (the web, agents) the same files come back as a zip archive.
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::io::Write;
use std::path::{Path, PathBuf};
@@ -101,6 +101,23 @@ fn safe_asset_name(name: &str) -> String {
if name.is_empty() { "asset".into() } else { name.into() }
}
/// A file placed: (page, bounding, page box) of each placed object showing it.
type PlacedPages = Vec<(u32, bool, vectorcraft_doc::PageBox)>;
/// The fonts of the type on `pages` of the PDF or `.ai` file `name` (`bytes`, found at `path`);
/// none for other files.
fn page_fonts(name: &str, bytes: &[u8], path: &str, pages: &PlacedPages) -> BTreeSet<super::fonts::UsedFont> {
let mut fonts = BTreeSet::new();
if super::place::document::is_pdf(name, bytes) {
for &(page, bounding, page_box) in pages {
if let Ok(a) = super::place::document::document_art(name, bytes, Some(path), page, bounding, page_box, C) {
fonts.extend(super::fonts::art_fonts(&a.doc, &a.nodes));
}
}
}
fonts
}
/// Collect dependencies depth-first so nested VectorCraft files are rewritten before
/// their parents: each parent can record the final hash of the rewritten child.
/// Cycles, excessive nesting, and unreadable children are reported, not fatal.
@@ -148,7 +165,20 @@ impl<'a> Collector<'a> {
self.warnings.push(message);
}
fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize) -> Result<()> {
/// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the
/// copy of `source` finds it only by its name, beside it.
fn renamed(&mut self, lines: &mut Vec<String>, source: &str, path: &str, name: &str, copy: &str) {
if Path::new(copy).file_name().and_then(|n| n.to_str()) != Some(name) {
let message = format!(
"{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview"
);
self.warn(lines, message);
}
}
/// Collects the files `doc` links. `by_name`: `doc` is a placed `.ai` file's editing data,
/// whose links are not rewritten, so its copy finds them by name beside it.
fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize, by_name: bool) -> Result<()> {
// The caller checks cycles and depth before descending; active documents have
// a destination reserved so back-links can point at their single packaged copy.
self.visiting.insert(source.to_string(), destination.to_string());
@@ -167,11 +197,24 @@ impl<'a> Collector<'a> {
// Different linked documents can reuse the same stale absolute path yet
// resolve it to distinct local assets. Keep a mapping per document.
let mut local = BTreeMap::<String, Copied>::new();
// The fonts of the type on the PDF and `.ai` pages placed linked.
let mut placed_fonts = BTreeSet::new();
let mut placed = BTreeMap::<String, PlacedPages>::new();
doc.visit_placed(|_, p| {
let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box);
let pages = placed.entry(p.link.path.clone()).or_default();
if !pages.contains(&page) {
pages.push(page);
}
});
if self.opts.copy_fonts && !self.opts.copy_links && !placed.is_empty() {
for file in super::links::linked_files(doc, Some(source)) {
if let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) {
placed_fonts.extend(page_fonts(&file.name, bytes, file.found_path.as_deref().unwrap_or(&file.path), pages));
}
}
}
if self.opts.copy_links {
let mut placed = HashSet::new();
doc.visit_placed(|_, p| {
placed.insert(p.link.path.clone());
});
heading(&mut lines, "LINKED FILES");
for file in super::links::linked_files(doc, Some(source)) {
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
@@ -183,8 +226,15 @@ impl<'a> Collector<'a> {
local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None });
continue;
}
if let Some(existing) = self.copies.get(found_path) {
local.insert(file.path.clone(), existing.clone());
if let Some(existing) = self.copies.get(found_path).cloned() {
if by_name {
self.renamed(&mut lines, source, &file.path, &file.name, &existing.name);
}
// Another document may place other pages of a PDF copied already.
if let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) {
placed_fonts.extend(page_fonts(&file.name, bytes, found_path, pages));
}
local.insert(file.path.clone(), existing);
continue;
}
let Some(mut bytes) = file.bytes else {
@@ -199,7 +249,23 @@ impl<'a> Collector<'a> {
let name = safe_asset_name(&file.name);
let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken });
let destination_file = format!("{dir}{unique}");
if placed.contains(&file.path) {
if by_name {
self.renamed(&mut lines, source, &file.path, &file.name, &destination_file);
}
// A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file
// read from its editing data links are copied beside it.
let pages = placed.get(&file.path);
let placed_file = pages.is_some();
if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) {
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
} else {
self.collect(&mut art, found_path, &destination_file, depth + 1, true)?;
}
} else if let Some(pages) = pages {
placed_fonts.extend(page_fonts(&file.name, &bytes, found_path, pages));
}
if placed_file && super::place::document::is_native(&file.name, &bytes) {
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
} else {
@@ -211,7 +277,7 @@ impl<'a> Collector<'a> {
let include_linked =
saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy()));
let mut nested = saved.doc;
self.collect(&mut nested, found_path, &destination_file, depth + 1)?;
self.collect(&mut nested, found_path, &destination_file, depth + 1, false)?;
if self.opts.relink {
// Preserve auxiliary native-file content when rewriting.
let mut options = vectorcraft_format::SaveOptions::for_doc(&nested);
@@ -250,7 +316,9 @@ impl<'a> Collector<'a> {
if self.opts.copy_fonts {
let db = vectorcraft_text::FontDb::global();
heading(&mut lines, "FONTS");
for used in super::fonts::used_fonts(doc) {
let mut used_fonts = super::fonts::used_fonts(doc);
used_fonts.append(&mut placed_fonts);
for used in used_fonts {
let font = super::fonts::font_label(&used);
let (family, style, version) = used;
// Found by any of its names, as the canvas draws it (in the version the type names).
@@ -393,7 +461,7 @@ fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Res
doc = d;
}
let mut collector = Collector::new(&o, root.clone(), &doc_name);
collector.collect(&mut doc, &doc_path, &doc_name, 0)?;
collector.collect(&mut doc, &doc_path, &doc_name, 0, false)?;
let mut entries = std::mem::take(&mut collector.entries);
entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc)));
let mut taken = std::mem::take(&mut collector.taken);
+92 -22
View File
@@ -1,10 +1,11 @@
//! Placed documents (`file.place` of a VectorCraft document from a file, with `link`): one
//! locked object showing an artboard of the document, a [`PlacedDocument`].
//! Placed documents (`file.place` of a VectorCraft document, a PDF or a `.ai` file from a file,
//! with `link`): one locked object showing an artboard of the document or a page of the PDF, a
//! [`PlacedDocument`].
//!
//! The object keeps the file's bytes (in the document's images, like image data) and the link; the
//! art it shows is read from them the way placing the file without Link reads it
//! ([`document_art`]: the artboard's art, with the file's own linked images found), so Break Link
//! and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands
//! ([`document_art`]: the artboard's or page's art, with the file's own linked images found), so
//! Break Link and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands
//! ([`super::super::links`]) tell when the file changed and read it again.
//!
//! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept
@@ -19,8 +20,9 @@ use std::sync::Arc;
use vectorcraft_doc::links::hash_bytes;
use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for};
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PlacedDocument, PlacementOptions};
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PageBox, PlacedDocument, PlacementOptions};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::CropTo;
use super::super::fileio;
use super::super::*;
@@ -29,7 +31,7 @@ use super::super::*;
pub(crate) const MAX_FILE: usize = 256 << 20;
/// The format ids of VectorCraft documents.
const NATIVE: [&str; 2] = ["vectorcraft", "template"];
/// The type a placed file's bytes are stored with.
/// The type a placed VectorCraft document's bytes are stored with (a PDF's: its format's).
const MIME: &str = "application/json";
/// Is `name` with `bytes` a VectorCraft document?
@@ -37,7 +39,55 @@ pub(crate) fn is_native(name: &str, bytes: &[u8]) -> bool {
fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id))
}
/// An artboard of a VectorCraft document, read as art to place.
/// Is `name` with `bytes` a PDF, or a `.ai` or `.ait` file read as PDF pages (not PostScript)?
pub(crate) fn is_pdf(name: &str, bytes: &[u8]) -> bool {
fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) && !vectorcraft_pdf::is_postscript(bytes)
}
/// The editing data of the `.ai` file `name` (`bytes`) when its PDF part is only a placeholder:
/// the document its pages are read from ([`super::pdf_art`]), with the files it links. `None` for
/// other files.
pub(crate) fn editing_data(name: &str, bytes: &[u8]) -> Option<Document> {
if !is_pdf(name, bytes) {
return None;
}
let read = fileio::page_document(bytes, 0, &fileio::LoadOptions::default());
match fileio::page_or_editing_data(name, bytes, None, read) {
Ok((doc, _, true)) => Some(doc),
_ => None,
}
}
/// The type the bytes of placed file `name` (`bytes`) are stored with: a PDF's format's, else
/// [`MIME`].
fn mime(name: &str, bytes: &[u8]) -> &'static str {
fileio::detect(name, bytes).filter(|_| is_pdf(name, bytes)).map_or(MIME, |f| f.mime)
}
/// The box of a PDF page placed linked for File › Place's `crop` (Bounding Box: the crop box,
/// the art's bounds being [`PlacedDocument::bounding`]).
pub(crate) fn page_box(crop: CropTo) -> PageBox {
match crop {
CropTo::Art => PageBox::Art,
CropTo::Trim => PageBox::Trim,
CropTo::Bleed => PageBox::Bleed,
CropTo::Media => PageBox::Media,
CropTo::Crop | CropTo::Bounding => PageBox::Crop,
}
}
/// The `crop` a PDF page placed linked showing `b` is read with.
fn crop_to(b: PageBox) -> CropTo {
match b {
PageBox::Crop => CropTo::Crop,
PageBox::Art => CropTo::Art,
PageBox::Trim => CropTo::Trim,
PageBox::Bleed => CropTo::Bleed,
PageBox::Media => CropTo::Media,
}
}
/// An artboard of a VectorCraft document, or a page of a PDF or `.ai` file, read as art to place.
pub(crate) struct DocumentArt {
/// The document, its layers taken: the art's resources.
pub doc: Document,
@@ -51,15 +101,30 @@ pub(crate) struct DocumentArt {
}
/// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when
/// there is one, so its linked images are found), clipped to the artboard unless `bounding`.
pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<DocumentArt> {
/// there is one, so its linked images are found), clipped to the artboard unless `bounding`; or
/// page `page` of the PDF or `.ai` file `name`, clipped to its box `page_box` unless `bounding`
/// ([`super::pdf_art`]).
pub(crate) fn document_art(
name: &str,
bytes: &[u8],
path: Option<&str>,
page: u32,
bounding: bool,
page_box: PageBox,
cmd: &str,
) -> Result<DocumentArt> {
let file = fileio::file_name(name);
if !is_native(name, bytes) {
return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document")));
let pdf = is_pdf(name, bytes);
if !pdf && !is_native(name, bytes) {
return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file")));
}
if bytes.len() > MAX_FILE {
return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20)));
}
if pdf {
let o = fileio::LoadOptions { crop: crop_to(page_box), ..Default::default() };
return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd);
}
let mut l = fileio::load(name, bytes)?;
// Its linked images show their files; its placed documents stay as they are.
super::super::links::resolve(&mut l.doc, path, false);
@@ -87,7 +152,7 @@ fn group(nodes: Vec<Node>, clip: Option<Rect>) -> Node {
/// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents
/// ([`vectorcraft_doc::placed_document::set_loader`]).
pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> {
let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, "draw").ok()?;
let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, p.page_box, "draw").ok()?;
Some((a.doc, group(a.nodes, a.clip), a.frame))
}
@@ -101,32 +166,36 @@ pub(crate) fn read_file_again(p: &PlacedDocument) -> Option<Vec<u8>> {
Some(bytes)
}
/// A VectorCraft document read for placing linked.
/// A VectorCraft document, or a PDF or `.ai` file, read for placing linked.
#[derive(Clone)]
pub(crate) struct Source {
/// The file's bytes, with their preview.
pub blob: ImageBlob,
/// The blob's key ([`key_for`]).
pub key: String,
/// What is shown, in the file's coordinates: the artboard or the art's bounds.
/// The box of a PDF page shown (unless the art's bounds are).
pub page_box: PageBox,
/// What is shown, in the file's coordinates: the artboard, the page's box or the art's bounds.
pub frame: Rect,
pub warnings: Vec<String>,
}
/// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing
/// artboard `page` (its art's bounds when `bounding`).
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result<Source> {
/// artboard `page`, or the PDF or `.ai` file for showing page `page` clipped to its box
/// `page_box` (its art's bounds when `bounding`).
pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str) -> Result<Source> {
// The same document placed the same way is read once (placing it again is free).
let key = key_for(&hash_bytes(bytes), page, bounding);
let key = key_for(&hash_bytes(bytes), page, bounding, page_box);
if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() {
return Ok(s);
}
let a = document_art(name, bytes, path, page, bounding, cmd)?;
let mut blob = ImageBlob::new(MIME, bytes.to_vec());
let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?;
let mut blob = ImageBlob::new(mime(name, bytes), bytes.to_vec());
let shown = PlacedDocument {
link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) },
key: key.clone(),
bounding,
page_box,
width: a.frame.width(),
height: a.frame.height(),
xf: Affine::IDENTITY,
@@ -136,7 +205,7 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo
blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new);
// Drawing it needn't read it again.
vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame));
let s = Source { blob, key, frame: a.frame, warnings: a.warnings };
let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings };
let mut m = lock_memo();
if m.len() >= MEMO_SIZE {
m.remove(0);
@@ -203,6 +272,7 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool)
link,
key: s.key.clone(),
bounding,
page_box: s.page_box,
width: s.frame.width(),
height: s.frame.height(),
xf: Affine::translate((s.frame.x0, s.frame.y0)),
@@ -229,7 +299,7 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) {
let Some(blob) = d.images.get(&p.key) else { continue };
match vectorcraft_doc::placed_document::full_bytes(&p, blob) {
Some(bytes) => {
out.images.insert(p.key.clone(), ImageBlob { mime: MIME.into(), bytes, proxy: blob.proxy.clone() });
out.images.insert(p.key.clone(), ImageBlob { mime: mime(p.link.name(), &bytes).into(), bytes, proxy: blob.proxy.clone() });
}
None => {
previews.insert(p.key);
@@ -256,7 +326,7 @@ pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> {
let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?;
let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob)
.ok_or_else(|| bad(cmd, format!("{} can't be read, or changed since it was read: relink or update it first", p.link.path)))?;
let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, cmd)?;
let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, p.page_box, cmd)?;
let m = p.art_xf(a.frame).ok_or_else(|| bad(cmd, format!("object {} has no size", id.0)))?;
let mut nodes = a.nodes;
super::adopt::adopt(d, &a.doc, &mut nodes);
+99 -59
View File
@@ -13,7 +13,8 @@
//! preview ([`fileio::load`]).
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
//! (`crop: "bounding"`).
//! (`crop: "bounding"`). With Link, a PDF/.ai/.ait page placed from a file becomes a placed
//! document instead, as a VectorCraft document does (below).
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
//! [`text`]).
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
@@ -38,7 +39,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling, SymbolBoxes};
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, Scaling, SymbolBoxes};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_pdf::CropTo;
@@ -62,7 +63,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), 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",
"{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; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; 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
),
@@ -104,8 +105,8 @@ enum Art {
/// A text file's, an RTF file's or a Word document's text, placed as area type filling the
/// natural box.
Text(text::Story),
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
/// bounds (not the artboard) are shown.
/// A VectorCraft document or a PDF/.ai page placed linked: the file read, the link, and
/// whether the art's bounds (not the artboard or page) are shown.
Document(document::Source, LinkInfo, bool),
Vector {
/// The loaded document: the source of the art's resources.
@@ -124,7 +125,7 @@ struct Loaded {
/// The art's natural box (what `at` centres and `rect` fits).
natural: Rect,
warnings: Vec<String>,
/// The link to a raster image's file (for Link), when read from a path.
/// The link to a raster image's file or a placed document's (for Link), when read from a path.
link: Option<LinkInfo>,
/// The artboard a DXF drawing fitted to an artboard, or not centred, was laid out on: it lands
/// on the target artboard the same way.
@@ -138,8 +139,9 @@ pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
}
/// The file `p` names, read into placeable art; a DXF drawing's Fit to Artboard fills `board`
/// (unless the params name their own box).
fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
/// (unless the params name their own box). A PDF/.ai page read from a path is placed linked with
/// Link when `link_pdf` (false for the queries, which read it embedded).
fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loaded> {
let src = fileio::source(p, cmd)?;
let name = fileio::file_name(src.name);
// An Affinity file is recognised by its signature, whatever its name says.
@@ -192,12 +194,12 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
if matches!(format.id, "vectorcraft" | "template") {
let page = page as u32;
if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) {
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, cmd)?;
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, PageBox::Crop, cmd)?;
let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) };
let (natural, warnings) = (s.frame, s.warnings.clone());
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
}
let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, cmd)?;
let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, PageBox::Crop, cmd)?;
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
}
@@ -209,48 +211,39 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
let warnings = fileio::too_large_note(&img.blob.bytes).into_iter().collect();
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None });
}
if pdf {
// Linked: placed like a VectorCraft document, read again with the page's box only. A page
// asked for with a password or with its text as outlines is embedded.
let linked = link_pdf && bool_or(p, "link", true) && opts.password.is_none() && opts.text_as == vectorcraft_pdf::TextAs::Text;
if let Some(path) = src.path.filter(|_| linked) {
let page = page as u32;
let s = document::source(src.name, &src.bytes, Some(path), page, !crop, document::page_box(opts.crop), cmd)?;
let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) };
let (natural, warnings) = (s.frame, s.warnings.clone());
return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None });
}
let a = pdf_art(src.name, &src.bytes, src.path, page, opts, !crop, cmd)?;
let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip };
return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None });
}
let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center);
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). 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 };
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).
let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
// An Affinity file whose native document couldn't be read is only its preview: it
// must never silently stand in for the art.
if l.preview_only {
return Err(bad(
cmd,
format!(
"{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing"
),
));
}
// Linked images show their files, found from its folder.
super::links::resolve(&mut l.doc, src.path, false);
(l.doc, l.warnings, false)
let (mut doc, warnings) = {
// Of the open options, only a DXF drawing's apply (the colour mode stays the file's).
let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() };
let mut l = fileio::load_with(src.name, &src.bytes, &o)?;
// An Affinity file whose native document couldn't be read is only its preview: it
// must never silently stand in for the art.
if l.preview_only {
return Err(bad(
cmd,
format!(
"{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing"
),
));
}
// Linked images show their files, found from its folder.
super::links::resolve(&mut l.doc, src.path, false);
(l.doc, l.warnings)
};
doc.drop_edit_modes();
let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored);
@@ -264,19 +257,64 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
let frame = doc.artboards.first().map(|a| a.rect).filter(|_| framed);
(art_of(layer_art(&layers)), frame)
} 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(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
// A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard.
let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] };
(on_artboard(&symbols, pages, board), crop.then_some(board))
let board = doc.artboards.get(page - 1).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
// An EPS file's art is whatever lies on the artboard.
(on_artboard(&symbols, &layers, board), crop.then_some(board))
};
let natural = clip.or_else(|| shown_bounds(&symbols, &nodes));
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board })
}
/// Page `page` (1-based) of the PDF/.ai/.ait file `src` (`bytes`, read from `path` when there is
/// one) as art to place: clipped to the box `opts.crop` names, or (`bounding`) the bounds of its
/// art; read with `opts.password`. Only the page placed is read. 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`, its linked images found from its folder.
pub(crate) fn pdf_art(
src: &str,
bytes: &[u8],
path: Option<&str>,
page: usize,
opts: fileio::LoadOptions,
bounding: bool,
cmd: &str,
) -> Result<document::DocumentArt> {
let name = fileio::file_name(src);
let index = page.checked_sub(1).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))?;
let o = fileio::LoadOptions { crop: if bounding { CropTo::Crop } else { opts.crop }, ..opts };
let mut read = fileio::page_document(bytes, index, &o);
// Such a file can have more artboards than placeholder pages.
if page > 1
&& read.is_err()
&& let Err(e) = fileio::page_document(bytes, 0, &o)
&& fileio::is_placeholder(&e)
{
read = Err(e);
}
let (mut doc, warnings, alone) =
fileio::page_or_editing_data(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, path, false);
}
doc.drop_edit_modes();
let layers = std::mem::take(&mut doc.layers);
let symbols = doc.symbol_boxes();
// The page placed is a PDF import's only one, with one layer; the editing data's art is
// whatever lies on the artboard.
let index = if alone { index } else { 0 };
let n = doc.artboards.len();
let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?;
let pages = if alone { &layers[..] } else { layers.get(index..=index).unwrap_or_default() };
let nodes = on_artboard(&symbols, pages, board);
let clip = (!bounding).then_some(board);
let frame = clip.or_else(|| shown_bounds(&symbols, &nodes));
let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
Ok(document::DocumentArt { doc, nodes, clip, frame, warnings })
}
/// The bounds of what `nodes` draw, as an export measures art
/// ([`vectorcraft_render::encode::drawn_bounds`]): hidden objects, guides and template layers
/// left out, symbol instances measured by their art in `symbols`.
@@ -435,7 +473,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
(true, _) => return Err(bad(PLACE, "replace: select the one object to replace")),
};
let target = board_at(&st.doc, at);
let mut loaded = load(p, PLACE, target)?;
let mut loaded = load(p, PLACE, target, true)?;
let old_node = old.and_then(|id| st.doc.node(id));
// Type reflows rather than scales: its frame takes the size it is placed at.
let is_text = matches!(loaded.art, Art::Text(_));
@@ -548,7 +586,8 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
q[k] = v.clone();
}
}
match load(&q, QUEUE, None) {
// Read for its size and thumbnail: each click places it with file.place.
match load(&q, QUEUE, None, false) {
Ok(l) => {
let (w, h) = (l.natural.width(), l.natural.height());
let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h });
@@ -606,7 +645,8 @@ fn thumbnail_png(l: Loaded, px: f64) -> Result<String> {
}
fn info(_: &mut Session, p: &Value) -> Result<Value> {
let mut l = load(p, "file.place.info", None)?;
// A PDF/.ai page linked or not has the same size and art.
let mut l = load(p, "file.place.info", None, false)?;
let natural = l.natural;
let mut out = json!({
"name": l.name,
+2 -2
View File
@@ -448,7 +448,7 @@ fn a_placed_document_brings_its_links_with_the_files_pixels() {
/// The placed (linked) files of a `.ai` file come in with the preview the file keeps of them: one
/// not found shows it, one found is read at once into the same bounds, which isn't a change. A copy
/// saved without PDF compatibility, placed, reads the found file too.
/// saved without PDF compatibility, placed without Link, reads the found file too.
#[test]
fn links_of_an_imported_file_are_read_where_they_are_found() {
use vectorcraft_testkit::ai;
@@ -487,7 +487,7 @@ fn links_of_an_imported_file_are_read_where_they_are_found() {
let alone = dir.file("alone.ai");
write(&alone, &ai::ai(data.as_bytes(), "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET"));
let mut s = session();
s.execute("file.place", &json!({"path": alone})).unwrap();
s.execute("file.place", &json!({"path": alone, "link": false})).unwrap();
let (_, im) = placed(&s);
assert!((im.width, im.height) == (600, 300) && !s.doc().unwrap().doc.images[&im.key].is_proxy(), "{im:?}");
}
+134
View File
@@ -402,6 +402,140 @@ fn zipped_package_recursively_relinks_without_absolute_creator_paths() {
verify_portable_nested_package(&dir.0.join("extracted/poster Folder"));
}
#[test]
fn package_copies_a_linked_pdf_page_as_it_is() {
let dir = Folder::new("package-placed-pdf");
let mut d = vectorcraft_doc::Document::new(100.0, 50.0);
let (layer, id) = (d.layers[0].id, d.alloc_id());
let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(RED[0], RED[1], RED[2]));
let look = vectorcraft_doc::Appearance::basic(red, vectorcraft_color::Paint::None, 0.0);
let page = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 50.0));
d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, page, look)).unwrap();
let pdf = cmd::fileio::encode_all(&d, "pdf", &json!({"preserveEditing": false})).unwrap().files.remove(0).1;
let src = dir.file("art/flyer.pdf");
write(&src, &pdf);
let mut s = session();
let placed = run(&mut s, "file.place", json!({"path": src}));
assert_eq!(placed["linked"], true, "{placed}");
save(&mut s, &dir.file("poster.vectorcraft"));
let r = run(&mut s, "file.package", json!({"folder": dir.file("out")}));
assert_eq!((r["links"].as_u64(), r["missingLinks"].clone(), r["warnings"].clone()), (Some(1), json!([]), json!([])), "{r}");
let root = dir.0.join("out/poster Folder");
assert_eq!(std::fs::read(root.join("Links/flyer.pdf")).unwrap(), pdf, "copied as it is");
// The packaged document shows the copy.
std::fs::remove_dir_all(dir.0.join("art")).unwrap();
let opened = open(&mut s, &root.join("poster.vectorcraft").to_string_lossy());
assert_eq!(opened["missingLinks"], json!([]), "{opened}");
assert!(s.doc().unwrap().doc.has_placed());
}
#[test]
fn package_copies_the_fonts_of_the_type_on_a_linked_pdf_page() {
let dir = Folder::new("package-placed-pdf-type");
let mut src = session();
run(&mut src, "text.create", json!({"x": 10, "y": 40, "text": "Flyer"}));
let pdf = run(&mut src, "document.serialize", json!({"format": "pdf", "preserveEditing": false}));
let path = dir.file("art/flyer.pdf");
write(&path, &vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap());
for (link, copy_links, out) in [(false, true, "embedded"), (true, true, "linked"), (true, false, "fonts only")] {
let mut s = session();
let placed = run(&mut s, "file.place", json!({"path": path, "link": link}));
assert_eq!(placed["linked"], link, "{placed}");
save(&mut s, &dir.file("poster.vectorcraft"));
let r = run(&mut s, "file.package", json!({"folder": dir.file(out), "copyLinks": copy_links}));
assert_eq!((r["fonts"].as_u64(), r["skippedFonts"].clone()), (Some(1), json!([])), "{out}: {r}");
assert!(dir.0.join(out).join("poster Folder/Fonts/SourceSans3-Regular.ttf").is_file(), "{out}");
}
}
#[test]
fn package_copies_the_fonts_of_each_page_of_a_pdf_that_two_documents_place() {
let dir = Folder::new("package-placed-pdf-pages");
let mut src = session();
run(&mut src, "text.create", json!({"x": 10, "y": 40, "text": "One"}));
run(&mut src, "artboard.new", json!({"x": 500, "y": 0, "width": 400, "height": 300}));
run(&mut src, "text.create", json!({"x": 510, "y": 40, "text": "Two", "font": "SourceSans3-Semibold"}));
let pdf = run(&mut src, "document.serialize", json!({"format": "pdf", "preserveEditing": false}));
let path = dir.file("art/flyer.pdf");
write(&path, &vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap());
// The poster places page 1, and a document it places shows page 2.
let mut part = session();
run(&mut part, "file.place", json!({"path": path, "page": 2}));
let part_path = dir.file("art/part.vectorcraft");
save(&mut part, &part_path);
let mut s = session();
run(&mut s, "file.place", json!({"path": path, "page": 1}));
run(&mut s, "file.place", json!({"path": part_path}));
save(&mut s, &dir.file("poster.vectorcraft"));
let r = run(&mut s, "file.package", json!({}));
assert_eq!((r["links"].as_u64(), r["fonts"].as_u64(), r["skippedFonts"].clone()), (Some(2), Some(2), json!([])), "{r}");
}
/// `dir/art/badge.ai`, saved without its PDF part, its art placing each of `pics` (600 × 300 px,
/// with a 2×1 preview) → its path.
fn ai_placing(dir: &Folder, pics: &[&str]) -> String {
use vectorcraft_testkit::ai;
let place = |pic: &str| {
let pic = pic.replace('\\', "/");
format!(
"%AI5_BeginPlace\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n({pic})`\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 \x7f\x10\x10\x60\x7f\x10\x10\x60\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n"
)
};
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &pics.iter().map(|p| place(p)).collect::<String>()));
let src = dir.file("art/badge.ai");
write(&src, &ai::ai(data.as_bytes(), "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET"));
src
}
#[test]
fn package_copies_the_files_a_linked_ai_files_editing_data_places_beside_it() {
let dir = Folder::new("package-placed-ai");
let pic = dir.file("art/photo.png");
write(&pic, &png(600, 300, RED));
let src = ai_placing(&dir, &[&pic]);
let mut s = session();
let placed = run(&mut s, "file.place", json!({"path": src}));
assert_eq!(placed["linked"], true, "{placed}");
save(&mut s, &dir.file("poster.vectorcraft"));
let r = run(&mut s, "file.package", json!({"folder": dir.file("out")}));
assert_eq!((r["links"].as_u64(), r["missingLinks"].clone(), r["warnings"].clone()), (Some(2), json!([]), json!([])), "{r}");
let root = dir.0.join("out/poster Folder");
assert_eq!(std::fs::read(root.join("Links/photo.png")).unwrap(), png(600, 300, RED));
// The copy of the .ai finds the picture beside it, not its preview.
std::fs::remove_dir_all(dir.0.join("art")).unwrap();
let copy = root.join("Links/badge.ai").to_string_lossy().into_owned();
let shows_the_file = || {
let bytes = std::fs::read(&copy).unwrap();
let a = cmd::place::document::document_art("badge.ai", &bytes, Some(&copy), 1, false, vectorcraft_doc::PageBox::Crop, "t").unwrap();
a.doc.images.values().any(|b| !b.is_proxy())
};
assert!(shows_the_file());
std::fs::remove_file(root.join("Links/photo.png")).unwrap();
assert!(!shows_the_file(), "the preview only");
}
#[test]
fn package_warns_when_a_file_a_linked_ai_file_places_is_copied_under_another_name() {
let dir = Folder::new("package-placed-ai-names");
let (one, two) = (dir.file("art/a/photo.png"), dir.file("art/b/photo.png"));
write(&one, &png(600, 300, RED));
write(&two, &png(600, 300, BLUE));
let src = ai_placing(&dir, &[&one, &two]);
let mut s = session();
run(&mut s, "file.place", json!({"path": src}));
save(&mut s, &dir.file("poster.vectorcraft"));
let r = run(&mut s, "file.package", json!({"folder": dir.file("out")}));
let warnings = r["warnings"].as_array().unwrap();
assert_eq!(r["links"].as_u64(), Some(3), "{r}");
assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("looks for it as photo.png and shows the file of that name"), "{r}");
// Moved, the copy of the .ai shows Links/photo.png for both, as the warning says.
std::fs::remove_dir_all(dir.0.join("art")).unwrap();
let copy = dir.0.join("out/poster Folder/Links/badge.ai").to_string_lossy().into_owned();
let bytes = std::fs::read(&copy).unwrap();
let a = cmd::place::document::document_art("badge.ai", &bytes, Some(&copy), 1, false, vectorcraft_doc::PageBox::Crop, "t").unwrap();
assert_eq!(a.doc.images.values().filter(|b| !b.is_proxy()).count(), 1);
}
#[test]
fn package_keeps_distinct_linked_documents_with_colliding_file_names() {
let dir = Folder::new("package-collisions");
+167 -1
View File
@@ -1,6 +1,7 @@
//! Placed documents: a VectorCraft document placed linked (`file.place`'s default for one read
//! from a file), drawn and output as vectors, saved with its preview, checked, updated and
//! relinked when the file changes, and turned into an editable copy (Break Link, Expand).
//! relinked when the file changes, and turned into an editable copy (Break Link, Expand). A PDF
//! or `.ai` page places the same way.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
@@ -437,6 +438,171 @@ fn relink_points_a_placed_document_at_another_file() {
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
}
/// A file made from scratch at `path` in `format` (`pdf`, or `ai`: a PDF-compatible .ai): one
/// 100×50 page per color of `pages`, filled with it (`extra` more rectangles on each change the
/// file's size).
fn pdf_source(path: &str, format: &str, pages: &[[u8; 3]], extra: usize) {
let mut d = Document::new(100.0, 50.0);
let layer = d.layers[0].id;
for (i, rgb) in pages.iter().enumerate() {
let x = i as f64 * 150.0;
let page = Rect::new(x, 0.0, x + 100.0, 50.0);
if i > 0 {
let mut b = d.artboards[0].clone();
(b.id, b.rect) = (i as u32 + 1, page);
d.artboards.push(b);
}
for _ in 0..=extra {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(page), look(*rgb))).unwrap();
}
}
let p = if format == "ai" { json!({"pdfCompatible": true}) } else { json!({"range": "all", "preserveEditing": false}) };
write(path, &cmd::fileio::encode_all(&d, format, &p).unwrap().files[0].1);
}
#[test]
fn a_pdf_page_placed_without_link_is_embedded() {
let dir = Folder::new("placed-pdf-embedded");
let src = dir.file("flyer.pdf");
pdf_source(&src, "pdf", &[RED], 0);
let mut s = session();
let r = s.execute("file.place", &json!({"path": src, "link": false, "at": [200, 150]})).unwrap();
assert_eq!(r["linked"], false, "{r}");
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
assert_eq!(n.kind_label(), "Clip Group");
assert!(!s.doc().unwrap().doc.has_placed());
// Text asked for as outlines is embedded with Link too.
let r = s.execute("file.place", &json!({"path": src, "textAs": "outlines"})).unwrap();
assert_eq!(r["linked"], false, "{r}");
assert!(!s.doc().unwrap().doc.has_placed());
}
#[test]
fn with_link_a_pdf_page_places_linked() {
let dir = Folder::new("placed-pdf-linked");
let src = dir.file("flyer.pdf");
pdf_source(&src, "pdf", &[RED], 0);
let mut s = session();
let id = place(&mut s, &src);
let p = placed(&s, id);
assert_eq!((p.link.path.as_str(), p.link.page, p.bounding), (src.as_str(), Some(1), false), "{p:?}");
assert!((p.width - 100.0).abs() < 1e-6 && (p.height - 50.0).abs() < 1e-6, "{p:?}");
assert_eq!(bounds(&s, id), Rect::new(150.0, 125.0, 250.0, 175.0));
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
let row = &s.execute("links.list", &json!({})).unwrap()["links"][0];
assert_eq!((row["format"].as_str(), row["document"].as_bool(), row["status"].as_str()), (Some("PDF"), Some(true), Some("ok")), "{row}");
assert_eq!(s.doc().unwrap().doc.images[&p.key].mime, "application/pdf");
// Break Link gives the group placing it without Link gives.
let r = s.execute("links.embed", &json!({"ids": [id.0]})).unwrap();
assert_eq!(r["embedded"], json!([id.0]), "{r}");
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().kind_label(), "Clip Group");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
#[test]
fn a_changed_pdf_is_modified_and_update_links_reads_it_again() {
let dir = Folder::new("placed-pdf-update");
let src = dir.file("flyer.pdf");
pdf_source(&src, "pdf", &[RED], 0);
let mut s = session();
let id = place(&mut s, &src);
pdf_source(&src, "pdf", &[BLUE], 1);
assert_eq!(s.execute("links.check", &json!({})).unwrap()["links"][0]["status"], "modified");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "shows the page as it read it");
let u = s.execute("links.update", &json!({})).unwrap();
assert_eq!(u["updated"], json!([id.0]), "{u}");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 0);
// Relinked to another PDF, it shows that file's page.
let other = dir.file("other.pdf");
pdf_source(&other, "pdf", &[RED], 2);
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap();
assert_eq!(r["relinked"], json!([id.0]), "{r}");
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
// A file it can't show is refused.
let notes = dir.file("notes.txt");
write(&notes, b"not a page");
let e = s.execute("links.relink", &json!({"ids": [id.0], "path": notes})).unwrap_err().to_string();
assert!(e.contains("isn't a VectorCraft document, a PDF or a PDF-compatible .ai file"), "{e}");
assert_eq!(placed(&s, id).link.path, other);
}
#[test]
fn a_linked_pdf_page_outputs_as_vectors_after_the_document_reopens() {
let dir = Folder::new("placed-pdf-outputs");
let src = dir.file("flyer.pdf");
pdf_source(&src, "pdf", &[RED], 3);
let mut s = session();
let id = place(&mut s, &src);
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
open(&mut s, &doc_path);
let key = placed(&s, id).key;
assert!(s.doc().unwrap().doc.images[&key].is_proxy(), "opened with the preview only");
// Output reads the file again: the page's paths, not the preview.
let pdf = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap();
assert_eq!(pdf["warnings"], json!([]), "{}", pdf["warnings"]);
let (images, paths) = pdf_contents(&decode(&pdf));
assert_eq!(images, 0, "no image in the PDF");
assert!(paths > 0);
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
assert!(!svg["text"].as_str().unwrap().contains("<image"), "vectors");
// Include Linked Files keeps the PDF itself.
let full = file_json(&decode(&s.execute("document.serialize", &json!({"format": "vectorcraft", "includeLinked": true})).unwrap()));
assert_eq!((full["images"][&key]["proxy"].clone(), full["images"][&key]["mime"].as_str()), (Value::Null, Some("application/pdf")));
}
#[test]
fn saving_and_reopening_keeps_a_linked_pdf_pages_number_and_box() {
let dir = Folder::new("placed-pdf-reopen");
let src = dir.file("flyer.pdf");
pdf_source(&src, "pdf", &[RED, BLUE], 4);
let mut s = session();
let r = s.execute("file.place", &json!({"path": src, "page": 2, "crop": "media", "at": [200, 150]})).unwrap();
assert_eq!(r["linked"], true, "{r}");
let id = NodeId(r["ids"][0].as_u64().unwrap());
let before = placed(&s, id);
assert_eq!((before.link.page, before.page_box), (Some(2), vectorcraft_doc::PageBox::Media), "{before:?}");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE), "page 2");
let doc_path = dir.file("poster.vectorcraft");
save(&mut s, &doc_path);
let saved = file_json(&std::fs::read(&doc_path).unwrap());
let kind = &saved["document"]["layers"][0]["kind"]["children"][0]["kind"];
assert_eq!((kind["type"].as_str(), kind["page_box"].as_str(), kind["link"]["page"].as_u64()), (Some("placeddocument"), Some("media"), Some(2)));
let r = open(&mut s, &doc_path);
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
let mut reopened = placed(&s, id);
assert_eq!(reopened.link.relative.take().as_deref(), Some("flyer.pdf"), "written on save");
assert_eq!(reopened, before);
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
// Read again from the file, it shows the same page and box.
pdf_source(&src, "pdf", &[RED, RED], 5);
s.execute("links.update", &json!({})).unwrap();
let after = placed(&s, id);
assert_eq!((after.link.page, after.page_box), (Some(2), vectorcraft_doc::PageBox::Media));
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
}
#[test]
fn a_pdf_compatible_ai_file_places_linked_like_a_pdf() {
let dir = Folder::new("placed-ai-linked");
let src = dir.file("badge.ai");
pdf_source(&src, "ai", &[RED], 0);
let mut s = session();
let id = place(&mut s, &src);
assert_eq!(placed(&s, id).link.path, src);
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
let row = &s.execute("links.list", &json!({})).unwrap()["links"][0];
assert_eq!(row["format"], "PDF-compatible .ai", "{row}");
pdf_source(&src, "ai", &[BLUE], 1);
let u = s.execute("links.update", &json!({})).unwrap();
assert_eq!(u["updated"], json!([id.0]), "{u}");
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
let (images, _) = pdf_contents(&decode(&s.execute("document.serialize", &json!({"format": "pdf"})).unwrap()));
assert_eq!(images, 0);
}
#[test]
fn a_placed_document_inside_a_placed_document_outputs_from_its_own_file() {
let dir = Folder::new("placed-nested");
@@ -31,6 +31,7 @@ fn doc_with(key: &str, bytes: &[u8]) -> Document {
link: LinkInfo::new("/art/card.vectorcraft"),
key: key.into(),
bounding: false,
page_box: Default::default(),
width: 40.0,
height: 20.0,
xf: Affine::translate((10.0, 10.0)) * Affine::scale(2.0),
+1
View File
@@ -4363,6 +4363,7 @@ imageTrace Outline Obrys
@msg no file system here zde není souborový systém
@msg no file writer chybí zápis souborů
@msg no files to place žádné soubory k umístění
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` není dokument VectorCraft, PDF ani soubor .ai kompatibilní s PDF
@msg no format to export to žádný formát pro export
@msg no free recovery area žádná volná oblast pro obnovení
@msg no gradient stop or freeform point is selected není vybrána žádná zarážka přechodu ani volný bod
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3985,6 +3985,7 @@ theme Appearance Erscheinungsbild
@msg no file system here hier gibt es kein Dateisystem
@msg no file writer kein Dateischreiber
@msg no files to place keine Dateien zum Platzieren
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` ist weder ein VectorCraft-Dokument noch eine PDF- oder PDF-kompatible .ai-Datei
@msg no format to export to kein Format für den Export
@msg no free recovery area kein freier Wiederherstellungsbereich
@msg no gradient stop or freeform point is selected es ist kein Farbstopp und kein Freiformpunkt ausgewählt
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4041,6 +4041,7 @@ theme Appearance Εμφάνιση
@msg no file system here δεν υπάρχει σύστημα αρχείων εδώ
@msg no file writer δεν υπάρχει μηχανισμός εγγραφής αρχείων
@msg no files to place δεν υπάρχουν αρχεία για τοποθέτηση
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file Το `{file}` δεν είναι έγγραφο VectorCraft, PDF ή αρχείο .ai συμβατό με PDF
@msg no format to export to δεν υπάρχει μορφή εξαγωγής
@msg no free recovery area δεν υπάρχει ελεύθερη περιοχή ανάκτησης
@msg no gradient stop or freeform point is selected δεν έχει επιλεγεί σημείο χρώματος ντεγκραντέ ή σημείο ελεύθερης μορφής
Can't render this file because it contains an unexpected character in line 2045 and column 19.
+1
View File
@@ -4060,6 +4060,7 @@ axis Both Ambos
@msg no file system here aquí no hay sistema de archivos
@msg no file writer no hay escritor de archivos
@msg no files to place no hay archivos para colocar
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` no es un documento de VectorCraft, un PDF ni un archivo .ai compatible con PDF
@msg no format to export to no hay formato al que exportar
@msg no free recovery area no hay ningún área de recuperación libre
@msg no gradient stop or freeform point is selected no hay ninguna parada de degradado ni punto de forma libre seleccionado
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4278,6 +4278,7 @@ imageTrace Outline Ääriviiva
@msg no file system here täällä ei ole tiedostojärjestelmää
@msg no file writer ei tiedostonkirjoittajaa
@msg no files to place ei sijoitettavia tiedostoja
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` ei ole VectorCraft-asiakirja, PDF- eikä PDF-yhteensopiva .ai-tiedosto
@msg no format to export to ei vientimuotoa
@msg no free recovery area ei vapaata palautusaluetta
@msg no gradient stop or freeform point is selected liukuvärin väripistettä tai vapaamuotoista pistettä ei ole valittu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4057,6 +4057,7 @@ axis Both Les deux
@msg no file system here aucun système de fichiers ici
@msg no file writer aucun module d’écriture de fichiers
@msg no files to place aucun fichier à importer
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` n’est ni un document VectorCraft, ni un PDF, ni un fichier .ai compatible PDF
@msg no format to export to aucun format d’exportation
@msg no free recovery area aucune zone de récupération libre
@msg no gradient stop or freeform point is selected aucune étape de dégradé ni point de forme libre n’est sélectionné
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4720,6 +4720,7 @@ imageTrace Outline Körvonal
@msg no file system here itt nincs fájlrendszer
@msg no file writer nincs fájlíró
@msg no files to place nincs elhelyezendő fájl
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file nem VectorCraft-dokumentum, PDF- vagy PDF-kompatibilis .ai-fájl: `{file}`
@msg No fonts are missing in this document Ebből a dokumentumból nem hiányzik betűtípus
@msg no format to export to nincs exportálási formátum
@msg no free recovery area nincs szabad helyreállítási terület
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4100,6 +4100,7 @@ axis Both Entrambi
@msg no file system here nessun file system disponibile qui
@msg no file writer nessun modulo di scrittura file
@msg no files to place nessun file da inserire
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` non è un documento VectorCraft, un PDF o un file .ai compatibile con PDF
@msg no format to export to nessun formato di esportazione
@msg no free recovery area nessuna area di recupero libera
@msg no gradient stop or freeform point is selected nessun cursore colore o punto a forma libera selezionato
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4463,6 +4463,7 @@ swatch Bright Violet ビビッドバイオレット
@msg no user library folder here この環境にはユーザーライブラリフォルダーがありません
@msg Couldn't place {name}: the document it was dropped on was closed {name} を配置できませんでした: ドロップ先のドキュメントは閉じられています
@msg no files to place 配置するファイルがありません
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` は VectorCraft ドキュメント、PDF、PDF 互換の .ai ファイルのいずれでもありません
@msg paths: give at least one file paths: ファイルを 1 つ以上指定してください
@msg Couldn't place {name}: {e} {name} を配置できませんでした: {e}
@msg no print service プリントサービスがありません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3843,6 +3843,7 @@ effect Regular ჩვეულებრივი
@msg no file system here აქ ფაილური სისტემა არ არის
@msg no file writer ფაილის ჩამწერი არ არის
@msg no files to place განსათავსებელი ფაილები არ არის
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` არ არის VectorCraft-ის დოკუმენტი, PDF ან PDF-თან თავსებადი .ai ფაილი
@msg no format to export to ექსპორტის ფორმატი არ არის
@msg no free recovery area აღდგენის თავისუფალი არე არ არის
@msg no gradient stop or freeform point is selected გრადიენტის გადასვლის წერტილი ან თავისუფალი წერტილი მონიშნული არ არის
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4395,6 +4395,7 @@ theme Appearance 외관
@msg no user library folder here 여기에는 사용자 라이브러리 폴더가 없습니다
@msg Couldn't place {name}: the document it was dropped on was closed {name}을(를) 가져올 수 없습니다: 놓은 문서가 닫혔습니다
@msg no files to place 가져올 파일이 없습니다
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}`은(는) VectorCraft 문서, PDF 또는 PDF 호환 .ai 파일이 아닙니다
@msg paths: give at least one file paths: 파일을 하나 이상 지정하세요
@msg Couldn't place {name}: {e} {name}을(를) 가져올 수 없습니다: {e}
@msg no print service 인쇄 서비스가 없습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -4185,6 +4185,7 @@ imageTrace Outline Kontur
@msg no file system here brak systemu plików
@msg no file writer brak modułu zapisu plików
@msg no files to place brak plików do umieszczenia
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` nie jest dokumentem VectorCraft, plikiem PDF ani plikiem .ai zgodnym z PDF
@msg no format to export to brak formatu eksportu
@msg no free recovery area brak wolnego obszaru odzyskiwania
@msg no gradient stop or freeform point is selected nie zaznaczono punktu koloru gradientu ani dowolnego punktu
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3635,6 +3635,7 @@ axis Both Ambos
@msg no file system here nenhum sistema de arquivos aqui
@msg no file writer sem gravador de arquivos
@msg no files to place nenhum arquivo para inserir
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` não é um documento do VectorCraft, um PDF nem um arquivo .ai compatível com PDF
@msg no format to export to nenhum formato para exportar
@msg no free recovery area nenhuma área de recuperação livre
@msg no gradient stop or freeform point is selected nenhuma parada de gradiente ou ponto de forma livre selecionado
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -4096,6 +4096,7 @@ axis Both Оба
@msg no file system here здесь нет файловой системы
@msg no file writer нет средства записи файлов
@msg no files to place нет файлов для размещения
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` не является документом VectorCraft, PDF или файлом .ai, совместимым с PDF
@msg no format to export to нет формата для экспорта
@msg no free recovery area нет свободной области восстановления
@msg no gradient stop or freeform point is selected не выделена ни точка перехода градиента, ни произвольная точка
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3977,6 +3977,7 @@ axis Both Båda
@msg no file system here inget filsystem här
@msg no file writer ingen filskrivare
@msg no files to place inga filer att placera
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` är inte ett VectorCraft-dokument, en PDF eller en PDF-kompatibel .ai-fil
@msg no format to export to inget format att exportera till
@msg no free recovery area inget ledigt återställningsområde
@msg no gradient stop or freeform point is selected inget övertoningsstopp eller fri punkt är markerad
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -3836,6 +3836,7 @@ axis Both Her İkisi
@msg no file system here burada dosya sistemi yok
@msg no file writer dosya yazarı yok
@msg no files to place yerleştirilecek dosya yok
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` bir VectorCraft belgesi, bir PDF ya da PDF ile uyumlu bir .ai dosyası değil
@msg no format to export to dışa aktarılacak format yok
@msg no free recovery area ücretsiz kurtarma alanı yok
@msg no gradient stop or freeform point is selected degrade durağı veya serbest biçim noktası seçili değil
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -4084,6 +4084,7 @@ axis Both Обидві
@msg no file system here тут немає файлової системи
@msg no file writer немає засобу запису файлів
@msg no files to place немає файлів для розміщення
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` не є документом VectorCraft, PDF або файлом .ai, сумісним із PDF
@msg no format to export to немає формату для експорту
@msg no free recovery area немає вільної області відновлення
@msg no gradient stop or freeform point is selected не виділено ні точку переходу градієнта, ні довільну точку
Can't render this file because it contains an unexpected character in line 733 and column 40.
@@ -166,7 +166,6 @@ Text options: {e}
`{_1}` would be overwritten by the package: choose a different folder or name
`{file}` has no art to place
`{file}` is over {_1} MB
`{file}` isn't a VectorCraft document
`{f}` belongs to a VectorCraft that is running
`{f}` is the copy of a document open now
`{id}` is no writable format (see document.formats)
+1
View File
@@ -4367,6 +4367,7 @@ theme Appearance Giao diện
@msg no file system here không có hệ thống tệp ở đây
@msg no file writer không có bộ ghi tệp
@msg no files to place không có tệp để đặt
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` không phải là tài liệu VectorCraft, tệp PDF hay tệp .ai tương thích PDF
@msg No fonts are missing in this document Không có phông chữ nào bị thiếu trong tài liệu này
@msg no format to export to không có định dạng để xuất
@msg no free recovery area không có vùng phục hồi trống
Can't render this file because it contains an unexpected character in line 4028 and column 10.
+1
View File
@@ -4187,6 +4187,7 @@ imageTrace Outline 轮廓
@msg no user library folder here 此处没有用户库文件夹
@msg Couldn't place {name}: the document it was dropped on was closed 无法置入 {name}:将它拖放到的文档已关闭
@msg no files to place 没有要置入的文件
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` 不是 VectorCraft 文档、PDF 或 PDF 兼容的 .ai 文件
@msg paths: give at least one file paths:请至少给出一个文件
@msg Couldn't place {name}: {e} 无法置入 {name}:{e}
@msg no print service 没有打印服务
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2173,6 +2173,7 @@ file-filter Templates 範本
@msg no user library folder here 此處沒有使用者資料庫資料夾
@msg Couldn't place {name}: the document it was dropped on was closed 無法置入 {name}:放置它的文件已關閉
@msg no files to place 沒有要置入的檔案
@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` 不是 VectorCraft 文件、PDF 或 PDF 相容的 .ai 檔案
@msg paths: give at least one file paths:請提供至少一個檔案
@msg Couldn't place {name}: {e} 無法置入 {name}:{e}
@msg no print service 沒有列印服務
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12 -5
View File
@@ -155,6 +155,7 @@ fn open_dialog(app: &mut VectorcraftApp, files: Vec<Value>) {
let units = app.session.active().map(|d| d.doc.units).unwrap_or_default();
let info: Vec<String> = files.iter().map(|f| summary(app, f, units)).collect();
let one_object = app.session.active().is_some_and(|d| d.selection.objects.len() == 1);
let linked: Vec<bool> = files.iter().map(|f| is_vectorcraft_file(app, f)).collect();
app.ui.dialog = Some(crate::state::Dialog::new(
crate::dialogs::place::KIND,
json!({
@@ -163,18 +164,24 @@ fn open_dialog(app: &mut VectorcraftApp, files: Vec<Value>) {
"template": false,
"replace": false,
"__replace": files.len() == 1 && one_object,
"__documents": files.iter().any(is_vectorcraft_file),
"__others": !files.iter().all(is_vectorcraft_file),
"__documents": linked.contains(&true),
"__others": linked.contains(&false),
"__info": info,
}),
));
}
/// Is dialog file entry `file` a VectorCraft document on disk (what Link places as one locked
/// object)?
fn is_vectorcraft_file(file: &Value) -> bool {
/// object), or a PDF or `.ai` file on disk? A `.ai` saved without PDF compatibility is PostScript,
/// which `file.place` embeds whether Link is on or not.
fn is_vectorcraft_file(app: &VectorcraftApp, file: &Value) -> bool {
let Some(path) = file.get("path").and_then(Value::as_str) else { return false };
let native = ["vectorcraft", vectorcraft_format::LEGACY_EXTENSION, "vctemplate"];
file.get("path").and_then(Value::as_str).is_some_and(|p| native.contains(&fileio::extension(p).as_str()))
match fileio::extension(path).as_str() {
e if native.contains(&e) => true,
"pdf" | "ai" | "ait" => app.services.read.as_ref().and_then(|r| r(path).ok()).is_none_or(|b| !vectorcraft_pdf::is_postscript(&b)),
_ => false,
}
}
/// The engine params naming `file` (a dialog file entry).
+42
View File
@@ -634,6 +634,48 @@ fn a_vectorcraft_document_places_linked_and_edit_original_opens_it() {
assert!(crate::dialogs::place::link_tip(d).contains("VectorCraft documents"));
}
#[test]
fn the_place_dialog_links_a_pdf_page_and_embeds_it_with_link_off() {
let mut app = app();
let mut src = Session::new();
src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap();
src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
let pdf = src.execute("document.serialize", &json!({"format": "pdf", "preserveEditing": false})).unwrap();
let path = temp_file("flyer.pdf", &vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap());
// Link on (the default): one locked object, linked to the file.
place_picked(&mut app, &path);
let d = app.ui.dialog.as_ref().expect("the Place dialog");
assert!(d.bool("link") && d.bool("__documents") && !d.bool("__others"), "Link places it as one locked object");
assert!(crate::dialogs::place::link_tip(d).contains("locked object"));
crate::dialogs::confirm(&mut app).unwrap();
let st = app.session.active().unwrap();
let vectorcraft_doc::NodeKind::PlacedDocument(p) = &st.doc.node(st.selection.objects[0]).unwrap().kind else { panic!("not a placed document") };
assert_eq!(p.link.path, path);
// Link off: the page's art is embedded.
place_picked(&mut app, &path);
app.ui.dialog.as_mut().unwrap().fields.insert("link".into(), json!(false));
crate::dialogs::confirm(&mut app).unwrap();
let st = app.session.active().unwrap();
assert_eq!(st.doc.node(st.selection.objects[0]).unwrap().kind_label(), "Clip Group");
}
#[test]
fn the_place_dialog_does_not_offer_a_postscript_ai_as_a_locked_object() {
let mut app = app();
let ps = b"%!PS-Adobe-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n1 0 0 setrgbcolor 10 10 50 50 rectfill\nshowpage\n%%EOF\n";
let path = temp_file("legacy.ai", ps);
// file.place embeds a .ai saved without PDF compatibility, even with Link on.
let r = app.session.execute("file.place", &json!({"path": path, "link": true})).unwrap();
assert_eq!(r["linked"], json!(false), "{r}");
let st = app.session.active().unwrap();
assert!(!matches!(st.doc.node(st.selection.objects[0]).unwrap().kind, NodeKind::PlacedDocument(_)));
// So Link's tooltip does not promise one locked object for it.
place_picked(&mut app, &path);
let d = app.ui.dialog.as_ref().expect("the Place dialog");
assert!(!d.bool("__documents") && d.bool("__others"), "{:?}", d.fields);
assert!(!crate::dialogs::place::link_tip(d).contains("locked object"));
}
/// Tool key `key` as the Control bar's Apply (Enter) and Cancel (Escape) send it.
fn tool_key(app: &mut VectorcraftApp, key: vectorcraft_tools::ToolKey) {
let view = app.view_info();
+12 -5
View File
@@ -2897,7 +2897,9 @@ unless `link: false`; a Photoshop document as its merged image), an SVG as one g
and swatches it uses. A VectorCraft document read from `path` is a placed document: one locked object showing its
artboard `page` (or, with `crop: "bounding"`, its art's bounds), linked to the file and read again when it changes
(see Linked images), and vectors in every output; with `link: false` (or from `dataBase64`) it is an editable copy
of its art, as one clipped group. `at` centres it, `rect` fits it, `replace`
of its art, as one clipped group. A PDF, `.ai` or `.ait` page read from `path` (with neither `password` nor
`textAs: "outlines"`) is a placed document the same way, showing page `page` clipped to its `crop` box (or its
art's bounds); with `link: false` it is placed as one clipped group. EPS and PostScript `.ai` files are embedded. `at` centres it, `rect` fits it, `replace`
swaps the selected object (keeping its place and transform), `template` puts it on a new template layer.
The art's bounds are the bounds of the art that shows, with its strokes and effects, as an export measures them:
hidden objects, guides and template layers do not count, and symbol instances are measured by their art. A page or artboard holds the objects
@@ -3166,10 +3168,15 @@ that includes a placed document fails and relinks nothing; placed documents are
files are. Find in Folder… in the Linked File Not Found dialog and in the Links panel menu (`ui.findLinksInFolder`) runs
both. The web build has no folder search.
Placed documents (a `.vectorcraft` file placed linked) work the same way: the document keeps the file's bytes and a
preview (JPEG or PNG, 1 px per point, at most 1024 px a side), and a save writes the preview (Include Linked Files:
the file); output reads the file again, or uses the preview with a warning when it is gone or changed.
`links.list` and `links.info` give them `document: true` and the artboard's `pageWidth` and `pageHeight` (pt).
Placed documents (a `.vectorcraft` file, or a PDF or `.ai` page, placed linked)
work the same way: the document keeps the file's bytes and a preview (JPEG or PNG, 1 px per point, at most 1024 px
a side), and a save writes the preview (Include Linked Files: the file); output reads the file again, or uses the
preview with a warning when it is gone or changed.
`links.list` and `links.info` give them `document: true`, the file's `format` (`VectorCraft`, `PDF`,
`PDF-compatible .ai`) and the artboard's or page's `pageWidth` and `pageHeight` (pt). A relinked PDF shows the same
page and box of the new file. File › Package copies a placed PDF or `.ai` file as it is. For a `.ai` file read from
its editing data, Package also copies the files its art places into the same folder. That file finds them by name,
and Package warns when one is copied under another name: the copy then shows the file of that name, or its preview.
Saving the file in the app updates the open documents that place it. `links.embed` (the Links panel's Break Link)
turns one into the editable copy placing without link gives, its symbols, patterns, swatches and images joining
the document; so does `object.expand`.
+1
View File
@@ -854,6 +854,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); CNC preset and VCarve QA remain pending.
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md).
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
- Linked PDF and `.ai` pages: File › Place with Link (the default) places a page of a PDF, `.ai` or `.ait` file read as PDF pages as a placed document, as it links an image, the page and its box (`crop`) kept with the link: Links check, update and relink it, Break Link and Object › Expand give the group placing it without Link gives, every output draws the page's vectors, and Package copies the file. Without Link, or with a password or `textAs: "outlines"`, the page is embedded. EPS and PostScript `.ai` files are still embedded (`crates/engine/src/tests_placed_document.rs`).
- File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests_place.rs`).
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The Print dialog's Media list also carries the Japanese papers — JIS B4 257 × 364 mm, JIS B5 182 × 257 mm and JIS B6 128 × 182 mm beside the ISO B4/B5 (250 × 353 and 176 × 250 mm, kept so settings and presets saved before them keep their paper), and はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm, 長形4号 90 × 225 mm, 角形2号 240 × 332 mm, 角形3号 216 × 277 mm and 四六判 127 × 188 mm; the JSON id or the dialog label names a paper, any case and spacing (`B5 (JIS)` or `b5jis`) (`crates/pdf/src/print/settings.rs`, `crates/pdf/src/tests_print.rs`). The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
- A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests_file_info.rs`, `crates/engine/src/tests_fileinfo.rs`).