mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Follow paths stored in documents only on this computer or the document's share
Links, data sources, IDML package links and book chapters go through io::document_path (craft-docpath) before they reach the file system: relative paths resolve beside the document, and paths to other computers, devices and URLs are missing, with the reason in links.list and Preflight. IDML and INDD packages read a linked file where its link points on this computer, then from the link's trailing names beside the package, only when it is a regular file up to 256 MB holding an image or a graphic. A file URI that names a host keeps it as a network path.
This commit is contained in:
Generated
+5
@@ -824,6 +824,10 @@ dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
@@ -1010,6 +1014,7 @@ version = "0.8.0"
|
||||
dependencies = [
|
||||
"caseless",
|
||||
"craft-atomic",
|
||||
"craft-docpath",
|
||||
"craft-print",
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
|
||||
@@ -73,6 +73,8 @@ zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# (MIT OR Apache-2.0, no dependencies, builds for wasm).
|
||||
indd = { git = "https://github.com/storytold/indd-utils", branch = "main" }
|
||||
|
||||
# Which paths stored in documents may be followed (craft-libs).
|
||||
craft-docpath = { path = "../secfix-craft-libs/crates/docpath" }
|
||||
# Atomic file replacement for saves and exports (craft-libs).
|
||||
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" }
|
||||
# File › Print through the system print spooler (craft-libs).
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-print = { workspace = true }
|
||||
craft-docpath = { workspace = true }
|
||||
designcraft-images = { workspace = true }
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
|
||||
@@ -36,6 +36,19 @@ fn save(_: &Book) -> Result<()> {
|
||||
Err(EngineError::Other("books need a file system".into()))
|
||||
}
|
||||
|
||||
/// A chapter's file, resolved against the book's file. A chapter on another computer (other than
|
||||
/// the book's own network share) or a device is not opened ([`crate::io::document_path`]).
|
||||
fn chapter(b: &Book, path: &str) -> Result<String> {
|
||||
crate::io::document_path(path, Some(std::path::Path::new(&b.path)))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.map_err(|why| EngineError::Other(format!("{path}: {why}")))
|
||||
}
|
||||
|
||||
/// Every chapter's file ([`chapter`]), in order.
|
||||
fn chapters(b: &Book) -> Result<Vec<String>> {
|
||||
b.documents.iter().map(|path| chapter(b, path)).collect()
|
||||
}
|
||||
|
||||
fn load(path: &str) -> Result<designcraft_doc::Document> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
@@ -45,7 +58,8 @@ fn load(path: &str) -> Result<designcraft_doc::Document> {
|
||||
vec![]
|
||||
};
|
||||
if path.to_lowercase().ends_with(".idml") {
|
||||
return super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
|
||||
return super::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(path)))
|
||||
.map_err(|e| EngineError::Other(format!("{path}: {e}")));
|
||||
}
|
||||
super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
@@ -144,7 +158,7 @@ fn paginate(s: &Session) -> Result<Value> {
|
||||
let mut next = 1u32;
|
||||
let mut chapter = 0u32;
|
||||
let mut chapters = Vec::with_capacity(b.documents.len());
|
||||
for path in &b.documents {
|
||||
for path in &self::chapters(b)? {
|
||||
writable_chapter(s, path, "book.paginate")?;
|
||||
let mut d = load(path)?;
|
||||
start_at(&mut d, next);
|
||||
@@ -167,13 +181,14 @@ fn paginate(s: &Session) -> Result<Value> {
|
||||
fn sync_styles(s: &Session) -> Result<Value> {
|
||||
let b = s.book.as_ref().ok_or_else(no_book)?;
|
||||
let mut batch = ChapterBatch::new(b.documents.len(), "book.syncStyles")?;
|
||||
let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
|
||||
let paths = chapters(b)?;
|
||||
let src_path = paths.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
|
||||
let src = load(src_path)?;
|
||||
let styles = src.styles.clone();
|
||||
let swatches = src.swatches.clone();
|
||||
drop(src);
|
||||
let mut n = 0;
|
||||
for (i, path) in b.documents.iter().enumerate() {
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
if i == b.style_source {
|
||||
continue;
|
||||
}
|
||||
@@ -304,7 +319,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut first = 1usize;
|
||||
let mut docs = Vec::new();
|
||||
for p in &b.documents {
|
||||
let pages = load(p).map(|d| d.page_count()).unwrap_or(0);
|
||||
let pages = chapter(b, p).and_then(|p| load(&p)).map(|d| d.page_count()).unwrap_or(0);
|
||||
docs.push(json!({"path": p, "pages": pages, "firstPage": first}));
|
||||
first = first.saturating_add(pages);
|
||||
}
|
||||
@@ -319,7 +334,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| {
|
||||
let b = s.book.clone().ok_or_else(no_book)?;
|
||||
let mut parts = Vec::new();
|
||||
for path in &b.documents {
|
||||
for path in &chapters(&b)? {
|
||||
let mut d = load(path)?;
|
||||
// The chapter's own fonts, for this export (skipped font files are logged).
|
||||
let _fonts = super::file::load_document_fonts(&mut d, path).0;
|
||||
|
||||
@@ -489,3 +489,21 @@ mod link_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod image_path_tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::image_candidates;
|
||||
|
||||
#[test]
|
||||
fn image_cells_resolve_beside_the_data_and_never_to_other_computers() {
|
||||
let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc"));
|
||||
for cell in [r"\\192.0.2.1\share\x.png", "file://192.0.2.1/s/x.png", "//192.0.2.1/s/x.png", "https://example.com/x.png"] {
|
||||
assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}");
|
||||
}
|
||||
let found = image_candidates("img/x.png", Some(data), Some(doc));
|
||||
assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]);
|
||||
assert!(image_candidates("img/x.png", None, None).is_empty(), "not beside the working directory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +98,20 @@ fn source_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a data source's file is, resolved against the document's file (`document`); `None` when
|
||||
/// it has none or the path may not be followed ([`crate::io::document_path`]).
|
||||
fn source_file(src: &DataSource, document: Option<&Path>) -> Option<PathBuf> {
|
||||
crate::io::document_path(src.path.as_deref()?, document).ok()
|
||||
}
|
||||
|
||||
fn source_update(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let (path, name, delimiter, sheet) = {
|
||||
let src = s.doc()?.doc.data_merge.sources.first().ok_or_else(|| bad("data.source.update", "select a data source"))?;
|
||||
let st = s.doc()?;
|
||||
let src = st.doc.data_merge.sources.first().ok_or_else(|| bad("data.source.update", "select a data source"))?;
|
||||
let path = src.path.clone().ok_or_else(|| bad("data.source.update", "this source has no file to update"))?;
|
||||
(path, src.name.clone(), src.delimiter, src.sheet.clone())
|
||||
let at =
|
||||
crate::io::document_path(&path, st.path.as_deref().map(Path::new)).map_err(|why| bad("data.source.update", format!("{path}: {why}")))?;
|
||||
(at.to_string_lossy().into_owned(), src.name.clone(), src.delimiter, src.sheet.clone())
|
||||
};
|
||||
let bytes = read_path(&path).map_err(|e| bad("data.source.update", e))?;
|
||||
let table = parse_file(&bytes, &name, Some(delimiter), sheet.as_deref()).map_err(|e| bad("data.source.update", e))?;
|
||||
@@ -199,7 +208,7 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let options = doc.data_merge.options.clone();
|
||||
let maps = identity_map(&doc);
|
||||
let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default();
|
||||
let data_dir = source.path.as_deref().and_then(parent_dir);
|
||||
let data_dir = source_file(&source, st.path.as_deref().map(Path::new)).as_deref().and_then(Path::parent).map(Path::to_path_buf);
|
||||
let doc_dir = st.path.as_deref().and_then(parent_dir);
|
||||
fill_row(&mut doc, &source.fields, &row, record as u32, &options, &maps, data_dir.as_deref(), doc_dir.as_deref());
|
||||
st.doc = Arc::new(doc);
|
||||
@@ -218,7 +227,7 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (table, dir) = load_inline(p).map_err(|e| bad("data.merge", e))?;
|
||||
(table, Vec::new(), dir, None)
|
||||
} else {
|
||||
let (table, warnings, dir, status) = linked_table(&st.doc).map_err(|e| bad("data.merge", e))?;
|
||||
let (table, warnings, dir, status) = linked_table(&st.doc, st.path.as_deref().map(Path::new)).map_err(|e| bad("data.merge", e))?;
|
||||
(table, warnings, dir, Some(status))
|
||||
};
|
||||
let mut options = st.doc.data_merge.options.clone();
|
||||
@@ -624,28 +633,24 @@ pub(crate) fn refresh_relative_paths(doc: &mut Document, doc_path: &Path) {
|
||||
}
|
||||
|
||||
/// On open, compare the absolute path with its fingerprint. A missing absolute path is tried
|
||||
/// again as `relative_path` beside the document.
|
||||
/// again as `relative_path` beside the document. Paths that may not be followed ([`crate::io::document_path`]) are missing.
|
||||
pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path>) {
|
||||
for src in &mut doc.data_merge.sources {
|
||||
let abs_exists = src.path.as_deref().is_some_and(crate::io::is_file);
|
||||
if abs_exists {
|
||||
if let Some(path) = src.path.clone() {
|
||||
src.status = status_of(Path::new(&path), src.fingerprint);
|
||||
}
|
||||
if let Some(path) = source_file(src, doc_path).filter(|p| crate::io::is_file(p)) {
|
||||
src.status = status_of(&path, src.fingerprint);
|
||||
continue;
|
||||
}
|
||||
if let (Some(doc_path), Some(rel)) = (doc_path, src.relative_path.clone())
|
||||
&& let Some(dir) = doc_path.parent()
|
||||
&& let Ok(candidate) = crate::io::document_path(&rel, Some(doc_path))
|
||||
&& crate::io::is_file(&candidate)
|
||||
{
|
||||
let candidate = dir.join(&rel);
|
||||
if crate::io::is_file(&candidate) {
|
||||
let saved = candidate.to_string_lossy().into_owned();
|
||||
src.fingerprint = fingerprint_of(Path::new(&saved));
|
||||
src.status = SourceStatus::Ok;
|
||||
src.relative_path = Some(relative_between(dir, Path::new(&saved)));
|
||||
src.path = Some(saved);
|
||||
continue;
|
||||
}
|
||||
let saved = candidate.to_string_lossy().into_owned();
|
||||
src.fingerprint = fingerprint_of(Path::new(&saved));
|
||||
src.status = SourceStatus::Ok;
|
||||
src.relative_path = Some(relative_between(dir, Path::new(&saved)));
|
||||
src.path = Some(saved);
|
||||
continue;
|
||||
}
|
||||
if src.path.is_some() {
|
||||
src.status = SourceStatus::Missing;
|
||||
@@ -680,11 +685,17 @@ fn read_source(p: &Value) -> std::result::Result<(DataSource, Value), String> {
|
||||
Ok((source, report))
|
||||
}
|
||||
|
||||
fn linked_table(doc: &Document) -> std::result::Result<(Table, Vec<String>, Option<PathBuf>, SourceStatus), String> {
|
||||
fn linked_table(doc: &Document, document: Option<&Path>) -> std::result::Result<(Table, Vec<String>, Option<PathBuf>, SourceStatus), String> {
|
||||
let src = doc.data_merge.sources.first().ok_or_else(|| "select a data source or pass csv or rows".to_string())?;
|
||||
let mut warnings = Vec::new();
|
||||
let status = if let Some(path) = &src.path {
|
||||
let status = status_of(Path::new(path), src.fingerprint);
|
||||
let status = match crate::io::document_path(path, document) {
|
||||
Ok(path) => status_of(&path, src.fingerprint),
|
||||
Err(why) => {
|
||||
warnings.push(format!("The data file is not read ({why}). This merge used the cached rows."));
|
||||
return Ok((cached_table(src), warnings, None, SourceStatus::Missing));
|
||||
}
|
||||
};
|
||||
match status {
|
||||
SourceStatus::Modified => warnings.push("The data file changed since it was read. This merge used the cached rows.".into()),
|
||||
SourceStatus::Missing => warnings.push("The data file is missing. This merge used the cached rows.".into()),
|
||||
@@ -694,9 +705,12 @@ fn linked_table(doc: &Document) -> std::result::Result<(Table, Vec<String>, Opti
|
||||
} else {
|
||||
src.status
|
||||
};
|
||||
let data_dir = src.path.as_deref().and_then(parent_dir);
|
||||
let table = Table { fields: src.fields.clone(), rows: src.rows.clone(), warnings: src.warnings.clone(), delimiter: src.delimiter };
|
||||
Ok((table, warnings, data_dir, status))
|
||||
let data_dir = source_file(src, document).as_deref().and_then(Path::parent).map(Path::to_path_buf);
|
||||
Ok((cached_table(src), warnings, data_dir, status))
|
||||
}
|
||||
|
||||
fn cached_table(src: &DataSource) -> Table {
|
||||
Table { fields: src.fields.clone(), rows: src.rows.clone(), warnings: src.warnings.clone(), delimiter: src.delimiter }
|
||||
}
|
||||
|
||||
fn set_source_status(doc: &mut Document, status: SourceStatus) -> bool {
|
||||
|
||||
@@ -277,32 +277,23 @@ fn next_slot(col: i32, row: i32, rows_first: bool, fits_col: &impl Fn(i32) -> bo
|
||||
(0, 0, true)
|
||||
}
|
||||
|
||||
/// Files to try for an image cell. `http` and `https` are not candidates. Relative paths are
|
||||
/// not resolved against the process working directory.
|
||||
/// Files to try for an image cell: an absolute path as it is, a relative one beside the data
|
||||
/// file, then beside the document. Relative paths are not resolved against the process working
|
||||
/// directory, and paths that may not be followed ([`crate::io::document_path`]: URLs, other
|
||||
/// computers, devices) are not candidates.
|
||||
pub fn image_candidates(cell: &str, data_dir: Option<&Path>, doc_dir: Option<&Path>) -> Vec<PathBuf> {
|
||||
let cell = cell.trim();
|
||||
if cell.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let lower = cell.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
return Vec::new();
|
||||
}
|
||||
let path = Path::new(cell);
|
||||
let mut out = Vec::new();
|
||||
let mut push = |p: PathBuf| {
|
||||
if !out.iter().any(|e| e == &p) {
|
||||
let found = std::iter::once(crate::io::document_path(cell, None))
|
||||
.chain(data_dir.map(|dir| crate::io::folder_path(cell, dir)))
|
||||
.chain(doc_dir.map(|dir| crate::io::folder_path(cell, dir)));
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for p in found.flatten() {
|
||||
if !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
};
|
||||
if path.is_absolute() {
|
||||
push(path.to_path_buf());
|
||||
}
|
||||
if let Some(dir) = data_dir {
|
||||
push(dir.join(cell));
|
||||
}
|
||||
if let Some(dir) = doc_dir {
|
||||
push(dir.join(cell));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -478,7 +478,7 @@ fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file")));
|
||||
}
|
||||
let (mut d, mut warnings) = load_bytes(&bytes)?;
|
||||
super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent());
|
||||
super::interchange::resolve_packaged_links(&mut d, Some(std::path::Path::new(path)));
|
||||
super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path)));
|
||||
let (fonts, faces, font_warnings) = load_document_fonts(&mut d, path);
|
||||
warnings.extend(font_warnings);
|
||||
@@ -926,7 +926,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
const ID: &str = "file.place";
|
||||
let src = if name.to_lowercase().ends_with(".idml") {
|
||||
super::interchange::import_idml_confined(bytes).map_err(|e| bad(ID, e.to_string()))?
|
||||
super::interchange::import_idml_confined(bytes, str_param(p, "path").map(std::path::Path::new)).map_err(|e| bad(ID, e.to_string()))?
|
||||
} else {
|
||||
from_bytes(bytes)?
|
||||
};
|
||||
@@ -1051,7 +1051,7 @@ fn file_revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
{
|
||||
let mut d = if path.to_ascii_lowercase().ends_with(".idml") {
|
||||
let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?;
|
||||
super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
super::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(&path))).map_err(|e| EngineError::Other(e.to_string()))?
|
||||
} else {
|
||||
from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)?
|
||||
};
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
//! Interchange formats: IDML (InDesign Markup Language) import and export, and INDD/INDT
|
||||
//! import (converted to IDML in memory by indd-utils).
|
||||
//!
|
||||
//! A package's linked files are read from where the link points on this computer, then from the
|
||||
//! link's trailing names beside the package (where a package moved from its author's computer
|
||||
//! keeps them). Links to other computers and devices are not followed
|
||||
//! ([`crate::io::document_path`]). Only regular files that hold an image or a graphic format are
|
||||
//! read, and only up to [`MAX_LINKED_FILE_BYTES`].
|
||||
|
||||
use designcraft_doc::Document;
|
||||
use serde_json::{Value, json};
|
||||
@@ -33,28 +39,40 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`], so the
|
||||
/// automation read root applies to the paths the package names, and its PDF crops resolved.
|
||||
pub(crate) fn import_idml_confined(bytes: &[u8]) -> designcraft_idml::Result<Document> {
|
||||
let mut d = designcraft_idml::import_idml_with(bytes, &|path| crate::io::read_file(path).ok())?;
|
||||
/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`] from
|
||||
/// beside `package` (the package's file, if it has one), so the automation read root applies to
|
||||
/// the paths the package names, and its PDF crops resolved.
|
||||
pub(crate) fn import_idml_confined(bytes: &[u8], package: Option<&std::path::Path>) -> designcraft_idml::Result<Document> {
|
||||
let read = |link: &str| -> Option<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
read_packaged_link(link, package).map(|(_, data)| data)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (link, package);
|
||||
None
|
||||
}
|
||||
};
|
||||
let mut d = designcraft_idml::import_idml_with(bytes, &read)?;
|
||||
resolve_pdf_crops(&mut d);
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup).
|
||||
pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<Document> {
|
||||
import_report(bytes, dir).map(|(document, _)| document)
|
||||
/// Import IDML bytes; `package` is the package's file (its folder is where links are looked for).
|
||||
pub fn import(bytes: &[u8], package: Option<&std::path::Path>) -> Result<Document> {
|
||||
import_report(bytes, package).map(|(document, _)| document)
|
||||
}
|
||||
|
||||
/// [`import`], with the problems that did not stop it (parts missing from the package, …).
|
||||
pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Document, Vec<String>)> {
|
||||
import_report_with_previews(bytes, dir, &std::collections::HashMap::new())
|
||||
pub fn import_report(bytes: &[u8], package: Option<&std::path::Path>) -> Result<(Document, Vec<String>)> {
|
||||
import_report_with_previews(bytes, package, &std::collections::HashMap::new())
|
||||
}
|
||||
|
||||
/// [`import_report`], showing `previews` (graphic `Self` → image) for linked files that can't be read.
|
||||
fn import_report_with_previews(
|
||||
bytes: &[u8],
|
||||
dir: Option<&std::path::Path>,
|
||||
package: Option<&std::path::Path>,
|
||||
previews: &std::collections::HashMap<String, std::sync::Arc<Vec<u8>>>,
|
||||
) -> Result<(Document, Vec<String>)> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -62,13 +80,13 @@ fn import_report_with_previews(
|
||||
let read = |link: &str| -> Option<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let (path, data) = read_packaged_link(link, dir)?;
|
||||
let (path, data) = read_packaged_link(link, package)?;
|
||||
resolved.borrow_mut().insert(link.to_string(), path);
|
||||
Some(data)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (link, &dir);
|
||||
let _ = (link, &package);
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -84,7 +102,7 @@ fn import_report_with_previews(
|
||||
std::sync::Arc::make_mut(asset).link = Some(path);
|
||||
}
|
||||
}
|
||||
resolve_packaged_links(&mut document, dir);
|
||||
resolve_packaged_links(&mut document, package);
|
||||
document
|
||||
};
|
||||
Ok((document, warnings))
|
||||
@@ -173,77 +191,97 @@ const MAX_LINK_PATH_BYTES: usize = 128 * 1024;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_LINK_SUFFIX_PARTS: usize = 8;
|
||||
|
||||
/// Largest linked file read when a package is opened (bytes); bigger files stay missing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn packaged_link_candidates(link: &str, dir: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
|
||||
pub(crate) const MAX_LINKED_FILE_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// The files to try for `link` in the package at `package`: the link itself, then its trailing
|
||||
/// names beside the package (without a package file, only an absolute link). Each is a path
|
||||
/// [`crate::io::document_path`] allows.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn packaged_link_candidates(link: &str, package: Option<&std::path::Path>) -> Vec<std::path::PathBuf> {
|
||||
// Enough for a maximum-length Windows path even with four-byte UTF-8 characters.
|
||||
// Bound splitting/allocation for link strings supplied by imported documents.
|
||||
if link.len() > MAX_LINK_PATH_BYTES {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut candidates = vec![std::path::PathBuf::from(link)];
|
||||
if let Some(dir) = dir {
|
||||
// IDML may retain Windows paths even when the package is opened on another platform.
|
||||
let parts: Vec<&str> = link.split(['/', '\\']).filter(|part| !part.is_empty()).collect();
|
||||
let safe_part = |part: &&str| *part != "." && *part != ".." && !part.contains(':');
|
||||
if !link.starts_with(['/', '\\']) && parts.iter().all(safe_part) {
|
||||
candidates.push(dir.join(parts.iter().collect::<std::path::PathBuf>()));
|
||||
let mut candidates: Vec<std::path::PathBuf> = crate::io::document_path(link, package).into_iter().collect();
|
||||
let Some(package) = package else { return candidates };
|
||||
// Names relative to the package, resolved beside it.
|
||||
let mut beside = |names: &[&str]| {
|
||||
if let Ok(path) = crate::io::document_path(&names.join("/"), Some(package))
|
||||
&& !candidates.contains(&path)
|
||||
{
|
||||
candidates.push(path);
|
||||
}
|
||||
// Packagers can keep subfolders under Links (for example, Links/illustrations/logo.ai).
|
||||
// Preserve that suffix instead of flattening every resource to its basename.
|
||||
if let Some(index) = parts.iter().rposition(|part| part.eq_ignore_ascii_case("Links")) {
|
||||
let suffix = &parts[index + 1..];
|
||||
if !suffix.is_empty() && suffix.iter().all(safe_part) {
|
||||
candidates.push(dir.join("Links").join(suffix.iter().collect::<std::path::PathBuf>()));
|
||||
}
|
||||
}
|
||||
// A package moved off its author's machine keeps its own subfolders (`/images/logo.png`
|
||||
// beside the .idml as `images/logo.png`): try the link's trailing folders, longest first.
|
||||
let first = parts.len().saturating_sub(MAX_LINK_SUFFIX_PARTS);
|
||||
for start in first..parts.len().saturating_sub(1) {
|
||||
if let Some(suffix) = parts.get(start..).filter(|s| s.iter().all(safe_part)) {
|
||||
candidates.push(dir.join(suffix.iter().collect::<std::path::PathBuf>()));
|
||||
}
|
||||
}
|
||||
if let Some(name) = parts.last().filter(|part| safe_part(part)) {
|
||||
candidates.push(dir.join(name));
|
||||
candidates.push(dir.join("Links").join(name));
|
||||
};
|
||||
// IDML may retain Windows paths even when the package is opened on another platform.
|
||||
let parts: Vec<&str> = link.split(['/', '\\']).filter(|part| !part.is_empty()).collect();
|
||||
let safe_part = |part: &&str| *part != "." && *part != ".." && !part.contains(':');
|
||||
// Packagers can keep subfolders under Links (for example, Links/illustrations/logo.ai).
|
||||
// Preserve that suffix instead of flattening every resource to its basename.
|
||||
if let Some(index) = parts.iter().rposition(|part| part.eq_ignore_ascii_case("Links")) {
|
||||
let suffix = parts.get(index + 1..).unwrap_or_default();
|
||||
if !suffix.is_empty() && suffix.iter().all(safe_part) {
|
||||
beside(&[&["Links"][..], suffix].concat());
|
||||
}
|
||||
}
|
||||
// A package moved off its author's machine keeps its own subfolders (`/images/logo.png`
|
||||
// beside the .idml as `images/logo.png`): try the link's trailing folders, longest first.
|
||||
let first = parts.len().saturating_sub(MAX_LINK_SUFFIX_PARTS);
|
||||
for start in first..parts.len().saturating_sub(1) {
|
||||
if let Some(suffix) = parts.get(start..).filter(|s| s.iter().all(safe_part)) {
|
||||
beside(suffix);
|
||||
}
|
||||
}
|
||||
if let Some(name) = parts.last().filter(|part| safe_part(part)) {
|
||||
beside(&[name]);
|
||||
beside(&["Links", name]);
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
/// Bytes an image or a graphic can be placed from (raster formats, PDF, SVG, PSD, EPS).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn is_graphic(bytes: &[u8]) -> bool {
|
||||
designcraft_images::mime(bytes) != "application/octet-stream" || designcraft_images::is_eps(bytes)
|
||||
}
|
||||
|
||||
/// Embedded images do not call the IDML resource loader. Rebind their missing links
|
||||
/// beside the opened document too, without replacing their embedded image bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::path::Path>) {
|
||||
let Some(dir) = dir else { return };
|
||||
pub(crate) fn resolve_packaged_links(document: &mut Document, package: Option<&std::path::Path>) {
|
||||
let Some(package) = package else { return };
|
||||
for asset in document.assets.values_mut() {
|
||||
let Some(link) = asset.link.as_deref() else { continue };
|
||||
if link.len() > MAX_LINK_PATH_BYTES || crate::io::is_file(link) {
|
||||
if link.len() > MAX_LINK_PATH_BYTES || crate::io::document_path(link, Some(package)).is_ok_and(crate::io::is_file) {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| crate::io::is_file(p)) else { continue };
|
||||
// Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI.
|
||||
let path = std::path::absolute(&path).unwrap_or(path);
|
||||
let Some(path) = packaged_link_candidates(link, Some(package)).into_iter().find(|p| crate::io::is_file(p)) else { continue };
|
||||
std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// The first candidate for `link` ([`packaged_link_candidates`]) that is a regular file no larger
|
||||
/// than [`MAX_LINKED_FILE_BYTES`] holding an image or a graphic, with its path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec<u8>)> {
|
||||
for path in packaged_link_candidates(link, dir) {
|
||||
if let Ok(bytes) = crate::io::read_file(&path) {
|
||||
// Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize
|
||||
// would introduce a verbatim prefix that is not an IDML file URI.
|
||||
let path = std::path::absolute(&path).unwrap_or(path);
|
||||
return Some((path.to_string_lossy().into_owned(), bytes));
|
||||
fn read_packaged_link(link: &str, package: Option<&std::path::Path>) -> Option<(String, Vec<u8>)> {
|
||||
for path in packaged_link_candidates(link, package) {
|
||||
if !crate::io::is_file(&path) || !crate::io::metadata(&path).is_ok_and(|m| m.len() <= MAX_LINKED_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
// At most one byte over the limit, in case the file grew since.
|
||||
let Ok(bytes) = crate::io::read_capped(&path, MAX_LINKED_FILE_BYTES) else { continue };
|
||||
if bytes.len() as u64 > MAX_LINKED_FILE_BYTES || !is_graphic(&bytes) {
|
||||
continue;
|
||||
}
|
||||
return Some((path.to_string_lossy().into_owned(), bytes));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, dir, file, title) = if let Some(b) = str_param(p, "base64") {
|
||||
let (bytes, package, file, title) = if let Some(b) = str_param(p, "base64") {
|
||||
let name = str_param(p, "name").unwrap_or_default();
|
||||
(base64_decode(b), None, name.to_string(), without_document_extension(name).to_string())
|
||||
} else if let Some(path) = str_param(p, "path") {
|
||||
@@ -253,7 +291,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b: Vec<u8> = Vec::new();
|
||||
let pp = std::path::Path::new(path);
|
||||
let file = pp.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string());
|
||||
(b, pp.parent().map(|d| d.to_path_buf()), file, pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())
|
||||
(b, Some(pp.to_path_buf()), file, pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())
|
||||
} else {
|
||||
return Err(bad("file.openIdml", "missing `path` or `base64`"));
|
||||
};
|
||||
@@ -280,7 +318,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
bytes
|
||||
};
|
||||
let (mut d, import_warnings) = import_report_with_previews(&bytes, dir.as_deref(), &previews)?;
|
||||
let (mut d, import_warnings) = import_report_with_previews(&bytes, package.as_deref(), &previews)?;
|
||||
warnings.extend(import_warnings);
|
||||
if !title.is_empty() {
|
||||
d.title = title;
|
||||
@@ -346,12 +384,12 @@ mod tests {
|
||||
std::fs::write(&path, &png).unwrap();
|
||||
std::fs::write(dir.join("logo.png"), b"a different file with the same basename").unwrap();
|
||||
|
||||
let doc = import(&idml, Some(&dir)).unwrap();
|
||||
let doc = import(&idml, Some(&dir.join("x.idml"))).unwrap();
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(*asset.data, png, "the packaged graphic must render even when its author-machine path no longer exists");
|
||||
let resolved = std::path::absolute(&path).unwrap();
|
||||
assert_eq!(asset.link.as_deref(), resolved.to_str(), "remember the resolved link so update and preflight use the packaged file");
|
||||
assert_eq!(super::super::links::status(asset), "ok");
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -364,11 +402,11 @@ mod tests {
|
||||
let (idml, png) = linked_idml("/images/logo.png", false).unwrap();
|
||||
std::fs::write(&path, &png).unwrap();
|
||||
|
||||
let doc = import(&idml, Some(&dir)).unwrap();
|
||||
let doc = import(&idml, Some(&dir.join("x.idml"))).unwrap();
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str());
|
||||
assert_eq!(super::super::links::status(asset), "ok");
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -380,11 +418,11 @@ mod tests {
|
||||
let (idml, png) = linked_idml("/original-computer/project/logo.png", false).unwrap();
|
||||
std::fs::write(&path, &png).unwrap();
|
||||
|
||||
let doc = import(&idml, Some(&dir)).unwrap();
|
||||
let doc = import(&idml, Some(&dir.join("x.idml"))).unwrap();
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str());
|
||||
assert_eq!(super::super::links::status(asset), "ok");
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
@@ -400,39 +438,90 @@ mod tests {
|
||||
let (idml, png) = linked_idml(original.to_str().unwrap(), true).unwrap();
|
||||
std::fs::write(&original, b"modified original").unwrap();
|
||||
std::fs::write(©, &png).unwrap();
|
||||
let doc = import(&idml, Some(&package)).unwrap();
|
||||
let doc = import(&idml, Some(&package.join("x.idml"))).unwrap();
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative");
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(super::super::links::status(asset), "modified");
|
||||
assert_eq!(super::super::links::status(asset, None), "modified");
|
||||
|
||||
std::fs::remove_file(&original).unwrap();
|
||||
std::fs::write(©, b"modified packaged copy").unwrap();
|
||||
let doc = import(&idml, Some(&package)).unwrap();
|
||||
let doc = import(&idml, Some(&package.join("x.idml"))).unwrap();
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(asset.link.as_deref(), copy.to_str());
|
||||
assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels");
|
||||
assert_eq!(super::super::links::status(asset), "modified");
|
||||
assert_eq!(super::super::links::status(asset, None), "modified");
|
||||
|
||||
std::fs::remove_file(©).unwrap();
|
||||
for folder in [Some(package.as_path()), None] {
|
||||
let packaged = package.join("x.idml");
|
||||
for folder in [Some(packaged.as_path()), None] {
|
||||
let doc = import(&idml, folder).unwrap();
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate");
|
||||
assert_eq!(*asset.data, png);
|
||||
assert_eq!(super::super::links::status(asset), "missing");
|
||||
assert_eq!(super::super::links::status(asset, None), "missing");
|
||||
}
|
||||
let oversized = "a/".repeat(MAX_LINK_PATH_BYTES);
|
||||
assert!(packaged_link_candidates(&oversized, Some(&package)).is_empty());
|
||||
assert!(packaged_link_candidates(&oversized, Some(&packaged)).is_empty());
|
||||
let mut doc = import(&idml, None).unwrap();
|
||||
Arc::make_mut(doc.assets.values_mut().next().unwrap()).link = Some(oversized.clone());
|
||||
resolve_packaged_links(&mut doc, Some(&package));
|
||||
resolve_packaged_links(&mut doc, Some(&packaged));
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!(asset.link.as_deref(), Some(oversized.as_str()));
|
||||
assert_eq!(*asset.data, png);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_local_graphic_files_are_read_from_links() {
|
||||
let base = std::env::temp_dir().join(format!("dc-idml-link-reach-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (dir, outside) = (base.join("package"), base.join("outside"));
|
||||
std::fs::create_dir_all(dir.join("Links")).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let package = dir.join("x.idml");
|
||||
let png = designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png();
|
||||
std::fs::write(dir.join("Links").join("notes.png"), b"not an image").unwrap();
|
||||
std::fs::write(outside.join("notes.txt"), b"not an image").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink("/dev/zero", dir.join("Links").join("zero.png")).unwrap();
|
||||
std::os::unix::fs::symlink("/dev/zero", outside.join("zero.png")).unwrap();
|
||||
}
|
||||
let (text, zero) = (outside.join("notes.txt"), outside.join("zero.png"));
|
||||
let links = [
|
||||
r"\\192.0.2.1\share\x.png",
|
||||
"file://192.0.2.1/s/x.png",
|
||||
text.to_str().unwrap(),
|
||||
zero.to_str().unwrap(),
|
||||
"/dev/zero",
|
||||
"Links/zero.png",
|
||||
"Links/notes.png",
|
||||
];
|
||||
for link in links {
|
||||
let (idml, _) = linked_idml(link, false).unwrap();
|
||||
let (doc, seen) = crate::io::touched(|| import(&idml, Some(&package)).unwrap());
|
||||
// Beside the package, and the link itself where it is on this computer.
|
||||
assert!(seen.iter().all(|p| p.starts_with(&dir) || p == std::path::Path::new(link)), "{link}: {seen:?}");
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert!(asset.data.is_empty(), "{link}");
|
||||
assert_eq!(super::super::links::status(asset, None), "missing", "{link}");
|
||||
}
|
||||
// An image elsewhere on this computer is read, and so is one beside the package.
|
||||
let away = outside.join("logo.png");
|
||||
std::fs::write(&away, &png).unwrap();
|
||||
let (idml, _) = linked_idml(away.to_str().unwrap(), false).unwrap();
|
||||
let doc = import(&idml, Some(&package)).unwrap();
|
||||
let asset = doc.assets.values().next().unwrap();
|
||||
assert_eq!((&*asset.data, asset.link.as_deref()), (&png, away.to_str()));
|
||||
assert_eq!(super::super::links::status(asset, None), "ok");
|
||||
std::fs::write(dir.join("Links").join("logo.png"), &png).unwrap();
|
||||
let (idml, _) = linked_idml("Links/logo.png", false).unwrap();
|
||||
let doc = import(&idml, Some(&package)).unwrap();
|
||||
assert_eq!(*doc.assets.values().next().unwrap().data, png);
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
/// INDD routing without an InDesign file: bytes with the INDD signature (or an .indd/.indt
|
||||
/// name) go to the converter, and what it can't read is an open error that adds no document.
|
||||
#[test]
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
//!
|
||||
//! A linked text file (File › Place with Create Links) remembers its file's size and hash on the
|
||||
//! story instead; updating it imports the file again into the story.
|
||||
//!
|
||||
//! A link is followed only where [`crate::io::document_path`] allows it, resolved against the
|
||||
//! document's own file; a refused link is missing, with the reason in `links.list`.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{AssetId, Content, Document, Fingerprint, ItemId, Selection, Story, StoryId, TextFileLink};
|
||||
@@ -18,10 +22,10 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| {
|
||||
cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| {
|
||||
// Taken out for the call so the document needn't be cloned on every poll.
|
||||
let mut checks = std::mem::take(&mut s.text_link_checks);
|
||||
let links = s.doc().map(|d| list(&d.doc, &mut checks));
|
||||
let links = s.doc().map(|d| list(&d.doc, d.path.as_deref().map(Path::new), &mut checks));
|
||||
s.text_link_checks = checks;
|
||||
Ok(Value::Array(links?))
|
||||
}),
|
||||
@@ -126,31 +130,47 @@ fn uses(d: &Document, aid: AssetId) -> Vec<ItemId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Why a link stored in the document at `document` isn't followed, if it isn't.
|
||||
pub fn refused(link: &str, document: Option<&Path>) -> Option<crate::io::Refused> {
|
||||
crate::io::document_path(link, document).err()
|
||||
}
|
||||
|
||||
/// Whether an asset's linked file is gone ([`status`] "missing" without reading the file). An
|
||||
/// asset can hold data while its file is missing: the document's preview of it.
|
||||
pub fn link_missing(a: &designcraft_doc::Asset) -> bool {
|
||||
let Some(path) = &a.link else { return false };
|
||||
/// asset can hold data while its file is missing: the document's preview of it. `document` is
|
||||
/// the document's file, which relative links are resolved against.
|
||||
pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool {
|
||||
let Some(link) = &a.link else { return false };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
!crate::io::exists(path)
|
||||
crate::io::document_path(link, document).map_or(true, |path| !crate::io::exists(path))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
let _ = (link, document);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Link status of an asset.
|
||||
pub fn status(a: &designcraft_doc::Asset) -> &'static str {
|
||||
let Some(path) = &a.link else { return "embedded" };
|
||||
/// Link status of an asset in the document at `document`. Only a regular file is compared, so a
|
||||
/// link to a device (`/dev/zero`) or a FIFO is missing rather than read.
|
||||
pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static str {
|
||||
let Some(link) = &a.link else { return "embedded" };
|
||||
// Nothing was read from the file (a package's link that wasn't followed): missing, as in
|
||||
// Preflight.
|
||||
if a.data.is_empty() {
|
||||
return "missing";
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
match crate::io::metadata(path) {
|
||||
let Ok(path) = crate::io::document_path(link, document) else { return "missing" };
|
||||
if !crate::io::is_file(&path) {
|
||||
return "missing";
|
||||
}
|
||||
match crate::io::metadata(&path) {
|
||||
Err(_) => "missing",
|
||||
Ok(m) if m.len() as usize != a.data.len() => "modified",
|
||||
// Same size: compare the bytes (cheap for the sizes involved, and exact).
|
||||
Ok(_) => match crate::io::read_file(path) {
|
||||
Ok(_) => match crate::io::read_capped(&path, a.data.len() as u64) {
|
||||
Ok(b) if b == *a.data => "ok",
|
||||
Ok(_) => "modified",
|
||||
Err(_) => "missing",
|
||||
@@ -159,7 +179,7 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str {
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
let _ = (link, document);
|
||||
"ok"
|
||||
}
|
||||
}
|
||||
@@ -196,14 +216,15 @@ pub(crate) type TextChecks = std::collections::HashMap<String, (Fingerprint, Str
|
||||
/// Link status of a linked text file: `tooLarge` when it is the recorded size but over
|
||||
/// [`MAX_TEXT_LINK_BYTES`], so its contents aren't checked. The file is read only when its size
|
||||
/// is the recorded one but its time isn't, and then once per new time (`checks`): a file saved
|
||||
/// or synced again without changes stays OK.
|
||||
pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks) -> &'static str {
|
||||
/// or synced again without changes stays OK. `document` is the document's file.
|
||||
pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks, document: Option<&Path>) -> &'static str {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if !crate::io::is_file(&l.path) {
|
||||
let Ok(path) = crate::io::document_path(&l.path, document) else { return "missing" };
|
||||
if !crate::io::is_file(&path) {
|
||||
return "missing";
|
||||
}
|
||||
let now = match crate::io::metadata(&l.path) {
|
||||
let now = match crate::io::metadata(&path) {
|
||||
Err(_) => return "missing",
|
||||
Ok(m) => super::datamerge::fingerprint_from(&m),
|
||||
};
|
||||
@@ -216,7 +237,7 @@ pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks) -> &'static
|
||||
let hash = match checks.get(&l.path) {
|
||||
Some((at, hash)) if *at == now => hash.clone(),
|
||||
_ if now.size > MAX_TEXT_LINK_BYTES => return "tooLarge",
|
||||
_ => match read_text(&l.path) {
|
||||
_ => match read_text(&path) {
|
||||
Ok((b, at)) => {
|
||||
let hash = fnv_hex(&b);
|
||||
// The time from before the read: a change during it is seen next time.
|
||||
@@ -232,7 +253,7 @@ pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks) -> &'static
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (l, checks);
|
||||
let _ = (l, checks, document);
|
||||
"ok"
|
||||
}
|
||||
}
|
||||
@@ -241,11 +262,11 @@ fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
fn list(d: &Document, checks: &mut TextChecks) -> Vec<Value> {
|
||||
fn list(d: &Document, document: Option<&Path>, checks: &mut TextChecks) -> Vec<Value> {
|
||||
let texts = d.stories.values().filter_map(|st| {
|
||||
let l = st.file_link.as_ref()?;
|
||||
let uses: Vec<Value> = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect();
|
||||
Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l, checks), "edited": story_hash(st) != l.story_hash, "uses": uses}))
|
||||
Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l, checks, document), "refused": refused(&l.path, document).map(|r| r.to_string()), "edited": story_hash(st) != l.story_hash, "uses": uses}))
|
||||
});
|
||||
let used = d.used_assets();
|
||||
d.assets
|
||||
@@ -274,7 +295,8 @@ fn list(d: &Document, checks: &mut TextChecks) -> Vec<Value> {
|
||||
})
|
||||
.collect();
|
||||
let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name());
|
||||
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
|
||||
let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string());
|
||||
json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a, document), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
|
||||
})
|
||||
.chain(texts)
|
||||
.collect()
|
||||
@@ -282,15 +304,16 @@ fn list(d: &Document, checks: &mut TextChecks) -> Vec<Value> {
|
||||
|
||||
/// A linked text file's bytes, with its size and time from before they were read.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_text(path: &str) -> Result<(Vec<u8>, Fingerprint)> {
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
|
||||
fn read_text(path: impl AsRef<Path>) -> Result<(Vec<u8>, Fingerprint)> {
|
||||
let path = path.as_ref();
|
||||
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
|
||||
// The roots' own message when they refuse the path.
|
||||
crate::file_access::check_read(path).map_err(EngineError::Other)?;
|
||||
let m = crate::io::metadata(path).map_err(err)?;
|
||||
if !crate::io::is_file(path) {
|
||||
return Err(EngineError::Other(format!("{path}: not a file")));
|
||||
return Err(EngineError::Other(format!("{}: not a file", path.display())));
|
||||
}
|
||||
let too_large = || EngineError::Other(format!("{path}: larger than {} MiB", MAX_TEXT_LINK_BYTES / (1024 * 1024)));
|
||||
let too_large = || EngineError::Other(format!("{}: larger than {} MiB", path.display(), MAX_TEXT_LINK_BYTES / (1024 * 1024)));
|
||||
if m.len() > MAX_TEXT_LINK_BYTES {
|
||||
return Err(too_large());
|
||||
}
|
||||
@@ -303,8 +326,8 @@ fn read_text(path: &str) -> Result<(Vec<u8>, Fingerprint)> {
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_text(path: &str) -> Result<(Vec<u8>, Fingerprint)> {
|
||||
Err(EngineError::Other(format!("{path}: files can't be read on the web")))
|
||||
fn read_text(path: impl AsRef<Path>) -> Result<(Vec<u8>, Fingerprint)> {
|
||||
Err(EngineError::Other(format!("{}: files can't be read on the web", path.as_ref().display())))
|
||||
}
|
||||
|
||||
/// Import a linked text file again; a warning says when edits made in the layout were replaced.
|
||||
@@ -318,15 +341,16 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn reload(path: &str) -> Result<(Vec<u8>, (u32, u32))> {
|
||||
fn reload(path: impl AsRef<Path>) -> Result<(Vec<u8>, (u32, u32))> {
|
||||
let path = path.as_ref();
|
||||
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{path}: unsupported or corrupt image")))?;
|
||||
let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{}: unsupported or corrupt image", path.display())))?;
|
||||
Ok((b, px))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn reload(path: &str) -> Result<(Vec<u8>, (u32, u32))> {
|
||||
Err(EngineError::Other(format!("{path}: files can't be read on the web")))
|
||||
fn reload(path: impl AsRef<Path>) -> Result<(Vec<u8>, (u32, u32))> {
|
||||
Err(EngineError::Other(format!("{}: files can't be read on the web", path.as_ref().display())))
|
||||
}
|
||||
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -368,11 +392,12 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none());
|
||||
let only: Option<Vec<u64>> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect());
|
||||
let d = &s.doc()?.doc;
|
||||
let st = s.doc()?;
|
||||
let (d, document) = (&st.doc, st.path.as_deref().map(Path::new));
|
||||
let mut todo: Vec<(AssetId, std::path::PathBuf)> = Vec::new();
|
||||
let mut not_found = Vec::new();
|
||||
for a in d.assets.values() {
|
||||
if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a) != "missing") {
|
||||
if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a, document) != "missing") {
|
||||
continue;
|
||||
}
|
||||
let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone()));
|
||||
@@ -380,9 +405,15 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = &ext {
|
||||
name = std::path::Path::new(&name).with_extension(e).into_os_string();
|
||||
}
|
||||
let folder = dir.clone().or_else(|| old.parent().map(|p| p.to_path_buf())).unwrap_or_default();
|
||||
let cand = folder.join(&name);
|
||||
if crate::io::exists(&cand) {
|
||||
// The link's own folder only where the link may be followed; the name only as a plain
|
||||
// file name (not a device).
|
||||
let folder = match &dir {
|
||||
Some(dir) => Some(dir.clone()),
|
||||
None => a.link.as_deref().and_then(|l| crate::io::document_path(l, document).ok()).and_then(|p| p.parent().map(Path::to_path_buf)),
|
||||
};
|
||||
let plain_name = name.to_str().is_some_and(|n| !n.contains(['/', '\\']) && craft_docpath::is_local(n));
|
||||
let cand = folder.filter(|_| plain_name).map(|f| f.join(&name));
|
||||
if let Some(cand) = cand.filter(|c| crate::io::exists(c)) {
|
||||
todo.push((a.id, cand));
|
||||
} else {
|
||||
not_found.push(name.to_string_lossy().to_string());
|
||||
@@ -397,30 +428,39 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = s.doc()?.doc.clone();
|
||||
let (d, document) = {
|
||||
let st = s.doc()?;
|
||||
(st.doc.clone(), st.path.clone())
|
||||
};
|
||||
let document = document.as_deref().map(Path::new);
|
||||
let checks = &mut s.text_link_checks;
|
||||
let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p));
|
||||
let all = only_asset.is_none() && only_story.is_none();
|
||||
let which: Vec<(AssetId, String)> = d
|
||||
// "modified" means the link resolved: refused links are missing.
|
||||
let resolved = |link: &str| crate::io::document_path(link, document).ok();
|
||||
let which: Vec<(AssetId, std::path::PathBuf)> = d
|
||||
.assets
|
||||
.values()
|
||||
.filter(|a| all || only_asset == Some(a.id.0))
|
||||
.filter(|a| status(a) == "modified")
|
||||
.filter_map(|a| a.link.clone().map(|l| (a.id, l)))
|
||||
.filter(|a| status(a, document) == "modified")
|
||||
.filter_map(|a| a.link.as_deref().and_then(resolved).map(|l| (a.id, l)))
|
||||
.collect();
|
||||
let texts: Vec<(StoryId, String)> = d
|
||||
let texts: Vec<(StoryId, String, std::path::PathBuf)> = d
|
||||
.stories
|
||||
.values()
|
||||
.filter(|st| all || only_story == Some(st.id))
|
||||
.filter_map(|st| st.file_link.as_ref().filter(|l| text_status(l, checks) == "modified").map(|l| (st.id, l.path.clone())))
|
||||
.filter_map(|st| {
|
||||
let l = st.file_link.as_ref().filter(|l| text_status(l, checks, document) == "modified")?;
|
||||
Some((st.id, l.path.clone(), resolved(&l.path)?))
|
||||
})
|
||||
.collect();
|
||||
let mut fresh = Vec::new();
|
||||
for (aid, path) in which {
|
||||
fresh.push((aid, reload(&path)?));
|
||||
}
|
||||
let mut fresh_texts = Vec::new();
|
||||
for (sid, path) in texts {
|
||||
let (bytes, before) = read_text(&path)?;
|
||||
for (sid, path, at) in texts {
|
||||
let (bytes, before) = read_text(&at)?;
|
||||
fresh_texts.push((sid, path, bytes, before));
|
||||
}
|
||||
let n = fresh.len() + fresh_texts.len();
|
||||
@@ -834,7 +874,7 @@ mod text_link_tests {
|
||||
// Saved and opened again: the link and its options are kept.
|
||||
let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
assert_eq!(back.story(sid).unwrap().file_link, story(&s, sid).file_link);
|
||||
assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default()), "ok");
|
||||
assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default(), None), "ok");
|
||||
|
||||
// The file moves: Missing; Relink to it imports it into the story.
|
||||
let moved = dir.join("moved").join("chapter.docx");
|
||||
@@ -973,7 +1013,7 @@ mod text_link_read_tests {
|
||||
|
||||
fn status_of(path: &std::path::Path, size: u64) -> &'static str {
|
||||
let l = TextFileLink { path: path.to_string_lossy().to_string(), size, hash: "0".into(), ..Default::default() };
|
||||
promptly(move || text_status(&l, &mut Default::default()))
|
||||
promptly(move || text_status(&l, &mut Default::default(), None))
|
||||
}
|
||||
|
||||
fn read(path: &std::path::Path) -> Result<usize, String> {
|
||||
|
||||
@@ -116,6 +116,7 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des
|
||||
pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
let Some(st) = s.active() else { return vec![] };
|
||||
let d = &st.doc;
|
||||
let document = st.path.as_deref().map(std::path::Path::new);
|
||||
let mut out = Vec::new();
|
||||
// Stories: overset and fonts.
|
||||
let mut missing_fonts: Vec<String> = Vec::new();
|
||||
@@ -251,10 +252,13 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
match &it.content {
|
||||
Content::Graphic(g) => match d.assets.get(&g.asset) {
|
||||
None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }),
|
||||
Some(a) if a.data.is_empty() || super::links::link_missing(a) => out.push(Issue {
|
||||
Some(a) if a.data.is_empty() || super::links::link_missing(a, document) => out.push(Issue {
|
||||
severity: "error",
|
||||
kind: "missingLink",
|
||||
message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)),
|
||||
message: match a.link.as_deref().and_then(|l| super::links::refused(l, document)) {
|
||||
Some(why) => format!("Missing link: {} ({why})", a.link.as_deref().unwrap_or(&a.name)),
|
||||
None => format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)),
|
||||
},
|
||||
item: Some(it.id.0),
|
||||
page,
|
||||
}),
|
||||
|
||||
+57
-1
@@ -8,12 +8,58 @@
|
||||
//! it is ([`is_file`], [`is_dir`], [`exists`], [`metadata`]), and as the roots' own message where
|
||||
//! it asks for the bytes.
|
||||
|
||||
pub use craft_docpath::Refused;
|
||||
pub use std::fs::Metadata;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The path to open for a path stored in a document (a link, a data source, a book chapter),
|
||||
/// resolved against `document`, the path of the file it came from, when there is one.
|
||||
///
|
||||
/// Paths to other computers (other than the document's own network share), devices and URLs are
|
||||
/// refused, and so is a result that isn't absolute: without a document, a relative path would
|
||||
/// depend on the working directory, and a Windows path names no file on other platforms. A
|
||||
/// refused path is never handed to the file system; callers treat it as a missing file.
|
||||
pub fn document_path(stored: &str, document: Option<&Path>) -> Result<PathBuf, Refused> {
|
||||
let document = document.map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
|
||||
let path = craft_docpath::resolve(stored, document.as_deref())?;
|
||||
if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) }
|
||||
}
|
||||
|
||||
/// [`document_path`] for a path stored in a file inside `folder` (a data file's image names).
|
||||
pub fn folder_path(stored: &str, folder: &Path) -> Result<PathBuf, Refused> {
|
||||
// Resolved against a document in the folder: only the document's folder is used.
|
||||
document_path(stored, Some(&folder.join("-")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TOUCHED: std::cell::RefCell<Option<Vec<PathBuf>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` and list every path it handed to this module, in order (on this thread).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn touched<T>(f: impl FnOnce() -> T) -> (T, Vec<PathBuf>) {
|
||||
let previous = TOUCHED.with(|t| t.replace(Some(Vec::new())));
|
||||
let out = f();
|
||||
let seen = TOUCHED.with(|t| t.replace(previous)).unwrap_or_default();
|
||||
(out, seen)
|
||||
}
|
||||
|
||||
fn touch(path: &Path) {
|
||||
#[cfg(test)]
|
||||
TOUCHED.with(|t| {
|
||||
if let Some(seen) = t.borrow_mut().as_mut() {
|
||||
seen.push(path.to_path_buf());
|
||||
}
|
||||
});
|
||||
#[cfg(not(test))]
|
||||
let _ = path;
|
||||
}
|
||||
|
||||
/// The bytes of the file at `path`, inside the automation read root when there is one.
|
||||
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>, String> {
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_read(path)?;
|
||||
std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
@@ -22,6 +68,7 @@ pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>, String> {
|
||||
/// failures. A refused path is a `PermissionDenied` error carrying the roots' message.
|
||||
pub fn read(path: impl AsRef<Path>) -> std::io::Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::read(path)
|
||||
}
|
||||
@@ -31,6 +78,7 @@ pub fn read(path: impl AsRef<Path>) -> std::io::Result<Vec<u8>> {
|
||||
pub fn read_capped(path: impl AsRef<Path>, cap: u64) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
let mut b = Vec::new();
|
||||
std::fs::File::open(path)?.take(cap.saturating_add(1)).read_to_end(&mut b)?;
|
||||
@@ -42,6 +90,7 @@ pub fn read_capped(path: impl AsRef<Path>, cap: u64) -> std::io::Result<Vec<u8>>
|
||||
/// roots' message.
|
||||
pub fn open(path: impl AsRef<Path>) -> std::io::Result<std::fs::File> {
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::File::open(path)
|
||||
}
|
||||
@@ -49,6 +98,7 @@ pub fn open(path: impl AsRef<Path>) -> std::io::Result<std::fs::File> {
|
||||
/// The text of the file at `path`, inside the automation read root when there is one.
|
||||
pub fn read_to_string(path: impl AsRef<Path>) -> Result<String, String> {
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_read(path)?;
|
||||
std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
@@ -56,6 +106,7 @@ pub fn read_to_string(path: impl AsRef<Path>) -> Result<String, String> {
|
||||
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
|
||||
pub fn write_file(path: impl AsRef<Path>, bytes: &[u8]) -> Result<(), String> {
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_write(path)?;
|
||||
// Replaced atomically where there is a real file system (see `atomic`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -71,6 +122,7 @@ pub fn write_file(path: impl AsRef<Path>, bytes: &[u8]) -> Result<(), String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn replace_with(path: impl AsRef<Path>, staged: impl AsRef<Path>) -> std::io::Result<()> {
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
let mut input = std::fs::File::open(staged)?;
|
||||
crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ()))
|
||||
@@ -80,6 +132,7 @@ pub fn replace_with(path: impl AsRef<Path>, staged: impl AsRef<Path>) -> std::io
|
||||
/// write root when there is one.
|
||||
pub fn writable(path: impl AsRef<Path>) -> std::io::Result<()> {
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::OpenOptions::new().write(true).open(path).map(drop)
|
||||
}
|
||||
@@ -88,6 +141,7 @@ pub fn writable(path: impl AsRef<Path>) -> std::io::Result<()> {
|
||||
/// one.
|
||||
pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<std::path::PathBuf> {
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::canonicalize(path)
|
||||
}
|
||||
@@ -96,6 +150,7 @@ pub fn canonicalize(path: impl AsRef<Path>) -> std::io::Result<std::path::PathBu
|
||||
/// there is one. A refused path is a `PermissionDenied` error carrying the roots' message.
|
||||
pub fn metadata(path: impl AsRef<Path>) -> std::io::Result<Metadata> {
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::metadata(path)
|
||||
}
|
||||
@@ -118,6 +173,7 @@ pub fn exists(path: impl AsRef<Path>) -> bool {
|
||||
/// The entries of the folder at `path`, inside the automation read root when there is one.
|
||||
pub fn read_dir(path: impl AsRef<Path>) -> std::io::Result<std::fs::ReadDir> {
|
||||
let path = path.as_ref();
|
||||
touch(path);
|
||||
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
std::fs::read_dir(path)
|
||||
}
|
||||
|
||||
@@ -744,6 +744,8 @@ mod tests_idml;
|
||||
#[cfg(test)]
|
||||
mod tests_ime;
|
||||
#[cfg(test)]
|
||||
mod tests_linked_paths;
|
||||
#[cfg(test)]
|
||||
mod tests_params;
|
||||
#[cfg(test)]
|
||||
mod tests_roots;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Paths stored in documents (links, data sources, book chapters) are followed only where
|
||||
//! [`crate::io::document_path`] allows: never to another computer, and resolved beside the
|
||||
//! document. Every path the engine hands the file system is recorded by [`crate::io::touched`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const UNC: &str = r"\\192.0.2.1\share\x.png";
|
||||
const FILE_URL: &str = "file://192.0.2.1/s/x.png";
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png()
|
||||
}
|
||||
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dc-linked-paths-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A document saved as `doc` with one placed graphic per link, linked to it.
|
||||
fn document_with_links(doc: &Path, links: &[&str]) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for (i, link) in links.iter().enumerate() {
|
||||
s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png()), "name": format!("{i}.png"), "x": 72 * i, "y": 72})).unwrap();
|
||||
let link = link.to_string();
|
||||
s.edit(move |d, _| {
|
||||
let a = d.assets.values_mut().find(|a| a.link.is_none() && a.name == format!("{i}.png")).unwrap();
|
||||
std::sync::Arc::make_mut(a).link = Some(link);
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.doc_mut().unwrap().path = Some(doc.to_string_lossy().into_owned());
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_to_other_computers_never_reach_the_file_system() {
|
||||
let dir = folder("remote");
|
||||
let doc = dir.join("doc.designcraft");
|
||||
let mut s = document_with_links(&doc, &[UNC, FILE_URL]);
|
||||
let (rows, seen) = crate::io::touched(|| {
|
||||
let rows = s.execute("links.list", &json!({})).unwrap();
|
||||
let issues = s.execute("preflight.run", &json!({})).unwrap();
|
||||
s.execute("links.relinkFolder", &json!({"extension": "jpg"})).unwrap();
|
||||
s.execute("links.update", &json!({})).unwrap();
|
||||
(rows, issues)
|
||||
});
|
||||
assert!(seen.is_empty(), "{seen:?}");
|
||||
let (rows, issues) = rows;
|
||||
for row in rows.as_array().unwrap() {
|
||||
assert_eq!(row["status"], "missing", "{row}");
|
||||
assert_eq!(row["refused"], "the path is on another computer", "{row}");
|
||||
}
|
||||
assert_eq!(issues.to_string().matches("Missing link").count(), 2, "{issues}");
|
||||
|
||||
// Data sources and book chapters stored the same way.
|
||||
let mut d = s.doc().unwrap().doc.as_ref().clone();
|
||||
d.data_merge.sources.push(designcraft_doc::DataSource {
|
||||
id: 1,
|
||||
path: Some(UNC.replace("x.png", "data.csv")),
|
||||
relative_path: Some(FILE_URL.replace("x.png", "data.csv")),
|
||||
name: "data.csv".into(),
|
||||
delimiter: designcraft_doc::Delimiter::Comma,
|
||||
sheet: None,
|
||||
fields: vec![],
|
||||
rows: vec![],
|
||||
fingerprint: None,
|
||||
status: designcraft_doc::SourceStatus::Ok,
|
||||
warnings: vec![],
|
||||
});
|
||||
std::fs::write(&doc, designcraft_format::save(&d).unwrap()).unwrap();
|
||||
let mut opened = Session::new();
|
||||
let ((), seen) = crate::io::touched(|| {
|
||||
opened.execute("file.open", &json!({"path": doc.to_string_lossy()})).unwrap();
|
||||
opened.execute("links.list", &json!({})).unwrap();
|
||||
});
|
||||
// The document itself and the folders beside it, nothing else.
|
||||
assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}");
|
||||
assert_eq!(opened.doc().unwrap().doc.data_merge.sources[0].status, designcraft_doc::SourceStatus::Missing);
|
||||
let book = dir.join("b.dcbook");
|
||||
std::fs::write(&book, json!({"documents": [UNC.replace("x.png", "c.designcraft"), FILE_URL.replace("x.png", "c.designcraft")]}).to_string())
|
||||
.unwrap();
|
||||
s.execute("book.open", &json!({"path": book.to_string_lossy()})).unwrap();
|
||||
let (list, seen) = crate::io::touched(|| s.execute("book.list", &json!({})).unwrap());
|
||||
assert!(seen.is_empty(), "{seen:?}");
|
||||
assert_eq!(list["documents"][0]["pages"], 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_link_resolves_beside_the_document() {
|
||||
let dir = folder("relative");
|
||||
std::fs::write(dir.join("logo.png"), png()).unwrap();
|
||||
let mut s = document_with_links(&dir.join("doc.designcraft"), &["logo.png"]);
|
||||
let (rows, seen) = crate::io::touched(|| s.execute("links.list", &json!({})).unwrap());
|
||||
assert_eq!(rows[0]["status"], "ok", "{rows}");
|
||||
assert!(rows[0]["refused"].is_null());
|
||||
assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -71,9 +71,10 @@ fn idml_links_are_read_through_the_roots() {
|
||||
// Linked, not embedded: opening the package has to read the file its link names.
|
||||
let idml = base64_idml(&mut s);
|
||||
let linked = |doc: &designcraft_doc::Document| doc.assets.values().filter(|a| !a.data.is_empty()).count();
|
||||
let unconfined = cmd::interchange::import_idml_confined(&idml).unwrap();
|
||||
let package = base.join("outside/x.idml");
|
||||
let unconfined = cmd::interchange::import_idml_confined(&idml, Some(&package)).unwrap();
|
||||
assert_eq!(linked(&unconfined), 1, "without roots the link is followed");
|
||||
let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml)).unwrap();
|
||||
let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml, Some(&package))).unwrap();
|
||||
assert_eq!(linked(&confined), 0, "with roots a link outside the read root is not read");
|
||||
// The same through the commands that open or place a package.
|
||||
let b64 = cmd::base64_encode(&idml);
|
||||
|
||||
@@ -3710,9 +3710,23 @@ fn wrap_path_of(pg: &El) -> std::result::Result<PathData, &'static str> {
|
||||
Ok(PathData::new(subs))
|
||||
}
|
||||
|
||||
/// IDML link URI → file system path (`file:/a%20b` → `/a b`, `file:///C:/x` → `C:/x`).
|
||||
/// IDML link URI → file system path (`file:/a%20b` → `/a b`, `file:///C:/x` → `C:/x`). A URI
|
||||
/// naming another computer keeps it as a network path (`file://host/s/x` → `//host/s/x`).
|
||||
pub(crate) fn uri_to_path(uri: &str) -> String {
|
||||
let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri);
|
||||
let rest = match uri.strip_prefix("file://") {
|
||||
Some(rest) => {
|
||||
let host_end = rest.find(['/', '\\']).unwrap_or(rest.len());
|
||||
let host = rest.get(..host_end).unwrap_or_default();
|
||||
if host.is_empty() || host.eq_ignore_ascii_case("localhost") {
|
||||
rest.get(host_end..).unwrap_or(rest)
|
||||
} else if matches!(host.as_bytes(), [letter, b':'] if letter.is_ascii_alphabetic()) {
|
||||
rest
|
||||
} else {
|
||||
return format!("//{}", percent_decode(rest));
|
||||
}
|
||||
}
|
||||
None => uri.strip_prefix("file:").unwrap_or(uri),
|
||||
};
|
||||
// `file:///C:/…` → `/C:/…` → `C:/…`
|
||||
let rest = match rest.as_bytes() {
|
||||
[b'/', _, b':', _, ..] => rest.get(1..).unwrap_or(rest),
|
||||
|
||||
@@ -1022,6 +1022,10 @@ fn round_trips_overridden_parent_items() {
|
||||
fn uri_and_base64_helpers() {
|
||||
assert_eq!(import::uri_to_path("file:/a%20b/c.png"), "/a b/c.png");
|
||||
assert_eq!(import::uri_to_path("file:///C:/x/y.jpg"), "C:/x/y.jpg");
|
||||
assert_eq!(import::uri_to_path("file://localhost/a/b.png"), "/a/b.png");
|
||||
assert_eq!(import::uri_to_path("file://C:/x/y.jpg"), "C:/x/y.jpg");
|
||||
// Another computer stays one: a network path, not a folder named after it.
|
||||
assert_eq!(import::uri_to_path("file://192.0.2.1/s/x%20y.png"), "//192.0.2.1/s/x y.png");
|
||||
assert_eq!(export::path_to_uri("/a b/c.png"), "file:/a%20b/c.png");
|
||||
let data: Vec<u8> = (0..=255u8).cycle().take(1000).collect();
|
||||
assert_eq!(base64_decode(&base64_encode(&data)), data);
|
||||
|
||||
@@ -2360,7 +2360,11 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
"tooLarge" => ("Too large to check", egui::Color32::from_rgb(0xe8, 0xb3, 0x2c)),
|
||||
_ => ("OK", t.text_dim),
|
||||
};
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, txt)).color(col).size(11.0));
|
||||
let status = crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, txt)).color(col).size(11.0));
|
||||
// Why a link isn't followed (a path on another computer, a device).
|
||||
if let Some(why) = r["refused"].as_str() {
|
||||
status.on_hover_text(why);
|
||||
}
|
||||
let uses = r["uses"].as_array().cloned().unwrap_or_default();
|
||||
let info = match uses.first() {
|
||||
Some(u) => {
|
||||
|
||||
@@ -422,6 +422,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded.
|
||||
- Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML).
|
||||
- Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name).
|
||||
- Paths a document names (links, data sources, book chapters) are followed only on this computer or on the document's own network share, relative ones beside the document; a link to another computer or a device is Missing, with the reason in `links.list` (`refused`) and Preflight. An IDML or INDD package's linked files are read from where the link points on this computer, then from the link's trailing names beside the package, and only regular files up to 256 MB that hold an image or a graphic. Images inside an SVG come only from `data:` URLs.
|
||||
- File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`).
|
||||
- Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read.
|
||||
- Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`.
|
||||
|
||||
Reference in New Issue
Block a user