diff --git a/crates/doc/src/asset_data.rs b/crates/doc/src/asset_data.rs index 8cd7b9885..3171efbaa 100644 --- a/crates/doc/src/asset_data.rs +++ b/crates/doc/src/asset_data.rs @@ -142,7 +142,7 @@ impl AssetData { /// [`AssetData::linked_under`] with `bytes` (the file's, just read) already in the store, so /// the first draw doesn't read the file again. pub fn linked_resident(key: SourceKey, source: LinkedSource, bytes: Arc>) -> Self { - store::global().insert(key, bytes); + store::global().keep(key, bytes); AssetData::linked_under(key, source) } @@ -191,6 +191,17 @@ impl AssetData { } } + /// Keep `bytes`, just read from the linked file and checked by the caller to be the ones its + /// source names, in the store, so the first [`AssetData::load`] doesn't read the file again. + /// Embedded values, bytes of another size and bytes over the store's budget are ignored. + pub fn keep_resident(&self, bytes: Arc>) { + if let Repr::Linked(l) = &self.repr + && bytes.len() as u64 == l.source.len + { + store::global().keep(self.key, bytes); + } + } + /// Drop the store's copy of linked bytes (they are read again when next loaded). pub fn evict(&self) { if self.is_linked() { diff --git a/crates/doc/src/asset_data/store.rs b/crates/doc/src/asset_data/store.rs index 0457fec70..3d6365107 100644 --- a/crates/doc/src/asset_data/store.rs +++ b/crates/doc/src/asset_data/store.rs @@ -193,8 +193,8 @@ impl Store { self.lock().touch(key) } - /// Keep `bytes` under `key` when they fit the budget. - pub(super) fn insert(&self, key: SourceKey, bytes: Arc>) { + /// Keep `bytes` under `key` as if a load had read them (not when over the budget). + pub(super) fn keep(&self, key: SourceKey, bytes: Arc>) { if bytes.len() as u64 <= self.budget { self.lock().keep(key, bytes, self.budget); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 44aaa85b7..04903dc0d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -21,7 +21,7 @@ mod snippets; pub use bytes::{DOCUMENT_SNIFF_BYTES, base64_decode, base64_encode, from_bytes, is_document, to_bytes, with_document_extension}; pub(crate) use open::load_document_fonts; pub use open::{RecentPreview, recent_preview}; -pub(crate) use place::{crop_pdf, read_source, validate_placement}; +pub(crate) use place::{crop_pdf, document_of, read_source, validate_placement}; pub use registry::specs; pub(crate) use snippets::{snippet_bytes, snippet_place}; diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs index 463f478d3..82d589d4d 100644 --- a/crates/engine/src/cmd/file/place.rs +++ b/crates/engine/src/cmd/file/place.rs @@ -6,6 +6,7 @@ use super::bytes::{base64_decode, from_bytes}; use super::{ Affine, Asset, AssetId, Content, Graphic, Item, ItemId, PdfCrop, Rect, Result, Selection, Session, Shape, Value, bad, json, ok, shapes, str_param, }; +use crate::cmd::links::{FileLink, give_bytes}; pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { // The file's size and time before it is read (a linked text file remembers them). @@ -37,9 +38,15 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { } // Keep the EPS source for link comparison; rendering uses its preview at the bounding box size. let eps_size = eps_size(&bytes, "file.place")?; + // A file placed from a path links to it unless `embed`. + let embed = p.get("embed").and_then(Value::as_bool) == Some(true); + let document = document_of(s); // Video and sound: a media frame (Window › Interactive › Media). if let Some(mime) = designcraft_doc::media_mime(&name) { - return crate::cmd::media::place_media(s, p, name, mime, bytes, link); + let file = link.as_deref().map(|l| FileLink::of(std::path::Path::new(l), before)); + let mut asset = Asset { name, mime: mime.to_string(), ..Default::default() }; + give_bytes(&mut asset, bytes, file, embed, document.as_deref()); + return crate::cmd::media::place_media(s, p, asset); } // Text files (plain, Word, RTF) flow into frames. if designcraft_textimport::is_text_file(&name) { @@ -89,17 +96,9 @@ pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { _ => PdfCrop::Crop, }; let mime = designcraft_render::image_mime(&bytes).to_string(); - let mut asset = Asset { - page: pdf_page, - name, - mime, - link, - link_fingerprint: crate::cmd::links::loaded_fingerprint(before, &bytes), - data: designcraft_doc::AssetData::embedded(bytes), - pixels: Some((pw, ph)), - pdf_crop, - ..Default::default() - }; + let file = link.as_deref().map(|l| FileLink::of(std::path::Path::new(l), before)); + let mut asset = Asset { page: pdf_page, name, mime, pixels: Some((pw, ph)), pdf_crop, ..Default::default() }; + give_bytes(&mut asset, bytes, file, embed, document.as_deref()); // The graphic's natural size: the box in points (the whole page when the box can't be found, // and then the document says so). let (bw, bh) = match crop_pdf(&mut asset) { @@ -210,16 +209,35 @@ pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> { Some(size) } -pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option)> { - read_linked_source(p).map(|(bytes, name, link, _)| (bytes, name, link)) +/// A file to place: its bytes and name, and its link record when it was read from a path. +pub(crate) struct Source { + pub bytes: Vec, + pub name: String, + file: Option, + /// `embed: true`: the bytes are kept in the document instead of linking the file. + embed: bool, } -/// [`read_source`], with the size and time to keep with a linked file's bytes -/// ([`crate::cmd::links::loaded_fingerprint`]): the file's from before it is read, not those of a -/// later version that replaces it while a network or syncing source is being loaded. -fn read_linked_source(p: &Value) -> Result<(Vec, String, Option, Option)> { +impl Source { + /// Give `a` these bytes: linked to the file they were read from, else embedded + /// ([`give_bytes`]). `a`'s `mime` and `page` are set. + pub(crate) fn give(self, a: &mut Asset, document: Option<&std::path::Path>) { + give_bytes(a, self.bytes, self.file, self.embed, document); + } +} + +/// The file of the document open in `s`, which relative links are recorded against. +pub(crate) fn document_of(s: &Session) -> Option { + s.doc().ok()?.path.as_deref().map(std::path::PathBuf::from) +} + +/// The file `p` names: `{path, embed?}` or `{base64, name?}`. Its size and time are taken before +/// it is read, so a later version that replaces it while a network or syncing source is being +/// loaded isn't vouched for ([`crate::cmd::links::loaded_fingerprint`]). +pub(crate) fn read_source(p: &Value) -> Result { + let embed = p.get("embed").and_then(Value::as_bool) == Some(true); if let Some(b) = str_param(p, "base64") { - return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None, None)); + return Ok(Source { bytes: base64_decode(b), name: str_param(p, "name").unwrap_or("image").to_string(), file: None, embed }); } let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; let before = crate::cmd::datamerge::fingerprint_from_path(path); @@ -228,8 +246,8 @@ fn read_linked_source(p: &Value) -> Result<(Vec, String, Option, Opt #[cfg(target_arch = "wasm32")] let b: Vec = vec![]; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); - let loaded = crate::cmd::links::loaded_fingerprint(before, &b); - Ok((b, name, Some(path.to_string()), loaded)) + let file = Some(FileLink::of(std::path::Path::new(path), before)); + Ok(Source { bytes: b, name, file, embed }) } fn eps_size(bytes: &[u8], command: &str) -> Result> { @@ -260,20 +278,20 @@ pub(crate) fn validate_placement(size: (f64, f64), frame: Rect, xf: Affine, comm /// Decode and validate the complete batch before replacing the placement cursor. pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { let append = p.get("append").and_then(Value::as_bool) == Some(true); + let embed = p.get("embed").and_then(Value::as_bool) == Some(true); + let document = document_of(s); if p.get("files").is_none() { - let (bytes, name, link, link_fingerprint) = read_linked_source(p)?; - let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; - let natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); - let mime = designcraft_render::image_mime(&bytes).to_string(); - let asset = Asset { - name, - mime, - link, - link_fingerprint, - data: designcraft_doc::AssetData::embedded(bytes), + let source = read_source(p)?; + let bytes = &source.bytes; + let (pw, ph) = designcraft_render::image_size(bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; + let natural = eps_size(bytes, "place.load")?.or_else(|| designcraft_render::natural_size(bytes, 0)).unwrap_or((pw as f64, ph as f64)); + let mut asset = Asset { + name: source.name.clone(), + mime: designcraft_render::image_mime(bytes).to_string(), pixels: Some((pw, ph)), ..Default::default() }; + source.give(&mut asset, document.as_deref()); return load_cursor(s, asset, natural, append); } let sources = p.get("files").and_then(Value::as_array).ok_or_else(|| bad("place.load", "files must be an array"))?; @@ -283,23 +301,21 @@ pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { check_cursor_room(s, sources.len(), append, "place.load")?; let mut images = Vec::with_capacity(sources.len()); for source in sources { - let (bytes, name, link, link_fingerprint) = read_linked_source(source)?; - let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", format!("unsupported or corrupt image: {name}")))?; - let size = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); + let mut source = read_source(source)?; + source.embed |= embed; + let bytes = &source.bytes; + let (pw, ph) = + designcraft_render::image_size(bytes).ok_or_else(|| bad("place.load", format!("unsupported or corrupt image: {}", source.name)))?; + let size = eps_size(bytes, "place.load")?.or_else(|| designcraft_render::natural_size(bytes, 0)).unwrap_or((pw as f64, ph as f64)); validate_placement(size, Rect::new(0.0, 0.0, size.0, size.1), Affine::IDENTITY, "place.load")?; - let mime = designcraft_render::image_mime(&bytes).to_string(); - images.push(( - Asset { - name, - mime, - link, - link_fingerprint, - data: designcraft_doc::AssetData::embedded(bytes), - pixels: Some((pw, ph)), - ..Default::default() - }, - size, - )); + let mut asset = Asset { + name: source.name.clone(), + mime: designcraft_render::image_mime(bytes).to_string(), + pixels: Some((pw, ph)), + ..Default::default() + }; + source.give(&mut asset, document.as_deref()); + images.push((asset, size)); } let queue = s.edit(|d, _| { let mut queue = std::collections::VecDeque::with_capacity(images.len()); @@ -802,9 +818,9 @@ pub(super) mod image_place_tests { }; // Written just now: another write in the same second would keep its time, so the time - // can't vouch for the bytes later and isn't kept. + // can't vouch for the bytes later: only the size is kept, with a time no file has. let asset = load(&mut session); - assert_eq!(asset.link_fingerprint, None); + assert_eq!(asset.link_fingerprint.map(|f| f.mtime), Some(crate::cmd::links::UNVERIFIED_MTIME)); assert_eq!(asset.link.as_deref(), image.path().to_str()); // A file that has been as it is for a while is remembered by its size and time. diff --git a/crates/engine/src/cmd/file/registry.rs b/crates/engine/src/cmd/file/registry.rs index d7b4e8bb7..0fba12d92 100644 --- a/crates/engine/src/cmd/file/registry.rs +++ b/crates/engine/src/cmd/file/registry.rs @@ -101,8 +101,8 @@ pub fn specs() -> Vec { cmd!(query "place.styles", "Import Options: Styles", [], None, "{path | base64, name} → {paragraph: [name], character: [name], conflicts: [name]} — the styles a Word/RTF file brings (for styleMap / styleConflicts)", has_doc, |s, p| { - let (bytes, name, _) = read_source(p)?; - let imp = designcraft_textimport::import(&name, &bytes).map_err(|e| bad("place.styles", e.to_string()))?; + let source = read_source(p)?; + let imp = designcraft_textimport::import(&source.name, &source.bytes).map_err(|e| bad("place.styles", e.to_string()))?; let st = &s.doc()?.doc.styles; let conflicts: Vec<&str> = imp .para_styles @@ -127,7 +127,7 @@ pub fn specs() -> Vec { "Load Place Cursor", [], None, - "{path | base64, name?, files?: [{path | base64, name?}], append? (keep the images already loaded and queue these after them)} — load up to 1000 images into the place cursor, in order (then click/drag with the placeGun tool)", + "{path | base64, name?, files?: [{path | base64, name?, embed?}], append? (keep the images already loaded and queue these after them), embed? (keep every image's bytes in the document; default: images read from a path link to their files)} — load up to 1000 images into the place cursor, in order (then click/drag with the placeGun tool)", has_doc, place_load ), @@ -163,7 +163,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), append? (with loadCursor: queue the image after those already loaded instead of replacing them), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`", + "{path?|base64?, name?, embed? (keep the file's bytes in the document; default: a graphic placed from a path links to its file, base64 bytes and the web always embed), frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), append? (with loadCursor: queue the image after those already loaded instead of replacing them), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`", has_doc, file_place ), diff --git a/crates/engine/src/cmd/file/snippets.rs b/crates/engine/src/cmd/file/snippets.rs index 5ff1f3795..21d986dce 100644 --- a/crates/engine/src/cmd/file/snippets.rs +++ b/crates/engine/src/cmd/file/snippets.rs @@ -23,8 +23,7 @@ pub(super) fn snippet_export(s: &mut Session, p: &Value) -> Result { } pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { - let (bytes, _, _) = read_source(p)?; - let src = from_bytes(&bytes)?; + let src = from_bytes(&read_source(p)?.bytes)?; let ids: Vec = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); if ids.is_empty() { return Err(bad("snippet.place", "the snippet is empty")); diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index 8c7fd0bbe..4f700320a 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -8,8 +8,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_selection, ok, targets}; use crate::{Result, Session}; -pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, bytes: Vec, link: Option) -> Result { - let video = mime.starts_with("video/"); +/// Place a video or sound file: `asset` has its name, type and bytes. +pub(crate) fn place_media(s: &mut Session, p: &Value, asset: Asset) -> Result { + let video = asset.mime.starts_with("video/"); let (w, h) = ( p.get("width").and_then(Value::as_f64).unwrap_or(if video { 320.0 } else { 200.0 }), p.get("height").and_then(Value::as_f64).unwrap_or(if video { 180.0 } else { 48.0 }), @@ -24,10 +25,9 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let spread = super::spread_param(p, "file.place")?; let lid = s.doc()?.active_layer; let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); - let mime = mime.to_string(); s.edit(|d, sel| { let fresh = AssetId(d.alloc()); - let aid = d.add_asset(Asset { id: fresh, name, mime, link, data: designcraft_doc::AssetData::embedded(bytes), ..Default::default() }); + let aid = d.add_asset(Asset { id: fresh, ..asset }); let id = match target { Some(fid) => { let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; @@ -90,27 +90,26 @@ pub fn specs() -> Vec { // A poster image is read before the edit. let poster = match p.get("poster") { Some(Value::Object(_)) => { - let (bytes, name, link) = super::file::read_source(&p["poster"])?; - let px = designcraft_render::image_size(&bytes).ok_or_else(|| bad("media.options", "the poster isn't a readable image"))?; - Some(Some((bytes, name, link, px))) + let source = super::file::read_source(&p["poster"])?; + let px = + designcraft_render::image_size(&source.bytes).ok_or_else(|| bad("media.options", "the poster isn't a readable image"))?; + let mut poster = Asset { + name: source.name.clone(), + mime: designcraft_render::image_mime(&source.bytes).to_string(), + pixels: Some(px), + ..Default::default() + }; + source.give(&mut poster, super::file::document_of(s).as_deref()); + Some(Some(poster)) } Some(Value::Null) => Some(None), _ => None, }; s.edit(|d, _| { let poster_id = match poster { - Some(Some((bytes, name, link, px))) => { + Some(Some(poster)) => { let fresh = AssetId(d.alloc()); - let mime = designcraft_render::image_mime(&bytes).to_string(); - Some(Some(d.add_asset(Asset { - id: fresh, - name, - mime, - link, - data: designcraft_doc::AssetData::embedded(bytes), - pixels: Some(px), - ..Default::default() - }))) + Some(Some(d.add_asset(Asset { id: fresh, ..poster }))) } Some(None) => Some(None), None => None, diff --git a/crates/engine/src/cmd/table/content.rs b/crates/engine/src/cmd/table/content.rs index df55e2942..a021ae9c8 100644 --- a/crates/engine/src/cmd/table/content.rs +++ b/crates/engine/src/cmd/table/content.rs @@ -62,9 +62,10 @@ pub(super) fn drop_cells(s: &mut Session, p: &Value) -> Result { pub(super) fn place_graphic(s: &mut Session, p: &Value) -> Result { const ID: &str = "table.placeGraphic"; let g = target(s, p, ID)?; - let (bytes, name, link) = crate::cmd::file::read_source(p)?; - let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?; - let (nw, nh) = designcraft_render::natural_size(&bytes, 0).ok_or_else(|| bad(ID, "invalid image dimensions"))?; + let source = crate::cmd::file::read_source(p)?; + let bytes = &source.bytes; + let (pw, ph) = designcraft_render::image_size(bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?; + let (nw, nh) = designcraft_render::natural_size(bytes, 0).ok_or_else(|| bad(ID, "invalid image dimensions"))?; let fill = p.get("fit").and_then(Value::as_str) == Some("fill"); // Compute fitting before the edit: a small EPS can overflow in a huge cell. let t = s.doc()?.doc.story(g.story).and_then(|st| st.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; @@ -78,18 +79,16 @@ pub(super) fn place_graphic(s: &mut Session, p: &Value) -> Result { let k = if fill { (bw / nw).max(bh / nh) } else { (bw / nw).min(bh / nh) }; let xf = designcraft_geom::Affine::translate(((bw - nw * k) / 2.0, (bh - nh * k) / 2.0)) * designcraft_geom::Affine::scale(k); crate::cmd::file::validate_placement((nw, nh), designcraft_geom::Rect::new(0.0, 0.0, bw, bh), xf, ID)?; + let mut asset = designcraft_doc::Asset { + name: source.name.clone(), + mime: designcraft_render::image_mime(bytes).to_string(), + pixels: Some((pw, ph)), + ..Default::default() + }; + source.give(&mut asset, crate::cmd::file::document_of(s).as_deref()); s.edit(|d, sel| { let fresh = designcraft_doc::AssetId(d.alloc()); - let mime = designcraft_render::image_mime(&bytes).to_string(); - let aid = d.add_asset(designcraft_doc::Asset { - id: fresh, - name, - mime, - link, - data: designcraft_doc::AssetData::embedded(bytes), - pixels: Some((pw, ph)), - ..Default::default() - }); + let aid = d.add_asset(designcraft_doc::Asset { id: fresh, ..asset }); let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?; let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?; diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs index df8820dad..0c27e498d 100644 --- a/crates/engine/src/cmd/table/registry.rs +++ b/crates/engine/src/cmd/table/registry.rs @@ -210,7 +210,7 @@ fn table_specs() -> Vec { "Convert Cell to Graphic Cell", ["Table"], None, - "{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)", + "{path? | base64?, name?: with base64, embed? (keep the bytes in the document; default: an image from a path links to its file), fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)", in_table, place_graphic ),