Merge pull request #78 from storytold/pr/m4-links-text-import
M4: linked images with previews, missing and modified links; Text Import Options (M4.53, M4.54)
This commit is contained in:
commit
4b956422c9
|
|
@ -8,6 +8,7 @@
|
|||
pub mod appearance;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
pub mod marks;
|
||||
pub mod metadata;
|
||||
|
|
@ -46,6 +47,7 @@ pub use appearance::{
|
|||
};
|
||||
pub use graph::{GraphKind, GraphSpec};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::LinkInfo;
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
pub use metadata::{CopyrightStatus, DocMetadata};
|
||||
pub use node::Knockout;
|
||||
|
|
@ -401,6 +403,10 @@ pub struct ImageBlob {
|
|||
pub mime: String,
|
||||
#[serde(skip)]
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
/// A linked image's low-resolution preview (PNG, see [`links`]): what a save writes when only
|
||||
/// linked images show this blob, and what `bytes` hold while the linked file can't be read.
|
||||
#[serde(skip)]
|
||||
pub proxy: Option<Arc<Vec<u8>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
//! Linked files: where a placed file lives and what it was when last read ([`LinkInfo`]), the
|
||||
//! low-resolution preview a linked image keeps for when its file can't be read
|
||||
//! ([`ImageBlob::proxy`]), and every image object of a document ([`Document::visit_images`]).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
|
||||
/// The longest side of a linked image's preview (pixels). Smaller images keep no preview: their
|
||||
/// full pixels are saved instead.
|
||||
pub const PROXY_SIZE: u32 = 256;
|
||||
/// The MIME type of previews.
|
||||
pub const PROXY_MIME: &str = "image/png";
|
||||
|
||||
/// A linked file: an image object shows the file at `path` (File → Place with Link) and the
|
||||
/// document saves only a preview of it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LinkInfo {
|
||||
/// The file's absolute path.
|
||||
pub path: String,
|
||||
/// The path relative to the document's folder (`/` separators), written on save: how the file
|
||||
/// is found after the document and its links move together.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub relative: Option<String>,
|
||||
/// The file's modification time when last read (milliseconds since the Unix epoch).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub modified: Option<u64>,
|
||||
/// The file's size when last read (bytes).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<u64>,
|
||||
/// The hash of the file's bytes when last read ([`hash_bytes`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hash: Option<String>,
|
||||
/// The page shown, for a linked document page (1-based).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub page: Option<u32>,
|
||||
}
|
||||
|
||||
impl LinkInfo {
|
||||
/// A link to `path` with nothing known about the file.
|
||||
pub fn new(path: impl Into<String>) -> Self {
|
||||
Self { path: path.into(), ..Self::default() }
|
||||
}
|
||||
|
||||
/// The file name: the path's last component, whichever system's separators it uses.
|
||||
pub fn name(&self) -> &str {
|
||||
self.path.rsplit(['/', '\\']).next().unwrap_or(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for LinkInfo {
|
||||
fn from(path: &str) -> Self {
|
||||
Self::new(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ImageObject::link`] from a link object or, as files saved before links had details, a path.
|
||||
pub(crate) fn de_link<'de, D: Deserializer<'de>>(d: D) -> Result<Option<LinkInfo>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Repr {
|
||||
Path(String),
|
||||
Info(LinkInfo),
|
||||
}
|
||||
Ok(Option::<Repr>::deserialize(d)?.map(|r| match r {
|
||||
Repr::Path(path) => LinkInfo::new(path),
|
||||
Repr::Info(info) => info,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A hash of `bytes` (FNV-1a), the same for identical files: `img` and 16 hex digits.
|
||||
pub fn hash_bytes(bytes: &[u8]) -> String {
|
||||
let h = bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3));
|
||||
format!("img{h:016x}")
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// A blob of encoded image `bytes` of type `mime`, without a preview.
|
||||
pub fn new(mime: impl Into<String>, bytes: Vec<u8>) -> Self {
|
||||
Self { mime: mime.into(), bytes: Arc::new(bytes), proxy: None }
|
||||
}
|
||||
|
||||
/// This blob with a preview of at most [`PROXY_SIZE`] pixels a side (PNG), when the image is
|
||||
/// larger than that and decodes; otherwise unchanged.
|
||||
pub fn with_proxy(mut self) -> Self {
|
||||
if self.proxy.is_none() {
|
||||
self.proxy = proxy_png(&self.bytes).map(Arc::new);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Are the bytes the preview (the linked file hasn't been read)?
|
||||
pub fn is_proxy(&self) -> bool {
|
||||
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// `bytes` decoded and shrunk to fit [`PROXY_SIZE`] as a PNG; `None` when small enough already or
|
||||
/// undecodable.
|
||||
fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
if w.max(h) <= PROXY_SIZE {
|
||||
return None;
|
||||
}
|
||||
let small = image::load_from_memory(bytes).ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE);
|
||||
let mut png = Vec::new();
|
||||
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(png)
|
||||
}
|
||||
|
||||
/// `n` and every descendant, opacity-mask art included.
|
||||
fn walk_all<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||||
f(n);
|
||||
if let Some(m) = &n.mask {
|
||||
walk_all(&m.art, f);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk_all(c, f);
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Visit every image object: in the layers, then in symbol definitions and pattern swatches,
|
||||
/// opacity-mask art included.
|
||||
pub fn visit_images<'a>(&'a self, mut f: impl FnMut(&'a Node, &'a ImageObject)) {
|
||||
let roots = self.layers.iter().chain(self.symbols.iter().map(|s| &s.art)).chain(self.patterns.iter().flat_map(|p| &p.art));
|
||||
for root in roots {
|
||||
walk_all(root, &mut |n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
f(n, im);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of image blobs only linked images show: what a save writes as their preview.
|
||||
pub fn linked_only_images(&self) -> BTreeSet<String> {
|
||||
if self.images.values().all(|b| b.proxy.is_none()) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
// Per key: (shown linked, shown embedded).
|
||||
let mut uses: BTreeMap<&str, (bool, bool)> = BTreeMap::new();
|
||||
self.visit_images(|_, im| {
|
||||
let u = uses.entry(im.key.as_str()).or_default();
|
||||
if im.link.is_some() {
|
||||
u.0 = true;
|
||||
} else {
|
||||
u.1 = true;
|
||||
}
|
||||
});
|
||||
uses.into_iter().filter(|(_, (linked, embedded))| *linked && !embedded).map(|(k, _)| k.to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::NodeId;
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([10, 200, 30, 255])).write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn image(d: &mut Document, key: &str, link: Option<LinkInfo>) -> NodeId {
|
||||
let id = d.alloc_id();
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(ImageObject { key: key.into(), width: 4, height: 4, xf: Affine::IDENTITY, link })))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_string_from_older_files_reads_as_a_link() {
|
||||
let old: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":"C:\\art\\photo.png"}"#).unwrap();
|
||||
let link = old.link.unwrap();
|
||||
assert_eq!((link.path.as_str(), link.name(), link.hash.as_deref()), ("C:\\art\\photo.png", "photo.png", None));
|
||||
let new: ImageObject =
|
||||
serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":{"path":"/a/b.png","size":9,"page":2}}"#).unwrap();
|
||||
assert_eq!(new.link, Some(LinkInfo { size: Some(9), page: Some(2), ..LinkInfo::new("/a/b.png") }));
|
||||
let none: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0]}"#).unwrap();
|
||||
assert_eq!(none.link, None);
|
||||
// Written as an object, with only what is known.
|
||||
let v = serde_json::to_value(&new).unwrap();
|
||||
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_images_get_a_small_png_preview() {
|
||||
let big = ImageBlob::new("image/png", png(600, 300)).with_proxy();
|
||||
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
|
||||
assert_eq!((p.width(), p.height()), (256, 128));
|
||||
assert!(!big.is_proxy(), "the bytes are the full image");
|
||||
assert!(ImageBlob::new("image/png", png(40, 20)).with_proxy().proxy.is_none(), "small images keep their pixels");
|
||||
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_blobs_no_embedded_image_shows_are_linked_only() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
for k in ["a", "b", "c"] {
|
||||
d.images.insert(k.into(), ImageBlob::new("image/png", png(300, 10)).with_proxy());
|
||||
}
|
||||
image(&mut d, "a", Some(LinkInfo::new("/a.png")));
|
||||
image(&mut d, "b", Some(LinkInfo::new("/b.png")));
|
||||
image(&mut d, "b", None);
|
||||
image(&mut d, "c", None);
|
||||
assert_eq!(d.linked_only_images().into_iter().collect::<Vec<_>>(), ["a"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -200,8 +200,10 @@ pub struct ImageObject {
|
|||
pub height: u32,
|
||||
/// Maps pixel space (0..w, 0..h) into the document.
|
||||
pub xf: Affine,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
/// The file a linked image shows (File → Place with Link); `None`: embedded. Files saved
|
||||
/// before links had details hold just the path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::links::de_link")]
|
||||
pub link: Option<crate::LinkInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -9,13 +9,12 @@ use crate::ImageBlob;
|
|||
impl ImageBlob {
|
||||
/// A PNG blob of `png` bytes.
|
||||
pub fn png(png: Vec<u8>) -> Self {
|
||||
Self { mime: "image/png".into(), bytes: Arc::new(png) }
|
||||
Self::new("image/png", png)
|
||||
}
|
||||
|
||||
/// A key derived from the bytes (FNV-1a), the same for identical images.
|
||||
pub fn content_key(&self) -> String {
|
||||
let h = self.bytes.iter().fold(0xcbf29ce484222325u64, |h, x| (h ^ *x as u64).wrapping_mul(0x100000001b3));
|
||||
format!("img{h:016x}")
|
||||
crate::links::hash_bytes(&self.bytes)
|
||||
}
|
||||
|
||||
/// The image with `f` applied to each pixel's (straight) RGB, alpha kept and fully transparent
|
||||
|
|
@ -48,42 +47,59 @@ impl ImageBlob {
|
|||
/// image, where every pixel is transparent, or when the image can't be decoded. The last image
|
||||
/// decoded is kept for the next sample (the Eyedropper samples one image click after click).
|
||||
pub fn sample(&self, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
|
||||
sample_pixels(&*self.decoded()?, x, y, size)
|
||||
}
|
||||
|
||||
/// [`Self::sample`] at (`x`, `y`) in the pixel space of an image object of `object` (width,
|
||||
/// height) pixels, which the decoded pixels may not match (a linked image showing its preview).
|
||||
pub fn sample_object(&self, x: f64, y: f64, object: (u32, u32), size: u32) -> Option<[u8; 4]> {
|
||||
let img = self.decoded()?;
|
||||
let k = |n: u32, of: u32| n as f64 / of.max(1) as f64;
|
||||
sample_pixels(&img, x * k(img.width(), object.0), y * k(img.height(), object.1), size)
|
||||
}
|
||||
|
||||
/// The decoded pixels (the last image decoded is kept for the next sample).
|
||||
fn decoded(&self) -> Option<Arc<image::RgbaImage>> {
|
||||
thread_local! {
|
||||
static LAST: std::cell::RefCell<Option<(String, Arc<image::RgbaImage>)>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
let key = self.content_key();
|
||||
let img = LAST.with_borrow_mut(|last| match last {
|
||||
LAST.with_borrow_mut(|last| match last {
|
||||
Some((k, img)) if *k == key => Some(img.clone()),
|
||||
_ => {
|
||||
let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8());
|
||||
*last = Some((key, img.clone()));
|
||||
Some(img)
|
||||
}
|
||||
})?;
|
||||
let (w, h) = (img.width() as i64, img.height() as i64);
|
||||
let (cx, cy) = (x.floor() as i64, y.floor() as i64);
|
||||
if !(0..w).contains(&cx) || !(0..h).contains(&cy) {
|
||||
return None;
|
||||
}
|
||||
let r = size.max(1) as i64 / 2;
|
||||
let (mut sum, mut alpha, mut n) = ([0u64; 3], 0u64, 0u64);
|
||||
for py in (cy - r).max(0)..=(cy + r).min(h - 1) {
|
||||
for px in (cx - r).max(0)..=(cx + r).min(w - 1) {
|
||||
let [pr, pg, pb, pa] = img.get_pixel(px as u32, py as u32).0;
|
||||
let a = pa as u64;
|
||||
sum = [sum[0] + pr as u64 * a, sum[1] + pg as u64 * a, sum[2] + pb as u64 * a];
|
||||
alpha += a;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if alpha == 0 {
|
||||
return None;
|
||||
}
|
||||
let c = |v: u64| ((v + alpha / 2) / alpha) as u8;
|
||||
Some([c(sum[0]), c(sum[1]), c(sum[2]), ((alpha + n / 2) / n) as u8])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ImageBlob::sample`] of decoded pixels `img`.
|
||||
fn sample_pixels(img: &image::RgbaImage, x: f64, y: f64, size: u32) -> Option<[u8; 4]> {
|
||||
let (w, h) = (img.width() as i64, img.height() as i64);
|
||||
let (cx, cy) = (x.floor() as i64, y.floor() as i64);
|
||||
if !(0..w).contains(&cx) || !(0..h).contains(&cy) {
|
||||
return None;
|
||||
}
|
||||
let r = size.max(1) as i64 / 2;
|
||||
let (mut sum, mut alpha, mut n) = ([0u64; 3], 0u64, 0u64);
|
||||
for py in (cy - r).max(0)..=(cy + r).min(h - 1) {
|
||||
for px in (cx - r).max(0)..=(cx + r).min(w - 1) {
|
||||
let [pr, pg, pb, pa] = img.get_pixel(px as u32, py as u32).0;
|
||||
let a = pa as u64;
|
||||
sum = [sum[0] + pr as u64 * a, sum[1] + pg as u64 * a, sum[2] + pb as u64 * a];
|
||||
alpha += a;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if alpha == 0 {
|
||||
return None;
|
||||
}
|
||||
let c = |v: u64| ((v + alpha / 2) / alpha) as u8;
|
||||
Some([c(sum[0]), c(sum[1]), c(sum[2]), ((alpha + n / 2) / n) as u8])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -110,6 +126,14 @@ mod tests {
|
|||
assert_eq!(b.sample(0.0, 0.0, 5), Some([85, 85, 85, 255]), "clipped to the image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_in_the_pixel_space_of_an_object_with_more_pixels() {
|
||||
// An object of 8 × 2 pixels showing these 4 × 1 (a linked image's preview).
|
||||
let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 255]], 4, 1);
|
||||
assert_eq!(b.sample_object(5.0, 1.5, (8, 2), 1), Some([0, 0, 255, 255]));
|
||||
assert_eq!(b.sample_object(1.0, 0.0, (4, 1), 1), b.sample(1.0, 0.0, 1), "the same size: the same pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_each_colour_once_and_keeps_alpha() {
|
||||
let b = blob(&[[255, 0, 0, 255], [255, 0, 0, 128], [0, 0, 255, 255], [10, 20, 30, 0]], 2, 2);
|
||||
|
|
|
|||
|
|
@ -68,7 +68,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some("textObjects")
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
images.insert(im.key.clone(), json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link }));
|
||||
images.insert(
|
||||
im.key.clone(),
|
||||
json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }),
|
||||
);
|
||||
Some("images")
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! `document.open`: every readable format into a new document.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
|
|
@ -132,7 +131,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
}
|
||||
|
||||
/// Open a file's bytes as the new active document (what `document.open` does) →
|
||||
/// `{index, title, format, warnings}`. `path` is kept for Save only for a native, non-template file.
|
||||
/// `{index, title, format, warnings, missingLinks, modifiedLinks, updatedLinks}`. `path` is kept
|
||||
/// for Save only for a native, non-template file; the document's linked files are looked for from
|
||||
/// it ([`crate::cmd::links::resolve`]).
|
||||
pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value> {
|
||||
open_bytes_with(s, name, bytes, path, &Value::Null)
|
||||
}
|
||||
|
|
@ -141,6 +142,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
|
|||
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
|
||||
let opts = LoadOptions::from_params("document.open", p)?;
|
||||
let Loaded { mut doc, format, warnings } = load_with(name, bytes, &opts)?;
|
||||
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
|
||||
// A template (saved by Save as Template, or an .ait file) opens as a new untitled document.
|
||||
let template = doc.template || format.id == "ait";
|
||||
if template {
|
||||
|
|
@ -150,7 +152,7 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<S
|
|||
let keep_path = format.id == "vectorcraft" && !template;
|
||||
let index = s.add_document(doc, path.filter(|_| keep_path));
|
||||
let title = s.documents()[index].title();
|
||||
Ok(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings }))
|
||||
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings }), links.to_json()))
|
||||
}
|
||||
|
||||
/// A file named by a command's params: `{path}` (read from disk) or `{name, dataBase64}`.
|
||||
|
|
@ -201,7 +203,7 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
|
|||
if width == 0 || height == 0 {
|
||||
return Err(err("the image is empty"));
|
||||
}
|
||||
let blob = ImageBlob { mime: mime.into(), bytes: Arc::new(bytes) };
|
||||
let blob = ImageBlob::new(mime, bytes);
|
||||
Ok(RasterImage { key: blob.content_key(), blob, width, height, ppi })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document",
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
|
@ -293,17 +293,30 @@ pub const FORMATS: &[Format] = &[
|
|||
pub const OPEN_EXTS: &[&str] =
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
|
||||
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
|
||||
pub const PLACE_EXTS: &[&str] =
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "txt"];
|
||||
|
||||
/// One dialog filter per readable format.
|
||||
fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions))
|
||||
}
|
||||
|
||||
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
|
||||
/// libraries (which open in the library panel) and flattener presets (imported).
|
||||
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
place_filters()
|
||||
std::iter::once(("All readable files", OPEN_EXTS))
|
||||
.chain(format_filters())
|
||||
.chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS)))
|
||||
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All readable files", then one per readable format.
|
||||
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.
|
||||
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All readable files", OPEN_EXTS)).chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions)))
|
||||
std::iter::once(("All placeable files", PLACE_EXTS)).chain(format_filters()).chain(std::iter::once(("Text", TEXT_EXTS)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
|
||||
|
|
@ -364,6 +377,35 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
|
|||
std::fs::create_dir_all(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
/// A file's size (bytes) and modification time (ms since the Unix epoch, when the file system
|
||||
/// keeps one); `None` when there is no file at `path`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn file_stamp(path: &str) -> Option<(u64, Option<u64>)> {
|
||||
let m = std::fs::metadata(path).ok().filter(std::fs::Metadata::is_file)?;
|
||||
let modified = m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_millis() as u64);
|
||||
Some((m.len(), modified))
|
||||
}
|
||||
|
||||
/// `path` made absolute against the working directory (as given when absolute already, or when
|
||||
/// that fails).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn absolute_path(path: &str) -> String {
|
||||
if std::path::Path::new(path).is_absolute() {
|
||||
return path.to_string();
|
||||
}
|
||||
std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn file_stamp(_: &str) -> Option<(u64, Option<u64>)> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn absolute_path(path: &str) -> String {
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn no_fs(path: &str) -> EngineError {
|
||||
EngineError::Other(format!("{path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64)"))
|
||||
|
|
|
|||
|
|
@ -20,13 +20,15 @@ pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Res
|
|||
}
|
||||
}
|
||||
|
||||
/// What Save writes for `st` in format `f` (see [`save_format`]) → the encoded file and the
|
||||
/// options to remember for the next Save. An SVG save takes the SVG options in `p`, else the ones
|
||||
/// the document was last saved with, and keeps hidden layers (hidden) unless they say otherwise.
|
||||
pub fn save_encoding(st: &DocState, f: &Format, p: &Value) -> Result<(Encoded, Value)> {
|
||||
/// What Save writes for `st` in format `f` (see [`save_format`]) to `path` → the encoded file and
|
||||
/// the options to remember for the next Save. An SVG save takes the SVG options in `p`, else the
|
||||
/// ones the document was last saved with, and keeps hidden layers (hidden) unless they say
|
||||
/// otherwise. A native file records its links' paths relative to `path`.
|
||||
pub fn save_encoding(st: &DocState, f: &Format, p: &Value, path: Option<&str>) -> Result<(Encoded, Value)> {
|
||||
const C: &str = "document.save";
|
||||
if f.id == "vectorcraft" {
|
||||
return Ok((encode_all(&st.doc, f.id, p)?, Value::Null));
|
||||
let relative = path.and_then(|path| crate::cmd::links::with_relative_paths(&st.doc, path));
|
||||
return Ok((encode_all(relative.as_ref().unwrap_or(&st.doc), f.id, p)?, Value::Null));
|
||||
}
|
||||
let given = super::svg_options(p).map_err(|e| bad(C, e))?;
|
||||
let opts = if given.is_empty() { st.save_options.clone() } else { Value::Object(given) };
|
||||
|
|
@ -56,7 +58,7 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
stamp_save_dates(s.doc_mut()?);
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let (enc, opts) = save_encoding(st, f, p)?;
|
||||
let (enc, opts) = save_encoding(st, f, p, path.as_deref())?;
|
||||
let Some(path) = path else {
|
||||
// Nowhere to save to (web, agents): hand the bytes back; the document stays modified.
|
||||
return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, json!({}));
|
||||
|
|
|
|||
|
|
@ -1038,7 +1038,7 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
|
|||
/// An image of `r` (in a clip group when it is clipped).
|
||||
fn raster_node(d: &mut Document, r: Raster) -> Node {
|
||||
let key = unique_key(d, "flattened");
|
||||
d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(r.png) });
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", r.png));
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None }));
|
||||
let Some(path) = r.clip else { return image };
|
||||
super::rasterfx::clip_group(d, path, FillRule::NonZero, image)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,477 @@
|
|||
//! Linked images (File → Place with Link): finding their files, telling when they changed, and
|
||||
//! reading them again.
|
||||
//!
|
||||
//! A linked image keeps its file's absolute path, the path relative to the document (written on
|
||||
//! save), the file's size, modification time and hash ([`LinkInfo`]), and a low-resolution
|
||||
//! preview that the document saves in place of the pixels ([`ImageBlob::proxy`]). A file is looked
|
||||
//! for at its path, then at its relative path and by name in the document's folder. When a
|
||||
//! document opens ([`resolve`]), the files found unchanged are read again, modified ones follow
|
||||
//! Preferences → File Handling → Update Links, and missing ones show their preview.
|
||||
//!
|
||||
//! `links.check` reports, `links.update` reads modified files again, `links.relink` points images
|
||||
//! at other files.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Component, Path};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::links::hash_bytes;
|
||||
use vectorcraft_doc::{Document, ImageBlob, LinkInfo, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_stamp, read_file};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "links.check",
|
||||
"Check Links",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image ids] (default: every linked image)} where each linked file is and whether it changed since it was read → {links: [{name, path, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file)}], missing, modified}",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
cmd!(
|
||||
"links.update",
|
||||
"Update Links",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image ids] (default: the linked images whose file was modified or is showing its preview)} read their linked files again; each image keeps its bounds. One undo step → {updated: [ids], missing: [ids]}",
|
||||
has_doc,
|
||||
update
|
||||
),
|
||||
cmd!(
|
||||
"links.relink",
|
||||
"Relink",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image ids] (default: the selected images; with folder and nothing selected, every missing link), path | folder} link images 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]}",
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- finding files ----------
|
||||
|
||||
/// What became of a linked file since it was read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Ok,
|
||||
Modified,
|
||||
Missing,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Status::Ok => "ok",
|
||||
Status::Modified => "modified",
|
||||
Status::Missing => "missing",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The image objects that show one linked file as one content (same path, same image).
|
||||
struct Group {
|
||||
link: LinkInfo,
|
||||
key: String,
|
||||
/// The objects in the layers (images in symbols and patterns share the content, not an id).
|
||||
ids: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// The linked images of `d` by file and content: those in the layers (only `ids` when given), then,
|
||||
/// without `ids`, the ones only symbols and patterns show.
|
||||
fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec<Group> {
|
||||
let mut out: Vec<Group> = vec![];
|
||||
let mut index: BTreeMap<(String, String), usize> = BTreeMap::new();
|
||||
let mut add = |link: &LinkInfo, key: &str, id: Option<NodeId>| {
|
||||
let i = *index.entry((link.path.clone(), key.to_string())).or_insert_with(|| {
|
||||
out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![] });
|
||||
out.len() - 1
|
||||
});
|
||||
if let (Some(id), Some(g)) = (id, out.get_mut(i)) {
|
||||
g.ids.push(id);
|
||||
}
|
||||
};
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& let Some(l) = &im.link
|
||||
&& ids.is_none_or(|ids| ids.contains(&n.id))
|
||||
{
|
||||
add(l, &im.key, Some(n.id));
|
||||
}
|
||||
});
|
||||
if ids.is_none() {
|
||||
d.visit_images(|_, im| {
|
||||
if let Some(l) = &im.link {
|
||||
add(l, &im.key, None);
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A linked file where it was found.
|
||||
struct Found {
|
||||
path: String,
|
||||
size: u64,
|
||||
modified: Option<u64>,
|
||||
/// Read when telling whether it changed needed them.
|
||||
bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Found {
|
||||
/// The file's bytes (read now unless already read).
|
||||
fn read(&mut self) -> Result<Vec<u8>> {
|
||||
match self.bytes.take() {
|
||||
Some(b) => Ok(b),
|
||||
None => read_file(&self.path),
|
||||
}
|
||||
}
|
||||
|
||||
/// `base` pointing at this file as read now (`bytes`).
|
||||
fn link(&self, bytes: &[u8], base: &LinkInfo) -> LinkInfo {
|
||||
LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash_bytes(bytes)), ..base.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The link to the file at `path` whose bytes were just read.
|
||||
pub(crate) fn link_info(path: &str, bytes: &[u8]) -> LinkInfo {
|
||||
let path = absolute_path(path);
|
||||
LinkInfo {
|
||||
size: Some(bytes.len() as u64),
|
||||
modified: file_stamp(&path).and_then(|(_, m)| m),
|
||||
hash: Some(hash_bytes(bytes)),
|
||||
..LinkInfo::new(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The file at `path` when there is one.
|
||||
fn found_at(path: String) -> Option<Found> {
|
||||
let (size, modified) = file_stamp(&path)?;
|
||||
Some(Found { path, size, modified, bytes: None })
|
||||
}
|
||||
|
||||
/// Where the file of `link` is: at its path, else at its relative path or by name in the
|
||||
/// document's folder `dir`.
|
||||
fn locate(link: &LinkInfo, dir: Option<&Path>) -> Option<Found> {
|
||||
let mut paths = vec![link.path.clone()];
|
||||
if let Some(dir) = dir {
|
||||
let near = link.relative.iter().map(|r| dir.join(r)).chain([dir.join(link.name())]);
|
||||
paths.extend(near.map(|p| p.to_string_lossy().into_owned()));
|
||||
}
|
||||
paths.into_iter().find_map(found_at)
|
||||
}
|
||||
|
||||
/// Where the file of group `g` is and whether it still has the content shown. Its bytes are read
|
||||
/// only when its size or modification time changed.
|
||||
fn probe(g: &Group, dir: Option<&Path>) -> (Status, Option<Found>) {
|
||||
let Some(mut f) = locate(&g.link, dir) else { return (Status::Missing, None) };
|
||||
let l = &g.link;
|
||||
if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified {
|
||||
return (Status::Ok, Some(f));
|
||||
}
|
||||
let Ok(bytes) = f.read() else { return (Status::Missing, None) };
|
||||
let same = match &l.hash {
|
||||
Some(h) => *h == hash_bytes(&bytes),
|
||||
// Linked before links kept a hash: the image the file decodes to.
|
||||
None => fileio::raster_image(&bytes).is_ok_and(|img| img.key == g.key),
|
||||
};
|
||||
f.bytes = Some(bytes);
|
||||
(if same { Status::Ok } else { Status::Modified }, Some(f))
|
||||
}
|
||||
|
||||
/// The folder of the document saved at `doc_path`.
|
||||
fn folder(doc_path: Option<&str>) -> Option<&Path> {
|
||||
doc_path.and_then(|p| Path::new(p).parent())
|
||||
}
|
||||
|
||||
/// Is image `key` of `d` showing its preview (its file not read)?
|
||||
fn previewing(d: &Document, key: &str) -> bool {
|
||||
d.images.get(key).is_none_or(ImageBlob::is_proxy)
|
||||
}
|
||||
|
||||
// ---------- reading files into the document ----------
|
||||
|
||||
/// Keep `full` (a file's pixels) as image `key` of `d`, unless pixels are there already; a
|
||||
/// preview there is kept, and a linked image gets one.
|
||||
pub(crate) fn store_image(d: &mut Document, key: &str, full: ImageBlob, linked: bool) {
|
||||
let blob = match d.images.get(key) {
|
||||
Some(b) if !b.is_proxy() => b.clone(),
|
||||
old => ImageBlob { proxy: old.and_then(|b| b.proxy.clone()), ..full },
|
||||
};
|
||||
d.images.insert(key.to_string(), if linked { blob.with_proxy() } else { blob });
|
||||
}
|
||||
|
||||
/// The file read for a link: the link to it and its image.
|
||||
type FileRead = (LinkInfo, RasterImage);
|
||||
|
||||
/// The file at `f` read for a link like `base` (its image decoded).
|
||||
fn read_image(f: &mut Found, base: &LinkInfo) -> Result<FileRead> {
|
||||
let bytes = f.read()?;
|
||||
Ok((f.link(&bytes, base), fileio::raster_image(&bytes)?))
|
||||
}
|
||||
|
||||
/// Image objects `ids` show the file read for `link` (`img`), each keeping its bounds.
|
||||
fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead) {
|
||||
store_image(d, &img.key, img.blob, true);
|
||||
for id in ids {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
{
|
||||
if (im.width, im.height) != (img.width, img.height) {
|
||||
im.xf *= Affine::scale_non_uniform(im.width as f64 / img.width as f64, im.height as f64 / img.height as f64);
|
||||
}
|
||||
(im.key, im.width, im.height, im.link) = (img.key.clone(), img.width, img.height, Some(link.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups as `{name, path, ids}` rows (those with objects in the layers).
|
||||
fn rows<'a>(gs: impl Iterator<Item = &'a Group>) -> Vec<Value> {
|
||||
gs.filter(|g| !g.ids.is_empty()).map(|g| json!({ "name": g.link.name(), "path": g.link.path, "ids": ids_json(&g.ids) })).collect()
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Vec<u64> {
|
||||
ids.iter().map(|id| id.0).collect()
|
||||
}
|
||||
|
||||
/// What [`resolve`] found when a document opened.
|
||||
#[derive(Default)]
|
||||
pub struct Resolved {
|
||||
/// `{name, path, ids}` of files that weren't found: their images show the preview.
|
||||
pub missing: Vec<Value>,
|
||||
/// Modified files left as they were (Update Links isn't Automatically).
|
||||
pub modified: Vec<Value>,
|
||||
/// Modified files read again (Update Links: Automatically).
|
||||
pub updated: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Resolved {
|
||||
/// The `document.open` result fields.
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({ "missingLinks": self.missing, "modifiedLinks": self.modified, "updatedLinks": self.updated })
|
||||
}
|
||||
}
|
||||
|
||||
/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read
|
||||
/// again (their images had the preview), modified ones are read again when `update`, links found
|
||||
/// away from their path take the new path. Nothing here is an undo step.
|
||||
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved {
|
||||
let mut out = Resolved::default();
|
||||
for g in groups(d, None) {
|
||||
let row = rows(std::iter::once(&g));
|
||||
match probe(&g, folder(doc_path)) {
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
(Status::Modified, Some(mut f)) if update => match read_image(&mut f, &g.link) {
|
||||
Ok(read) => {
|
||||
install(d, &g.ids, read);
|
||||
out.updated.extend(row);
|
||||
}
|
||||
Err(_) => out.missing.extend(row),
|
||||
},
|
||||
(Status::Modified, _) => out.modified.extend(row),
|
||||
(Status::Ok, Some(mut f)) => {
|
||||
let preview = previewing(d, &g.key);
|
||||
// Read when the images show the preview, or when the file's details changed.
|
||||
let bytes = if preview || f.bytes.is_some() { f.read().ok() } else { None };
|
||||
if preview {
|
||||
let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else {
|
||||
out.missing.extend(row);
|
||||
continue;
|
||||
};
|
||||
store_image(d, &g.key, img.blob, true);
|
||||
} else if let Some(b) = d.images.get(&g.key).filter(|b| b.proxy.is_none()) {
|
||||
// Linked before links kept a preview.
|
||||
let b = b.clone().with_proxy();
|
||||
d.images.insert(g.key.clone(), b);
|
||||
}
|
||||
// Found away from its path, or read again: the link follows.
|
||||
let link = match &bytes {
|
||||
Some(b) => f.link(b, &g.link),
|
||||
None => LinkInfo { path: f.path.clone(), ..g.link.clone() },
|
||||
};
|
||||
if link != g.link {
|
||||
set_links(d, &g.ids, &link);
|
||||
}
|
||||
}
|
||||
(Status::Ok, None) => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Image objects `ids` link to `link`.
|
||||
fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) {
|
||||
for id in ids {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
{
|
||||
im.link = Some(link.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- relative paths ----------
|
||||
|
||||
/// `target` relative to folder `base` with `/` separators; `None` when they share no root (another
|
||||
/// drive, or either is relative).
|
||||
fn relative_path(target: &Path, base: &Path) -> Option<String> {
|
||||
let (t, b): (Vec<Component>, Vec<Component>) = (target.components().collect(), base.components().collect());
|
||||
if !matches!(t.first(), Some(Component::Prefix(_) | Component::RootDir)) {
|
||||
return None;
|
||||
}
|
||||
// Windows paths compare without case.
|
||||
let same = |x: &Component, y: &Component| if cfg!(windows) { x.as_os_str().eq_ignore_ascii_case(y.as_os_str()) } else { x == y };
|
||||
let common = t.iter().zip(&b).take_while(|(x, y)| same(x, y)).count();
|
||||
if common == 0 {
|
||||
return None;
|
||||
}
|
||||
let ups = std::iter::repeat_n(std::borrow::Cow::Borrowed(".."), b.len().saturating_sub(common));
|
||||
let rest = t.iter().skip(common).map(|c| c.as_os_str().to_string_lossy());
|
||||
Some(ups.chain(rest).collect::<Vec<std::borrow::Cow<str>>>().join("/"))
|
||||
}
|
||||
|
||||
/// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they
|
||||
/// are already.
|
||||
pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
|
||||
let dest = absolute_path(dest);
|
||||
let dir = Path::new(&dest).parent()?;
|
||||
let mut changes = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& let Some(l) = &im.link
|
||||
{
|
||||
let r = relative_path(Path::new(&l.path), dir);
|
||||
if r != l.relative {
|
||||
changes.push((n.id, r));
|
||||
}
|
||||
}
|
||||
});
|
||||
if changes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut d = d.clone();
|
||||
for (id, r) in changes {
|
||||
if let Some(n) = d.node_mut(id)
|
||||
&& let NodeKind::Image(im) = &mut n.kind
|
||||
&& let Some(l) = &mut im.link
|
||||
{
|
||||
l.relative = r;
|
||||
}
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let ids = ids_param(p, "ids");
|
||||
let (mut missing, mut modified) = (0, 0);
|
||||
let mut links = vec![];
|
||||
for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) {
|
||||
let (status, found) = probe(g, folder(st.path.as_deref()));
|
||||
missing += usize::from(status == Status::Missing);
|
||||
modified += usize::from(status == Status::Modified);
|
||||
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
|
||||
if let Some(f) = found.filter(|f| f.path != g.link.path) {
|
||||
row["found"] = json!(f.path);
|
||||
}
|
||||
if previewing(&st.doc, &g.key) {
|
||||
row["preview"] = json!(true);
|
||||
}
|
||||
links.push(row);
|
||||
}
|
||||
Ok(json!({ "links": links, "missing": missing, "modified": modified }))
|
||||
}
|
||||
|
||||
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let ids = ids_param(p, "ids");
|
||||
let (mut reads, mut missing) = (vec![], vec![]);
|
||||
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) {
|
||||
match probe(&g, folder(st.path.as_deref())) {
|
||||
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
|
||||
// Unchanged and showing the file: nothing to read unless asked for by id.
|
||||
(Status::Ok, Some(_)) if ids.is_none() && !previewing(&st.doc, &g.key) => {}
|
||||
// Unreadable as an image counts as missing.
|
||||
(_, Some(mut f)) => match read_image(&mut f, &g.link) {
|
||||
Ok(read) => reads.push((g.ids, read)),
|
||||
Err(_) => missing.extend(ids_json(&g.ids)),
|
||||
},
|
||||
}
|
||||
}
|
||||
let updated: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
|
||||
if !reads.is_empty() {
|
||||
s.edit("Update Links", |d, _| {
|
||||
reads.into_iter().for_each(|(ids, read)| install(d, &ids, read));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "updated": updated, "missing": missing }))
|
||||
}
|
||||
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.relink";
|
||||
let st = s.doc()?;
|
||||
let folder_param = str_param(p, "folder");
|
||||
let image = |id: &NodeId| st.doc.node(*id).and_then(|n| if let NodeKind::Image(im) = &n.kind { Some(im) } else { None });
|
||||
let ids: Vec<NodeId> = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => {
|
||||
let selected: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect();
|
||||
match folder_param {
|
||||
Some(_) if selected.is_empty() => groups(&st.doc, None)
|
||||
.into_iter()
|
||||
.filter(|g| probe(g, folder(st.path.as_deref())).0 == Status::Missing)
|
||||
.flat_map(|g| g.ids)
|
||||
.collect(),
|
||||
_ => selected,
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(id) = ids.iter().find(|id| image(id).is_none()) {
|
||||
return Err(bad(C, format!("object {} is not an image", id.0)));
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select the images to relink, or pass ids"));
|
||||
}
|
||||
// (ids, the file) per file to read.
|
||||
let mut files: Vec<(Vec<NodeId>, String)> = vec![];
|
||||
let mut not_found = vec![];
|
||||
match (str_param(p, "path"), folder_param) {
|
||||
(Some(path), _) => files.push((ids, absolute_path(path))),
|
||||
(None, Some(dir)) => {
|
||||
for id in ids {
|
||||
let Some(link) = image(&id).and_then(|im| im.link.as_ref()) else { continue };
|
||||
let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy());
|
||||
match files.iter_mut().find(|(_, p)| *p == path) {
|
||||
Some((ids, _)) => ids.push(id),
|
||||
None if file_stamp(&path).is_some() => files.push((vec![id], path)),
|
||||
None => not_found.push(link.name().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None) => return Err(bad(C, "give path (a file) or folder")),
|
||||
}
|
||||
let mut reads = vec![];
|
||||
for (ids, path) in files {
|
||||
let bytes = read_file(&path)?;
|
||||
let img = fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?;
|
||||
reads.push((ids, (link_info(&path, &bytes), img)));
|
||||
}
|
||||
let relinked: Vec<u64> = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect();
|
||||
if !reads.is_empty() {
|
||||
s.edit("Relink", |d, _| {
|
||||
reads.into_iter().for_each(|(ids, read)| install(d, &ids, read));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
not_found.sort();
|
||||
not_found.dedup();
|
||||
Ok(json!({ "relinked": relinked, "notFound": not_found }))
|
||||
}
|
||||
|
|
@ -395,7 +395,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let id = s.edit("Rasterize", |d, sel| {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let mut node = Node::new(id, NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None }));
|
||||
|
|
@ -547,7 +547,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png), RasterColorModel::Document)?;
|
||||
s.edit("Crop Image", |d, _| {
|
||||
let key = unique_key(d, "crop");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
n.kind = NodeKind::Image(ImageObject {
|
||||
key,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ pub(crate) mod gradient;
|
|||
pub(crate) mod graph;
|
||||
pub mod help;
|
||||
mod layer;
|
||||
pub mod links;
|
||||
mod live;
|
||||
pub(crate) mod maskedit;
|
||||
pub(crate) mod menucmds;
|
||||
|
|
@ -218,6 +219,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(docsetup::specs());
|
||||
v.extend(newdoc::specs());
|
||||
v.extend(place::specs());
|
||||
v.extend(links::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
//! the clipboard.
|
||||
//!
|
||||
//! - A raster image becomes one image at 100% of its physical size: its pixels at the resolution
|
||||
//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its path with
|
||||
//! `link`.
|
||||
//! the file declares ([`fileio::ppi`]; 72 ppi when it declares none), linked to its file with
|
||||
//! `link` ([`super::links`]).
|
||||
//! - An SVG becomes one group of its art.
|
||||
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
|
||||
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
|
||||
//! (`crop: "bounding"`).
|
||||
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
|
||||
//! [`text`]).
|
||||
//!
|
||||
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
|
||||
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
|
||||
|
|
@ -15,12 +17,13 @@
|
|||
//! `place` tool) with several files, which emits one `file.place` per click or drag.
|
||||
|
||||
mod adopt;
|
||||
mod text;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Appearance, Document, ImageObject, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use vectorcraft_pdf::CropTo;
|
||||
|
||||
|
|
@ -34,6 +37,8 @@ const QUEUE: &str = "file.place.queue";
|
|||
const MAX_QUEUE: usize = 100;
|
||||
/// The longest thumbnail side `thumbnail` may ask for (px).
|
||||
const MAX_THUMBNAIL: f64 = 512.0;
|
||||
/// A text file's natural box (`file.place.info`, the place cursor): a letter page less 1 in margins.
|
||||
const TEXT_FRAME: Rect = Rect::new(0.0, 0.0, 468.0, 648.0);
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
|
|
@ -42,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
|
|
@ -69,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Load Place Cursor",
|
||||
[],
|
||||
None,
|
||||
"{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}",
|
||||
"{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}",
|
||||
has_doc,
|
||||
queue
|
||||
),
|
||||
|
|
@ -81,6 +86,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
/// A file's art at its natural size, before it joins a document.
|
||||
enum Art {
|
||||
Image(RasterImage),
|
||||
/// A text file's text, placed as area type filling the natural box.
|
||||
Text(String),
|
||||
Vector {
|
||||
/// The loaded document: the source of the art's resources.
|
||||
src: Box<Document>,
|
||||
|
|
@ -98,8 +105,8 @@ struct Loaded {
|
|||
/// The art's natural box (what `at` centres and `rect` fits).
|
||||
natural: Rect,
|
||||
warnings: Vec<String>,
|
||||
/// The file's path (for Link).
|
||||
path: Option<String>,
|
||||
/// The link to a raster image's file (for Link), when read from a path.
|
||||
link: Option<LinkInfo>,
|
||||
}
|
||||
|
||||
/// Points per pixel of a raster image at the resolution it declares (72 ppi when none).
|
||||
|
|
@ -112,6 +119,13 @@ fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
|
|||
fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
||||
let src = fileio::source(p, cmd)?;
|
||||
let name = fileio::file_name(src.name);
|
||||
if fileio::TEXT_EXTS.contains(&fileio::extension(src.name).as_str()) {
|
||||
let text = text::import(&src.bytes, text::TextOptions::parse(p, cmd)?, cmd)?;
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
||||
}
|
||||
return Ok(Loaded { name, format: &text::FORMAT, art: Art::Text(text), natural: TEXT_FRAME, warnings: vec![], link: None });
|
||||
}
|
||||
let format = fileio::detect(src.name, &src.bytes)
|
||||
.ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
let page = match p.get("page") {
|
||||
|
|
@ -127,12 +141,12 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
|||
));
|
||||
}
|
||||
let crop = opts.crop != CropTo::Bounding;
|
||||
let path = src.path.map(str::to_string);
|
||||
if format.raster {
|
||||
let img = fileio::raster_image(&src.bytes)?;
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
let natural = Rect::new(0.0, 0.0, img.width as f64 * sx, img.height as f64 * sy);
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], path });
|
||||
let link = src.path.map(|path| super::links::link_info(path, &src.bytes));
|
||||
return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings: vec![], link });
|
||||
}
|
||||
let (mut doc, warnings) = match format.id {
|
||||
// Only the page placed is read, its artboard the box asked for (Bounding Box: the art's
|
||||
|
|
@ -142,7 +156,9 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
|||
fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))?
|
||||
}
|
||||
_ => {
|
||||
let l = fileio::load(src.name, &src.bytes)?;
|
||||
let mut l = fileio::load(src.name, &src.bytes)?;
|
||||
// Linked images (a native document's) show their files, found from its folder.
|
||||
super::links::resolve(&mut l.doc, src.path, false);
|
||||
(l.doc, l.warnings)
|
||||
}
|
||||
};
|
||||
|
|
@ -167,7 +183,7 @@ fn load(p: &Value, cmd: &str) -> Result<Loaded> {
|
|||
};
|
||||
let natural = clip.or_else(|| nodes.iter().fold(None, |acc, n| vectorcraft_geom::union_opt(acc, n.visual_bounds())));
|
||||
let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?;
|
||||
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, path })
|
||||
Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None })
|
||||
}
|
||||
|
||||
/// A visible, non-template layer.
|
||||
|
|
@ -191,14 +207,15 @@ fn art_of<'a>(nodes: impl Iterator<Item = &'a Arc<Node>>) -> Vec<Node> {
|
|||
}
|
||||
|
||||
/// The art as one object of `d` (its resources added), at its natural size and position.
|
||||
fn build(d: &mut Document, l: Loaded, link: Option<String>) -> Node {
|
||||
fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
||||
let mut node = match l.art {
|
||||
Art::Image(img) => {
|
||||
let (sx, sy) = pt_per_px(img.ppi);
|
||||
d.images.entry(img.key.clone()).or_insert(img.blob);
|
||||
super::links::store_image(d, &img.key, img.blob, link.is_some());
|
||||
let im = ImageObject { key: img.key, width: img.width, height: img.height, xf: Affine::scale_non_uniform(sx, sy), link };
|
||||
Node::new(d.alloc_id(), NodeKind::Image(im))
|
||||
}
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))),
|
||||
Art::Vector { src, mut nodes, clip } => {
|
||||
adopt::adopt(d, &src, &mut nodes);
|
||||
let mut children: Vec<Arc<Node>> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect();
|
||||
|
|
@ -236,10 +253,10 @@ fn rect_param(p: &Value) -> Result<Option<Rect>> {
|
|||
}
|
||||
|
||||
/// The transform that puts new art with the natural box `natural` where `old` is: an image's scale
|
||||
/// and rotation relative to its 100% size (its resolution's) and its centre are kept; other objects
|
||||
/// keep their centre.
|
||||
fn replace_xf(d: &Document, old: &Node, natural: Rect) -> Affine {
|
||||
if let NodeKind::Image(im) = &old.kind {
|
||||
/// and rotation relative to its 100% size (its resolution's) and its centre are kept (with
|
||||
/// `image_xf`); other objects keep their centre.
|
||||
fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool) -> Affine {
|
||||
if image_xf && let NodeKind::Image(im) = &old.kind {
|
||||
let (sx, sy) = pt_per_px(d.images.get(&im.key).and_then(|b| fileio::ppi::resolution(&b.bytes)));
|
||||
let full = Affine::scale_non_uniform(sx, sy);
|
||||
let centre = full * Point::new(im.width as f64 / 2.0, im.height as f64 / 2.0);
|
||||
|
|
@ -277,9 +294,20 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
(true, _) => return Err(bad(PLACE, "replace: select the one object to replace")),
|
||||
};
|
||||
let mut loaded = load(p, PLACE)?;
|
||||
let old_node = old.and_then(|id| st.doc.node(id));
|
||||
// Type reflows rather than scales: its frame takes the size it is placed at.
|
||||
let is_text = matches!(loaded.art, Art::Text(_));
|
||||
if is_text {
|
||||
let size = match (old_node.and_then(Node::geometric_bounds), rect) {
|
||||
(Some(b), _) => b.size(),
|
||||
(None, Some(r)) => r.size(),
|
||||
(None, None) => text_frame(&st.doc, at).size(),
|
||||
};
|
||||
loaded.natural = Rect::from_origin_size(Point::ORIGIN, size);
|
||||
}
|
||||
let natural = loaded.natural;
|
||||
let m = match (old.and_then(|id| st.doc.node(id)), rect) {
|
||||
(Some(o), _) => replace_xf(&st.doc, o, natural),
|
||||
let m = match (old_node, rect) {
|
||||
(Some(o), _) => replace_xf(&st.doc, o, natural, !is_text),
|
||||
(None, Some(r)) => fit(natural, r),
|
||||
(None, None) => {
|
||||
let c = at.or_else(|| st.doc.artboards.first().map(|a| a.rect.center())).unwrap_or_default();
|
||||
|
|
@ -288,7 +316,7 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
let parent = st.insertion_parent();
|
||||
let below = st.current_layer().or_else(|| parent.and_then(|p| st.doc.layer_of(p)));
|
||||
let link = loaded.path.take().filter(|_| bool_or(p, "link", true) && loaded.format.raster);
|
||||
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 (id, size) = s.edit("Place", |d, sel| {
|
||||
|
|
@ -317,6 +345,13 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
)
|
||||
}
|
||||
|
||||
/// Placed type's frame without `rect` or Replace: the artboard under `at` (else the first) less a
|
||||
/// 36 pt margin.
|
||||
fn text_frame(d: &Document, at: Option<Point>) -> Rect {
|
||||
let board = at.and_then(|c| d.artboards.iter().find(|a| a.rect.contains(c))).or(d.artboards.first()).map_or(TEXT_FRAME, |a| a.rect);
|
||||
Rect::from_center_size(board.center(), ((board.width() - 72.0).max(72.0), (board.height() - 72.0).max(72.0)))
|
||||
}
|
||||
|
||||
/// A new locked template layer named after the file, right below the layer `below` (else at the
|
||||
/// bottom).
|
||||
fn template_layer(d: &mut Document, below: Option<NodeId>, name: &str) -> Result<NodeId> {
|
||||
|
|
@ -359,7 +394,7 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let thumbnail = p.get("thumbnail").and_then(Value::as_f64);
|
||||
let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]);
|
||||
for mut q in queued_files(p)? {
|
||||
for k in ["link", "template", "page", "crop"] {
|
||||
for k in ["link", "template", "page", "crop", "text"] {
|
||||
if let Some(v) = p.get(k) {
|
||||
q[k] = v.clone();
|
||||
}
|
||||
|
|
@ -462,7 +497,7 @@ fn image_info(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"id": id.0,
|
||||
"name": n.display_name(),
|
||||
"linked": im.link.is_some(),
|
||||
"link": im.link,
|
||||
"link": im.link.as_ref().map(|l| &l.path),
|
||||
"colorMode": st.doc.images.get(&im.key).map_or("RGB", |b| color_mode(&b.bytes)),
|
||||
"pixelWidth": im.width,
|
||||
"pixelHeight": im.height,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
//! Placing a plain-text file (Text Import Options): its bytes decoded by character set and
|
||||
//! platform, extra returns and spaces cleaned up, the text set as area type.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Text files as `file.place` reports them (not a format `document.open` reads).
|
||||
pub(super) const FORMAT: fileio::Format = fileio::Format {
|
||||
id: "txt",
|
||||
label: "Text",
|
||||
extensions: fileio::TEXT_EXTS,
|
||||
mime: "text/plain",
|
||||
read: true,
|
||||
write: false,
|
||||
raster: false,
|
||||
options: &[],
|
||||
};
|
||||
|
||||
/// The most bytes a text file placed as type may have.
|
||||
const MAX_TEXT: usize = 4 << 20;
|
||||
|
||||
/// Text Import Options (`file.place`'s `text` param).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct TextOptions {
|
||||
/// Decode as the platform's 8-bit character set (Windows-1252 or Mac Roman) instead of
|
||||
/// Unicode.
|
||||
ansi: bool,
|
||||
/// The file comes from a Mac (its 8-bit character set is Mac Roman).
|
||||
mac: bool,
|
||||
/// Join the lines of each block of text (blank lines end paragraphs).
|
||||
remove_line_returns: bool,
|
||||
/// Drop the blank lines between paragraphs.
|
||||
remove_paragraph_returns: bool,
|
||||
/// Replace runs of this many spaces or more with a tab (2 or more).
|
||||
spaces_to_tab: Option<usize>,
|
||||
}
|
||||
|
||||
impl TextOptions {
|
||||
/// The options in `p.text` for command `cmd`.
|
||||
pub(super) fn parse(p: &Value, cmd: &str) -> Result<Self> {
|
||||
let Some(t) = p.get("text") else { return Ok(Self::default()) };
|
||||
if !t.is_object() {
|
||||
return Err(bad(cmd, "text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}"));
|
||||
}
|
||||
let choice = |key: &str, values: [&str; 2]| match str_param(t, key) {
|
||||
None => Ok(false),
|
||||
Some(v) if v == values[0] => Ok(false),
|
||||
Some(v) if v == values[1] => Ok(true),
|
||||
Some(v) => Err(bad(cmd, format!("text.{key} `{v}`: use \"{}\" or \"{}\"", values[0], values[1]))),
|
||||
};
|
||||
let spaces_to_tab = match t.get("replaceSpaces") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_u64().filter(|n| (2..=100).contains(n)).ok_or_else(|| bad(cmd, "text.replaceSpaces must be a whole number from 2 to 100"))?
|
||||
as usize,
|
||||
),
|
||||
};
|
||||
Ok(Self {
|
||||
ansi: choice("characterSet", ["unicode", "ansi"])?,
|
||||
mac: choice("platform", ["windows", "mac"])?,
|
||||
remove_line_returns: bool_or(t, "removeLineReturns", false),
|
||||
remove_paragraph_returns: bool_or(t, "removeParagraphReturns", false),
|
||||
spaces_to_tab,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a text file's `bytes`, decoded and cleaned up as `o` says: paragraphs separated by
|
||||
/// `\n`.
|
||||
pub(super) fn import(bytes: &[u8], o: TextOptions, cmd: &str) -> Result<String> {
|
||||
if bytes.len() > MAX_TEXT {
|
||||
return Err(bad(cmd, format!("the text file is too large to place as type (at most {} MB)", MAX_TEXT >> 20)));
|
||||
}
|
||||
Ok(clean(&decode(bytes, o), o))
|
||||
}
|
||||
|
||||
/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark; bytes that aren't UTF-8 are
|
||||
/// read as the platform's 8-bit set), or the platform's 8-bit character set.
|
||||
fn decode(bytes: &[u8], o: TextOptions) -> String {
|
||||
let eight_bit = |b: &[u8]| -> String { b.iter().map(|&c| if o.mac { mac_roman(c) } else { windows_1252(c) }).collect() };
|
||||
if o.ansi {
|
||||
return eight_bit(bytes);
|
||||
}
|
||||
let utf16 = |rest: &[u8], le: bool| {
|
||||
let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
|
||||
char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
|
||||
};
|
||||
match bytes {
|
||||
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
|
||||
[0xFF, 0xFE, rest @ ..] => utf16(rest, true),
|
||||
[0xFE, 0xFF, rest @ ..] => utf16(rest, false),
|
||||
_ => std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte `b` of Windows-1252.
|
||||
fn windows_1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', //
|
||||
'\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
|
||||
];
|
||||
match b {
|
||||
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or(char::REPLACEMENT_CHARACTER),
|
||||
_ => char::from(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte `b` of Mac Roman.
|
||||
fn mac_roman(b: u8) -> char {
|
||||
const HIGH: [char; 128] = [
|
||||
'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', //
|
||||
'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', //
|
||||
'†', '°', '¢', '£', '§', '•', '¶', 'ß', '®', '©', '™', '´', '¨', '≠', 'Æ', 'Ø', //
|
||||
'∞', '±', '≤', '≥', '¥', 'µ', '∂', '∑', '∏', 'π', '∫', 'ª', 'º', 'Ω', 'æ', 'ø', //
|
||||
'¿', '¡', '¬', '√', 'ƒ', '≈', '∆', '«', '»', '…', '\u{A0}', 'À', 'Ã', 'Õ', 'Œ', 'œ', //
|
||||
'–', '—', '“', '”', '‘', '’', '÷', '◊', 'ÿ', 'Ÿ', '⁄', '€', '‹', '›', 'fi', 'fl', //
|
||||
'‡', '·', '‚', '„', '‰', 'Â', 'Ê', 'Á', 'Ë', 'È', 'Í', 'Î', 'Ï', 'Ì', 'Ó', 'Ô', //
|
||||
'\u{F8FF}', 'Ò', 'Ú', 'Û', 'Ù', 'ı', 'ˆ', '˜', '¯', '˘', '˙', '˚', '¸', '˝', '˛', 'ˇ',
|
||||
];
|
||||
match b {
|
||||
0x80.. => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or(char::REPLACEMENT_CHARACTER),
|
||||
_ => char::from(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` with its line breaks (CR LF, CR or LF) as paragraph breaks, cleaned up as `o` says:
|
||||
/// with `remove_line_returns` each block of lines is one paragraph (a blank line ends it, and stays
|
||||
/// as an empty paragraph unless `remove_paragraph_returns`); with only `remove_paragraph_returns`
|
||||
/// each line is a paragraph and blank lines go. Other control characters than tabs are dropped,
|
||||
/// and so are blank lines at the end.
|
||||
fn clean(text: &str, o: TextOptions) -> String {
|
||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let text: String = text.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
|
||||
let blank = |l: &str| l.trim().is_empty();
|
||||
let mut paras: Vec<String> = vec![];
|
||||
if o.remove_line_returns {
|
||||
let mut block: Option<String> = None;
|
||||
for line in text.split('\n') {
|
||||
if blank(line) {
|
||||
paras.extend(block.take());
|
||||
if !o.remove_paragraph_returns {
|
||||
paras.push(String::new());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match &mut block {
|
||||
Some(b) => {
|
||||
b.push(' ');
|
||||
b.push_str(line.trim());
|
||||
}
|
||||
None => block = Some(line.trim_end().to_string()),
|
||||
}
|
||||
}
|
||||
paras.extend(block);
|
||||
} else {
|
||||
paras = text.split('\n').filter(|l| !(o.remove_paragraph_returns && blank(l))).map(str::to_string).collect();
|
||||
}
|
||||
while paras.last().is_some_and(|p| blank(p)) {
|
||||
paras.pop();
|
||||
}
|
||||
let out = paras.join("\n");
|
||||
match o.spaces_to_tab {
|
||||
Some(n) => tabs_for_spaces(&out, n),
|
||||
None => out,
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` with each run of `n` or more spaces replaced by a tab.
|
||||
fn tabs_for_spaces(s: &str, n: usize) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut run = 0;
|
||||
let flush = |out: &mut String, run: usize| {
|
||||
if run >= n {
|
||||
out.push('\t');
|
||||
} else {
|
||||
out.extend(std::iter::repeat_n(' ', run));
|
||||
}
|
||||
};
|
||||
for c in s.chars() {
|
||||
if c == ' ' {
|
||||
run += 1;
|
||||
continue;
|
||||
}
|
||||
flush(&mut out, run);
|
||||
run = 0;
|
||||
out.push(c);
|
||||
}
|
||||
flush(&mut out, run);
|
||||
out
|
||||
}
|
||||
|
||||
/// The area type of `text` filling `frame` (at the origin; placing moves it).
|
||||
pub(super) fn area_text(text: String, frame: Rect) -> vectorcraft_doc::TextObject {
|
||||
let mut t = vectorcraft_doc::TextObject::point(Point::ORIGIN, "", vectorcraft_doc::CharStyle::default());
|
||||
if let Some(run) = t.runs.first_mut() {
|
||||
run.text = text;
|
||||
}
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
t
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn opts(lines: bool, paras: bool) -> TextOptions {
|
||||
TextOptions { remove_line_returns: lines, remove_paragraph_returns: paras, ..TextOptions::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_with_returns_removed_gives_one_paragraph_per_block() {
|
||||
let text = "First line\r\nof one block\r\n\r\nSecond block\r\nends here\r\n\r\n\r\nThird\r\n";
|
||||
assert_eq!(clean(text, opts(true, true)), "First line of one block\nSecond block ends here\nThird");
|
||||
assert_eq!(clean(text, opts(true, false)), "First line of one block\n\nSecond block ends here\n\n\nThird");
|
||||
assert_eq!(clean(text, opts(false, true)), "First line\nof one block\nSecond block\nends here\nThird");
|
||||
assert_eq!(clean(text, opts(false, false)), "First line\nof one block\n\nSecond block\nends here\n\n\nThird");
|
||||
assert_eq!(clean("Old\rMac\r\rText", opts(true, true)), "Old Mac\nText", "CR alone breaks lines too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_of_spaces_become_tabs_and_controls_go() {
|
||||
let o = TextOptions { spaces_to_tab: Some(3), ..TextOptions::default() };
|
||||
assert_eq!(clean("a b c d\u{0}e\tf", o), "a b\tc\tde\tf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_by_character_set_and_platform() {
|
||||
let unicode = TextOptions::default();
|
||||
assert_eq!(decode("café".as_bytes(), unicode), "café");
|
||||
assert_eq!(decode(&[0xEF, 0xBB, 0xBF, b'h', b'i'], unicode), "hi", "UTF-8 byte-order mark");
|
||||
assert_eq!(decode(&[0xFF, 0xFE, b'h', 0, 0xE9, 0], unicode), "hé", "UTF-16 LE");
|
||||
assert_eq!(decode(&[0xFE, 0xFF, 0, b'h', 0x20, 0xAC], unicode), "h€", "UTF-16 BE");
|
||||
assert_eq!(decode(&[b'c', b'a', b'f', 0xE9, 0x80], unicode), "café€", "not UTF-8: Windows-1252");
|
||||
let ansi = TextOptions { ansi: true, ..TextOptions::default() };
|
||||
assert_eq!(decode(&[0x93, b'q', 0x94, 0xE9], ansi), "“q”é");
|
||||
let mac = TextOptions { ansi: true, mac: true, ..TextOptions::default() };
|
||||
assert_eq!(decode(&[0xD2, b'q', 0xD3, 0x8E, 0xA5, 0xDB], mac), "“q”é•€");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_parse_and_reject_bad_values() {
|
||||
let p = serde_json::json!({"text": {"characterSet": "ansi", "platform": "mac", "removeLineReturns": true, "replaceSpaces": 4}});
|
||||
let o = TextOptions::parse(&p, "file.place").unwrap();
|
||||
assert_eq!(o, TextOptions { ansi: true, mac: true, remove_line_returns: true, remove_paragraph_returns: false, spaces_to_tab: Some(4) });
|
||||
assert_eq!(TextOptions::parse(&serde_json::json!({}), "file.place").unwrap(), TextOptions::default());
|
||||
for bad in
|
||||
[serde_json::json!({"text": 3}), serde_json::json!({"text": {"platform": "amiga"}}), serde_json::json!({"text": {"replaceSpaces": 1}})]
|
||||
{
|
||||
assert!(TextOptions::parse(&bad, "file.place").is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -201,7 +201,7 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
|||
while out.images.contains_key(&key) {
|
||||
key.push('+');
|
||||
}
|
||||
out.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
|
||||
out.images.insert(key.clone(), ImageBlob::new("image/png", png));
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let mut image = Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None }));
|
||||
image.name = Some("Raster effect".into());
|
||||
|
|
|
|||
|
|
@ -963,6 +963,8 @@ mod tests_layerclip;
|
|||
#[cfg(test)]
|
||||
mod tests_linked_stops;
|
||||
#[cfg(test)]
|
||||
mod tests_links;
|
||||
#[cfg(test)]
|
||||
mod tests_live;
|
||||
#[cfg(test)]
|
||||
mod tests_maskview;
|
||||
|
|
@ -1029,6 +1031,8 @@ mod tests_targeting;
|
|||
#[cfg(test)]
|
||||
mod tests_textedit;
|
||||
#[cfg(test)]
|
||||
mod tests_textimport;
|
||||
#[cfg(test)]
|
||||
mod tests_tileedge;
|
||||
#[cfg(test)]
|
||||
mod tests_tints;
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node
|
|||
let png = r.encode_png();
|
||||
let (w, h) = (r.width, r.height);
|
||||
s.edit("img", |d, sel| {
|
||||
d.images.insert("img1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
|
||||
d.images.insert("img1".into(), ImageBlob::new("image/png", png));
|
||||
let id = d.alloc_id();
|
||||
let l = d.default_layer();
|
||||
d.insert(l, 0, Node::new(id, NodeKind::Image(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None })))?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,324 @@
|
|||
//! Linked images: what a link records, the preview saved in place of the pixels, finding files
|
||||
//! that moved, missing and modified files (`document.open`, `links.check`, `links.update`,
|
||||
//! `links.relink`).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A fresh folder for one test (removed when dropped).
|
||||
struct Folder(PathBuf);
|
||||
|
||||
impl Folder {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-links-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
fn file(&self, name: &str) -> String {
|
||||
self.0.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Folder {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w`×`h` PNG of one colour.
|
||||
fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
let [r, g, b] = rgb;
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([r, g, b, 255])).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
const RED: [u8; 3] = [230, 20, 20];
|
||||
const BLUE: [u8; 3] = [20, 20, 230];
|
||||
|
||||
fn write(path: &str, bytes: &[u8]) {
|
||||
if let Some(dir) = Path::new(path).parent() {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Place the file at `path` (linked) → its id.
|
||||
fn place(s: &mut Session, path: &str) -> NodeId {
|
||||
let r = s.execute("file.place", &json!({"path": path, "at": [200, 150]})).unwrap();
|
||||
assert_eq!(r["linked"], true, "{r}");
|
||||
NodeId(r["ids"][0].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn image(s: &Session, id: NodeId) -> ImageObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Image(im) => im.clone(),
|
||||
k => panic!("not an image: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
/// The colour at the centre of the active document's artboard.
|
||||
fn centre_colour(doc: &Document) -> [u8; 3] {
|
||||
let img = vectorcraft_render::Renderer::new().render_region(doc, Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, true);
|
||||
let [r, g, b, _] = img.pixel(200, 150);
|
||||
[r, g, b]
|
||||
}
|
||||
|
||||
fn near(a: [u8; 3], b: [u8; 3]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 6)
|
||||
}
|
||||
|
||||
/// The first image object's JSON in a saved document.
|
||||
fn saved_image(v: &mut Value) -> &mut Value {
|
||||
&mut v["document"]["layers"][0]["kind"]["children"][0]["kind"]
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, path: &str) {
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, path: &str) -> Value {
|
||||
s.execute("document.open", &json!({"path": path})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_link_records_the_file_and_the_document_saves_only_a_preview() {
|
||||
let dir = Folder::new("record");
|
||||
let pic = dir.file("art/photo.png");
|
||||
let bytes = png(600, 300, RED);
|
||||
write(&pic, &bytes);
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
let im = image(&s, id);
|
||||
let link = im.link.clone().unwrap();
|
||||
assert_eq!((link.path.as_str(), link.size), (pic.as_str(), Some(bytes.len() as u64)));
|
||||
assert_eq!(link.hash.as_deref(), Some(vectorcraft_doc::links::hash_bytes(&bytes).as_str()));
|
||||
assert!(link.modified.is_some_and(|m| m > 1_600_000_000_000), "{link:?}");
|
||||
let blob = &s.doc().unwrap().doc.images[&im.key];
|
||||
assert!(blob.proxy.is_some() && !blob.is_proxy(), "full pixels, with a preview to save");
|
||||
let doc_path = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
|
||||
let entry = &saved["images"][&im.key];
|
||||
assert_eq!(entry["proxy"], true);
|
||||
assert!(entry["data"].as_str().unwrap().len() * 3 / 4 < bytes.len(), "the preview is smaller than the file");
|
||||
let saved_link = saved_image(&mut saved)["link"].clone();
|
||||
assert_eq!((saved_link["path"].as_str(), saved_link["relative"].as_str()), (Some(pic.as_str()), Some("art/photo.png")));
|
||||
// Embedding keeps the pixels, and no preview is saved for them.
|
||||
s.execute("file.place", &json!({"path": pic, "link": false})).unwrap();
|
||||
save(&mut s, &doc_path);
|
||||
let saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap();
|
||||
assert_eq!(saved["images"][&im.key]["proxy"], Value::Null, "an embedded image shows it too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_relative_path_finds_the_file_after_the_folder_moves() {
|
||||
let a = Folder::new("move-a");
|
||||
let b = Folder::new("move-b");
|
||||
let pic = a.file("art/photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
save(&mut s, &a.file("poster.vectorcraft"));
|
||||
// Move the document with its links: the old folder is gone.
|
||||
std::fs::remove_dir_all(&b.0).unwrap();
|
||||
std::fs::rename(&a.0, &b.0).unwrap();
|
||||
let r = open(&mut s, &b.file("poster.vectorcraft"));
|
||||
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
|
||||
let moved = b.file("art/photo.png");
|
||||
let im = image(&s, id);
|
||||
assert_eq!(Path::new(&im.link.unwrap().path), Path::new(&moved), "the link follows the file");
|
||||
assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy(), "the file's pixels were read");
|
||||
let c = s.execute("links.check", &json!({})).unwrap();
|
||||
assert_eq!((c["links"][0]["status"].as_str(), c["missing"].as_u64()), (Some("ok"), Some(0)), "{c}");
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_link_renders_its_preview_and_relinks() {
|
||||
let dir = Folder::new("missing");
|
||||
let pic = dir.file("photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
let placed = bounds(&s, id);
|
||||
let doc_path = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
std::fs::remove_file(&pic).unwrap();
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!(r["missingLinks"], json!([{"name": "photo.png", "path": pic, "ids": [id.0]}]));
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.images[&image(&s, id).key].is_proxy(), "the preview stands in");
|
||||
assert!(near(centre_colour(doc), RED), "missing still renders");
|
||||
let c = s.execute("links.check", &json!({})).unwrap();
|
||||
assert_eq!((c["links"][0]["status"].as_str(), c["links"][0]["preview"].as_bool(), c["missing"].as_u64()), (Some("missing"), Some(true), Some(1)));
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap(), json!({"updated": [], "missing": [id.0]}));
|
||||
// Replace: another file, smaller; the image keeps its bounds.
|
||||
let other = dir.file("other/blue.png");
|
||||
write(&other, &png(300, 100, BLUE));
|
||||
let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap();
|
||||
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}));
|
||||
let im = image(&s, id);
|
||||
assert_eq!((im.width, im.height, im.link.unwrap().path), (300, 100, other.clone()));
|
||||
let b = bounds(&s, id);
|
||||
assert!((b.x0 - placed.x0).abs() < 1e-6 && (b.y1 - placed.y1).abs() < 1e-6, "{b:?} vs {placed:?}");
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "one undo step");
|
||||
// Relink to Folder: the file of the link's name there.
|
||||
write(&dir.file("found/photo.png"), &png(600, 300, BLUE));
|
||||
let r = s.execute("links.relink", &json!({"folder": dir.file("found")})).unwrap();
|
||||
assert_eq!(r, json!({"relinked": [id.0], "notFound": []}), "nothing selected: every missing link");
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
assert!(s.execute("links.relink", &json!({"ids": [id.0]})).is_err(), "path or folder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modified_link_follows_the_update_links_preference() {
|
||||
let dir = Folder::new("modified");
|
||||
let pic = dir.file("photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
let placed = bounds(&s, id);
|
||||
let doc_path = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
write(&pic, &png(400, 400, BLUE));
|
||||
let c = s.execute("links.check", &json!({})).unwrap();
|
||||
assert_eq!((c["links"][0]["status"].as_str(), c["modified"].as_u64()), (Some("modified"), Some(1)), "{c}");
|
||||
|
||||
// Ask When Modified (the default): reported, left as it was.
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!((r["modifiedLinks"][0]["ids"].clone(), r["updatedLinks"].clone()), (json!([id.0]), json!([])), "{r}");
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "the preview until updated");
|
||||
let r = s.execute("links.update", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"updated": [id.0], "missing": []}));
|
||||
let im = image(&s, id);
|
||||
assert_eq!((im.width, im.height), (400, 400));
|
||||
let b = bounds(&s, id);
|
||||
assert!((b.width() - placed.width()).abs() < 1e-6 && (b.height() - placed.height()).abs() < 1e-6, "keeps its bounds: {b:?}");
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Update Links");
|
||||
let c = s.execute("links.check", &json!({})).unwrap();
|
||||
assert_eq!(c["links"][0]["status"], "ok");
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], json!([]), "nothing left to update");
|
||||
|
||||
// Automatically: read again as the document opens.
|
||||
s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap();
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}");
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE));
|
||||
assert!(s.doc().unwrap().history.undo.is_empty(), "opening is not an undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_from_older_files_load_and_keep_their_pixels() {
|
||||
let dir = Folder::new("legacy");
|
||||
let pic = dir.file("photo.png");
|
||||
let bytes = png(600, 300, RED);
|
||||
write(&pic, &bytes);
|
||||
let mut s = session();
|
||||
s.execute("file.place", &json!({"path": pic, "at": [200, 150], "link": false})).unwrap();
|
||||
// As older versions saved a link: the path alone, the pixels embedded.
|
||||
let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
saved_image(&mut v)["link"] = json!(pic);
|
||||
v["format"] = json!("drawcraft");
|
||||
let legacy = serde_json::to_vec(&v).unwrap();
|
||||
let doc_path = dir.file("old.drawcraft");
|
||||
write(&doc_path, &legacy);
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}");
|
||||
let st = s.doc().unwrap();
|
||||
let id = st.doc.layers[0].children().unwrap()[0].id;
|
||||
let im = image(&s, id);
|
||||
let link = im.link.unwrap();
|
||||
assert_eq!((link.path.as_str(), link.size), (pic.as_str(), Some(bytes.len() as u64)), "the details are filled in");
|
||||
assert!(s.doc().unwrap().doc.images[&im.key].proxy.is_some(), "a preview to save from now on");
|
||||
// Gone, the embedded pixels still show.
|
||||
std::fs::remove_file(&pic).unwrap();
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!(r["missingLinks"][0]["name"], "photo.png");
|
||||
assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy());
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_file_system_the_saved_preview_shows() {
|
||||
// What the web does: the document arrives as bytes and no linked file can be read.
|
||||
let dir = Folder::new("web");
|
||||
let pic = dir.file("photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||||
std::fs::remove_file(&pic).unwrap();
|
||||
let doc = vectorcraft_format::load(&saved).unwrap();
|
||||
assert!(doc.images.values().all(|b| b.is_proxy()));
|
||||
assert!(near(centre_colour(&doc), RED), "the preview renders");
|
||||
let r = s.execute("document.open", &json!({"name": "poster.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&saved)})).unwrap();
|
||||
assert_eq!(r["missingLinks"][0]["ids"], json!([id.0]));
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
// Exports carry the preview.
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
assert!(svg["text"].as_str().unwrap().contains("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_are_written_against_the_saved_file() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
let (lid, nid) = (d.layers[0].id, d.alloc_id());
|
||||
let root = if cfg!(windows) { "C:\\work" } else { "/work" };
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let im =
|
||||
ImageObject { key: "k".into(), width: 1, height: 1, xf: Default::default(), link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))) };
|
||||
d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(im))).unwrap();
|
||||
let rel = |dest: String| {
|
||||
let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone());
|
||||
match &d.node(nid).unwrap().kind {
|
||||
NodeKind::Image(im) => im.link.as_ref().unwrap().relative.clone(),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
assert_eq!(rel(format!("{root}{sep}doc.vectorcraft")).as_deref(), Some("art/a.png"));
|
||||
assert_eq!(rel(format!("{root}{sep}docs{sep}deep{sep}doc.vectorcraft")).as_deref(), Some("../../art/a.png"));
|
||||
assert_eq!(rel(format!("{root}{sep}art{sep}doc.vectorcraft")).as_deref(), Some("a.png"));
|
||||
if cfg!(windows) {
|
||||
assert_eq!(rel("D:\\other\\doc.vectorcraft".into()), None, "another drive");
|
||||
assert_eq!(rel("c:\\WORK\\doc.vectorcraft".into()).as_deref(), Some("art/a.png"), "without case");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_document_brings_its_links_with_the_files_pixels() {
|
||||
let dir = Folder::new("place-doc");
|
||||
let pic = dir.file("photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
place(&mut s, &pic);
|
||||
let doc_path = dir.file("part.vectorcraft");
|
||||
save(&mut s, &doc_path);
|
||||
let mut s = session();
|
||||
s.execute("file.place", &json!({"path": doc_path})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let mut placed = vec![];
|
||||
doc.visit_images(|_, im| placed.push(im.clone()));
|
||||
let [im] = &placed[..] else { panic!("one image: {placed:?}") };
|
||||
assert_eq!(im.link.as_ref().map(|l| l.path.as_str()), Some(pic.as_str()), "still linked");
|
||||
assert!(!doc.images[&im.key].is_proxy(), "the file's pixels, not the preview");
|
||||
}
|
||||
|
|
@ -301,7 +301,7 @@ fn bad_place_params_are_errors() {
|
|||
for p in [
|
||||
json!({}),
|
||||
json!({"name": "x.png", "dataBase64": "%%%"}),
|
||||
json!({"name": "notes.txt", "dataBase64": vectorcraft_format::base64_encode(b"hello")}),
|
||||
json!({"name": "notes.xyz", "dataBase64": vectorcraft_format::base64_encode(b"hello")}),
|
||||
json!({"name": "x.png", "dataBase64": ok, "rect": [0, 0, 0, 10]}),
|
||||
json!({"name": "x.png", "dataBase64": ok, "at": [1e12, 0]}),
|
||||
json!({"name": "x.png", "dataBase64": ok, "page": 0}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
//! File → Place of a text file: Text Import Options, and the text set as area type.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TextKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn place_text(s: &mut Session, text: &[u8], extra: Value) -> Value {
|
||||
let mut p = json!({"name": "notes.txt", "dataBase64": vectorcraft_format::base64_encode(text)});
|
||||
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
|
||||
o.extend(e);
|
||||
}
|
||||
s.execute("file.place", &p).unwrap()
|
||||
}
|
||||
|
||||
/// The placed type's text and frame bounds.
|
||||
fn placed(s: &Session, r: &Value) -> (String, Rect) {
|
||||
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("not type: {:?}", n.kind) };
|
||||
assert!(matches!(t.kind, TextKind::Area { .. }), "area type");
|
||||
(t.plain_text(), n.geometric_bounds().unwrap())
|
||||
}
|
||||
|
||||
fn close(a: Rect, b: Rect) -> bool {
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_with_returns_removed_places_one_paragraph_per_block_as_area_type() {
|
||||
let mut s = session();
|
||||
let text = b"Dear reader,\r\nthis is the first\r\nparagraph.\r\n\r\nAnd here\r\nthe second.\r\n";
|
||||
let opts = json!({"text": {"removeLineReturns": true, "removeParagraphReturns": true, "replaceSpaces": 3}});
|
||||
let r = place_text(&mut s, text, opts);
|
||||
assert_eq!((r["format"].as_str(), r["name"].as_str(), r["linked"].as_bool()), (Some("txt"), Some("notes.txt"), Some(false)));
|
||||
let (body, frame) = placed(&s, &r);
|
||||
assert_eq!(body, "Dear reader, this is the first paragraph.\nAnd\there the second.");
|
||||
assert!(close(frame, Rect::new(36.0, 36.0, 364.0, 264.0)), "the artboard less a 36 pt margin: {frame:?}");
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.history.undo.len(), st.selection.objects.len()), (1, 1), "one undo step, selected");
|
||||
// Without options each line is a paragraph; `rect` sets the frame.
|
||||
let r = place_text(&mut s, text, json!({"rect": [10, 20, 100, 50]}));
|
||||
let (body, frame) = placed(&s, &r);
|
||||
assert_eq!(body, "Dear reader,\nthis is the first\nparagraph.\n\nAnd here\nthe second.");
|
||||
assert!(close(frame, Rect::new(10.0, 20.0, 110.0, 70.0)), "{frame:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_files_report_and_queue_with_their_options() {
|
||||
let mut s = session();
|
||||
let file = json!({"name": "a.txt", "dataBase64": vectorcraft_format::base64_encode(b"caf\xe9")});
|
||||
let info = s.execute("file.place.info", &file).unwrap();
|
||||
assert_eq!((info["format"].as_str(), info["width"].as_f64(), info["height"].as_f64()), (Some("txt"), Some(468.0), Some(648.0)));
|
||||
// Not UTF-8: read as Windows-1252, or Mac Roman for a Mac file.
|
||||
let r = place_text(&mut s, b"caf\x8e", json!({"text": {"characterSet": "ansi", "platform": "mac"}}));
|
||||
assert_eq!(placed(&s, &r).0, "café");
|
||||
let r = s.execute("file.place.queue", &json!({"files": [file], "text": {"characterSet": "ansi"}})).unwrap();
|
||||
assert_eq!((r["count"].as_u64(), r["files"][0]["format"].as_str()), (Some(1), Some("txt")));
|
||||
assert_eq!(s.tool_options()["name"], "a.txt");
|
||||
let bad = json!({"name": "a.txt", "dataBase64": "YQ==", "text": {"platform": "beos"}});
|
||||
assert!(s.execute("file.place", &bad).is_err());
|
||||
let blank = json!({"name": "blank.txt", "dataBase64": vectorcraft_format::base64_encode(b" \r\n\t\r\n")});
|
||||
assert!(s.execute("file.place", &blank).is_err(), "no text to place");
|
||||
// Replace: the frame takes the replaced object's bounds.
|
||||
s.execute("shape.rectangle", &json!({"x": 50, "y": 60, "width": 80, "height": 40})).unwrap();
|
||||
let r = place_text(&mut s, b"Swap", json!({"replace": true}));
|
||||
let (body, frame) = placed(&s, &r);
|
||||
assert_eq!(body, "Swap");
|
||||
assert!(close(frame, Rect::new(50.0, 60.0, 130.0, 100.0)), "{frame:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_reads_what_open_reads_and_text_files() {
|
||||
use cmd::fileio::{OPEN_EXTS, PLACE_EXTS, TEXT_EXTS};
|
||||
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS].concat());
|
||||
let filters: Vec<_> = cmd::fileio::place_filters().collect();
|
||||
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("Text", TEXT_EXTS))));
|
||||
assert!(!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt")), "Open doesn't read text files");
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
//! Untrusted text files placed with File → Place never crash: any bytes, with any Text Import
|
||||
//! Options, place as area type or fail with an error.
|
||||
//!
|
||||
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test text_import_fuzz` runs a deeper search.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use proptest::prelude::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_testkit::catch_quiet;
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 64, failure_persistence: None, ..ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn any_bytes_place_as_text_without_panics(
|
||||
bytes in prop::collection::vec(prop_oneof![any::<u8>(), Just(b'\r'), Just(b'\n'), Just(b' ')], 0..400),
|
||||
bom in prop::sample::select(vec![&b""[..], b"\xEF\xBB\xBF", b"\xFF\xFE", b"\xFE\xFF"]),
|
||||
ansi in any::<bool>(),
|
||||
mac in any::<bool>(),
|
||||
lines in any::<bool>(),
|
||||
paras in any::<bool>(),
|
||||
spaces in prop::option::of(0u64..8),
|
||||
) {
|
||||
let data = [bom, &bytes[..]].concat();
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let text = json!({
|
||||
"characterSet": if ansi { "ansi" } else { "unicode" },
|
||||
"platform": if mac { "mac" } else { "windows" },
|
||||
"removeLineReturns": lines,
|
||||
"removeParagraphReturns": paras,
|
||||
"replaceSpaces": spaces,
|
||||
});
|
||||
let p = json!({"name": "fuzz.txt", "dataBase64": vectorcraft_format::base64_encode(&data), "text": text, "thumbnail": 8});
|
||||
let _ = s.execute("file.place.info", &p);
|
||||
let _ = s.execute("file.place", &p);
|
||||
});
|
||||
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
|
||||
}
|
||||
}
|
||||
|
|
@ -6,12 +6,13 @@
|
|||
//! "document": { …vectorcraft_doc::Document… },
|
||||
//! "images": { "<key>": { "mime": "image/png", "data": "<base64>" } } }
|
||||
//! ```
|
||||
//! An image only linked images show is saved as its low-resolution preview (`"proxy": true`); the
|
||||
//! engine reads the linked file again when the document opens.
|
||||
//! It is lossless for everything in the document model and preserves unknown fields under
|
||||
//! `document.unknown`. Readers must reject files whose `version` is newer than they support.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_doc::{Document, ImageBlob};
|
||||
|
|
@ -46,6 +47,9 @@ pub enum FormatError {
|
|||
struct Image {
|
||||
mime: String,
|
||||
data: String,
|
||||
/// `data` is a linked image's preview ([`ImageBlob::proxy`]), not the file's bytes.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
proxy: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
|
@ -63,7 +67,18 @@ struct File {
|
|||
/// ([`Document::without_edit_modes`]).
|
||||
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
|
||||
let doc = doc.without_edit_modes();
|
||||
let images = doc.images.iter().map(|(k, b)| (k.clone(), Image { mime: b.mime.clone(), data: base64_encode(&b.bytes) })).collect();
|
||||
let linked = doc.linked_only_images();
|
||||
let images = doc
|
||||
.images
|
||||
.iter()
|
||||
.map(|(k, b)| {
|
||||
let image = match b.proxy.as_ref().filter(|_| linked.contains(k)) {
|
||||
Some(p) => Image { mime: vectorcraft_doc::links::PROXY_MIME.into(), data: base64_encode(p), proxy: true },
|
||||
None => Image { mime: b.mime.clone(), data: base64_encode(&b.bytes), proxy: false },
|
||||
};
|
||||
(k.clone(), image)
|
||||
})
|
||||
.collect();
|
||||
let f = File {
|
||||
format: "vectorcraft".into(),
|
||||
version: VERSION,
|
||||
|
|
@ -95,7 +110,12 @@ pub fn load(bytes: &[u8]) -> Result<Document, FormatError> {
|
|||
let mut doc = f.document;
|
||||
for (k, img) in f.images {
|
||||
let bytes = base64_decode(&img.data).ok_or_else(|| FormatError::BadImage(k.clone()))?;
|
||||
doc.images.insert(k, ImageBlob { mime: img.mime, bytes: Arc::new(bytes) });
|
||||
let mut blob = ImageBlob::new(img.mime, bytes);
|
||||
// Until the linked file is read, the preview stands in for it.
|
||||
if img.proxy {
|
||||
blob.proxy = Some(blob.bytes.clone());
|
||||
}
|
||||
doc.images.insert(k, blob);
|
||||
}
|
||||
// Saved mid-edit by an older version: drop the opacity-mask editing layer.
|
||||
doc.drop_edit_modes();
|
||||
|
|
@ -177,7 +197,7 @@ mod tests {
|
|||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap();
|
||||
d.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![1, 2, 3, 250]) });
|
||||
d.images.insert("k".into(), ImageBlob::new("image/png", vec![1, 2, 3, 250]));
|
||||
let bytes = save(&d, true);
|
||||
assert!(sniff(&bytes));
|
||||
let back = load(&bytes).unwrap();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
//! Linked images in `.vectorcraft` files: their link details round-trip, and an image only linked
|
||||
//! images show is saved as its preview.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind};
|
||||
use vectorcraft_format::{base64_decode, load, save};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
fn image(d: &mut Document, key: &str, link: Option<LinkInfo>) {
|
||||
let (id, layer) = (d.alloc_id(), d.layers[0].id);
|
||||
let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link };
|
||||
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
|
||||
}
|
||||
|
||||
fn blob(full: &[u8], proxy: Option<&[u8]>) -> ImageBlob {
|
||||
ImageBlob { proxy: proxy.map(|p| Arc::new(p.to_vec())), ..ImageBlob::new("image/jpeg", full.to_vec()) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_details_round_trip_and_linked_only_images_save_their_preview() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let link = LinkInfo {
|
||||
relative: Some("art/photo.jpg".into()),
|
||||
modified: Some(1_700_000_000_000),
|
||||
size: Some(4),
|
||||
hash: Some("img0123456789abcdef".into()),
|
||||
..LinkInfo::new("/work/art/photo.jpg")
|
||||
};
|
||||
d.images.insert("linked".into(), blob(&[1, 2, 3, 4], Some(&[9, 9])));
|
||||
d.images.insert("both".into(), blob(&[5, 6, 7], Some(&[8])));
|
||||
d.images.insert("small".into(), blob(&[4, 4], None));
|
||||
image(&mut d, "linked", Some(link.clone()));
|
||||
image(&mut d, "both", Some(LinkInfo::new("/work/b.jpg")));
|
||||
image(&mut d, "both", None);
|
||||
image(&mut d, "small", Some(LinkInfo::new("/work/s.jpg")));
|
||||
let bytes = save(&d, false);
|
||||
let v: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let saved = |k: &str| {
|
||||
(
|
||||
v["images"][k]["mime"].as_str().unwrap().to_string(),
|
||||
base64_decode(v["images"][k]["data"].as_str().unwrap()).unwrap(),
|
||||
v["images"][k]["proxy"].clone(),
|
||||
)
|
||||
};
|
||||
assert_eq!(saved("linked"), ("image/png".into(), vec![9, 9], Value::Bool(true)), "only linked images show it: the preview");
|
||||
assert_eq!(saved("both"), ("image/jpeg".into(), vec![5, 6, 7], Value::Null), "an embedded image shows it too: the full bytes");
|
||||
assert_eq!(saved("small"), ("image/jpeg".into(), vec![4, 4], Value::Null), "no preview: the full bytes");
|
||||
|
||||
let back = load(&bytes).unwrap();
|
||||
let linked = &back.images["linked"];
|
||||
assert_eq!((linked.bytes.as_slice(), linked.is_proxy()), (&[9u8, 9][..], true), "the preview stands in until the file is read");
|
||||
assert!(!back.images["both"].is_proxy() && back.images["both"].proxy.is_none());
|
||||
let mut links = vec![];
|
||||
back.visit_images(|_, im| links.push(im.link.clone()));
|
||||
assert!(links.contains(&Some(link)), "{links:?}");
|
||||
// Saved again unread, the preview stays the preview.
|
||||
let again: Value = serde_json::from_slice(&save(&back, false)).unwrap();
|
||||
assert_eq!(again["images"]["linked"], v["images"]["linked"]);
|
||||
}
|
||||
|
|
@ -2,8 +2,6 @@
|
|||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, ImageBlob, NodeId};
|
||||
|
|
@ -73,7 +71,7 @@ proptest! {
|
|||
#[test]
|
||||
fn images_roundtrip(data in prop::collection::vec(any::<u8>(), 0..2000), key in "[a-z]{1,8}") {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
d.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(data.clone()) });
|
||||
d.images.insert(key.clone(), ImageBlob::new("image/png", data.clone()));
|
||||
let back = load(&save(&d, false)).unwrap();
|
||||
prop_assert_eq!(back.images.get(&key).map(|b| b.bytes.as_ref().clone()), Some(data));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ mod tests_freeform;
|
|||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
#[cfg(test)]
|
||||
mod tests_links;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
#[cfg(test)]
|
||||
mod tests_svg;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
//! Linked images over MCP, headless: a document opened without its linked file reports it, and
|
||||
//! `links.relink` / `links.check` reach it through `run_command`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Headless, call_tool};
|
||||
|
||||
fn run(h: &mut Headless, command: &str, params: Value) -> Value {
|
||||
let r = call_tool(h, "run_command", &json!({"command": command, "params": params}));
|
||||
assert!(!r.is_error, "{command}: {r:?}");
|
||||
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_links_report_missing_files_and_relink() {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-links-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = |n: &str| dir.join(n).to_string_lossy().into_owned();
|
||||
let mut h = Headless::new();
|
||||
run(&mut h, "file.new", json!({"width": 300, "height": 200}));
|
||||
let png = run(&mut h, "document.serialize", json!({"format": "png"}));
|
||||
std::fs::write(file("a.png"), vectorcraft_format::base64_decode(png["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
std::fs::write(file("b.png"), vectorcraft_format::base64_decode(png["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let id = run(&mut h, "file.place", json!({"path": file("a.png")}))["ids"][0].clone();
|
||||
run(&mut h, "document.save", json!({"path": file("doc.vectorcraft")}));
|
||||
std::fs::remove_file(file("a.png")).unwrap();
|
||||
let r = run(&mut h, "document.open", json!({"path": file("doc.vectorcraft")}));
|
||||
assert_eq!(r["missingLinks"][0]["ids"], json!([id]), "{r}");
|
||||
assert_eq!(run(&mut h, "links.check", json!({}))["missing"], 1);
|
||||
let r = run(&mut h, "links.relink", json!({"ids": [id], "path": file("b.png")}));
|
||||
assert_eq!(r["relinked"], json!([id]));
|
||||
assert_eq!(run(&mut h, "links.check", json!({}))["links"][0]["status"], "ok");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -638,7 +638,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
if jpeg {
|
||||
let (w, h) = (r.width(), r.height());
|
||||
let bytes = st.raw_data().to_vec();
|
||||
self.add_image(key, || Some((ImageBlob { mime: "image/jpeg".into(), bytes: Arc::new(bytes) }, w, h)), transform);
|
||||
self.add_image(key, || Some((ImageBlob::new("image/jpeg", bytes), w, h)), transform);
|
||||
return;
|
||||
}
|
||||
let mut decoded: Option<Decoded> = None;
|
||||
|
|
@ -659,7 +659,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
return;
|
||||
};
|
||||
let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64);
|
||||
self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }, w, h)), xf);
|
||||
self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf);
|
||||
}
|
||||
Image::Stencil(s) => {
|
||||
let key = hayro_interpret::CacheKey::cache_key(&s);
|
||||
|
|
@ -677,11 +677,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
);
|
||||
let Some((rgba, w, h, sf)) = decoded else { return };
|
||||
let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64);
|
||||
self.add_image(
|
||||
key ^ 0x5_7e9c,
|
||||
|| rgba_png(rgba, w, h).map(|png| (ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }, w, h)),
|
||||
xf,
|
||||
);
|
||||
self.add_image(key ^ 0x5_7e9c, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ fn tiny_png() -> Vec<u8> {
|
|||
#[test]
|
||||
fn image_roundtrip() {
|
||||
let mut d = doc(200.0, 200.0);
|
||||
d.images.insert("img1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(tiny_png()) });
|
||||
d.images.insert("img1".into(), ImageBlob::new("image/png", tiny_png()));
|
||||
let im = ImageObject { key: "img1".into(), width: 4, height: 2, xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0), link: None };
|
||||
add(&mut d, Node::new(NodeId(0), NodeKind::Image(im)));
|
||||
let out = roundtrip(&d);
|
||||
|
|
|
|||
|
|
@ -1231,9 +1231,14 @@ impl Renderer {
|
|||
// Outline mode draws the image's frame, and with Document Setup → Show Images in Outline
|
||||
// Mode its pixels in greyscale under the frame.
|
||||
let outline = f.opts.outline;
|
||||
let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, outline) };
|
||||
// A linked image's preview is cached apart from the file's pixels, which share its key.
|
||||
let cache_key = match f.doc.images.get(&im.key) {
|
||||
Some(b) if b.is_proxy() => std::borrow::Cow::Owned(format!("{}\u{0}proxy", im.key)),
|
||||
_ => std::borrow::Cow::Borrowed(im.key.as_str()),
|
||||
};
|
||||
let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, &cache_key, outline) };
|
||||
if let Some(pm) = pixels {
|
||||
let pm = if outline { pm } else { self.ink_image(&im.key, &pm, f.ink) };
|
||||
let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink) };
|
||||
let sx = im.width as f64 / pm.width().max(1) as f64;
|
||||
let sy = im.height as f64 / pm.height().max(1) as f64;
|
||||
ctx.set_transform(f.view * im.xf);
|
||||
|
|
@ -1249,20 +1254,20 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
/// The decoded pixels of image blob `key` (cached), or a greyscale copy of them.
|
||||
fn image_pixmap(&mut self, doc: &Document, key: &str, grey: bool) -> Option<Arc<Pixmap>> {
|
||||
let colour = match self.images.get(key) {
|
||||
/// The decoded pixels of image blob `key` (cached as `cache_key`), or a greyscale copy of them.
|
||||
fn image_pixmap(&mut self, doc: &Document, key: &str, cache_key: &str, grey: bool) -> Option<Arc<Pixmap>> {
|
||||
let colour = match self.images.get(cache_key) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
let pm = Arc::new(paint::decode_pixmap(&doc.images.get(key)?.bytes)?);
|
||||
self.images.insert(key.to_string(), pm.clone());
|
||||
self.images.insert(cache_key.to_string(), pm.clone());
|
||||
pm
|
||||
}
|
||||
};
|
||||
if !grey {
|
||||
return Some(colour);
|
||||
}
|
||||
let grey_key = format!("{key}\u{0}grey");
|
||||
let grey_key = format!("{cache_key}\u{0}grey");
|
||||
if let Some(p) = self.images.get(&grey_key) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ fn placed_images_are_separated_not_blackened() {
|
|||
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
|
||||
d.images.insert("red".into(), ImageBlob::new("image/png", png));
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "red".into(), width: 4, height: 4, xf: Affine::IDENTITY, link: None }));
|
||||
add(&mut d, n);
|
||||
let got = Renderer::new().render_region(&d, d.artboards[0].rect, 1.0, true).pixel(1, 1);
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ fn image_doc(effects: Vec<Effect>) -> Document {
|
|||
let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
|
||||
d.images.insert("red".into(), ImageBlob::new("image/png", png));
|
||||
let mut n = Node::new(
|
||||
NodeId(0),
|
||||
NodeKind::Image(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None }),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
//! Document Setup options the renderer honours.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
|
@ -10,7 +8,7 @@ fn doc_with_red_image() -> Document {
|
|||
let mut d = Document::new(40.0, 40.0);
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
|
||||
d.images.insert("red".into(), ImageBlob::new("image/png", png));
|
||||
let (id, layer) = (d.alloc_id(), d.layers[0].id);
|
||||
let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None };
|
||||
d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap();
|
||||
|
|
|
|||
|
|
@ -1154,7 +1154,7 @@ impl Writer<'_> {
|
|||
NodeKind::Image(im) => {
|
||||
let link = self.opts.images == ImageMode::Link;
|
||||
let href = match (im.link.as_ref().filter(|_| link), self.doc.images.get(&im.key)) {
|
||||
(Some(l), _) => l.clone(),
|
||||
(Some(l), _) => l.path.clone(),
|
||||
(None, Some(b)) if !b.bytes.is_empty() && link => {
|
||||
// Named after the bytes: image keys ("raster-1"…) repeat across documents.
|
||||
let name = format!("{}.{}", b.content_key(), image_ext(&b.mime));
|
||||
|
|
@ -1165,7 +1165,7 @@ impl Writer<'_> {
|
|||
}
|
||||
(None, Some(b)) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)),
|
||||
_ => match &im.link {
|
||||
Some(l) => l.clone(),
|
||||
Some(l) => l.path.clone(),
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -770,7 +770,7 @@ impl Importer {
|
|||
.and_then(|r| r.into_dimensions().ok())
|
||||
.unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32));
|
||||
let key = format!("img-{:016x}", fnv1a(bytes));
|
||||
self.doc.images.entry(key.clone()).or_insert_with(|| ImageBlob { mime: mime.into(), bytes: Arc::new(bytes.to_vec()) });
|
||||
self.doc.images.entry(key.clone()).or_insert_with(|| ImageBlob::new(mime, bytes.to_vec()));
|
||||
let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64);
|
||||
Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link: None })))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@
|
|||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Document, ImageBlob, ImageObject, Node, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
|
|
@ -55,7 +53,7 @@ fn every_combination_reads_back() {
|
|||
/// A document with one embedded image under `key`.
|
||||
fn image_doc(key: &str, bytes: Vec<u8>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert(key.into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(bytes) });
|
||||
d.images.insert(key.into(), ImageBlob::new("image/png", bytes));
|
||||
let l = d.layers[0].id;
|
||||
let im = ImageObject { key: key.into(), width: 2, height: 2, xf: Affine::IDENTITY, link: None };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(im));
|
||||
|
|
|
|||
|
|
@ -343,7 +343,7 @@ fn image_roundtrip() {
|
|||
NodeKind::Image(ImageObject { key: "k".into(), width: 4, height: 2, xf: Affine::translate((10.0, 20.0)) * Affine::scale(10.0), link: None }),
|
||||
);
|
||||
let mut d = doc_with(vec![n]);
|
||||
d.images.insert("k".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png.clone()) });
|
||||
d.images.insert("k".into(), ImageBlob::new("image/png", png.clone()));
|
||||
let s = export(&d, &ExportOptions::default());
|
||||
assert!(s.contains("data:image/png;base64,"), "{s}");
|
||||
let r = roundtrip(&d);
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ fn image_color_at(cx: &ToolContext, n: &Node, p: Point) -> Option<Color> {
|
|||
return None;
|
||||
}
|
||||
let px = im.xf.inverse() * p;
|
||||
let [r, g, b, _] = cx.doc.images.get(&im.key)?.sample(px.x, px.y, cx.raster_sample)?;
|
||||
let [r, g, b, _] = cx.doc.images.get(&im.key)?.sample_object(px.x, px.y, (im.width, im.height), cx.raster_sample)?;
|
||||
Some(Color::rgb8(r, g, b))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,14 +61,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
|
||||
/// A dropdown row bound to the string `d.fields[key]`; `options` are (value, label).
|
||||
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]) {
|
||||
let cur = d.str(key);
|
||||
let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l);
|
||||
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
|
||||
widgets::label_row(ui, label, LABEL, |ui| {
|
||||
if let Some((value, _)) = widgets::dropdown(ui, key, shown, &labels, 220.0).and_then(|i| options.get(i)) {
|
||||
d.fields.insert(key.into(), json!(value));
|
||||
}
|
||||
});
|
||||
form::choice(ui, d, key, label, (LABEL, 220.0), options);
|
||||
}
|
||||
|
||||
/// A checkbox bound to `d.fields[key]`.
|
||||
|
|
|
|||
|
|
@ -101,6 +101,19 @@ pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
/// A dropdown row bound to the string `d.fields[key]`: `label` in a column `widths.0` wide, the
|
||||
/// dropdown `widths.1` wide; `options` are (value, label).
|
||||
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) {
|
||||
let cur = d.str(key);
|
||||
let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l);
|
||||
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
|
||||
crate::widgets::label_row(ui, label, widths.0, |ui| {
|
||||
if let Some((value, _)) = crate::widgets::dropdown(ui, key, shown, &labels, widths.1).and_then(|i| options.get(i)) {
|
||||
d.fields.insert(key.into(), json!(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The generic dialog body: a field per non-boolean value (positions and indices hidden), the
|
||||
/// distances in `unit`.
|
||||
pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
//! The linked files a document opened without, one at a time: Replace picks another file
|
||||
//! (`links.relink`), and with Apply to All the other missing files are looked for by name in its
|
||||
//! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the
|
||||
//! rest. Afterwards, with Preferences › Update Links: Ask When Modified, the modified links are
|
||||
//! offered for update (`links.update`).
|
||||
//!
|
||||
//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of
|
||||
//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty)
|
||||
//! and `discard` (Ignore).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of a missing linked file.
|
||||
pub const KIND: &str = "missingLinks";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| "Linked File Not Found".into(),
|
||||
body,
|
||||
confirm,
|
||||
ok: Some("Replace…"),
|
||||
discard: Some("Ignore"),
|
||||
min_width: 380.0,
|
||||
max_width: Some(460.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// The ids in `rows` (`[{ids}]`).
|
||||
fn ids(rows: &[Value]) -> Vec<Value> {
|
||||
rows.iter().flat_map(|r| r["ids"].as_array().cloned().unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
/// After a document opened (`document.open`'s result `r`): ask about its missing linked files,
|
||||
/// then about its modified ones. The web reads no linked files, so missing ones just show their
|
||||
/// previews there.
|
||||
pub fn after_open(app: &mut VectorcraftApp, r: &Value) {
|
||||
let rows = |k: &str| r[k].as_array().cloned().unwrap_or_default();
|
||||
let (missing, modified) = (rows("missingLinks"), ids(&rows("modifiedLinks")));
|
||||
if missing.is_empty() || cfg!(target_arch = "wasm32") {
|
||||
if !missing.is_empty() {
|
||||
app.status(format!("{} linked file(s) can't be read here: their previews show", missing.len()));
|
||||
}
|
||||
return ask_update(app, modified);
|
||||
}
|
||||
open(app, missing, modified);
|
||||
}
|
||||
|
||||
fn open(app: &mut VectorcraftApp, missing: Vec<Value>, modified: Vec<Value>) {
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "missing": missing, "modified": modified, "applyToAll": false, "path": "" })));
|
||||
}
|
||||
|
||||
/// With Update Links: Ask When Modified, offer to read the modified linked files `ids` again.
|
||||
fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
|
||||
if ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
if app.session.prefs.update_links != "askWhenModified" {
|
||||
app.status(format!("{} linked image(s) changed on disk: Update Links shows the new versions", ids.len()));
|
||||
return;
|
||||
}
|
||||
let detail = format!("{} linked image(s) changed since this document was saved. Show the new versions?", ids.len());
|
||||
super::confirm::ask(app, "Update Modified Links", &detail, "links.update", json!({ "ids": ids }));
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
|
||||
let Some(current) = missing.first() else { return true };
|
||||
let rest = missing.len() - 1;
|
||||
let name = current["name"].as_str().unwrap_or_default();
|
||||
widgets::list_box(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(6.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_space(3.0);
|
||||
ui.label(egui::RichText::new(name).font(theme::semibold(12.0)).color(t.text_strong));
|
||||
let path = current["path"].as_str().unwrap_or_default();
|
||||
ui.add(egui::Label::new(egui::RichText::new(path).size(11.5).color(t.text_dim)).truncate()).on_hover_text(path);
|
||||
ui.add_space(3.0);
|
||||
});
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
widgets::dim_label(ui, "Replace it with another file, or ignore it: its images keep showing the preview saved with the document.");
|
||||
if rest > 0 {
|
||||
ui.add_space(10.0);
|
||||
let all = d.bool("applyToAll");
|
||||
let label = format!("Apply to All ({} more missing)", rest);
|
||||
if widgets::check(ui, &label, all, true) {
|
||||
d.fields.insert("applyToAll".into(), json!(!all));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Replace (with `path`, else a picked file) or Ignore (`discard`) the first missing file, then
|
||||
/// ask about the next.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let missing = d.fields.get("missing").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let modified = d.fields.get("modified").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let all = d.bool("applyToAll");
|
||||
let Some((current, rest)) = missing.split_first() else {
|
||||
app.ui.dialog = None;
|
||||
return Ok(Value::Null);
|
||||
};
|
||||
let mut rest = rest.to_vec();
|
||||
let mut out = Value::Null;
|
||||
if d.bool("discard") {
|
||||
if all {
|
||||
rest.clear();
|
||||
}
|
||||
} else {
|
||||
let path = match d.str("path") {
|
||||
p if !p.is_empty() => p,
|
||||
_ => app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?,
|
||||
};
|
||||
out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?;
|
||||
// Apply to All: the others by name in the same folder; any not there are asked about.
|
||||
let folder = std::path::Path::new(&path).parent().map(|p| p.to_string_lossy().into_owned());
|
||||
if let (true, false, Some(folder)) = (all, rest.is_empty(), folder) {
|
||||
let r = app.run("links.relink", json!({ "ids": ids(&rest), "folder": folder }))?;
|
||||
let done = r["relinked"].as_array().cloned().unwrap_or_default();
|
||||
rest.retain(|row| row["ids"].as_array().is_none_or(|ids| ids.iter().any(|id| !done.contains(id))));
|
||||
}
|
||||
}
|
||||
if rest.is_empty() {
|
||||
app.ui.dialog = None;
|
||||
ask_update(app, modified);
|
||||
} else {
|
||||
open(app, rest, modified);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) -> String {
|
||||
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
|
||||
}
|
||||
|
||||
fn png(rgb: [u8; 3]) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
let [r, g, b] = rgb;
|
||||
image::RgbaImage::from_pixel(400, 300, image::Rgba([r, g, b, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A document with two placed linked files, saved to a fresh folder; the files are then
|
||||
/// deleted → (app, folder, document path).
|
||||
fn opened_without_links(name: &str) -> (VectorcraftApp, std::path::PathBuf, String) {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-missing-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
for n in ["a.png", "b.png"] {
|
||||
let p = dir.join(n);
|
||||
std::fs::write(&p, png([200, 0, 0])).unwrap();
|
||||
app.run("file.place", json!({"path": p.to_string_lossy()})).unwrap();
|
||||
}
|
||||
let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned();
|
||||
app.run("document.save", json!({"path": doc})).unwrap();
|
||||
for n in ["a.png", "b.png"] {
|
||||
std::fs::remove_file(dir.join(n)).unwrap();
|
||||
}
|
||||
crate::io::open_path(&mut app, &doc).unwrap();
|
||||
(app, dir, doc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_without_linked_files_asks_to_replace_or_ignore_each() {
|
||||
let (mut app, dir, _) = opened_without_links("each");
|
||||
let d = app.ui.dialog.as_ref().expect("the dialog");
|
||||
assert_eq!((d.kind.as_str(), d.fields["missing"].as_array().unwrap().len()), (KIND, 2));
|
||||
let text = frame(&mut app);
|
||||
for label in ["Linked File Not Found", "a.png", "Apply to All (1 more missing)", "Replace…", "Ignore", "Cancel"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
// Replace the first with a file given by path (the file picker otherwise).
|
||||
let blue = dir.join("new/blue.png");
|
||||
std::fs::create_dir_all(blue.parent().unwrap()).unwrap();
|
||||
std::fs::write(&blue, png([0, 0, 200])).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(blue.to_string_lossy()));
|
||||
let r = super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(r["relinked"].as_array().unwrap().len(), 1);
|
||||
let d = app.ui.dialog.as_ref().expect("asks about the next");
|
||||
assert_eq!(d.fields["missing"][0]["name"], "b.png");
|
||||
assert!(!d.fields.contains_key("discard"));
|
||||
assert!(frame(&mut app).contains("b.png"));
|
||||
// Ignore: done, the image keeps its preview.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let c = app.run("links.check", json!({})).unwrap();
|
||||
let status: Vec<&str> = c["links"].as_array().unwrap().iter().map(|l| l["status"].as_str().unwrap()).collect();
|
||||
assert_eq!((status, c["missing"].as_u64()), (vec!["ok", "missing"], Some(1)));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_all_replaces_the_others_from_the_same_folder() {
|
||||
let (mut app, dir, _) = opened_without_links("all");
|
||||
let found = dir.join("found");
|
||||
std::fs::create_dir_all(&found).unwrap();
|
||||
for n in ["a.png", "b.png"] {
|
||||
std::fs::write(found.join(n), png([0, 0, 200])).unwrap();
|
||||
}
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("applyToAll".into(), json!(true));
|
||||
d.fields.insert("path".into(), json!(found.join("a.png").to_string_lossy()));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "both relinked");
|
||||
let c = app.run("links.check", json!({})).unwrap();
|
||||
assert_eq!(c["missing"], 0, "{c}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_links_are_offered_for_update_when_the_preference_asks() {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-modified-ui-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
let pic = dir.join("a.png");
|
||||
std::fs::write(&pic, png([200, 0, 0])).unwrap();
|
||||
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
|
||||
let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned();
|
||||
app.run("document.save", json!({"path": doc})).unwrap();
|
||||
std::fs::write(&pic, png([0, 0, 200])).unwrap();
|
||||
crate::io::open_path(&mut app, &doc).unwrap();
|
||||
let d = app.ui.dialog.as_ref().expect("asks");
|
||||
assert_eq!((d.kind.as_str(), d.str("__command")), (super::super::confirm::KIND, "links.update".to_string()));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.run("links.check", json!({})).unwrap()["modified"], 0);
|
||||
// Manually: no question.
|
||||
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
|
||||
std::fs::write(&pic, png([0, 200, 0])).unwrap();
|
||||
crate::io::open_path(&mut app, &doc).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ mod form;
|
|||
mod gradient_stop;
|
||||
pub mod graphic_style_options;
|
||||
pub mod import_pdf;
|
||||
pub mod missing_links;
|
||||
pub mod new_color_group;
|
||||
mod new_document;
|
||||
pub mod new_swatch;
|
||||
|
|
@ -45,6 +46,7 @@ pub mod spot_colors;
|
|||
pub(crate) mod svg_options;
|
||||
pub mod swatch_conflict;
|
||||
pub mod swatch_options;
|
||||
pub mod text_import;
|
||||
pub mod tile_edge_color;
|
||||
mod tools;
|
||||
mod transform;
|
||||
|
|
@ -194,6 +196,8 @@ registry! {
|
|||
SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC,
|
||||
FileInfo: [file_info::KIND] => file_info::SPEC,
|
||||
RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC,
|
||||
MissingLinks: [missing_links::KIND] => missing_links::SPEC,
|
||||
TextImport: [text_import::KIND] => text_import::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
//! Text Import Options: placing a text file asks how to read it (platform and character set) and
|
||||
//! what to clean up (line breaks inside paragraphs, blank lines between them, runs of spaces). OK
|
||||
//! places it as area type (`file.place` with `text`).
|
||||
//!
|
||||
//! Fields: `platform` (`windows` | `mac`), `characterSet` (`unicode` | `ansi`),
|
||||
//! `removeLineReturns`, `removeParagraphReturns`, `replaceSpaces` (on or off), `spaces` (how many
|
||||
//! spaces make a tab, 2–100), and `__params` (the `file.place` params naming the file).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Text Import Options.
|
||||
pub const KIND: &str = "textImport";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Text Import Options".into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM };
|
||||
|
||||
/// The label column of the Encoding rows.
|
||||
const LABEL: f32 = 104.0;
|
||||
|
||||
/// Is the file `p` names (`path` or `name`) a text file?
|
||||
pub fn is_text(p: &Value) -> bool {
|
||||
let name = p.get("path").or_else(|| p.get("name")).and_then(Value::as_str).unwrap_or_default();
|
||||
vectorcraft_engine::cmd::fileio::TEXT_EXTS.contains(&vectorcraft_engine::cmd::fileio::extension(name).as_str())
|
||||
}
|
||||
|
||||
/// Ask how to place the text file `params` (`file.place` params) names.
|
||||
pub fn open(app: &mut VectorcraftApp, params: &Value) -> Value {
|
||||
let fields = json!({
|
||||
"platform": if cfg!(target_os = "macos") { "mac" } else { "windows" },
|
||||
"characterSet": "unicode",
|
||||
"removeLineReturns": false,
|
||||
"removeParagraphReturns": false,
|
||||
"replaceSpaces": false,
|
||||
"spaces": 3,
|
||||
"__params": params,
|
||||
});
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
json!({ "dialog": KIND })
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
widgets::subheader(ui, "Encoding");
|
||||
form::choice(ui, d, "platform", "Platform:", (LABEL, 180.0), &[("windows", "Windows"), ("mac", "Mac")]);
|
||||
form::choice(ui, d, "characterSet", "Character Set:", (LABEL, 180.0), &[("unicode", "Unicode"), ("ansi", "ANSI")]);
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Line Breaks");
|
||||
for (key, label) in
|
||||
[("removeLineReturns", "Join the lines of each paragraph"), ("removeParagraphReturns", "Remove blank lines between paragraphs")]
|
||||
{
|
||||
let on = d.bool(key);
|
||||
if widgets::check(ui, label, on, true) {
|
||||
d.fields.insert(key.into(), json!(!on));
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Spaces");
|
||||
let on = d.bool("replaceSpaces");
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::check(ui, "Replace", on, true) {
|
||||
d.fields.insert("replaceSpaces".into(), json!(!on));
|
||||
}
|
||||
ui.add_enabled_ui(on, |ui| {
|
||||
if let Some(n) = widgets::plain_field(ui, "text-import-spaces", d.f64("spaces", 3.0), "", 0, 44.0) {
|
||||
d.fields.insert("spaces".into(), json!(n.round().clamp(2.0, 100.0)));
|
||||
}
|
||||
widgets::dim_label(ui, "or more spaces with a tab");
|
||||
});
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// `file.place`'s `text` options from the fields.
|
||||
fn options(d: &Dialog) -> Value {
|
||||
let mut o = json!({
|
||||
"platform": d.str("platform"),
|
||||
"characterSet": d.str("characterSet"),
|
||||
"removeLineReturns": d.bool("removeLineReturns"),
|
||||
"removeParagraphReturns": d.bool("removeParagraphReturns"),
|
||||
});
|
||||
if d.bool("replaceSpaces") {
|
||||
o["replaceSpaces"] = json!(d.f64("spaces", 3.0).round().clamp(2.0, 100.0) as u64);
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// Places the file with the options; closes first, so an error shows in the status bar.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut params = d.fields.get("__params").cloned().unwrap_or_else(|| json!({}));
|
||||
params["text"] = options(d);
|
||||
app.ui.dialog = None;
|
||||
crate::place::run(app, ¶ms)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::doc::NodeKind;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) -> String {
|
||||
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placing_a_text_file_asks_for_the_import_options_then_places_area_type() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let text = b"one\r\ntwo\r\n\r\nthree\r\n";
|
||||
let file = json!({"name": "notes.txt", "dataBase64": vectorcraft_format::base64_encode(text)});
|
||||
let r = app.run("file.place", file).unwrap();
|
||||
assert_eq!(r["dialog"], KIND);
|
||||
let text = frame(&mut app);
|
||||
for label in [
|
||||
"Text Import Options",
|
||||
"Encoding",
|
||||
"Platform:",
|
||||
"Character Set:",
|
||||
"Unicode",
|
||||
"Line Breaks",
|
||||
"Join the lines of each paragraph",
|
||||
"Spaces",
|
||||
"OK",
|
||||
"Cancel",
|
||||
] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("removeLineReturns".into(), json!(true));
|
||||
d.fields.insert("removeParagraphReturns".into(), json!(true));
|
||||
assert_eq!(options(d)["removeLineReturns"], true);
|
||||
let r = super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let st = app.session.doc().unwrap();
|
||||
let n = st.doc.node(vectorcraft_engine::doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap();
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("type") };
|
||||
assert_eq!(t.plain_text(), "one two\nthree", "one paragraph per block");
|
||||
assert_eq!(st.history.undo.len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O
|
|||
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
|
||||
app.status(format!("Opened with {} note(s): {}", w.len(), w[0].as_str().unwrap_or_default()));
|
||||
}
|
||||
crate::dialogs::missing_links::after_open(app, &r);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +112,7 @@ pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool, param
|
|||
let f = fileio::save_format(format_param(params), Some(&path))?;
|
||||
fileio::stamp_save_dates(app.session.active_mut().ok_or("no document")?);
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let (enc, opts) = fileio::save_encoding(st, f, params).map_err(|e| e.to_string())?;
|
||||
let (enc, opts) = fileio::save_encoding(st, f, params, Some(&path)).map_err(|e| e.to_string())?;
|
||||
let doc = st.doc.clone();
|
||||
write_encoded(app, &doc, &path, &enc)?;
|
||||
if let Some(st) = app.session.active_mut() {
|
||||
|
|
@ -139,7 +140,8 @@ pub fn save_as(app: &mut VectorcraftApp, copy: bool, p: &Value) -> Result<Value,
|
|||
return save(app, Some(path), true, p).map(|p| json!({ "path": p }));
|
||||
}
|
||||
let doc = app.session.active().ok_or("no document")?.doc.clone();
|
||||
let enc = fileio::encode_all(&doc, f.id, p).map_err(|e| e.to_string())?;
|
||||
let copy = vectorcraft_engine::cmd::links::with_relative_paths(&doc, &path);
|
||||
let enc = fileio::encode_all(copy.as_ref().unwrap_or(&doc), f.id, p).map_err(|e| e.to_string())?;
|
||||
write_encoded(app, &doc, &path, &enc)?;
|
||||
app.status(format!("Saved a copy as {path}"));
|
||||
Ok(json!({ "path": path }))
|
||||
|
|
@ -317,7 +319,7 @@ mod tests {
|
|||
place_bytes(&mut app, "tiny.webp", &webp).unwrap();
|
||||
let placed = app.session.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(image_size(&app, placed), (3, 2));
|
||||
assert!(place_bytes(&mut app, "x.txt", b"hello").is_err());
|
||||
assert!(place_bytes(&mut app, "x.xyz", b"hello").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -67,9 +67,13 @@ fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result<Value, String
|
|||
// ---------- File → Place… ----------
|
||||
|
||||
/// File → Place… (`file.place`): with a file (`path` or `name` + `dataBase64`), place it, centred
|
||||
/// in the view unless `at`, `rect` or `replace` say otherwise; with `paths`, the Place dialog for
|
||||
/// them; with neither, pick the files first.
|
||||
/// in the view unless `at`, `rect` or `replace` say otherwise (a text file without `text` options
|
||||
/// asks for them first); with `paths`, the Place dialog for them; with neither, pick the files
|
||||
/// first.
|
||||
pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if p.get("text").is_none() && crate::dialogs::text_import::is_text(p) {
|
||||
return Ok(crate::dialogs::text_import::open(app, p));
|
||||
}
|
||||
if p.get("path").is_none() && p.get("dataBase64").is_none() {
|
||||
let files: Vec<Value> = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(|path| json!({ "path": path })).collect(),
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ fn a_dropped_png_is_centred_at_the_pointer() {
|
|||
let want = Xf::new(rect, app.view().unwrap()).to_doc(pos);
|
||||
let c = n.geometric_bounds().unwrap().center();
|
||||
assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "{c:?} vs {want:?}");
|
||||
assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_deref() == Some(path.as_str())), "linked by default");
|
||||
assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_ref().map(|l| l.path.as_str()) == Some(path.as_str())), "linked by default");
|
||||
assert_eq!(app.session.documents().len(), 1, "placed, not opened");
|
||||
// Shift embeds.
|
||||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], true);
|
||||
|
|
|
|||
|
|
@ -253,3 +253,17 @@ agents) opens the `rasterEffectsSettings` dialog. Its fields are what `document.
|
|||
`ui.dialog.confirm` runs it with them as one undo step; a bad value keeps the dialog open. Object → Rasterize… opens a
|
||||
`command` dialog for `object.rasterize` whose fields (`ppi`, `colorModel`, `background`, `antiAlias`, `clippingMask`,
|
||||
`addAround`) start from these settings.
|
||||
|
||||
Missing linked files: `app.open` of a document whose linked images' files can't be found (`document.open`'s
|
||||
`missingLinks`) opens the `missingLinks` dialog for the first (fields `missing`: `[{name, path, ids}]`, `modified`,
|
||||
`applyToAll`, `path`). `ui.dialog.set {field: "path", value}` then `ui.dialog.confirm` replaces it with that file
|
||||
(`links.relink`; without `path` a file is picked), and with `applyToAll` the others are looked for by name in the same
|
||||
folder; `ui.dialog.set {field: "discard", value: true}` then confirm ignores it (the images keep their preview), with
|
||||
`applyToAll` the rest too. The next missing file is asked about after each answer; `ui.dialog.cancel` stops asking.
|
||||
Then, with the preference `updateLinks: "askWhenModified"`, modified linked files are offered for update in a
|
||||
`confirm` dialog whose `ui.dialog.confirm` runs `links.update`.
|
||||
|
||||
Text Import Options: placing a `.txt` file through the app (`file.place` with a file and no `text` options, the Place
|
||||
dialog, a drop) opens the `textImport` dialog (fields `platform`: `windows`/`mac`, `characterSet`: `unicode`/`ansi`,
|
||||
`removeLineReturns`, `removeParagraphReturns`, `replaceSpaces`, `spaces`). `ui.dialog.confirm` places the file as area
|
||||
type with those options (`file.place {text: {…}}`).
|
||||
|
|
|
|||
30
docs/mcp.md
30
docs/mcp.md
|
|
@ -921,3 +921,33 @@ in one undo step.
|
|||
```json
|
||||
{"name":"run_command","arguments":{"command":"document.rasterEffectsSettings","params":{"resolution":"high","background":"white","addAround":36}}}
|
||||
```
|
||||
|
||||
## Linked images
|
||||
|
||||
A placed image linked to its file (`file.place {path}`, Link on) records the file's absolute path, its size,
|
||||
modification time and hash, and keeps a low-resolution preview (at most 256 px a side): a `.vectorcraft` save writes
|
||||
the preview instead of the pixels, plus the path relative to the saved file. `document.open` reads
|
||||
the linked files again, looking for each at its path, then at its relative path and by name in the document's folder
|
||||
(so a folder moved with its links still opens): the result lists `missingLinks` (their images show the preview),
|
||||
`modifiedLinks` (left as they were; read again only with the preference `updateLinks: "automatically"`, then they are
|
||||
in `updatedLinks`), each as `{name, path, ids}`. `links.check` reports every link's `status` (`ok`, `modified`,
|
||||
`missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at
|
||||
another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. Without a
|
||||
file system (the web), linked images show their previews.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"links.check","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"links.relink","params":{"ids":[12],"path":"/new/photo.png"}}}
|
||||
{"name":"run_command","arguments":{"command":"links.update","params":{}}}
|
||||
```
|
||||
|
||||
Placing a text file (`.txt`) sets it as area type: `file.place {path | name+dataBase64, text?: {characterSet?:
|
||||
"unicode" | "ansi", platform?: "windows" | "mac", removeLineReturns?, removeParagraphReturns?, replaceSpaces?: n}}`
|
||||
(Text Import Options). Unicode reads UTF-8, or UTF-16 with a byte-order mark; ANSI reads Windows-1252 (Mac Roman on
|
||||
`mac`). `removeLineReturns` joins the lines of each block into one paragraph, `removeParagraphReturns` drops blank
|
||||
lines, `replaceSpaces: 3` turns runs of 3 or more spaces into tabs. The frame fills `rect`, the replaced object's
|
||||
bounds, or the artboard less a 36 pt margin.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/notes.txt","text":{"removeLineReturns":true,"removeParagraphReturns":true}}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue