mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 14:20:24 +00:00
Merge main into feat/pict-open-place
This commit is contained in:
@@ -73,6 +73,8 @@ pub struct RasterImage {
|
||||
pub height: u32,
|
||||
/// The resolution the file declares (see [`super::ppi::resolution`]).
|
||||
pub ppi: Option<(f64, f64)>,
|
||||
/// A DCS file's clipping path, in the image's pixel space ([`crate::cmd::place::dcs`]).
|
||||
pub clip: Option<vectorcraft_geom::PathData>,
|
||||
}
|
||||
|
||||
pub(super) fn err(e: impl std::fmt::Display) -> EngineError {
|
||||
@@ -386,6 +388,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let (mut doc, mut warnings, restored) = match format.id {
|
||||
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
// A DCS file's plates print from its file: it is only placed linked.
|
||||
if crate::cmd::place::dcs::is_dcs(bytes) {
|
||||
return Err(err(format!("can't open `{}`: {}", file_name(name), crate::cmd::place::dcs::LINK_ONLY)));
|
||||
}
|
||||
let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes)));
|
||||
restore_or_import(editing, || {
|
||||
let r = vectorcraft_eps::import_names(bytes, opts.editing_data, opts.trust_editing_data, opts.legacy_names)
|
||||
@@ -1024,13 +1030,13 @@ fn stored(bytes: Vec<u8>, mime: &'static str, (width, height): (u32, u32), ppi:
|
||||
return Err(err("the image is empty"));
|
||||
}
|
||||
let blob = ImageBlob::new(mime, bytes);
|
||||
Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi })
|
||||
Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi, clip: None })
|
||||
}
|
||||
|
||||
/// An image as a document of its physical size at the resolution it declares (as Place sizes it;
|
||||
/// 72 ppi, 1 px = 1 pt, when it declares none), the image named after the file.
|
||||
pub(super) fn raster_doc(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
let RasterImage { key, blob, width, height, ppi } = raster_image(bytes)?;
|
||||
let RasterImage { key, blob, width, height, ppi, .. } = raster_image(bytes)?;
|
||||
let (sx, sy) = crate::cmd::place::pt_per_px(ppi);
|
||||
let mut d = Document::new(width as f64 * sx, height as f64 * sy);
|
||||
let layer = d.layers[0].id;
|
||||
|
||||
+312
-54
@@ -31,10 +31,10 @@ use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes};
|
||||
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, ids_param, object_param, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, ids_param, object_param, str_param, switch_or};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Update Links",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids]}",
|
||||
"{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids], warnings?: [a DCS file's spot plate whose ink a swatch of the document already names, as file.place warns]}",
|
||||
has_doc,
|
||||
update
|
||||
),
|
||||
@@ -70,7 +70,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, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, 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; to is the file name alone when the document's folder is outside the automation read roots)}",
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, 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; an error for a DCS file that reads plate files of its own), 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; to is the file name alone when the document's folder is outside the automation read roots), warnings?: (as links.update's)}",
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Links",
|
||||
[],
|
||||
None,
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots",
|
||||
"{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first, dcsTransparency?: false (Show DCS Transparency Interactions: mark the images placed from DCS files that are transparent, themselves or a group or layer holding them, or that overlap a visible object with its own transparency; it looks at every object, so it takes longer)} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format (DCS for an image placed from a DCS file), pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), dcsTransparency?: true (with dcsTransparency)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Embed Image",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: the selected linked ones)} break the links (the Links panel's Break Link): images keep the linked files' pixels in the document; a placed document becomes an editable copy of its art, as placing its file without link gives (its symbols, patterns, swatches and images joining the document). An image whose file can't be found and that shows the saved preview, or a placed document whose file can't be read or changed, stays linked: relink or update it first. One undo step → {embedded: [ids], missing: [ids]}",
|
||||
"{ids?: [image or placed document ids] (default: the selected linked ones)} break the links (the Links panel's Break Link): images keep the linked files' pixels in the document; a placed document becomes an editable copy of its art, as placing its file without link gives (its symbols, patterns, swatches and images joining the document). An image whose file can't be found and that shows the saved preview, or a placed document whose file can't be read or changed, stays linked: relink or update it first. An image placed from a DCS file can't be embedded (an error, and nothing changes). One undo step → {embedded: [ids], missing: [ids]}",
|
||||
has_doc,
|
||||
embed
|
||||
),
|
||||
@@ -160,6 +160,8 @@ struct Group {
|
||||
ids: Vec<NodeId>,
|
||||
/// Placed documents: what they show.
|
||||
document: Option<Shown>,
|
||||
/// An image placed from a DCS file: the plate files of their own its plates were read from.
|
||||
plate_files: Vec<vectorcraft_doc::separated::PlateFile>,
|
||||
}
|
||||
|
||||
/// What a placed document shows: whether the art's bounds (not the artboard or page) are shown,
|
||||
@@ -175,9 +177,17 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option<Shown>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]).
|
||||
/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]): an
|
||||
/// image placed from a DCS file has its plate files of their own too, beside its file.
|
||||
pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec<u64>)> {
|
||||
groups(d, None).into_iter().filter(|g| !g.ids.is_empty()).map(|g| (g.link.path, ids_json(&g.ids))).collect()
|
||||
let mut out = vec![];
|
||||
for g in groups(d, None).into_iter().filter(|g| !g.ids.is_empty()) {
|
||||
let ids = ids_json(&g.ids);
|
||||
let plates: Vec<String> = g.plate_files.iter().filter_map(|f| super::place::dcs::plate_file_path(&g.link.path, &f.name)).collect();
|
||||
out.push((g.link.path, ids.clone()));
|
||||
out.extend(plates.into_iter().map(|p| (p, ids.clone())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when
|
||||
@@ -189,7 +199,11 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||
let wanted: Option<std::collections::HashSet<NodeId>> = ids.map(|ids| ids.iter().copied().collect());
|
||||
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 });
|
||||
let plate_files = match document {
|
||||
None => d.images.get(key).map(ImageBlob::plate_files).unwrap_or_default(),
|
||||
Some(_) => vec![],
|
||||
};
|
||||
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document, plate_files });
|
||||
out.len() - 1
|
||||
});
|
||||
if let (Some(id), Some(g)) = (id, out.get_mut(i)) {
|
||||
@@ -335,10 +349,14 @@ fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option<craft_docpath::Ref
|
||||
}
|
||||
|
||||
/// Where the file of group `g` (of a document looking from `base`) is and whether it still has
|
||||
/// the content shown. Its bytes are read only when its size or modification time changed.
|
||||
/// the content shown. Its bytes are read only when its size or modification time changed. An image
|
||||
/// placed from a DCS file is modified when one of its plate files of their own changed.
|
||||
fn probe(g: &Group, base: LinkBase) -> (Status, Option<Found>) {
|
||||
let Some(mut f) = locate(&g.link, base) else { return (Status::Missing, None) };
|
||||
let l = &g.link;
|
||||
if !g.plate_files.is_empty() && super::place::dcs::plate_files_changed(&g.plate_files, &f.path) {
|
||||
return (Status::Modified, Some(f));
|
||||
}
|
||||
if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified {
|
||||
return (Status::Ok, Some(f));
|
||||
}
|
||||
@@ -346,7 +364,7 @@ fn probe(g: &Group, base: LinkBase) -> (Status, Option<Found>) {
|
||||
let same = match &l.hash {
|
||||
Some(h) => *h == hash_bytes(&bytes),
|
||||
// Linked before links kept a hash: the image the file decodes to.
|
||||
None => g.document.is_none() && fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key),
|
||||
None => g.document.is_none() && super::place::dcs::raster(&bytes, Some(&f.path)).is_ok_and(|img| img.key == g.key),
|
||||
};
|
||||
f.bytes = Some(bytes);
|
||||
(if same { Status::Ok } else { Status::Modified }, Some(f))
|
||||
@@ -433,7 +451,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a
|
||||
previews.insert(key);
|
||||
continue;
|
||||
};
|
||||
let Ok(img) = fileio::raster_image(&bytes) else {
|
||||
let Ok(img) = super::place::dcs::raster(&bytes, Some(&f.path)) else {
|
||||
previews.insert(key);
|
||||
continue;
|
||||
};
|
||||
@@ -512,10 +530,11 @@ pub(crate) fn readable_and_kept(blob: ImageBlob, unread: bool, linked: bool) ->
|
||||
Ok(blob)
|
||||
}
|
||||
|
||||
/// The linked image file `bytes`, read ([`fileio::raster_image`]) with its preview; an error when
|
||||
/// its pixels can't be read ([`readable`]).
|
||||
fn linked_image(bytes: &[u8]) -> Result<RasterImage> {
|
||||
let mut img = fileio::raster_image(bytes)?;
|
||||
/// The linked image file `bytes` (read from `path`, when there is one), read with its preview: a
|
||||
/// DCS file's plates, else its image ([`super::place::dcs::raster`]); an error when its pixels
|
||||
/// can't be read ([`readable`]).
|
||||
fn linked_image(bytes: &[u8], path: Option<&str>) -> Result<RasterImage> {
|
||||
let mut img = super::place::dcs::raster(bytes, path)?;
|
||||
img.blob = readable(img.blob, kept_unread(bytes), true)?;
|
||||
Ok(img)
|
||||
}
|
||||
@@ -536,7 +555,7 @@ fn read_image(f: &mut Found, base: &LinkInfo, reads: &mut ImageReads) -> Result<
|
||||
let img = match reads.get(&f.path) {
|
||||
Some((h, img)) if *h == hash => img.clone(),
|
||||
_ => {
|
||||
let img = linked_image(&bytes)?;
|
||||
let img = linked_image(&bytes, Some(&f.path))?;
|
||||
reads.insert(f.path.clone(), (hash.clone(), img.clone()));
|
||||
img
|
||||
}
|
||||
@@ -586,12 +605,89 @@ fn clip_shape((frame, room): Clip) -> PathData {
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame)
|
||||
}
|
||||
|
||||
/// What happens to the clip of an object a link update remakes.
|
||||
enum ClipTo {
|
||||
/// It stays as it is.
|
||||
Keep,
|
||||
/// It is clipped to this path: the clipping path of the clip group holding just it changes,
|
||||
/// else it goes into a new clip group.
|
||||
Path(PathData),
|
||||
/// It goes into a new clip group of this path, inside the clip group that holds it.
|
||||
Nest(PathData),
|
||||
/// The clipping path of the clip group holding just it goes (a DCS file's clipping path that
|
||||
/// its new file lacks): the group stays as a plain group of it, with its id, name, visibility
|
||||
/// and transparency.
|
||||
Off,
|
||||
}
|
||||
|
||||
/// The clipping path, with its fill rule, of the clip group holding just object `id` of `d`, when
|
||||
/// one holds it.
|
||||
fn clip_path_of(d: &Document, id: NodeId) -> Option<(&PathData, FillRule)> {
|
||||
let g = d.node(d.parent_of(id)?)?;
|
||||
match &g.kind {
|
||||
NodeKind::Group { children, clip: true } => match &children[..] {
|
||||
[c, o] if o.id == id => match &c.kind {
|
||||
NodeKind::Path { path, rule, clipping: true, .. } => Some((path, *rule)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Do `a` and `b` have the same subpaths, each closed or open alike, and the same anchors, each
|
||||
/// within a thousandth of a point?
|
||||
fn same_path(a: &PathData, b: &PathData) -> bool {
|
||||
let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| p.distance(q) < 1e-3;
|
||||
a.anchor_count() == b.anchor_count()
|
||||
&& a.subpaths.len() == b.subpaths.len()
|
||||
&& a.subpaths.iter().zip(&b.subpaths).all(|(x, y)| x.closed == y.closed)
|
||||
&& a.anchors()
|
||||
.zip(b.anchors())
|
||||
.all(|((si, ai, x), (sj, aj, y))| si == sj && ai == aj && near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out))
|
||||
}
|
||||
|
||||
/// The clip an image placed with `old_xf` takes when it shows `img`, read from a file, placed with
|
||||
/// `new_xf`: Placement Options' clip `clip` when they clip it, else the clipping path a DCS file
|
||||
/// draws `img` through. When neither clips it, a clip group holding just it whose clipping path
|
||||
/// `held` is its old DCS file's (`old_clip`, in the old pixels, under the nonzero rule its clip
|
||||
/// groups get) goes.
|
||||
fn clip_to(
|
||||
old_xf: Affine,
|
||||
old_clip: Option<&PathData>,
|
||||
clip: Option<Clip>,
|
||||
img: &RasterImage,
|
||||
new_xf: Affine,
|
||||
held: Option<(&PathData, FillRule)>,
|
||||
) -> ClipTo {
|
||||
if let Some(clip) = clip {
|
||||
return ClipTo::Path(clip_shape(clip));
|
||||
}
|
||||
// Whether the clip group holding just the image is the old DCS file's: a clipping path whose
|
||||
// fill rule was changed in the document is not.
|
||||
let file_held = match (old_clip, held) {
|
||||
(Some(old), Some((held, rule))) => rule == FillRule::NonZero && same_path(&old.transformed(old_xf), held),
|
||||
_ => false,
|
||||
};
|
||||
match img.clip.as_ref().map(|path| path.transformed(new_xf)) {
|
||||
// A clipping mask made in the document stays, with the file's clip group inside it.
|
||||
Some(path) if held.is_some() && !file_held => ClipTo::Nest(path),
|
||||
Some(path) => ClipTo::Path(path),
|
||||
None if file_held => ClipTo::Off,
|
||||
None => ClipTo::Keep,
|
||||
}
|
||||
}
|
||||
|
||||
/// The object an object becomes, and what happens to its clip, given the clipping path of the clip
|
||||
/// group holding just it ([`ClipTo`]).
|
||||
type Remake<'a> = dyn FnMut(&Node, Option<(&PathData, FillRule)>) -> (Node, ClipTo) + 'a;
|
||||
|
||||
/// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and
|
||||
/// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for.
|
||||
struct Nested<'a> {
|
||||
pick: &'a dyn Fn(&Node) -> bool,
|
||||
/// The object an object becomes, and the clip it takes ([`placed_xf`]).
|
||||
make: &'a mut dyn FnMut(&Node) -> (Node, Option<Clip>),
|
||||
make: &'a mut Remake<'a>,
|
||||
/// The next node id for a clip group (taken from the document after the walk).
|
||||
next: u64,
|
||||
}
|
||||
@@ -622,12 +718,12 @@ impl Nested<'_> {
|
||||
if !(self.pick)(n) {
|
||||
return self.update(n, true);
|
||||
}
|
||||
let (new, clip) = (self.make)(n);
|
||||
let (new, clip) = (self.make)(n, None);
|
||||
let mut new = Arc::new(new);
|
||||
self.update(&mut new, true);
|
||||
*n = match clip {
|
||||
Some(clip) => Arc::new(self.clip_group(new, clip)),
|
||||
None => new,
|
||||
ClipTo::Path(path) | ClipTo::Nest(path) => Arc::new(self.clip_group(new, path)),
|
||||
ClipTo::Keep | ClipTo::Off => new,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -640,6 +736,8 @@ impl Nested<'_> {
|
||||
(!Arc::ptr_eq(&art, &m.art)).then_some(art)
|
||||
});
|
||||
let mut slots: Vec<(usize, Arc<Node>)> = vec![];
|
||||
// The object a clip group that loses its clipping path holds alone.
|
||||
let mut declipped = None;
|
||||
if let Some(children) = n.children() {
|
||||
// A clip group holding just the object: its clipping path changes.
|
||||
let clip_path = match (&n.kind, &children[..]) {
|
||||
@@ -655,32 +753,45 @@ impl Nested<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (new, clip) = (self.make)(c);
|
||||
let held = clip_path.filter(|_| i == 1).and_then(|p| match &p.kind {
|
||||
NodeKind::Path { path, rule, .. } => Some((path, *rule)),
|
||||
_ => None,
|
||||
});
|
||||
let (new, clip) = (self.make)(c, held);
|
||||
let mut new = Arc::new(new);
|
||||
self.update(&mut new, true);
|
||||
match (clip, clip_path) {
|
||||
(None, _) => slots.push((i, new)),
|
||||
(Some(clip), Some(path)) if i == 1 => {
|
||||
(ClipTo::Keep, _) => slots.push((i, new)),
|
||||
(ClipTo::Path(clip), Some(path)) if i == 1 => {
|
||||
let mut path = Node::clone(path);
|
||||
if let NodeKind::Path { path: p, .. } = &mut path.kind {
|
||||
*p = clip_shape(clip);
|
||||
*p = clip;
|
||||
}
|
||||
slots.extend([(0, Arc::new(path)), (1, new)]);
|
||||
}
|
||||
(Some(clip), _) => {
|
||||
(ClipTo::Path(clip) | ClipTo::Nest(clip), _) => {
|
||||
let group = self.clip_group(new, clip);
|
||||
slots.push((i, Arc::new(group)));
|
||||
}
|
||||
(ClipTo::Off, Some(_)) if i == 1 => declipped = Some(new),
|
||||
(ClipTo::Off, _) => slots.push((i, new)),
|
||||
}
|
||||
}
|
||||
}
|
||||
if mask.is_none() && slots.is_empty() {
|
||||
if mask.is_none() && slots.is_empty() && declipped.is_none() {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(n);
|
||||
if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) {
|
||||
m.art = art;
|
||||
}
|
||||
// A clip group that stays as a plain group of just the object.
|
||||
if let Some(object) = declipped {
|
||||
if let NodeKind::Group { children, clip } = &mut n.kind {
|
||||
(*children, *clip) = (vec![object], false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(children) = n.children_mut() {
|
||||
for (i, c) in slots {
|
||||
if let Some(slot) = children.get_mut(i) {
|
||||
@@ -690,12 +801,12 @@ impl Nested<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip group of `object` clipped to `clip`, as [`clip_image`] makes one in the layers.
|
||||
fn clip_group(&mut self, object: Arc<Node>, clip: Clip) -> Node {
|
||||
/// A clip group of `object` clipped to `clip`, as [`clip_image_to`] makes one in the layers.
|
||||
fn clip_group(&mut self, object: Arc<Node>, clip: PathData) -> Node {
|
||||
let (path_id, group_id) = (NodeId(self.next), NodeId(self.next + 1));
|
||||
self.next += 2;
|
||||
let none = vectorcraft_color::Paint::None;
|
||||
let mut path = Node::path(path_id, clip_shape(clip), Appearance::basic(none.clone(), none, 0.0));
|
||||
let mut path = Node::path(path_id, clip, Appearance::basic(none.clone(), none, 0.0));
|
||||
if let NodeKind::Path { clipping, .. } = &mut path.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
@@ -723,13 +834,13 @@ fn install_document(
|
||||
};
|
||||
if let Some(also) = also {
|
||||
let pick = |n: &Node| shows(n, also, true);
|
||||
let mut make = |n: &Node| {
|
||||
let mut make = |n: &Node, _: Option<(&PathData, FillRule)>| {
|
||||
let mut n = n.clone();
|
||||
let mut clip = None;
|
||||
let mut clip = ClipTo::Keep;
|
||||
if let NodeKind::PlacedDocument(p) = &mut n.kind {
|
||||
let (xf, c) = placed(p);
|
||||
(p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone());
|
||||
clip = c;
|
||||
clip = c.map_or(ClipTo::Keep, |c| ClipTo::Path(clip_shape(c)));
|
||||
}
|
||||
(n, clip)
|
||||
};
|
||||
@@ -801,10 +912,9 @@ fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), n
|
||||
(frame * at * Affine::scale_non_uniform(size.0 / nw, size.1 / nh), (o.clip && over).then_some((frame, room)))
|
||||
}
|
||||
|
||||
/// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just
|
||||
/// it changes, else the image goes into a new clip group.
|
||||
fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> {
|
||||
let path = clip_shape(clip);
|
||||
/// The clipping path of the clip group of `d` holding just object `id` becomes `path`; false when
|
||||
/// no such clip group holds it.
|
||||
fn set_clip_path(d: &mut Document, id: NodeId, path: PathData) -> bool {
|
||||
let clip_path = d.parent_of(id).and_then(|p| match &d.node(p)?.kind {
|
||||
NodeKind::Group { children, clip: true } => match &children[..] {
|
||||
[c, im] if im.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c.id),
|
||||
@@ -812,12 +922,30 @@ fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> {
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
if let Some(c) = clip_path {
|
||||
if let Some(Node { kind: NodeKind::Path { path: p, .. }, .. }) = d.node_mut(c) {
|
||||
*p = path;
|
||||
}
|
||||
let Some(c) = clip_path else { return false };
|
||||
if let Some(Node { kind: NodeKind::Path { path: p, .. }, .. }) = d.node_mut(c) {
|
||||
*p = path;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just
|
||||
/// it changes, else the image goes into a new clip group.
|
||||
fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> {
|
||||
clip_image_to(d, id, clip_shape(clip))
|
||||
}
|
||||
|
||||
/// Clip object `id` of `d` to `path`: the clipping path of the clip group holding just it changes,
|
||||
/// else it goes into a new clip group.
|
||||
fn clip_image_to(d: &mut Document, id: NodeId, path: PathData) -> Result<()> {
|
||||
if set_clip_path(d, id, path.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
new_clip_group(d, id, path)
|
||||
}
|
||||
|
||||
/// Put object `id` of `d` into a new clip group of `path`, where it is.
|
||||
fn new_clip_group(d: &mut Document, id: NodeId, path: PathData) -> Result<()> {
|
||||
let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let image = d.remove(id)?;
|
||||
let none = vectorcraft_color::Paint::None;
|
||||
@@ -839,7 +967,7 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_
|
||||
// the app connect to another computer.
|
||||
let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?;
|
||||
let bytes = read_file(&path)?;
|
||||
let read = (link_info(&path, &bytes), linked_image(&bytes)?);
|
||||
let read = (link_info(&path, &bytes), linked_image(&bytes, Some(&path))?);
|
||||
install(d, &[id], read, Some(Preserve::Bounds), None)
|
||||
}
|
||||
|
||||
@@ -853,15 +981,17 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
|
||||
// Every image picked shows content `also.1`, so one resolution serves them all.
|
||||
let old_pt = file_pt(d, also.1);
|
||||
let pick = |n: &Node| shows(n, also, false);
|
||||
let mut make = |n: &Node| {
|
||||
// The clipping path the old content's DCS file drew it through.
|
||||
let old_clip = d.images.get(also.1).and_then(ImageBlob::separated_clip);
|
||||
let mut make = |n: &Node, held: Option<(&PathData, FillRule)>| {
|
||||
let mut n = n.clone();
|
||||
let mut clip = None;
|
||||
let mut clip = ClipTo::Keep;
|
||||
if let NodeKind::Image(im) = &mut n.kind {
|
||||
let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement };
|
||||
let size = |w: u32, h: u32| (w as f64, h as f64);
|
||||
let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o);
|
||||
clip = clip_to(im.xf, old_clip.as_ref(), c, &img, xf, held);
|
||||
(im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone()));
|
||||
clip = c;
|
||||
}
|
||||
(n, clip)
|
||||
};
|
||||
@@ -872,15 +1002,43 @@ fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Optio
|
||||
let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement };
|
||||
let size = |w: u32, h: u32| (w as f64, h as f64);
|
||||
let (xf, clip) = placed_xf(im.xf, size(im.width, im.height), file_pt(d, &im.key), size(img.width, img.height), new_pt, o);
|
||||
// Placement Options' clip, else a DCS file's clipping path, read again with its plates.
|
||||
let old_clip = d.images.get(&im.key).and_then(ImageBlob::separated_clip);
|
||||
clips.push((*id, clip_to(im.xf, old_clip.as_ref(), clip, &img, xf, clip_path_of(d, *id))));
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
{
|
||||
(im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone()));
|
||||
}
|
||||
clips.extend(clip.map(|c| (*id, c)));
|
||||
}
|
||||
// A DCS file's spot plates print on spot swatches of their inks.
|
||||
super::place::dcs::add_spot_swatches(d, &img.blob.spot_plates());
|
||||
store_image(d, &img.key, img.blob, true);
|
||||
clips.into_iter().try_for_each(|(id, clip)| clip_image(d, id, clip))
|
||||
clips.into_iter().try_for_each(|(id, clip)| match clip {
|
||||
ClipTo::Keep => Ok(()),
|
||||
ClipTo::Path(path) => clip_image_to(d, id, path),
|
||||
ClipTo::Nest(path) => new_clip_group(d, id, path),
|
||||
ClipTo::Off => unclip(d, id),
|
||||
})
|
||||
}
|
||||
|
||||
/// Take the clipping path of the clip group holding just object `id` of `d` away: the group stays
|
||||
/// as a plain group of the object, with its id, name, visibility and transparency as they were.
|
||||
fn unclip(d: &mut Document, id: NodeId) -> Result<()> {
|
||||
let Some((g, clip)) = d.parent_of(id).and_then(|g| match &d.node(g)?.kind {
|
||||
NodeKind::Group { children, clip: true } => match &children[..] {
|
||||
[c, o] if o.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some((g, c.id)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}) else {
|
||||
return Ok(());
|
||||
};
|
||||
d.remove(clip)?;
|
||||
if let Some(Node { kind: NodeKind::Group { clip, .. }, .. }) = d.node_mut(g) {
|
||||
*clip = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Groups (of the document saved at `doc_path`) as `{name, path, ids}` rows (those with objects in
|
||||
@@ -1013,7 +1171,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps
|
||||
// Read when the images show the preview, or when the file's details changed.
|
||||
let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None };
|
||||
if preview && !eps_preview {
|
||||
let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, linked_image(b).ok()?))) else {
|
||||
let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, linked_image(b, Some(&f.path)).ok()?))) else {
|
||||
out.missing.extend(row);
|
||||
continue;
|
||||
};
|
||||
@@ -1051,12 +1209,12 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps
|
||||
m.iter().find(|(k, doc, _)| k == key && *doc == document.is_some()).map(|(_, _, new)| new)
|
||||
};
|
||||
let pick = |n: &Node| new(n).is_some();
|
||||
let mut make = |n: &Node| {
|
||||
let mut make = |n: &Node, _: Option<(&PathData, FillRule)>| {
|
||||
let mut out = n.clone();
|
||||
if let (Some(link), Some(l)) = (new(n), link_mut(&mut out)) {
|
||||
*l = link.clone();
|
||||
}
|
||||
(out, None)
|
||||
(out, ClipTo::Keep)
|
||||
};
|
||||
Nested { pick: &pick, make: &mut make, next: 0 }.run(d);
|
||||
}
|
||||
@@ -1328,10 +1486,44 @@ fn update_where(s: &mut Session, ids: Option<Vec<NodeId>>, path: Option<&str>) -
|
||||
}
|
||||
}
|
||||
let updated: Vec<u64> = reads.iter().flat_map(|(g, _)| ids_json(&g.ids)).collect();
|
||||
let warnings = spot_swatch_notes(&st.doc, reads.iter().map(|(_, read)| read));
|
||||
if !reads.is_empty() {
|
||||
s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))?;
|
||||
}
|
||||
Ok(json!({ "updated": updated, "missing": missing }))
|
||||
let mut out = json!({ "updated": updated, "missing": missing });
|
||||
if !warnings.is_empty() {
|
||||
out["warnings"] = json!(warnings);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The warnings for the spot plates of the DCS files in `reads` whose ink a swatch of `d` already
|
||||
/// names in another color, or as a process color ([`super::place::dcs::swatch_notes`]), or that an
|
||||
/// earlier file of `reads` adds as a spot swatch in another color, each once.
|
||||
fn spot_swatch_notes<'a>(d: &Document, reads: impl Iterator<Item = &'a Read>) -> Vec<String> {
|
||||
let mut out: Vec<String> = vec![];
|
||||
// The spot swatches the reads add, in the order they are installed.
|
||||
let mut added: Vec<(String, [f32; 4])> = vec![];
|
||||
for read in reads {
|
||||
let Read::Image((_, img)) = read else { continue };
|
||||
let spots = img.blob.spot_plates();
|
||||
let mut notes = super::place::dcs::swatch_notes(d, &spots);
|
||||
for (name, cmyk) in spots.into_iter().filter(|(name, _)| d.swatch(name).is_none()) {
|
||||
match added.iter().find(|(n, _)| *n == name) {
|
||||
Some((_, first)) if first.iter().zip(cmyk).any(|(a, b)| (a - b).abs() >= 0.005) => {
|
||||
notes.push(format!("the document's swatch `{name}` is kept: it is a different color"));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => added.push((name, cmyk)),
|
||||
}
|
||||
}
|
||||
for note in notes {
|
||||
if !out.contains(¬e) {
|
||||
out.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Which links `links.relink` points at the new file (the Replace choice of Relink).
|
||||
@@ -1513,7 +1705,11 @@ 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(linked_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) };
|
||||
let img = if images.is_empty() { None } else { Some(linked_image(&bytes, Some(&path)).map_err(|e| bad(C, format!("{path}: {e}")))?) };
|
||||
// A copy beside the document would point at plate files it can't find there.
|
||||
if copy_to.is_some() && img.as_ref().is_some_and(|img| !img.blob.plate_files().is_empty()) {
|
||||
return Err(bad(C, format!("{path}: copy takes a DCS file only when its plates are inside it; relink to it where its plate files are")));
|
||||
}
|
||||
let mut sources = vec![];
|
||||
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)?));
|
||||
@@ -1548,12 +1744,16 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let relinked: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
|
||||
let warnings = spot_swatch_notes(&s.doc()?.doc, reads.iter().map(|(_, read)| read));
|
||||
if !reads.is_empty() {
|
||||
s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None)))?;
|
||||
}
|
||||
not_found.sort();
|
||||
not_found.dedup();
|
||||
let mut out = json!({ "relinked": relinked, "notFound": not_found });
|
||||
if !warnings.is_empty() {
|
||||
out["warnings"] = json!(warnings);
|
||||
}
|
||||
if copy_to.is_some() {
|
||||
// The copies are in the document's folder, which the client did not give.
|
||||
for pair in copied.iter_mut().chain(kept.iter_mut()) {
|
||||
@@ -1712,6 +1912,9 @@ pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option
|
||||
|
||||
/// The format of the file image `im` of `d` shows: its link's extension, else its pixels' type.
|
||||
fn format_label(d: &Document, im: &ImageObject) -> &'static str {
|
||||
if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_separated) {
|
||||
return "DCS";
|
||||
}
|
||||
im.link
|
||||
.as_ref()
|
||||
.and_then(|l| fileio::format_for_name(l.name()).or_else(|| fileio::readable_format_for_name(l.name())))
|
||||
@@ -1732,6 +1935,45 @@ fn status_rank(status: &str) -> usize {
|
||||
["missing", "modified", "ok", "embedded"].iter().position(|s| *s == status).unwrap_or(4)
|
||||
}
|
||||
|
||||
/// The DCS images in the layers of `d` that are transparent (themselves, or a group or layer that
|
||||
/// holds them) or overlap an object that is, as Show DCS Transparency Interactions marks them.
|
||||
fn dcs_transparency(d: &Document) -> BTreeSet<NodeId> {
|
||||
/// Each visible DCS image's id, bounds and whether it is transparent, and the bounds of each
|
||||
/// other visible object with its own transparency.
|
||||
fn walk(d: &Document, n: &Node, held: bool, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec<Rect>) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
let own = n.shows_own_transparency();
|
||||
match &n.kind {
|
||||
NodeKind::Image(im) if d.images.get(&im.key).is_some_and(ImageBlob::is_separated) => {
|
||||
if let Some(b) = n.geometric_bounds() {
|
||||
images.push((n.id, b, held || own));
|
||||
// A transparent DCS image is an object with its own transparency for the
|
||||
// others it overlaps.
|
||||
if own {
|
||||
see_through.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if own => see_through.extend(n.geometric_bounds()),
|
||||
_ => {}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk(d, c, held || own, images, see_through);
|
||||
}
|
||||
}
|
||||
let (mut images, mut see_through) = (vec![], vec![]);
|
||||
for l in &d.layers {
|
||||
walk(d, l, false, &mut images, &mut see_through);
|
||||
}
|
||||
images
|
||||
.into_iter()
|
||||
.filter(|(_, b, transparent)| *transparent || see_through.iter().any(|t| t.intersect(*b).area() > 0.0))
|
||||
.map(|(id, ..)| id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.list";
|
||||
let st = s.doc()?;
|
||||
@@ -1739,6 +1981,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !matches!(show, "all" | "missing" | "modified" | "embedded") {
|
||||
return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded")));
|
||||
}
|
||||
let interactions = if switch_or(p, "dcsTransparency", false, C)? { dcs_transparency(&st.doc) } else { BTreeSet::new() };
|
||||
let statuses = statuses(st);
|
||||
let mut objects = vec![];
|
||||
st.doc.walk(|n| {
|
||||
@@ -1793,6 +2036,9 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if preview {
|
||||
row["preview"] = json!(true);
|
||||
}
|
||||
if interactions.contains(&n.id) {
|
||||
row["dcsTransparency"] = json!(true);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
let text = |r: &Value, k: &str| r[k].as_str().unwrap_or_default().to_lowercase();
|
||||
@@ -1831,6 +2077,14 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the linked images or placed documents, or pass ids"));
|
||||
}
|
||||
// A DCS file's plates print from its file.
|
||||
let separated = |id: &NodeId| match kind(id) {
|
||||
Some(NodeKind::Image(im)) => st.doc.images.get(&im.key).is_some_and(ImageBlob::is_separated),
|
||||
_ => false,
|
||||
};
|
||||
if let Some(id) = ids.iter().find(|id| separated(id)) {
|
||||
return Err(bad(C, format!("object {}: {}", id.0, super::place::dcs::LINK_ONLY)));
|
||||
}
|
||||
// Placed documents become editable copies of their art: those whose file can be read.
|
||||
let (mut documents, mut missing) = (vec![], vec![]);
|
||||
for id in ids.iter().copied() {
|
||||
@@ -1849,6 +2103,10 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
match probe(&g, LinkBase::of(st)) {
|
||||
(Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link, &mut image_reads) {
|
||||
// One showing its preview is a DCS file only its file tells.
|
||||
Ok((_, img)) if img.blob.is_separated() => {
|
||||
return Err(bad(C, format!("object {}: {}", g.ids.first().map_or(0, |id| id.0), super::place::dcs::LINK_ONLY)));
|
||||
}
|
||||
Ok(read) => {
|
||||
done.extend(&g.ids);
|
||||
reads.push((g.ids, read));
|
||||
@@ -1915,7 +2173,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let path = absolute_path(&if fileio::extension(path).is_empty() { format!("{path}.{ext}") } else { path.to_string() });
|
||||
// Read before writing: pixels that can't be read leave no file behind.
|
||||
let img = linked_image(&bytes)?;
|
||||
let img = linked_image(&bytes, Some(&path))?;
|
||||
write_file(&path, &bytes)?;
|
||||
let read = (link_info(&path, &bytes), img);
|
||||
s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds), None))?;
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Close Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), in?: [x,y] (handle into the first anchor, placed as path.setHandle places it: a smooth first anchor keeps its outgoing handle in line at its own length, a corner keeps it where it is), out?: [x,y] (the first anchor's outgoing handle, placed after `in`; the anchor becomes a corner), independent?: bool (`in` makes the first anchor a corner and leaves its outgoing handle)}",
|
||||
"{id, subpath?: usize (the open subpath to close, 0 included; default: the last open one), at?: [x,y] (move the first anchor there, its handles with it, before `in` and `out` are placed), in?: [x,y] (handle into the first anchor, placed as path.setHandle places it: a smooth first anchor keeps its outgoing handle in line at its own length, a corner keeps it where it is), out?: [x,y] (the first anchor's outgoing handle, placed after `in`; the anchor becomes a corner), independent?: bool (`in` makes the first anchor a corner and leaves its outgoing handle)}",
|
||||
has_doc,
|
||||
close
|
||||
),
|
||||
@@ -205,6 +205,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Close the active open subpath of `id`, optionally setting the handles of the first anchor. The
|
||||
/// incoming handle moves as Move Direction Handle moves it: the outgoing handle turns with it only
|
||||
/// on a smooth anchor, so the first segment of a path that starts at a corner keeps its shape.
|
||||
/// `at` first moves the anchor, its handles with it (the Pen's closing drag with Space held).
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?;
|
||||
let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize);
|
||||
@@ -212,6 +213,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => Ok(None),
|
||||
Some(h) => finite_numbers(h).map(|[x, y]| Some(Point::new(x, y))).ok_or_else(|| bad("path.close", format!("`{key}` must be [x, y]"))),
|
||||
};
|
||||
let at = handle("at")?;
|
||||
let h_in = handle("in")?;
|
||||
let h_out = handle("out")?;
|
||||
let independent = switch_or(p, "independent", false, "path.close")?;
|
||||
@@ -223,6 +225,9 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
sp.closed = true;
|
||||
if let Some(first) = sp.anchors.first_mut() {
|
||||
if let Some(to) = at {
|
||||
first.translate(to - first.p);
|
||||
}
|
||||
if let Some(h) = h_in {
|
||||
first.set_handle(false, h, independent);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
//! - An EPS file (or a PostScript .ai) becomes one group, clipped to its bounding box or bounded by
|
||||
//! its art: the document an EPS file of ours carries, else what its PostScript draws, else its
|
||||
//! preview ([`fileio::load`]).
|
||||
//! - A DCS file, an EPS file with one program per printing plate, becomes one linked image of its
|
||||
//! plates ([`dcs`]); it is never embedded.
|
||||
//! - 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"`). With Link, a PDF/.ai/.ait page placed from a file becomes a placed
|
||||
@@ -32,6 +34,7 @@
|
||||
//! `place` tool) with several files, which emits one `file.place` per click or drag.
|
||||
|
||||
pub(crate) mod adopt;
|
||||
pub(crate) mod dcs;
|
||||
mod doc;
|
||||
pub(crate) mod document;
|
||||
pub(crate) mod docx;
|
||||
@@ -69,7 +72,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; 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; refused while an object of a symbol instance's art is selected), 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, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; 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, swatches and brushes they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
"{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; refused while an object of a symbol instance's art is selected), 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, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; 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, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
@@ -78,7 +81,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Placed File Info",
|
||||
[],
|
||||
None,
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), page?, crop?, password?, thumbnail?: px, link?: bool, dxf?: {…} (both as file.place)} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}",
|
||||
"{path | name | dataBase64 (a file's path, or its name with its bytes), page?, crop?, password?, thumbnail?: px, link?: bool, dxf?: {…} (both as file.place)} what file.place would place, without placing it → {name, format, width, height (pt at 100%), pixelWidth?, pixelHeight?, ppi?: [x, y], colorMode?: RGB|Grayscale|CMYK (raster images), plates?: [ink names] (a DCS file), warnings, thumbnailBase64?: PNG of at most `thumbnail` (≤ 512) px on its longer side}",
|
||||
always,
|
||||
info
|
||||
),
|
||||
@@ -114,6 +117,8 @@ enum Art {
|
||||
/// 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),
|
||||
/// A DCS file: one linked image of its plates, clipped by its clipping path ([`dcs`]).
|
||||
Dcs(Box<dcs::DcsArt>),
|
||||
Vector {
|
||||
/// The loaded document: the source of the art's resources.
|
||||
src: Box<Document>,
|
||||
@@ -205,6 +210,15 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
|
||||
));
|
||||
}
|
||||
let crop = opts.crop != CropTo::Bounding;
|
||||
// A DCS file stays linked to its file, which its plates print from; its parameters are checked
|
||||
// as an EPS file's are.
|
||||
if dcs::is_dcs(&src.bytes) {
|
||||
let path = src.path.filter(|_| bool_or(p, "link", true)).map(fileio::absolute_path).ok_or_else(|| bad(cmd, dcs::LINK_ONLY))?;
|
||||
let a = dcs::read(&src.bytes, Some(&path)).map_err(|e| bad(cmd, format!("{name}: {e}")))?;
|
||||
let link = super::links::link_info(&path, &src.bytes);
|
||||
let (natural, warnings) = (a.page, a.warnings.clone());
|
||||
return Ok(Loaded { name, format, art: Art::Dcs(Box::new(a)), natural, warnings, link: Some(link), board: None });
|
||||
}
|
||||
// A VectorCraft document: placed linked from its file, else as an editable copy.
|
||||
if matches!(format.id, "vectorcraft" | "template") {
|
||||
let page = page as u32;
|
||||
@@ -408,6 +422,21 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||||
}
|
||||
Art::Dcs(a) => {
|
||||
let a = *a;
|
||||
super::links::store_image(d, &a.img.key, a.img.blob, link.is_some());
|
||||
dcs::add_spot_swatches(d, &a.spots);
|
||||
let im =
|
||||
ImageObject { key: a.img.key, width: a.img.width, height: a.img.height, xf: a.xf, link, placement: Default::default(), pixels: None };
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
match a.clip {
|
||||
Some(c) => {
|
||||
let children = vec![Arc::new(d.reid(&c)), Arc::new(image)];
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })
|
||||
}
|
||||
None => image,
|
||||
}
|
||||
}
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural, &d.setup)))),
|
||||
Art::Document(s, link, bounding) => document::node(d, &s, link, bounding),
|
||||
Art::Vector { src, mut nodes, clip } => {
|
||||
@@ -506,9 +535,13 @@ impl PlaceJob {
|
||||
pub fn read(self) -> Result<PlaceRead> {
|
||||
let mut loaded = load(&self.params, PLACE, self.target, true)?;
|
||||
let linked = loaded.link.is_some() && bool_or(&self.params, "link", true);
|
||||
if let Art::Image(img) = &mut loaded.art {
|
||||
let unread = super::links::kept_unread(&img.blob.bytes);
|
||||
img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?;
|
||||
match &mut loaded.art {
|
||||
Art::Image(img) => {
|
||||
let unread = super::links::kept_unread(&img.blob.bytes);
|
||||
img.blob = super::links::readable_and_kept(img.blob.clone(), unread, linked)?;
|
||||
}
|
||||
Art::Dcs(a) => a.img.blob = super::links::readable(a.img.blob.clone(), false, true)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(PlaceRead { job: self, loaded })
|
||||
}
|
||||
@@ -628,7 +661,11 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result<Value> {
|
||||
let below = st.current_layer().or_else(|| parent.and_then(|p| st.doc.layer_of(p)));
|
||||
let link = loaded.link.take().filter(|_| bool_or(p, "link", true));
|
||||
let linked = link.is_some();
|
||||
let (name, format, warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings));
|
||||
let (name, format, mut warnings) = (loaded.name.clone(), loaded.format.id, std::mem::take(&mut loaded.warnings));
|
||||
let dcs_clipped = matches!(&loaded.art, Art::Dcs(a) if a.clip.is_some());
|
||||
if let Art::Dcs(a) = &loaded.art {
|
||||
warnings.extend(dcs::swatch_notes(&st.doc, &a.spots));
|
||||
}
|
||||
let (id, size) = s.edit("Place", |d, sel| {
|
||||
let node = transformed(build(d, loaded, link), m);
|
||||
let size = node.geometric_bounds_with(&d.symbol_boxes()).unwrap_or_default();
|
||||
@@ -650,9 +687,18 @@ fn apply(s: &mut Session, read: PlaceRead) -> Result<Value> {
|
||||
}
|
||||
Ok((id, size))
|
||||
})?;
|
||||
Ok(
|
||||
json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings }),
|
||||
)
|
||||
let mut out = json!({ "ids": [id.0], "name": name, "format": format, "linked": linked, "width": size.width(), "height": size.height(), "warnings": warnings });
|
||||
// A DCS file clipped by its clipping path: its image, which the Links commands take.
|
||||
if dcs_clipped {
|
||||
let image = s.doc()?.doc.node(id).and_then(|n| match &n.kind {
|
||||
NodeKind::Group { children, clip: true } => children.get(1).filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.id),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(image) = image {
|
||||
out["image"] = json!(image.0);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The artboard under `at` (its edges included, [`Document::artboard_holding`]), else the first.
|
||||
@@ -750,10 +796,14 @@ fn read_queue(p: Value, queued: Vec<Value>) -> Result<QueueRead> {
|
||||
// so that drawing it does not decode the file again.
|
||||
let checked = load(&q, QUEUE, None, false).and_then(|mut l| {
|
||||
let linked = l.link.is_some() && bool_or(&q, "link", true);
|
||||
if let Art::Image(img) = &mut l.art {
|
||||
let unread = super::links::kept_unread(&img.blob.bytes);
|
||||
let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable };
|
||||
img.blob = check(img.blob.clone(), unread, linked)?;
|
||||
match &mut l.art {
|
||||
Art::Image(img) => {
|
||||
let unread = super::links::kept_unread(&img.blob.bytes);
|
||||
let check = if thumbnail.is_some() { super::links::readable_and_kept } else { super::links::readable };
|
||||
img.blob = check(img.blob.clone(), unread, linked)?;
|
||||
}
|
||||
Art::Dcs(a) => a.img.blob = super::links::readable(a.img.blob.clone(), false, true)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(l)
|
||||
});
|
||||
@@ -871,6 +921,15 @@ pub fn read_info(p: &Value) -> Result<Value> {
|
||||
out["ppi"] = json!([x, y]);
|
||||
out["colorMode"] = json!(color_mode(&img.blob.bytes));
|
||||
}
|
||||
if let Art::Dcs(a) = &l.art {
|
||||
let (x, y) = a.img.ppi.unwrap_or((72.0, 72.0));
|
||||
out["pixelWidth"] = json!(a.img.width);
|
||||
out["pixelHeight"] = json!(a.img.height);
|
||||
out["ppi"] = json!([x, y]);
|
||||
out["colorMode"] = json!("CMYK");
|
||||
let plates: Vec<String> = a.img.blob.separated().map(|s| s.plates.iter().map(|p| p.name.clone()).collect()).unwrap_or_default();
|
||||
out["plates"] = json!(plates);
|
||||
}
|
||||
if let Some(px) = p.get("thumbnail").and_then(Value::as_f64) {
|
||||
out["thumbnailBase64"] = json!(thumbnail_png(l, px)?);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! DCS files (`file.place` with Link): an EPS file with one program per printing plate
|
||||
//! ([`vectorcraft_eps::dcs`]). It is placed as one linked image whose plates are kept with its
|
||||
//! pixels ([`vectorcraft_doc::separated`]): the screen and composite output show the plates mixed
|
||||
//! into CMYK, and print separations put each plate on its own ink. Its spot plates become spot
|
||||
//! swatches, and the clipping path its composite is drawn through clips it. A DCS file can't be
|
||||
//! opened or embedded (Embed, Place without Link): the image stays linked to its file, which the
|
||||
//! Links commands read again ([`raster`]).
|
||||
|
||||
use vectorcraft_color::swatch::Swatch;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::separated::PlateFile;
|
||||
use vectorcraft_doc::{Document, ImageBlob, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use super::super::fileio::{self, RasterImage};
|
||||
use super::super::{EngineError, Result};
|
||||
|
||||
/// The largest plate file read (bytes).
|
||||
const MAX_PLATE_FILE: u64 = 1 << 30;
|
||||
|
||||
/// What refuses to open or embed a DCS file.
|
||||
pub(crate) const LINK_ONLY: &str = "DCS files can only be placed linked: use File › Place with Link on";
|
||||
|
||||
/// A DCS file read for placing.
|
||||
pub(crate) struct DcsArt {
|
||||
pub img: RasterImage,
|
||||
/// The image's pixels onto the file's page (its bounding box, top-left at the origin).
|
||||
pub xf: Affine,
|
||||
pub page: Rect,
|
||||
/// The clipping path the composite is drawn through, on the page.
|
||||
pub clip: Option<Node>,
|
||||
/// The spot plates' inks and their process equivalents.
|
||||
pub spots: Vec<(String, [f32; 4])>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Is `bytes` a DCS file (an EPS file naming its plates)?
|
||||
pub(crate) fn is_dcs(bytes: &[u8]) -> bool {
|
||||
vectorcraft_eps::dcs::read(bytes).is_some()
|
||||
}
|
||||
|
||||
/// DCS file `bytes`, read from `path` (when there is one: plate files of their own are found in its
|
||||
/// folder), with every plate.
|
||||
pub(crate) fn read(bytes: &[u8], path: Option<&str>) -> Result<DcsArt> {
|
||||
let dcs = vectorcraft_eps::dcs::read(bytes).ok_or_else(|| EngineError::Other("not a DCS file".into()))?;
|
||||
let mut files = vec![];
|
||||
let mut file = |name: &str| plate_file(path, name, &mut files);
|
||||
let p = vectorcraft_eps::dcs::place(bytes, &dcs, &mut file).map_err(EngineError::Other)?;
|
||||
// The clipping path in the image's pixel space, where the Links commands put it again; the
|
||||
// image keeps it, so they know a clip group of that path as the file's.
|
||||
let to_pixels = (p.xf.determinant().abs() > 1e-12).then(|| p.xf.inverse());
|
||||
let clip = match (&p.clip, to_pixels) {
|
||||
(Some(Node { kind: NodeKind::Path { path, .. }, .. }), Some(inv)) => Some(path.transformed(inv)),
|
||||
_ => None,
|
||||
};
|
||||
let blob = ImageBlob::separated_tiff_clipped(&p.separated, p.ppi, &files, clip.as_ref())
|
||||
.ok_or_else(|| EngineError::Other("the plates can't be kept".into()))?;
|
||||
let img = RasterImage { key: blob.content_key(), blob, width: p.separated.width, height: p.separated.height, ppi: p.ppi, clip };
|
||||
let spots = p.separated.spots().map(|s| (s.name.clone(), s.cmyk)).collect();
|
||||
Ok(DcsArt { img, xf: p.xf, page: p.page, clip: p.clip, spots, warnings: p.warnings })
|
||||
}
|
||||
|
||||
/// A linked file's image, as the Links commands read it again: a DCS file's plates
|
||||
/// ([`read`]), else the image file ([`fileio::raster_image`]).
|
||||
pub(crate) fn raster(bytes: &[u8], path: Option<&str>) -> Result<RasterImage> {
|
||||
if is_dcs(bytes) { read(bytes, path).map(|a| a.img) } else { fileio::raster_image(bytes) }
|
||||
}
|
||||
|
||||
/// The path of the plate file a DCS file at `main` names `name`: the file of that name (its last
|
||||
/// component, of any system's path) in `main`'s folder, with that name.
|
||||
fn plate_path(main: Option<&str>, name: &str) -> std::result::Result<(String, String), String> {
|
||||
let base = name.rsplit(['/', '\\', ':']).next().unwrap_or(name);
|
||||
let folder = main.map(std::path::Path::new).and_then(std::path::Path::parent);
|
||||
let Some(folder) = folder.filter(|_| !base.is_empty()) else {
|
||||
return Err(format!("`{name}` is a file of its own, which can only be found next to the DCS file's path"));
|
||||
};
|
||||
Ok((folder.join(base).to_string_lossy().into_owned(), base.to_string()))
|
||||
}
|
||||
|
||||
/// The path of the plate file named `name` that the DCS file at `main` (a path as a link keeps
|
||||
/// it) was read with ([`plate_path`]); `None` when `main` has no folder.
|
||||
pub(crate) fn plate_file_path(main: &str, name: &str) -> Option<String> {
|
||||
plate_path(Some(main), name).ok().map(|(path, _)| path)
|
||||
}
|
||||
|
||||
/// The plate file a DCS file at `main` names `name` ([`plate_path`]), noted in `files` as it is now.
|
||||
fn plate_file(main: Option<&str>, name: &str, files: &mut Vec<PlateFile>) -> std::result::Result<Vec<u8>, String> {
|
||||
let (path, base) = plate_path(main, name)?;
|
||||
match fileio::file_stamp(&path) {
|
||||
None => Err(format!("`{base}` is not in the DCS file's folder")),
|
||||
Some((size, _)) if size > MAX_PLATE_FILE => Err(format!("`{base}` is larger than {} MB", MAX_PLATE_FILE >> 20)),
|
||||
Some((size, modified)) => {
|
||||
let bytes = fileio::read_file(&path).map_err(|e| e.to_string())?;
|
||||
files.push(PlateFile { name: base, size, modified: modified.unwrap_or(0) });
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether one of `files`, the plate files of their own a separated image was read from
|
||||
/// ([`ImageBlob::plate_files`]), next to the DCS file at `main`, is gone or changed since.
|
||||
pub(crate) fn plate_files_changed(files: &[PlateFile], main: &str) -> bool {
|
||||
files.iter().any(|f| {
|
||||
let stamp = plate_path(Some(main), &f.name).ok().and_then(|(path, _)| fileio::file_stamp(&path));
|
||||
stamp.is_none_or(|(size, modified)| size != f.size || modified.unwrap_or(0) != f.modified)
|
||||
})
|
||||
}
|
||||
|
||||
/// Add `spots` to `d` as spot swatches, each whose name no swatch has, in its process equivalent.
|
||||
pub(crate) fn add_spot_swatches(d: &mut Document, spots: &[(String, [f32; 4])]) {
|
||||
for (name, [c, m, y, k]) in spots {
|
||||
if d.swatch(name).is_none() {
|
||||
d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(Color::cmyk(*c, *m, *y, *k)), global: true, spot: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A warning for each of `spots` whose name a swatch of `d` already has that isn't a spot color in
|
||||
/// the same process equivalent: the document's swatch stays, and the plate prints by it.
|
||||
pub(crate) fn swatch_notes(d: &Document, spots: &[(String, [f32; 4])]) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
for (name, cmyk) in spots {
|
||||
let Some(sw) = d.swatch(name) else { continue };
|
||||
let same = sw.spot
|
||||
&& matches!(sw.paint.color(), Some(Color::Cmyk { c, m, y, k }) if [c, m, y, k].iter().zip(cmyk).all(|(a, b)| (a - b).abs() < 0.005));
|
||||
if !same {
|
||||
let how = if sw.spot { "a different color" } else { "a process color, so the plate prints as process inks" };
|
||||
out.push(format!("the document's swatch `{name}` is kept: it is {how}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -112,6 +112,9 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
|
||||
let mut pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
|
||||
pv.warnings.extend(type_warnings(doc, set.print_layers, &areas(&pv)));
|
||||
// What the job loses comes first, then the notes on the options.
|
||||
let settings = std::mem::take(&mut pv.setting_notes);
|
||||
pv.warnings.extend(settings);
|
||||
let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
v["settings"] = to_json(&set)?;
|
||||
Ok(v)
|
||||
@@ -199,7 +202,9 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut r =
|
||||
vectorcraft_pdf::print(printed, &PrintOptions { settings: set, for_printer: bool_or(p, "forPrinter", false), ..Default::default() })
|
||||
.map_err(|e| pdf_error(PRINT, e))?;
|
||||
// What the job loses comes first, then the notes on the options.
|
||||
r.warnings.extend(typed);
|
||||
r.warnings.extend(r.setting_notes);
|
||||
return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings }));
|
||||
}
|
||||
let level = match p.get("level").filter(|v| !v.is_null()) {
|
||||
@@ -209,12 +214,13 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => Level::default(),
|
||||
};
|
||||
let mut warnings = vec![];
|
||||
// What the job loses, then the notes on the options: a reply lists them in that order.
|
||||
let (mut warnings, mut setting_notes) = (vec![], vec![]);
|
||||
if !bitmap && (tile_images || doc.layers.iter().any(|l| l.shows_transparency())) {
|
||||
warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string());
|
||||
}
|
||||
if !set.color.profile.trim().is_empty() && set.output.mode == vectorcraft_pdf::OutputMode::Composite {
|
||||
warnings.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string());
|
||||
setting_notes.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string());
|
||||
}
|
||||
let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness);
|
||||
let plan = vectorcraft_pdf::plan(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
|
||||
@@ -244,5 +250,11 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let areas: Vec<Rect> = plan.sheets.iter().filter_map(|sh| on_page(sh.area, sh.view * sh.place, sh.size)).collect();
|
||||
warnings.extend(type_warnings(&full, which, &areas));
|
||||
for w in plan.setting_notes.iter().chain(&out.setting_notes) {
|
||||
if !warnings.contains(w) && !setting_notes.contains(w) {
|
||||
setting_notes.push(w.clone());
|
||||
}
|
||||
}
|
||||
warnings.extend(setting_notes);
|
||||
write_or_return(path, &out.bytes, json!({ "pages": plan.order.len(), "format": "postscript", "warnings": warnings }))
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select All Unused",
|
||||
["Window", "Swatches"],
|
||||
None,
|
||||
"{} → {names: [the swatches nothing uses, in panel order]}: a colour is used when a paint links to it or an unlinked paint, gradient stop or mesh point has its colour; a gradient when a gradient links to it or has its stops; a pattern when it fills or strokes anything (art, symbols, pattern tiles and graphic styles count). None is never listed",
|
||||
"{} → {names: [the swatches nothing uses, in panel order]}: a colour is used when a paint links to it or an unlinked paint, gradient stop or mesh point has its colour; a gradient when a gradient links to it or has its stops; a pattern when it fills or strokes anything (art, symbols, pattern tiles and graphic styles count); a color, spot or process, when an image placed from a DCS file that the art shows has a spot plate of its name. None is never listed",
|
||||
has_doc,
|
||||
swatch_unused
|
||||
),
|
||||
@@ -988,8 +988,28 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let colors: HashSet<ColorKey> = colors.iter().filter_map(color_key).collect();
|
||||
let patterns: HashSet<&String> = patterns.iter().collect();
|
||||
let gradients: HashSet<GradientKey> = gradients.iter().filter_map(gradient_key).collect();
|
||||
// The spot inks the images placed from DCS files have plates for: their swatches print them.
|
||||
// Only images the art shows count: a deleted one's pixels stay for undo.
|
||||
let mut shown: HashSet<&str> = HashSet::new();
|
||||
for root in d
|
||||
.layers
|
||||
.iter()
|
||||
.chain(d.symbols.iter().map(|s| &s.art))
|
||||
.chain(d.graph_designs.iter().map(|g| &g.art))
|
||||
.chain(d.patterns.iter().flat_map(|p| &p.art))
|
||||
{
|
||||
vectorcraft_doc::swatches::walk_with_masks(root, &mut |n| {
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
shown.insert(im.key.as_str());
|
||||
}
|
||||
});
|
||||
}
|
||||
let plate_inks: HashSet<String> =
|
||||
shown.iter().filter_map(|k| d.images.get(*k)).flat_map(vectorcraft_doc::ImageBlob::spot_plates).map(|(name, _)| name).collect();
|
||||
let used = |w: &Swatch| {
|
||||
gradient_links.contains(&w.name)
|
||||
// A spot swatch prints the plate; a process one gives the color it prints as process.
|
||||
|| (w.paint.color().is_some() && plate_inks.contains(&w.name))
|
||||
|| match w.tint_of() {
|
||||
Some((base, tint)) => float_key(tint).is_some_and(|t| link_tints.contains(&(base, t))),
|
||||
None => link_names.contains(w.name.as_str()),
|
||||
|
||||
@@ -184,7 +184,9 @@ impl Session {
|
||||
if changed.is_empty() {
|
||||
return Ok(none());
|
||||
}
|
||||
let ids: Vec<u64> = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).collect();
|
||||
// Each once: a DCS file and its plate files list the same objects.
|
||||
let mut seen = HashSet::new();
|
||||
let ids: Vec<u64> = changed.iter().flat_map(|(_, ids)| ids.iter().copied()).filter(|id| seen.insert(*id)).collect();
|
||||
let mode = if update { "automatically" } else { self.prefs.update_links.as_str() };
|
||||
match mode {
|
||||
"automatically" => {
|
||||
|
||||
@@ -44,6 +44,7 @@ mod containers;
|
||||
mod css;
|
||||
mod cut;
|
||||
mod dashalign;
|
||||
mod dcs;
|
||||
mod distort;
|
||||
mod docsetup;
|
||||
mod document_move;
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
//! DCS files placed with Link (`file.place`): one linked image of their plates, their spot plates
|
||||
//! as spot swatches, clipped by their composite's clipping path, never opened or embedded; and
|
||||
//! the Links panel's Show DCS Transparency Interactions (`links.list` with `dcsTransparency`).
|
||||
//! The files are made from scratch as http://www.telegraphics.net/doc/DCS.txt describes them.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A fresh scratch folder for one test.
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-dcs-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A plate program: one 2 × 2 grayscale image (`gray`, row by row) on a 20 × 10 pt page.
|
||||
fn plate_eps(gray: [u8; 4]) -> String {
|
||||
let hex: String = gray.iter().map(|g| format!("{g:02x}")).collect();
|
||||
format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n20 10 scale\n2 2 8 [2 0 0 -2 0 2] <{hex}> image\nshowpage\n%%Trailer\n%%EOF\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// A single-file DCS 2.0 file of `plates` (name, plate program), with the header lines `extra`; its
|
||||
/// composite is drawn through a clipping path, the left half of the page.
|
||||
fn single_file(plates: &[(&str, String)], extra: &str) -> Vec<u8> {
|
||||
let header = |offsets: &[usize]| {
|
||||
let mut s = String::from("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n");
|
||||
s.push_str(extra);
|
||||
for ((name, eps), at) in plates.iter().zip(offsets) {
|
||||
s.push_str(&format!("%%PlateFile: ({name}) EPS #{at:010} {:010}\n", eps.len()));
|
||||
}
|
||||
s.push_str("%%EndComments\ngsave newpath 0 0 moveto 10 0 lineto 10 10 lineto 0 10 lineto closepath clip newpath\n");
|
||||
s.push_str("20 10 scale 2 2 8 [2 0 0 -2 0 2] <808080808080808080808080> false 3 colorimage grestore showpage\n%%EOF\n");
|
||||
s
|
||||
};
|
||||
let main = header(&vec![0; plates.len()]).len();
|
||||
let mut offsets = vec![];
|
||||
let mut at = main;
|
||||
for (_, eps) in plates {
|
||||
offsets.push(at);
|
||||
at += eps.len();
|
||||
}
|
||||
let mut out = header(&offsets).into_bytes();
|
||||
for (_, eps) in plates {
|
||||
out.extend(eps.as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Cyan solid on the top row, PANTONE 185 C at 50% on the left column.
|
||||
fn two_plates() -> Vec<u8> {
|
||||
single_file(
|
||||
&[("Cyan", plate_eps([0, 0, 255, 255])), ("PANTONE 185 C", plate_eps([127, 255, 127, 255]))],
|
||||
"%%DocumentCustomColors: (PANTONE 185 C)\n%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n",
|
||||
)
|
||||
}
|
||||
|
||||
fn write(dir: &std::path::Path, name: &str, bytes: &[u8]) -> String {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// The image inside placed object `id` (itself, or the image of its clipping group).
|
||||
fn image_of(s: &Session, id: &Value) -> Node {
|
||||
let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
|
||||
let mut found = None;
|
||||
n.walk(&mut |c: &Node| {
|
||||
if found.is_none() && matches!(c.kind, NodeKind::Image(_)) {
|
||||
found = Some(c.clone());
|
||||
}
|
||||
});
|
||||
found.expect("an image")
|
||||
}
|
||||
|
||||
fn error(s: &mut Session, cmd: &str, p: Value) -> String {
|
||||
s.execute(cmd, &p).unwrap_err().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dcs_file_places_as_one_linked_image_of_its_plates() {
|
||||
let dir = temp_dir("place");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
|
||||
assert_eq!(r["linked"], true);
|
||||
assert_eq!(r["warnings"], json!([]));
|
||||
let id = &r["ids"][0];
|
||||
let placed = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone();
|
||||
let NodeKind::Group { children, clip: true } = &placed.kind else { panic!("a clipping group: {:?}", placed.kind) };
|
||||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }), "the composite's clipping path");
|
||||
assert_eq!(children[0].geometric_bounds().map(|b| b.width().round()), Some(10.0));
|
||||
assert_eq!(r["width"].as_f64().map(f64::round), Some(10.0), "clipped to the left half of its 20 pt page");
|
||||
let image = image_of(&s, id);
|
||||
let NodeKind::Image(im) = &image.kind else { not_an_image() };
|
||||
assert!(im.link.as_ref().is_some_and(|l| l.path.ends_with("pic.eps")));
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let plates = doc.images[&im.key].separated().expect("its plates are kept");
|
||||
assert_eq!(plates.plates.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Cyan", "PANTONE 185 C"]);
|
||||
assert_eq!(plates.plates[1].data, [128, 0, 128, 0]);
|
||||
let sw = doc.swatch("PANTONE 185 C").expect("a spot swatch for the spot plate");
|
||||
assert!(sw.spot);
|
||||
assert_eq!(sw.paint, Paint::solid(Color::cmyk(0.0, 0.91, 0.76, 0.0)));
|
||||
assert!(!doc.swatches_iter().any(|w| w.spot && w.name == "Cyan"), "process plates add no spot swatch");
|
||||
let list = s.execute("links.list", &json!({})).unwrap();
|
||||
assert_eq!(list["links"][0]["format"], "DCS");
|
||||
assert_eq!(list["links"][0]["status"], "ok");
|
||||
let info = s.execute("file.place.info", &json!({ "path": path })).unwrap();
|
||||
assert_eq!(info["plates"], json!(["Cyan", "PANTONE 185 C"]));
|
||||
assert_eq!(info["colorMode"], "CMYK");
|
||||
assert_eq!(info["pixelWidth"], 2);
|
||||
// One undo step takes the swatch away too.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.swatch("PANTONE 185 C").is_none());
|
||||
}
|
||||
|
||||
fn not_an_image() -> ! {
|
||||
panic!("an image")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dcs_file_is_never_opened_or_embedded() {
|
||||
let dir = temp_dir("refused");
|
||||
let bytes = two_plates();
|
||||
let path = write(&dir, "pic.eps", &bytes);
|
||||
let mut s = session();
|
||||
let only = "DCS files can only be placed linked";
|
||||
assert!(error(&mut s, "file.place", json!({ "path": path, "link": false })).contains(only));
|
||||
let data = json!({ "name": "pic.eps", "dataBase64": vectorcraft_format::base64_encode(&bytes) });
|
||||
assert!(error(&mut s, "file.place", data).contains(only), "bytes alone can't be linked");
|
||||
assert!(error(&mut s, "document.open", json!({ "path": path })).contains(only));
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let image = image_of(&s, &r["ids"][0]);
|
||||
let e = error(&mut s, "links.embed", json!({ "ids": [image.id.0] }));
|
||||
assert!(e.contains(only), "{e}");
|
||||
let NodeKind::Image(im) = &image_of(&s, &r["ids"][0]).kind else { not_an_image() };
|
||||
assert!(im.link.is_some(), "still linked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swatch_of_the_same_name_is_kept_with_a_warning() {
|
||||
let dir = temp_dir("swatch");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "PANTONE 185 C", "color": {"c": 0.0, "m": 1.0, "y": 1.0, "k": 0.0}, "spot": true})).unwrap();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
assert_eq!(r["warnings"], json!(["the document's swatch `PANTONE 185 C` is kept: it is a different color"]));
|
||||
let sw = s.doc().unwrap().doc.swatch("PANTONE 185 C").unwrap().clone();
|
||||
assert_eq!(sw.paint.color(), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plate_files_of_their_own_are_read_from_the_dcs_files_folder() {
|
||||
let dir = temp_dir("files");
|
||||
write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes());
|
||||
write(&dir, "pic.K", plate_eps([255, 255, 255, 0]).as_bytes());
|
||||
let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: OldDisk:Scans:pic.C\n%%BlackPlate: pic.K\n%%EndComments\nshowpage\n";
|
||||
let path = write(&dir, "pic.eps", main.as_bytes());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let image = image_of(&s, &r["ids"][0]);
|
||||
assert!(matches!(image.kind, NodeKind::Image(_)), "no clipping path without a composite");
|
||||
let NodeKind::Image(im) = &image.kind else { not_an_image() };
|
||||
let plates = s.doc().unwrap().doc.images[&im.key].separated().unwrap();
|
||||
assert_eq!(plates.plates[0].data, [255, 0, 0, 0], "the plate file found by its name in the folder");
|
||||
assert_eq!(plates.plates[1].data, [0, 0, 0, 255]);
|
||||
std::fs::remove_file(dir.join("pic.K")).unwrap();
|
||||
let e = error(&mut s, "file.place", json!({ "path": path }));
|
||||
assert!(e.contains("the Black plate can't be read") && e.contains("`pic.K` is not in the DCS file's folder"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_link_reads_the_plates_again() {
|
||||
let dir = temp_dir("update");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let id = image_of(&s, &r["ids"][0]).id;
|
||||
let changed = single_file(&[("Cyan", plate_eps([0, 0, 0, 0])), ("Varnish", plate_eps([255; 4]))], "%%CMYKCustomColor: 0 0 .2 0 (Varnish)\n");
|
||||
std::fs::write(&path, &changed).unwrap();
|
||||
let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
assert_eq!(u["updated"], json!([id.0]));
|
||||
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() };
|
||||
let plates = s.doc().unwrap().doc.images[&im.key].separated().unwrap();
|
||||
assert_eq!(plates.plates.len(), 2);
|
||||
assert_eq!(plates.plates[0].data, [255; 4]);
|
||||
let sw = s.doc().unwrap().doc.swatch("Varnish").expect("a spot swatch for the plate the file gained");
|
||||
assert!(sw.spot);
|
||||
assert_eq!(sw.paint, Paint::solid(Color::cmyk(0.0, 0.0, 0.2, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_link_takes_the_files_new_clipping_path() {
|
||||
let dir = temp_dir("update-clip");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
|
||||
let group = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let id = image_of(&s, &r["ids"][0]).id;
|
||||
let clip_width = |s: &Session| {
|
||||
let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(group).unwrap().kind else { panic!("a clipping group") };
|
||||
children[0].geometric_bounds().map(|b| b.width())
|
||||
};
|
||||
let before = clip_width(&s).unwrap();
|
||||
// The same file, its composite now drawn through the left quarter of the page (same length,
|
||||
// so the plates stay where the header says).
|
||||
let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1);
|
||||
std::fs::write(&path, text.as_bytes()).unwrap();
|
||||
let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
assert_eq!(u["updated"], json!([id.0]));
|
||||
let after = clip_width(&s).unwrap();
|
||||
assert!((after - before / 2.0).abs() < 1e-6, "the clipping path follows the file: {before} → {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_link_clips_an_image_whose_file_gained_a_clipping_path() {
|
||||
let dir = temp_dir("update-gained-clip");
|
||||
// The composite drawn without its clipping path (same length).
|
||||
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
|
||||
let path = write(&dir, "pic.eps", unclipped.as_bytes());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::Image(_)), "no clipping path, no group");
|
||||
std::fs::write(&path, two_plates()).unwrap();
|
||||
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let group = doc.node(doc.parent_of(id).expect("a parent")).unwrap();
|
||||
let NodeKind::Group { children, clip: true } = &group.kind else { panic!("a clipping group: {:?}", group.kind) };
|
||||
assert_eq!(children[0].geometric_bounds().map(|b| b.width().round()), Some(10.0), "the left half of the page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_plate_file_marks_the_link_modified() {
|
||||
let dir = temp_dir("plate-changed");
|
||||
write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes());
|
||||
let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n";
|
||||
let path = write(&dir, "pic.eps", main.as_bytes());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let id = image_of(&s, &r["ids"][0]).id;
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "ok");
|
||||
// Only the plate file changes (a size of its own, so the change shows whatever the clock).
|
||||
write(&dir, "pic.C", format!("{}% more\n", plate_eps([255, 255, 255, 0])).as_bytes());
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "modified");
|
||||
let u = s.execute("links.update", &json!({})).unwrap();
|
||||
assert_eq!(u["updated"], json!([id.0]));
|
||||
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() };
|
||||
assert_eq!(s.doc().unwrap().doc.images[&im.key].separated().unwrap().plates[0].data, [0, 0, 0, 255]);
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "ok");
|
||||
std::fs::remove_file(dir.join("pic.C")).unwrap();
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()["links"][0]["status"], "modified", "a plate file that is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_link_watch_reads_the_link_again_when_only_a_plate_file_changes() {
|
||||
let dir = temp_dir("plate-watch");
|
||||
write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes());
|
||||
let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n";
|
||||
let path = write(&dir, "pic.eps", main.as_bytes());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let id = image_of(&s, &r["ids"][0]).id;
|
||||
let files: Vec<String> = crate::link_watch::linked_files(&s.doc().unwrap().doc).into_iter().map(|(p, _)| p).collect();
|
||||
assert!(files.iter().any(|p| p.ends_with("pic.C")), "the plate file is watched too: {files:?}");
|
||||
// The first look only remembers the files.
|
||||
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([]));
|
||||
write(&dir, "pic.C", format!("{}% more\n", plate_eps([255, 255, 255, 0])).as_bytes());
|
||||
assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([id.0]));
|
||||
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() };
|
||||
assert_eq!(s.doc().unwrap().doc.images[&im.key].separated().unwrap().plates[0].data, [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
/// The width of the clipping path of the clip group holding the image in the art of symbol
|
||||
/// `name`; `None` when no clip group holds it.
|
||||
fn symbol_clip_width(s: &Session, name: &str) -> Option<f64> {
|
||||
fn find(n: &Node) -> Option<Option<f64>> {
|
||||
if let NodeKind::Group { children, clip: true } = &n.kind
|
||||
&& let [c, im] = &children[..]
|
||||
&& matches!(im.kind, NodeKind::Image(_))
|
||||
{
|
||||
return Some(c.geometric_bounds().map(|b| b.width()));
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Image(_)) {
|
||||
return Some(None);
|
||||
}
|
||||
n.children().and_then(|ch| ch.iter().find_map(|c| find(c)))
|
||||
}
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
find(&doc.symbols.iter().find(|w| w.name == name).expect("the symbol").art).expect("an image in the symbol")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_link_takes_the_files_clipping_path_into_symbols_too() {
|
||||
let dir = temp_dir("update-symbol-clip");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
|
||||
let id = image_of(&s, &r["ids"][0]).id;
|
||||
let other = s.execute("file.place", &json!({ "path": path, "at": [200, 100] })).unwrap();
|
||||
s.execute("symbol.new", &json!({ "name": "Pic", "ids": [other["ids"][0]] })).unwrap();
|
||||
let before = symbol_clip_width(&s, "Pic").expect("the file's clipping path");
|
||||
// The composite now drawn through the left quarter of the page (same length).
|
||||
let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1);
|
||||
std::fs::write(&path, text.as_bytes()).unwrap();
|
||||
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
let after = symbol_clip_width(&s, "Pic").expect("still clipped");
|
||||
assert!((after - before / 2.0).abs() < 1e-6, "the symbol's clipping path follows the file: {before} → {after}");
|
||||
// The file without a clipping path (same length): the symbol's image is no longer clipped.
|
||||
let unclipped = text.replacen("closepath clip newpath", "closepath newpath ", 1);
|
||||
std::fs::write(&path, unclipped.as_bytes()).unwrap();
|
||||
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
assert_eq!(symbol_clip_width(&s, "Pic"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_lost_its_clipping_path_unclips_the_image() {
|
||||
let dir = temp_dir("update-lost-clip");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let id = image_of(&s, &r["ids"][0]).id;
|
||||
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
|
||||
std::fs::write(&path, unclipped.as_bytes()).unwrap();
|
||||
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let placed = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
assert_eq!(doc.node(placed).and_then(plain_group_of_image), Some((false, true)), "the placed group stays, without the clip");
|
||||
let parent = doc.parent_of(id).expect("a parent");
|
||||
assert!(!matches!(doc.node(parent).unwrap().kind, NodeKind::Group { clip: true, .. }), "the image is not clipped");
|
||||
// Relinked to a plain image: no clip group either.
|
||||
let png = dir.join("plain.png");
|
||||
image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap();
|
||||
std::fs::write(&path, two_plates()).unwrap();
|
||||
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
assert!(
|
||||
s.doc().unwrap().doc.parent_of(id).is_some_and(|p| matches!(s.doc().unwrap().doc.node(p).unwrap().kind, NodeKind::Group { clip: true, .. }))
|
||||
);
|
||||
s.execute("links.relink", &json!({ "ids": [id.0], "path": png.to_string_lossy() })).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(
|
||||
!matches!(doc.node(doc.parent_of(id).unwrap()).unwrap().kind, NodeKind::Group { clip: true, .. }),
|
||||
"a plain image isn't clipped by the DCS file's path"
|
||||
);
|
||||
}
|
||||
|
||||
/// The kind of group `g` is, as (clip, the kinds of its children are just one image).
|
||||
fn plain_group_of_image(g: &Node) -> Option<(bool, bool)> {
|
||||
match &g.kind {
|
||||
NodeKind::Group { children, clip } => Some((*clip, matches!(&children[..], [c] if matches!(c.kind, NodeKind::Image(_))))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_or_transparent_clip_group_loses_the_files_old_clipping_path() {
|
||||
let dir = temp_dir("lost-clip-kept-group");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let place = |s: &mut Session, x: f64| s.execute("file.place", &json!({ "path": path, "at": [x, 100] })).unwrap();
|
||||
let (faded, hidden, in_symbol) = (place(&mut s, 100.0), place(&mut s, 200.0), place(&mut s, 300.0));
|
||||
let (faded, hidden) = (NodeId(faded["ids"][0].as_u64().unwrap()), NodeId(hidden["ids"][0].as_u64().unwrap()));
|
||||
let image = image_of(&s, &json!(faded.0)).id;
|
||||
s.execute("transparency.set", &json!({"ids": [faded.0, in_symbol["ids"][0]], "opacity": 50})).unwrap();
|
||||
s.edit("hide", |d, _| {
|
||||
if let Some(n) = d.node_mut(hidden) {
|
||||
n.visible = false;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("symbol.new", &json!({ "name": "Pic", "ids": [in_symbol["ids"][0]] })).unwrap();
|
||||
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
|
||||
std::fs::write(&path, unclipped.as_bytes()).unwrap();
|
||||
let hidden_image = image_of(&s, &json!(hidden.0)).id;
|
||||
s.execute("links.update", &json!({ "ids": [image.0, hidden_image.0] })).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
for (g, why) in [(faded, "the transparent group"), (hidden, "the hidden group")] {
|
||||
let n = doc.node(g).unwrap_or_else(|| panic!("{why} stays"));
|
||||
assert_eq!(plain_group_of_image(n), Some((false, true)), "{why} holds the image alone, unclipped");
|
||||
}
|
||||
assert!((doc.node(faded).unwrap().opacity - 0.5).abs() < 1e-6, "its transparency kept");
|
||||
assert!(!doc.node(hidden).unwrap().visible, "still hidden");
|
||||
// In a symbol's art too.
|
||||
let art = &doc.symbols.iter().find(|w| w.name == "Pic").expect("the symbol").art;
|
||||
let mut found = None;
|
||||
art.walk(&mut |n: &Node| {
|
||||
if found.is_none() && n.children().is_some_and(|ch| ch.iter().any(|c| matches!(c.kind, NodeKind::Image(_)))) {
|
||||
found = plain_group_of_image(n);
|
||||
}
|
||||
});
|
||||
assert_eq!(found, Some((false, true)), "the symbol's transparent group holds the image alone, unclipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipping_mask_of_ones_own_stays_when_the_link_is_updated() {
|
||||
let dir = temp_dir("own-mask");
|
||||
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
|
||||
let path = write(&dir, "pic.eps", unclipped.as_bytes());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
|
||||
let id = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 95, "y": 95, "width": 4, "height": 4})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({ "ids": [id.0, rect] })).unwrap();
|
||||
let g = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(g), "the clipping mask made in the document stays");
|
||||
let mask_width = |s: &Session| {
|
||||
let NodeKind::Group { children, .. } = &s.doc().unwrap().doc.node(g).unwrap().kind else { panic!("the mask's group") };
|
||||
children[0].geometric_bounds().map(|b| b.width().round())
|
||||
};
|
||||
assert_eq!(mask_width(&s), Some(4.0));
|
||||
// The file gains a clipping path: its clip group goes inside the mask, which keeps its path.
|
||||
std::fs::write(&path, two_plates()).unwrap();
|
||||
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let inner = doc.parent_of(id).expect("a parent");
|
||||
assert_ne!(inner, g);
|
||||
assert!(matches!(doc.node(inner).unwrap().kind, NodeKind::Group { clip: true, .. }), "the file's clip group");
|
||||
assert_eq!(doc.parent_of(inner), Some(g));
|
||||
assert_eq!(mask_width(&s), Some(4.0), "the mask's path is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dcs_file_takes_the_place_parameters_an_eps_file_takes() {
|
||||
let dir = temp_dir("params");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
assert!(error(&mut s, "file.place", json!({ "path": path, "page": 0 })).contains("page"));
|
||||
assert!(error(&mut s, "file.place", json!({ "path": path, "crop": "trim" })).contains("crop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipped_placement_answers_its_image_for_the_links_commands() {
|
||||
let dir = temp_dir("image-id");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
assert_eq!(r["image"], json!(image_of(&s, &r["ids"][0]).id.0));
|
||||
let info = s.execute("links.info", &json!({ "id": r["image"] })).unwrap();
|
||||
assert_eq!(info["format"], "DCS");
|
||||
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
|
||||
let plain = write(&dir, "plain.eps", unclipped.as_bytes());
|
||||
assert!(s.execute("file.place", &json!({ "path": plain })).unwrap().get("image").is_none(), "an image placed alone is its own id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_and_relink_warn_of_a_swatch_kept_for_a_new_spot_plate() {
|
||||
let dir = temp_dir("update-swatch");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let id = image_of(&s, &r["ids"][0]).id;
|
||||
s.execute("swatch.new", &json!({"name": "Varnish", "color": {"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap();
|
||||
let changed = single_file(&[("Cyan", plate_eps([0, 0, 0, 0])), ("Varnish", plate_eps([255; 4]))], "%%CMYKCustomColor: 0 0 .2 0 (Varnish)\n");
|
||||
std::fs::write(&path, &changed).unwrap();
|
||||
let u = s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
let kept = "the document's swatch `Varnish` is kept: it is a process color, so the plate prints as process inks";
|
||||
assert_eq!(u["warnings"], json!([kept]));
|
||||
let other = write(&dir, "other.eps", &changed);
|
||||
let r = s.execute("links.relink", &json!({ "ids": [id.0], "path": other })).unwrap();
|
||||
assert_eq!(r["warnings"], json!([kept]));
|
||||
assert!(s.execute("links.update", &json!({ "ids": [id.0] })).unwrap().get("warnings").is_some(), "every read warns");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_dcs_image_leaves_its_spot_swatch_unused() {
|
||||
let dir = temp_dir("unused");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C");
|
||||
assert!(!unused(&mut s), "in use while the image shows it");
|
||||
s.execute("select.set", &json!({ "ids": [r["ids"][0]] })).unwrap();
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert!(unused(&mut s), "unused once the image is deleted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_process_swatch_giving_a_dcs_plate_its_color_is_used() {
|
||||
let dir = temp_dir("unused-process");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
s.execute("swatch.new", &json!({"name": "PANTONE 185 C", "color": {"c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap();
|
||||
let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C");
|
||||
assert!(unused(&mut s), "nothing uses it yet");
|
||||
s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
assert!(!unused(&mut s), "the plate of its name prints as its process inks");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opened_clipping_path_is_not_the_files_and_stays() {
|
||||
let dir = temp_dir("opened-clip");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let group = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let id = image_of(&s, &r["ids"][0]).id;
|
||||
// The clipping path opened, its anchors where they were.
|
||||
s.edit("open", |d, _| {
|
||||
let clip = d.node(group).and_then(|g| g.children()).map(|ch| ch[0].id).unwrap();
|
||||
if let Some(Node { kind: NodeKind::Path { path, .. }, .. }) = d.node_mut(clip) {
|
||||
path.subpaths[0].closed = false;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
|
||||
std::fs::write(&path, unclipped.as_bytes()).unwrap();
|
||||
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(group), "a clipping path edited in the document stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipping_path_given_another_fill_rule_is_not_the_files_and_stays() {
|
||||
let dir = temp_dir("rule-clip");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let group = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let id = image_of(&s, &r["ids"][0]).id;
|
||||
// The clipping path's fill rule changed, its anchors where they were.
|
||||
s.edit("rule", |d, _| {
|
||||
let clip = d.node(group).and_then(|g| g.children()).map(|ch| ch[0].id).unwrap();
|
||||
if let Some(Node { kind: NodeKind::Path { rule, .. }, .. }) = d.node_mut(clip) {
|
||||
*rule = vectorcraft_geom::FillRule::EvenOdd;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
|
||||
std::fs::write(&path, unclipped.as_bytes()).unwrap();
|
||||
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.parent_of(id), Some(group), "a clipping path whose fill rule was changed stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_even_odd_clipping_path_is_placed_and_matched_under_the_nonzero_rule() {
|
||||
let dir = temp_dir("eo-clip");
|
||||
// The same length, so the plates' offsets hold.
|
||||
let eo = String::from_utf8(two_plates()).unwrap().replacen("gsave newpath 0 0 moveto", "gsave 0 0 moveto ", 1);
|
||||
let eo = eo.replacen("closepath clip newpath", "closepath eoclip newpath", 1);
|
||||
assert_eq!(eo.len(), two_plates().len());
|
||||
let path = write(&dir, "pic.eps", eo.as_bytes());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let group = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let id = image_of(&s, &r["ids"][0]).id;
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let clip = &doc.node(group).and_then(|g| g.children()).expect("the file's clip group")[0];
|
||||
assert!(matches!(clip.kind, NodeKind::Path { rule: vectorcraft_geom::FillRule::NonZero, clipping: true, .. }), "{:?}", clip.kind);
|
||||
// The file without its clipping path: the group's clipping path is the file's, and goes.
|
||||
let unclipped = String::from_utf8(two_plates()).unwrap().replacen("closepath clip newpath", "closepath newpath ", 1);
|
||||
std::fs::write(&path, unclipped.as_bytes()).unwrap();
|
||||
s.execute("links.update", &json!({ "ids": [id.0] })).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(group).and_then(plain_group_of_image), Some((false, true)), "the file's clipping path is gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_updated_together_warn_when_they_add_one_ink_in_two_colors() {
|
||||
let dir = temp_dir("update-two-inks");
|
||||
let (a, b) = (write(&dir, "a.eps", &two_plates()), write(&dir, "b.eps", &two_plates()));
|
||||
let mut s = session();
|
||||
s.execute("file.place", &json!({ "path": a })).unwrap();
|
||||
s.execute("file.place", &json!({ "path": b })).unwrap();
|
||||
let varnish =
|
||||
|c: &str| single_file(&[("Cyan", plate_eps([0; 4])), ("Varnish", plate_eps([255; 4]))], &format!("%%CMYKCustomColor: {c} (Varnish)\n"));
|
||||
std::fs::write(&a, varnish("0 0 .2 0")).unwrap();
|
||||
std::fs::write(&b, varnish("1 0 0 0")).unwrap();
|
||||
let u = s.execute("links.update", &json!({})).unwrap();
|
||||
assert_eq!(u["updated"].as_array().map(Vec::len), Some(2), "{u}");
|
||||
assert_eq!(u["warnings"], json!(["the document's swatch `Varnish` is kept: it is a different color"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relink_with_copy_refuses_a_dcs_file_with_plate_files() {
|
||||
let dir = temp_dir("copy");
|
||||
write(&dir, "pic.C", plate_eps([0, 255, 255, 255]).as_bytes());
|
||||
let main = "%!PS-Adobe-2.0 EPSF-1.2\n%%BoundingBox: 0 0 20 10\n%%CyanPlate: pic.C\n%%EndComments\nshowpage\n";
|
||||
let path = write(&dir, "pic.eps", main.as_bytes());
|
||||
let docs = dir.join("docs");
|
||||
std::fs::create_dir_all(&docs).unwrap();
|
||||
let mut s = session();
|
||||
s.execute("document.save", &json!({ "path": docs.join("doc.vectorcraft").to_string_lossy() })).unwrap();
|
||||
let png = dir.join("plain.png");
|
||||
image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap();
|
||||
let r = s.execute("file.place", &json!({ "path": png.to_string_lossy() })).unwrap();
|
||||
let id = r["ids"][0].clone();
|
||||
let e = error(&mut s, "links.relink", json!({ "ids": [id], "path": path, "copy": true }));
|
||||
assert!(e.contains("plate files"), "{e}");
|
||||
assert!(!docs.join("pic.eps").exists(), "nothing is copied");
|
||||
// A single-file DCS file has its plates inside it: it is copied.
|
||||
let single = write(&dir, "single.eps", &two_plates());
|
||||
s.execute("links.relink", &json!({ "ids": [id], "path": single, "copy": true })).unwrap();
|
||||
assert!(docs.join("single.eps").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_document_reads_the_plates_again_and_prints_the_spot_ink() {
|
||||
let dir = temp_dir("saved");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let saved = dir.join("doc.vectorcraft").to_string_lossy().into_owned();
|
||||
let mut s = session();
|
||||
s.execute("file.place", &json!({ "path": path })).unwrap();
|
||||
let unused = s.execute("swatch.unused", &json!({})).unwrap();
|
||||
assert!(!unused["names"].as_array().unwrap().iter().any(|n| n == "PANTONE 185 C"), "the plate's swatch is in use: {unused}");
|
||||
s.execute("document.save", &json!({ "path": saved })).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({ "path": saved })).unwrap();
|
||||
let list = s.execute("links.list", &json!({})).unwrap();
|
||||
assert_eq!(list["links"][0]["format"], "DCS");
|
||||
assert_eq!(list["links"][0]["status"], "ok");
|
||||
let id = NodeId(list["links"][0]["id"].as_u64().unwrap());
|
||||
let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() };
|
||||
let plates = s.doc().unwrap().doc.images[&im.key].separated().expect("the plates, read from the file again");
|
||||
assert_eq!(plates.plates[1].data, [128, 0, 128, 0]);
|
||||
let v = s.execute("print.preview", &json!({"settings": {"output": {"mode": "separations"}}})).unwrap();
|
||||
let inks: Vec<&str> = v["sheets"].as_array().unwrap().iter().filter_map(|p| p["ink"].as_str()).collect();
|
||||
assert_eq!(inks, ["Cyan", "Magenta", "Yellow", "Black", "PANTONE 185 C"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() {
|
||||
let dir = temp_dir("transparency");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
|
||||
let image = image_of(&s, &r["ids"][0]).id;
|
||||
let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true;
|
||||
assert!(!marked(&mut s), "nothing transparent");
|
||||
assert!(s.execute("links.list", &json!({})).unwrap()["links"][0].get("dcsTransparency").is_none());
|
||||
// A half-transparent square away from it, then over it.
|
||||
let far = s.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [far], "opacity": 50})).unwrap();
|
||||
assert!(!marked(&mut s), "a transparent object elsewhere");
|
||||
s.execute("shape.rectangle", &json!({"x": 90, "y": 90, "width": 20, "height": 20})).unwrap();
|
||||
let over = s.execute("shape.rectangle", &json!({"x": 92, "y": 92, "width": 4, "height": 4})).unwrap()["id"].as_u64().unwrap();
|
||||
assert!(!marked(&mut s), "opaque objects over it");
|
||||
s.execute("transparency.set", &json!({"ids": [over], "blend": "multiply"})).unwrap();
|
||||
assert!(marked(&mut s), "a blend mode over it");
|
||||
s.execute("transparency.set", &json!({"ids": [over], "blend": "normal"})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [image.0], "opacity": 80})).unwrap();
|
||||
assert!(marked(&mut s), "the image itself");
|
||||
assert!(error(&mut s, "links.list", json!({ "dcsTransparency": "yes" })).contains("dcsTransparency"), "a flag that isn't true or false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcs_transparency_counts_a_transparent_dcs_image_over_another() {
|
||||
let dir = temp_dir("transparency-two");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let a = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
|
||||
let b = s.execute("file.place", &json!({ "path": path, "at": [103, 100] })).unwrap();
|
||||
let (a, b) = (image_of(&s, &a["ids"][0]).id, image_of(&s, &b["ids"][0]).id);
|
||||
s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 80})).unwrap();
|
||||
let list = s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap();
|
||||
let marked = |id: NodeId| list["links"].as_array().unwrap().iter().any(|r| r["id"] == id.0 && r["dcsTransparency"] == true);
|
||||
assert!(marked(a), "the transparent image");
|
||||
assert!(marked(b), "the opaque image it overlaps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcs_transparency_counts_characters_with_a_transparent_gradient() {
|
||||
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let dir = temp_dir("transparency-text");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 96, "y": 104, "text": "Hi", "size": 12})).unwrap()["id"].as_u64().unwrap());
|
||||
let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true;
|
||||
assert!(!marked(&mut s), "opaque characters over it");
|
||||
let mut stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
stops[1].opacity = 0.5;
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
s.edit("t", |d, _| {
|
||||
if let Some(Node { kind: NodeKind::Text(text), .. }) = d.node_mut(t) {
|
||||
text.runs[0].style.fill = gradient.clone();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(marked(&mut s), "characters filled with a gradient that has a transparent stop");
|
||||
assert!(s.doc().unwrap().doc.node(t).unwrap().shows_transparency());
|
||||
}
|
||||
@@ -3278,6 +3278,14 @@ fn close_path_places_the_first_anchors_handles() {
|
||||
s.execute("path.close", &json!({"id": id.0, "in": [60.0, 140.0], "out": [120.0, 60.0]})).unwrap();
|
||||
let b = path(&s, id).subpaths[0].anchors[0];
|
||||
assert_eq!((b.h_in, b.h_out, b.kind), (Point::new(60.0, 140.0), Point::new(120.0, 60.0), AnchorKind::Corner));
|
||||
// `at` moves the anchor, its handles with it, before `in` is placed.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
refuses_with(&mut s, "path.close", &json!({"id": id.0, "at": "120, 90"}), "`at` must be [x, y]");
|
||||
s.execute("path.close", &json!({"id": id.0, "at": [120.0, 90.0], "in": [60.0, 140.0]})).unwrap();
|
||||
let b = path(&s, id).subpaths[0].anchors[0];
|
||||
assert_eq!((b.p, b.h_in, b.h_out), (Point::new(120.0, 90.0), Point::new(60.0, 140.0), Point::new(170.0, 90.0)), "{b:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).subpaths[0].anchors[0], a, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -254,6 +254,35 @@ fn type_in_a_missing_family_warns_as_pdf_export_does() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The warnings of `file.print` (PDF and PostScript) and `print.preview` list what the job
|
||||
/// leaves out of the document or prints differently first, then the notes on the options.
|
||||
#[test]
|
||||
fn what_a_print_loses_comes_before_the_notes_on_the_options() {
|
||||
let (mut s, _) = session();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
let notes = |v: &Value| -> Vec<String> { v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() };
|
||||
// A fixed flatness isn't written to a PDF: a note on the options, made before the type's.
|
||||
let fixed = json!({"settings": {"graphics": {"autoFlatness": false}}});
|
||||
for v in [print(&mut s, fixed.clone()).1, s.execute("print.preview", &fixed).unwrap()] {
|
||||
let n = notes(&v);
|
||||
assert!(n.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{n:?}");
|
||||
assert!(n.last().is_some_and(|w| w.starts_with("a fixed flatness")), "{n:?}");
|
||||
}
|
||||
// PostScript doesn't apply the printer profile.
|
||||
let profile = json!({"format": "postscript", "settings": {"color": {"profile": vectorcraft_color::cms::SRGB}}});
|
||||
let n = notes(&s.execute("file.print", &profile).unwrap());
|
||||
assert!(n.iter().any(|w| w.contains("Nonexistent Sans Probe")), "{n:?}");
|
||||
assert!(n.last().is_some_and(|w| w.starts_with("the printer profile")), "{n:?}");
|
||||
// An artboard larger than Letter: copies per sheet that don't fit come after the art that
|
||||
// doesn't print.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 700, "height": 900})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 700, "height": 900})).unwrap();
|
||||
let n = notes(&print(&mut s, json!({"settings": {"copiesPerSheet": true}})).1);
|
||||
assert!(n.iter().any(|w| w.contains("part of it doesn't print")), "{n:?}");
|
||||
assert!(n.last().is_some_and(|w| w.ends_with("it prints one copy per sheet")), "{n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_that_does_not_fit_its_frame_is_named() {
|
||||
let (mut s, _) = session();
|
||||
|
||||
Reference in New Issue
Block a user