DES-10 T6: graphics placed from a file link to it by default

File > Place, the place cursor (place.load, grid, contact sheet), media,
media posters and table graphic cells record the file's absolute path,
its path relative to the document, size, time and content hash, and a
proxy image, and keep the bytes read for placing in the linked-bytes
store for the first draw. embed: true keeps the bytes in the document
with the link record. base64 bytes and the web always embed.

(cherry picked from commit 4f5bd99088b2f32bece3be08258ec66c15dd3b44)
This commit is contained in:
aiongg committed 2026-11-14 06:46:11 +00:00
1 parent 957e073664
commit ade2d3cfd4
9 files changed
+115 -91

No files matched your search

+12 -1
View File
@@ -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<Vec<u8>>) -> 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<Vec<u8>>) {
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() {
+2 -2
View File
@@ -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<Vec<u8>>) {
/// 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<Vec<u8>>) {
if bytes.len() as u64 <= self.budget {
self.lock().keep(key, bytes, self.budget);
}
+1 -1
View File
@@ -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};
+65 -49
View File
@@ -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<Value> {
// 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<Value> {
}
// 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<Value> {
_ => 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<u8>, String, Option<String>)> {
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<u8>,
pub name: String,
file: Option<FileLink>,
/// `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<u8>, String, Option<String>, Option<designcraft_doc::Fingerprint>)> {
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<std::path::PathBuf> {
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<Source> {
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<u8>, String, Option<String>, Opt
#[cfg(target_arch = "wasm32")]
let b: Vec<u8> = 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<Option<(f64, f64)>> {
@@ -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<Value> {
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<Value> {
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.
+4 -4
View File
@@ -101,8 +101,8 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
"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<CommandSpec> {
"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
),
+1 -2
View File
@@ -23,8 +23,7 @@ pub(super) fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result<Value> {
let (bytes, _, _) = read_source(p)?;
let src = from_bytes(&bytes)?;
let src = from_bytes(&read_source(p)?.bytes)?;
let ids: Vec<ItemId> = 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"));
+17 -18
View File
@@ -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<u8>, link: Option<String>) -> Result<Value> {
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<Value> {
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<CommandSpec> {
// 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,
+12 -13
View File
@@ -62,9 +62,10 @@ pub(super) fn drop_cells(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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}")))?;
+1 -1
View File
@@ -210,7 +210,7 @@ fn table_specs() -> Vec<CommandSpec> {
"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
),