mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-30 13:16:56 -06:00
Merge storytold/main into fonts/reload-changed-fonts
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -354,7 +354,7 @@ fn place_image(
|
||||
report.warnings.push(format!("Record {record}, field {field}: missing image {cell}."));
|
||||
return;
|
||||
};
|
||||
let Some((pw, ph)) = designcraft_render::image_size(&bytes) else {
|
||||
let Some(((pw, ph), (nw, nh))) = designcraft_render::image_size(&bytes).zip(designcraft_render::natural_size(&bytes, 0)) else {
|
||||
if let Some(it) = doc.item_mut(item) {
|
||||
it.content = Content::Unassigned;
|
||||
}
|
||||
@@ -367,22 +367,9 @@ fn place_image(
|
||||
report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() });
|
||||
return;
|
||||
};
|
||||
let (nw, nh) = (f64::from(pw), f64::from(ph));
|
||||
let mode = fitting_of(&options.fitting);
|
||||
let fresh = AssetId(doc.alloc());
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string());
|
||||
let link = options.link_images.then(|| path.to_string_lossy().into_owned());
|
||||
let aid = doc.add_asset(Asset {
|
||||
id: fresh,
|
||||
name,
|
||||
mime: designcraft_render::image_mime(&bytes).to_string(),
|
||||
link,
|
||||
data: Arc::new(bytes),
|
||||
pixels: Some((pw, ph)),
|
||||
..Default::default()
|
||||
});
|
||||
let mut g = Graphic {
|
||||
asset: aid,
|
||||
asset: AssetId(0),
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((inner.x0, inner.y0)),
|
||||
auto_fit: mode,
|
||||
@@ -401,6 +388,27 @@ fn place_image(
|
||||
let m = bb.center();
|
||||
g.xf = Affine::translate((c.x - m.x, c.y - m.y)) * g.xf;
|
||||
}
|
||||
// Reject geometry that cannot be represented before an asset is allocated for it.
|
||||
if super::super::file::validate_placement((nw, nh), inner, g.xf, "data.merge").is_err() {
|
||||
if let Some(it) = doc.item_mut(item) {
|
||||
it.content = Content::Unassigned;
|
||||
}
|
||||
report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() });
|
||||
report.warnings.push(format!("Record {record}, field {field}: image {cell} exceeds finite geometry limits."));
|
||||
return;
|
||||
}
|
||||
let fresh = AssetId(doc.alloc());
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string());
|
||||
let link = options.link_images.then(|| path.to_string_lossy().into_owned());
|
||||
g.asset = doc.add_asset(Asset {
|
||||
id: fresh,
|
||||
name,
|
||||
mime: designcraft_render::image_mime(&bytes).to_string(),
|
||||
link,
|
||||
data: Arc::new(bytes),
|
||||
pixels: Some((pw, ph)),
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(it) = doc.item_mut(item) {
|
||||
it.content = Content::Graphic(g);
|
||||
}
|
||||
@@ -489,3 +497,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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use designcraft_pdf::{Marks, PdfOptions, Standard};
|
||||
use serde_json::{Value, json};
|
||||
use std::{borrow::Cow, sync::Arc};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -23,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{path?, type?: saddleStitch|twoUpConsecutive, spaceBetween? (pt)} — printer spreads as PDF (pages imposed in booklet order) → {path, bytes, sheets} (no path: {base64, …})",
|
||||
has_doc, print_booklet),
|
||||
cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None,
|
||||
"{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes} (no path: {text, bytes})",
|
||||
"{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes, warnings} (no path: {text, bytes, warnings})",
|
||||
has_doc, export_html),
|
||||
cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None,
|
||||
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})",
|
||||
@@ -55,6 +56,39 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// HTML and reflowable EPUB embed the EPS preview; keep the session's source bytes intact.
|
||||
fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Document>, Vec<String>) {
|
||||
// Match the exporter's reading order (including Article exclusions), not every asset
|
||||
// in the document: unused or hidden graphics must not prevent an export.
|
||||
let mut used = std::collections::BTreeSet::new();
|
||||
for block in designcraft_epub::reading_order(d) {
|
||||
let designcraft_epub::Block::Image(id) = block else { continue };
|
||||
let Some(it) = d.item(id) else { continue };
|
||||
let designcraft_doc::Content::Graphic(g) = &it.content else { continue };
|
||||
used.insert(g.asset);
|
||||
if d.assets.get(&g.asset).is_some_and(|a| designcraft_doc::media_kind(&a.mime).is_some())
|
||||
&& let Some(poster) = it.media.as_ref().and_then(|m| m.poster)
|
||||
{
|
||||
used.insert(poster);
|
||||
}
|
||||
}
|
||||
let mut out = Cow::Borrowed(d);
|
||||
let mut warnings = Vec::new();
|
||||
for id in used {
|
||||
let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else { continue };
|
||||
let png = designcraft_images::eps_png(&a.data);
|
||||
if png.is_none() {
|
||||
warnings.push(format!("{}: unusable EPS preview; graphic omitted", a.name));
|
||||
}
|
||||
if let Some(a) = out.to_mut().assets.get_mut(&id) {
|
||||
let a = Arc::make_mut(a);
|
||||
a.mime = "image/png".into();
|
||||
a.data = Arc::new(png.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
(out, warnings)
|
||||
}
|
||||
|
||||
fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let opts = designcraft_epub::HtmlOptions {
|
||||
@@ -62,14 +96,15 @@ fn export_html(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
language: str_param(p, "language").map(str::to_string),
|
||||
generic_families: generic_families(&st.doc),
|
||||
};
|
||||
let text = designcraft_epub::export_html(&st.doc, &opts);
|
||||
let (doc, warnings) = eps_previews(&st.doc);
|
||||
let text = designcraft_epub::export_html(&doc, &opts);
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?;
|
||||
Ok(json!({"path": path, "bytes": text.len()}))
|
||||
Ok(json!({"path": path, "bytes": text.len(), "warnings": warnings}))
|
||||
}
|
||||
None => Ok(json!({"bytes": text.len(), "text": text})),
|
||||
None => Ok(json!({"bytes": text.len(), "text": text, "warnings": warnings})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,9 +300,11 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => Ok(serde_json::json!({"base64": super::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages.len()})),
|
||||
};
|
||||
}
|
||||
let bytes = designcraft_epub::export_epub(epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
let (epub_doc, eps_warnings) = eps_previews(epub_doc);
|
||||
warnings.extend(eps_warnings);
|
||||
let bytes = designcraft_epub::export_epub(&epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
warnings.extend(
|
||||
designcraft_epub::images_without_alt(epub_doc)
|
||||
designcraft_epub::images_without_alt(&epub_doc)
|
||||
.into_iter()
|
||||
.map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -608,13 +608,8 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if lname.ends_with(".idml") || lname.ends_with(".designcraft") {
|
||||
return place_layout_page(s, p, &name, &bytes);
|
||||
}
|
||||
// EPS: shown and printed through its preview (or a placeholder) at its bounding box size.
|
||||
let (bytes, eps_size) = if designcraft_images::is_eps(&bytes) {
|
||||
let (proxy, size) = designcraft_images::eps_proxy(&bytes).ok_or_else(|| bad("file.place", "the EPS has no bounding box"))?;
|
||||
(proxy, Some(size))
|
||||
} else {
|
||||
(bytes, None)
|
||||
};
|
||||
// Keep the EPS source for link comparison; rendering uses its preview at the bounding box size.
|
||||
let eps_size = eps_size(&bytes, "file.place")?;
|
||||
// Video and sound: a media frame (Window › Interactive › Media).
|
||||
if let Some(mime) = designcraft_doc::media_mime(&name) {
|
||||
return super::media::place_media(s, p, name, mime, bytes, link);
|
||||
@@ -705,10 +700,12 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops.
|
||||
let k = (r.width() / bw).max(r.height() / bh);
|
||||
let (gw, gh) = (bw * k, bh * k);
|
||||
let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k);
|
||||
validate_placement((bw, bh), r, xf, "file.place")?;
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: aid,
|
||||
size: (bw, bh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k),
|
||||
xf,
|
||||
clip: None,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
@@ -724,6 +721,8 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = ItemId(d.alloc());
|
||||
let k = w / nw;
|
||||
let r = super::finite_rect("file.place", Rect::new(x, y, x + bw * k, y + bh * k))?;
|
||||
let xf = Affine::translate((x, y)) * Affine::scale(k);
|
||||
validate_placement((bw, bh), r, xf, "file.place")?;
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
|
||||
it.object_style = d.styles.default_graphic_frame.clone();
|
||||
if let Some(e) = d.styles.object_effects(&it.object_style) {
|
||||
@@ -732,7 +731,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: aid,
|
||||
size: (bw, bh),
|
||||
xf: Affine::translate((x, y)) * Affine::scale(k),
|
||||
xf,
|
||||
clip: None,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
@@ -811,10 +810,35 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)
|
||||
Ok((b, name, Some(path.to_string())))
|
||||
}
|
||||
|
||||
fn eps_size(bytes: &[u8], command: &str) -> Result<Option<(f64, f64)>> {
|
||||
if !designcraft_images::is_eps(bytes) {
|
||||
return Ok(None);
|
||||
}
|
||||
designcraft_images::eps_size(bytes)
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(command, "the EPS has a missing or invalid bounding box (dimensions must be 0.001–20,000 points)"))
|
||||
}
|
||||
|
||||
/// Fitting can overflow even with finite inputs. Never persist NaN/Inf as JSON nulls.
|
||||
pub(crate) fn validate_placement(size: (f64, f64), frame: Rect, xf: Affine, command: &str) -> Result<()> {
|
||||
let valid_rect = |r: Rect| [r.x0, r.y0, r.x1, r.y1, r.width(), r.height()].iter().all(|n| n.is_finite()) && r.width() >= 0.0 && r.height() >= 0.0;
|
||||
if !size.0.is_finite()
|
||||
|| !size.1.is_finite()
|
||||
|| size.0 <= 0.0
|
||||
|| size.1 <= 0.0
|
||||
|| !valid_rect(frame)
|
||||
|| !xf.as_coeffs().iter().all(|n| n.is_finite())
|
||||
|| !valid_rect(xf.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1)))
|
||||
{
|
||||
return Err(bad(command, "image placement exceeds finite geometry limits"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn place_load(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (bytes, name, link) = read_source(p)?;
|
||||
let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?;
|
||||
let natural = designcraft_render::natural_size(&bytes, 0).unwrap_or((pw as f64, ph as f64));
|
||||
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, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() };
|
||||
load_cursor(s, asset, natural)
|
||||
@@ -845,55 +869,53 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let frame = super::id_param(p, "frame");
|
||||
let rect = super::rect_param(p, "rect");
|
||||
let (x, y) = (super::f64_or(p, "x", 0.0), super::f64_or(p, "y", 0.0));
|
||||
let d = &s.doc()?.doc;
|
||||
let (frame_rect, xf, auto_fit) = if let Some(fid) = frame {
|
||||
let r = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.inner_bounds();
|
||||
let k = (r.width() / nw).max(r.height() / nh);
|
||||
let xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k);
|
||||
// Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops.
|
||||
(r, xf, Default::default())
|
||||
} else {
|
||||
if d.spread(sr).is_none() {
|
||||
return Err(bad("place.drop", "no such spread"));
|
||||
}
|
||||
// Drag: fit proportionally; click: actual size at the point.
|
||||
let (r, k) = match rect {
|
||||
Some(r) if r.width() > 2.0 && r.height() > 2.0 => {
|
||||
let k = (r.width() / nw).min(r.height() / nh);
|
||||
(Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k)
|
||||
}
|
||||
_ => (Rect::new(x, y, x + nw, y + nh), 1.0),
|
||||
};
|
||||
(r, Affine::translate((r.x0, r.y0)) * Affine::scale(k), Default::default())
|
||||
};
|
||||
validate_placement((nw, nh), frame_rect, xf, "place.drop")?;
|
||||
let r = s.edit(|d, sel| {
|
||||
let id = match frame {
|
||||
Some(fid) => {
|
||||
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
|
||||
let r = it.inner_bounds();
|
||||
let k = (r.width() / nw).max(r.height() / nh);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k),
|
||||
clip: None,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
});
|
||||
fid
|
||||
}
|
||||
None => {
|
||||
// Drag: fit proportionally into the dragged rect; click: actual size at the point.
|
||||
let (frame_r, k) = match rect {
|
||||
Some(r) if r.width() > 2.0 && r.height() > 2.0 => {
|
||||
let k = (r.width() / nw).min(r.height() / nh);
|
||||
(Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k)
|
||||
}
|
||||
_ => (Rect::new(x, y, x + nw, y + nh), 1.0),
|
||||
};
|
||||
let frame_r = super::finite_rect("place.drop", frame_r)?;
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_r));
|
||||
it.object_style = d.styles.default_graphic_frame.clone();
|
||||
if let Some(e) = d.styles.object_effects(&it.object_style) {
|
||||
it.effects = e.clone();
|
||||
}
|
||||
it.content = Content::Graphic(Graphic {
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((frame_r.x0, frame_r.y0)) * Affine::scale(k),
|
||||
clip: None,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
});
|
||||
d.insert_item(sr, it, None)?;
|
||||
id
|
||||
let graphic = Content::Graphic(Graphic {
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf,
|
||||
clip: None,
|
||||
auto_fit,
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
});
|
||||
let id = if let Some(fid) = frame {
|
||||
d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.content = graphic;
|
||||
fid
|
||||
} else {
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_rect));
|
||||
it.object_style = d.styles.default_graphic_frame.clone();
|
||||
if let Some(e) = d.styles.object_effects(&it.object_style) {
|
||||
it.effects = e.clone();
|
||||
}
|
||||
it.content = graphic;
|
||||
d.insert_item(sr, it, None)?;
|
||||
id
|
||||
};
|
||||
*sel = Selection::items(vec![id]);
|
||||
Ok(json!({"id": id.0}))
|
||||
@@ -926,7 +948,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 +1073,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)?)?
|
||||
};
|
||||
@@ -1438,6 +1460,9 @@ mod new_document_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod eps_place_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod pdf_crop_tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Placing EPS artwork: the source is kept and only the preview is rasterised.
|
||||
//!
|
||||
//! These helpers write real files, so they live in their own `_tests.rs` file: the
|
||||
//! file-system guard in `tests_file_guard.rs` checks `src` files but exempts test ones.
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn tiff_eps() -> Vec<u8> {
|
||||
let mut tiff = b"II".to_vec();
|
||||
tiff.extend(42u16.to_le_bytes());
|
||||
tiff.extend(8u32.to_le_bytes());
|
||||
tiff.extend(10u16.to_le_bytes());
|
||||
for (tag, ty, count, value) in [
|
||||
(256u16, 4u16, 1u32, 2u32),
|
||||
(257, 4, 1, 2),
|
||||
(258, 3, 3, 134),
|
||||
(259, 3, 1, 1),
|
||||
(262, 3, 1, 2),
|
||||
(273, 4, 1, 140),
|
||||
(277, 3, 1, 3),
|
||||
(278, 4, 1, 2),
|
||||
(279, 4, 1, 12),
|
||||
(284, 3, 1, 1),
|
||||
] {
|
||||
tiff.extend(tag.to_le_bytes());
|
||||
tiff.extend(ty.to_le_bytes());
|
||||
tiff.extend(count.to_le_bytes());
|
||||
tiff.extend(value.to_le_bytes());
|
||||
}
|
||||
tiff.extend(0u32.to_le_bytes());
|
||||
for _ in 0..3 {
|
||||
tiff.extend(8u16.to_le_bytes());
|
||||
}
|
||||
tiff.extend([255, 0, 0].repeat(4));
|
||||
let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EOF\n";
|
||||
let mut eps = vec![0xC5, 0xD0, 0xD3, 0xC6];
|
||||
for n in [30usize, ps.len(), 0, 0, 30 + ps.len(), tiff.len()] {
|
||||
eps.extend((n as u32).to_le_bytes());
|
||||
}
|
||||
eps.extend([255, 255]);
|
||||
eps.extend(ps);
|
||||
eps.extend(tiff);
|
||||
eps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_degenerate_bounds_are_rejected_without_edits() {
|
||||
for bounds in ["0 0 1e-320 1e-320", "0 0 1e308 1e308", "0 0 inf 10"] {
|
||||
let source = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: {bounds}\n%%EOF\n");
|
||||
for command in ["file.place", "place.load"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
assert!(
|
||||
s.execute(command, &json!({"base64": base64_encode(source.as_bytes()), "name": "bad.eps", "width": 100})).is_err(),
|
||||
"{command}: {bounds}"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
assert_eq!(s.doc().unwrap().revision, revision);
|
||||
assert!(s.loaded.is_none());
|
||||
designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_fitting_overflow_is_rejected_before_document_edits() {
|
||||
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 0.01 0.01\n%%EOF\n";
|
||||
let params = json!({"base64": base64_encode(source), "name": "small.eps"});
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
assert!(s.execute("file.place", &json!({"base64": base64_encode(source), "name": "small.eps", "width": 1e308})).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
s.execute("place.load", ¶ms).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
assert!(s.execute("place.drop", &json!({"rect": [0, 0, 1e308, 1e308]})).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
s.execute("place.drop", &json!({"rect": [72, 72, 172, 122]})).unwrap();
|
||||
designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_table_and_data_merge_reject_fitting_overflow() {
|
||||
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 0.01 0.01\n%%EOF\n";
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect":[72,72,372,272],"content":"text"})).unwrap();
|
||||
s.execute("text.select", &json!({"story":r["story"],"anchor":0,"focus":0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows":1,"cols":1,"width":1e308})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
assert!(s.execute("table.placeGraphic", &json!({"base64":base64_encode(source),"name":"small.eps","fit":"fill"})).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
designcraft_format::load(&designcraft_format::save(&before).unwrap()).unwrap();
|
||||
|
||||
let path = std::env::temp_dir().join(format!("dc-eps-fit-{}.eps", std::process::id()));
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("data.source.select", &json!({"bytes":base64_encode(format!("@Photo\n{}\n",path.display()).as_bytes()),"name":"source.csv"})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect":[0,0,1e308,100],"content":"graphic"})).unwrap();
|
||||
s.execute("data.placeholder.add", &json!({"field":"Photo","item":r["id"]})).unwrap();
|
||||
let result = s.execute("data.merge", &json!({"fitting":"fillProportionally"})).unwrap();
|
||||
assert_eq!(result["missingImages"].as_array().unwrap().len(), 1);
|
||||
assert!(s.doc().unwrap().doc.assets.is_empty());
|
||||
designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_table_and_data_merge_use_bounding_box_points() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-eps-routes-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (index, bytes) in [b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EOF\n".to_vec(), tiff_eps()].into_iter().enumerate() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72,72,372,272], "content":"text"})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor":0, "focus":0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows":1,"cols":1})).unwrap();
|
||||
s.execute("table.placeGraphic", &json!({"base64": base64_encode(&bytes), "name":"logo.eps"})).unwrap();
|
||||
let table = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap();
|
||||
assert_eq!(table.cells[0].graphic.as_ref().unwrap().size, (100.0, 50.0), "table {index}");
|
||||
let path = dir.join(format!("logo-{index}.eps"));
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("data.source.select", &json!({"bytes": base64_encode(format!("@Photo\n{}\n", path.display()).as_bytes()), "name":"source.csv"}))
|
||||
.unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect":[72,72,372,272],"content":"graphic"})).unwrap();
|
||||
s.execute("data.placeholder.add", &json!({"field":"Photo","item":r["id"]})).unwrap();
|
||||
let result = s.execute("data.merge", &json!({"fitting":"none"})).unwrap();
|
||||
assert!(result["missingImages"].as_array().unwrap().is_empty());
|
||||
let g = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.doc
|
||||
.spreads
|
||||
.iter()
|
||||
.flat_map(|sp| &sp.items)
|
||||
.find_map(|it| if let Content::Graphic(g) = &it.content { Some(g) } else { None })
|
||||
.unwrap();
|
||||
assert_eq!(g.size, (100.0, 50.0), "merge {index}");
|
||||
designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_idml_roundtrip_preserves_source_and_resource_type() {
|
||||
use std::io::Read;
|
||||
for bytes in [b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%EOF\n".to_vec(), tiff_eps()] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"base64":base64_encode(&bytes),"name":"logo.eps"})).unwrap();
|
||||
let source = s.doc().unwrap().doc.clone();
|
||||
let idml = designcraft_idml::export_idml(&source);
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&idml)).unwrap();
|
||||
let names: Vec<_> = z.file_names().filter(|n| n.starts_with("Spreads/")).map(str::to_string).collect();
|
||||
let mut xml = String::new();
|
||||
for name in names {
|
||||
z.by_name(&name).unwrap().read_to_string(&mut xml).unwrap();
|
||||
}
|
||||
assert!(xml.contains("<EPS "), "EPS resource element missing");
|
||||
assert!(xml.contains("Encapsulated PostScript (EPS)"));
|
||||
assert!(!xml.contains("Portable Network Graphics (PNG)"));
|
||||
let restored = designcraft_idml::import_idml(&idml).unwrap();
|
||||
let a = restored.assets.values().next().unwrap();
|
||||
assert_eq!(a.mime, "application/postscript");
|
||||
assert_eq!(a.data.as_slice(), bytes);
|
||||
assert!(designcraft_render::decode_pixmap(&a.data).is_some());
|
||||
// A linked-only EPS keeps its type even when the external file is unavailable;
|
||||
// when the resolver supplies it, sniff the raw source rather than PNG metadata.
|
||||
let mut linked = (*source).clone();
|
||||
let a = Arc::make_mut(linked.assets.values_mut().next().unwrap());
|
||||
a.link = Some("/synthetic/logo.eps".into());
|
||||
let idml = designcraft_idml::export_idml_with(&linked, &designcraft_idml::ExportOptions { embed_images: false });
|
||||
let missing = designcraft_idml::import_idml_with(&idml, &|_| None).unwrap();
|
||||
let a = missing.assets.values().next().unwrap();
|
||||
assert_eq!(a.mime, "application/postscript");
|
||||
assert!(a.data.is_empty());
|
||||
let resolved = designcraft_idml::import_idml_with(&idml, &|path| {
|
||||
assert_eq!(path, "/synthetic/logo.eps");
|
||||
Some(bytes.clone())
|
||||
})
|
||||
.unwrap();
|
||||
let a = resolved.assets.values().next().unwrap();
|
||||
assert_eq!(a.mime, "application/postscript");
|
||||
assert_eq!(a.data.as_slice(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_place_cursor_retains_source_and_uses_bounding_box_size() {
|
||||
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%HiResBoundingBox: 0 0 100.5 50.25\n%%EOF\n";
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let result = s.execute("place.load", &json!({"base64": base64_encode(source), "name": "logo.eps"})).unwrap();
|
||||
let aid = AssetId(result["asset"].as_u64().unwrap());
|
||||
assert_eq!(s.doc().unwrap().doc.assets[&aid].data.as_slice(), source);
|
||||
assert_eq!(s.doc().unwrap().doc.assets[&aid].mime, "application/postscript");
|
||||
assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((201, 101)));
|
||||
let result = s.execute("place.drop", &json!({"x": 72, "y": 72})).unwrap();
|
||||
let it = s.doc().unwrap().doc.item(ItemId(result["id"].as_u64().unwrap())).unwrap();
|
||||
let Content::Graphic(g) = &it.content else { panic!("expected a graphic") };
|
||||
assert_eq!(g.size, (100.5, 50.25));
|
||||
assert_eq!((it.bounds().width(), it.bounds().height()), g.size);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for invalid in ["%!PS-Adobe-3.0 EPSF-3.0\nno bounding box\n", "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 inf 10\n"] {
|
||||
let corrupt = json!({"base64": base64_encode(invalid.as_bytes()), "name": "bad.eps"});
|
||||
assert!(s.execute("place.load", &corrupt).is_err());
|
||||
assert!(s.execute("file.place", &corrupt).is_err());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
+579
-83
@@ -7,10 +7,16 @@
|
||||
//!
|
||||
//! 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};
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::{AssetId, Content, Document, Fingerprint, Item, ItemId, Selection, Story, StoryId, TextFileLink};
|
||||
use designcraft_geom::Affine;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
@@ -18,10 +24,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, &s.cache, d.path.as_deref().map(Path::new), &mut checks));
|
||||
s.text_link_checks = checks;
|
||||
Ok(Value::Array(links?))
|
||||
}),
|
||||
@@ -30,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Relink…",
|
||||
["Window", "Links"],
|
||||
None,
|
||||
"{asset | story, path} — use another file for every placement of the graphic, or import another text file into the linked story",
|
||||
"{asset | story, path} — use another file for every placement of the graphic (keeps the selected PDF page; error if missing), or import another text file into the linked story",
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
@@ -126,31 +132,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 +181,7 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str {
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
let _ = (link, document);
|
||||
"ok"
|
||||
}
|
||||
}
|
||||
@@ -196,14 +218,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 +239,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 +255,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,40 +264,93 @@ 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> {
|
||||
/// A graphic showing an asset: the item, its page, and the transform from its parent's space to
|
||||
/// the spread.
|
||||
struct Shown<'a> {
|
||||
item: &'a Item,
|
||||
page: Option<usize>,
|
||||
parent_xf: Affine,
|
||||
}
|
||||
|
||||
/// Where an asset shows: frames on spreads (see [`uses`]) and anchored or inline objects, whose
|
||||
/// page and position come from the frame their story lays them out in.
|
||||
fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec<Shown<'a>> {
|
||||
fn walk<'a>(it: &'a Item, xf: Affine, aid: AssetId, page: Option<usize>, out: &mut Vec<Shown<'a>>) {
|
||||
if let Content::Graphic(g) = &it.content
|
||||
&& g.asset == aid
|
||||
{
|
||||
out.push(Shown { item: it, page, parent_xf: xf });
|
||||
}
|
||||
for c in it.children() {
|
||||
walk(c, xf * it.xf, aid, page, out);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<Shown> = uses(d, aid)
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let loc = d.find(id)?;
|
||||
Some(Shown { item: d.item_at(&loc)?, page: d.page_of_item(id), parent_xf: d.parent_xf(&loc) })
|
||||
})
|
||||
.collect();
|
||||
for st in d.stories.values() {
|
||||
let mut composed = None;
|
||||
for (k, o) in st.objects.iter().enumerate() {
|
||||
let mut found = false;
|
||||
o.item.walk(&mut |i| found |= matches!(&i.content, Content::Graphic(g) if g.asset == aid));
|
||||
if !found {
|
||||
continue;
|
||||
}
|
||||
let cs = composed.get_or_insert_with(|| cache.get(d, st.id, None));
|
||||
let placed = cs.frames.iter().find_map(|ft| ft.objects.iter().find(|p| p.index == k).map(|p| (ft.frame, p.origin)));
|
||||
// Overset: no page; the resolution of the object as it would show.
|
||||
let (page, xf) = match placed {
|
||||
Some((fid, origin)) => {
|
||||
let frame_xf =
|
||||
d.find(fid).map_or(Affine::IDENTITY, |loc| d.parent_xf(&loc)) * d.item(fid).map_or(Affine::IDENTITY, |i| d.text_xf(i));
|
||||
(d.page_of_item(fid), frame_xf * Affine::translate(origin.to_vec2()))
|
||||
}
|
||||
None => (None, Affine::IDENTITY),
|
||||
};
|
||||
walk(&o.item, xf, aid, page, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn list(d: &Document, cache: &Cache, 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
|
||||
.values()
|
||||
.filter(|a| used.contains(&a.id))
|
||||
.map(|a| {
|
||||
let uses: Vec<Value> = uses(d, a.id)
|
||||
let uses: Vec<Value> = shown(d, cache, a.id)
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
.map(|u| {
|
||||
let it = u.item;
|
||||
// Effective resolution: image pixels over the size it is shown at.
|
||||
let ppi = (|| {
|
||||
let loc = d.find(id)?;
|
||||
let it = d.item_at(&loc)?;
|
||||
let Content::Graphic(g) = &it.content else { return None };
|
||||
// Vector (placed PDF or SVG): no effective resolution.
|
||||
if a.mime == "application/pdf" || a.mime == "image/svg+xml" {
|
||||
return None;
|
||||
}
|
||||
let (pw, _) = a.pixels?;
|
||||
let xf = d.parent_xf(&loc) * it.xf * g.xf;
|
||||
let xf = u.parent_xf * it.xf * g.xf;
|
||||
let shown_w = xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)).width();
|
||||
(shown_w > 0.0).then(|| (pw as f64 / (shown_w / 72.0)).round())
|
||||
})();
|
||||
let page = d.page_of_item(id).map(|p| d.page_name(p));
|
||||
json!({"id": id.0, "page": page, "ppi": ppi})
|
||||
let page = u.page.map(|p| d.page_name(p));
|
||||
json!({"id": it.id.0, "page": page, "ppi": ppi})
|
||||
})
|
||||
.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 +358,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 +380,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 +395,41 @@ 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())))
|
||||
}
|
||||
|
||||
/// The replacement for `a`: bytes read from `read`, with `link` recorded as the stored link.
|
||||
/// Relink passes the file the user chose as both. Update passes the path the stored link resolved
|
||||
/// to and the stored link itself, so refreshing a link never rewrites what the document holds.
|
||||
fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result<Arc<designcraft_doc::Asset>> {
|
||||
let (bytes, px) = reload(read)?;
|
||||
let page = if designcraft_render::is_pdf(&bytes) { a.page } else { 0 };
|
||||
let px = if page > 0 {
|
||||
designcraft_render::pdf_page_size(&bytes, page as usize)
|
||||
.map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32))
|
||||
.ok_or_else(|| bad("links", format!("{}: can't read PDF page {}", read.display(), u64::from(page) + 1)))?
|
||||
} else {
|
||||
px
|
||||
};
|
||||
let mut a = a.clone();
|
||||
a.mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
a.data = Arc::new(bytes);
|
||||
a.pixels = Some(px);
|
||||
a.page = page;
|
||||
a.name = Path::new(link).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| link.to_string());
|
||||
a.link = Some(link.to_string());
|
||||
// A placed PDF keeps its crop choice; its box is found on the new file.
|
||||
super::file::crop_pdf(&mut a);
|
||||
Ok(Arc::new(a))
|
||||
}
|
||||
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -340,18 +443,12 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
let aid = asset_param(p, "links.relink")?;
|
||||
let (bytes, px) = reload(&path)?;
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
|
||||
let a = s.doc()?.doc.assets.get(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?;
|
||||
// Build the replacement before the edit: a file that cannot be read leaves the document as it was.
|
||||
let fresh = relinked_asset(a, Path::new(&path), &path)?;
|
||||
s.edit(|d, _| {
|
||||
let a = d.assets.get_mut(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?;
|
||||
let a = Arc::make_mut(a);
|
||||
a.mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
a.data = Arc::new(bytes.clone());
|
||||
a.pixels = Some(px);
|
||||
a.name = name.clone();
|
||||
a.link = Some(path.clone());
|
||||
// A placed PDF keeps its crop choice; its box is found on the new file.
|
||||
super::file::crop_pdf(a);
|
||||
let name = fresh.name.clone();
|
||||
d.assets.insert(aid, fresh);
|
||||
Ok(json!({"name": name}))
|
||||
})
|
||||
}
|
||||
@@ -363,16 +460,20 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::file_access::check_read(dir).map_err(EngineError::Other)?;
|
||||
}
|
||||
let ext = str_param(p, "extension").map(|e| e.trim_start_matches('.').to_string());
|
||||
if ext.as_ref().is_some_and(|e| e.contains(['/', '\\', '\0'])) {
|
||||
return Err(bad("links.relinkFolder", "extension must not contain path separators or NUL"));
|
||||
}
|
||||
if dir.is_none() && ext.is_none() {
|
||||
return Err(bad("links.relinkFolder", "give `dir` and/or `extension`"));
|
||||
}
|
||||
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,47 +481,70 @@ 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());
|
||||
}
|
||||
}
|
||||
let mut n = 0;
|
||||
// Load every replacement before committing any of them: a bad file must not leave an
|
||||
// unrecorded partial edit. Successful batches commit once and have one undo step.
|
||||
let mut fresh = Vec::new();
|
||||
for (aid, path) in todo {
|
||||
relink(s, &json!({"asset": aid.0, "path": path.to_string_lossy()}))?;
|
||||
n += 1;
|
||||
let a = d.assets.get(&aid).ok_or_else(|| bad("links.relinkFolder", "no such graphic"))?;
|
||||
fresh.push((aid, relinked_asset(a, &path, &path.to_string_lossy())?));
|
||||
}
|
||||
let n = fresh.len();
|
||||
if n > 0 {
|
||||
s.edit(|d, _| {
|
||||
for (aid, a) in fresh {
|
||||
d.assets.insert(aid, a);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"relinked": n, "notFound": not_found}))
|
||||
}
|
||||
|
||||
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
|
||||
.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)))
|
||||
.collect();
|
||||
let texts: Vec<(StoryId, String)> = d
|
||||
// A link craft-docpath refuses is missing, so it is never read or refreshed.
|
||||
let resolved = |link: &str| crate::io::document_path(link, document).ok();
|
||||
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)?));
|
||||
for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status(a, document) == "modified") {
|
||||
// The bytes come from where the stored link resolves; the link itself stays as the
|
||||
// document stored it, so refreshing never rewrites a relative link to an absolute one.
|
||||
if let Some((link, at)) = a.link.as_deref().and_then(|l| resolved(l).map(|at| (l, at))) {
|
||||
fresh.push((a.id, relinked_asset(a, &at, link)?));
|
||||
}
|
||||
}
|
||||
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();
|
||||
@@ -428,13 +552,8 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({"updated": 0}));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
for (aid, (bytes, px)) in &fresh {
|
||||
if let Some(a) = d.assets.get_mut(aid) {
|
||||
let a = Arc::make_mut(a);
|
||||
a.data = Arc::new(bytes.clone());
|
||||
a.pixels = Some(*px);
|
||||
super::file::crop_pdf(a);
|
||||
}
|
||||
for (aid, a) in fresh {
|
||||
d.assets.insert(aid, a);
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
for (sid, path, bytes, before) in &fresh_texts {
|
||||
@@ -452,6 +571,315 @@ mod tests {
|
||||
designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png()
|
||||
}
|
||||
|
||||
fn pdf(sizes: &[(u32, u32)]) -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap();
|
||||
for (i, (width, height)) in sizes.iter().enumerate() {
|
||||
s.execute("layout.pageSize", &json!({"pages": [i + 1], "width": width, "height": height})).unwrap();
|
||||
}
|
||||
let result = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
super::super::file::base64_decode(result["base64"].as_str().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchored_image_lists_its_page_and_resolution() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
|
||||
let r = s
|
||||
.execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."}))
|
||||
.unwrap();
|
||||
let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
|
||||
let placed = s
|
||||
.execute(
|
||||
"file.place",
|
||||
&json!({"base64": super::super::file::base64_encode(&png(10, 10)), "name": "dot.png", "x": 450, "y": 450, "width": 36}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = placed["id"].as_u64().unwrap();
|
||||
let before = s.execute("links.list", &json!({})).unwrap();
|
||||
assert_eq!(before[0]["uses"][0]["ppi"], 20.0);
|
||||
s.execute("anchored.insert", &json!({"ids": [id], "story": sid.0, "pos": 7})).unwrap();
|
||||
let list = s.execute("links.list", &json!({})).unwrap();
|
||||
let uses = list[0]["uses"].as_array().unwrap();
|
||||
assert_eq!(uses.len(), 1, "anchored image must have one use: {list}");
|
||||
assert_eq!(uses[0]["page"], "2");
|
||||
assert_eq!(uses[0]["ppi"], 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_web_exports_convert_tiff_and_skip_bad_used_assets() {
|
||||
use std::io::Read;
|
||||
let bytes = super::super::file::eps_place_tests::tiff_eps();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let placed = s.execute("file.place", &json!({"base64":super::super::file::base64_encode(&bytes),"name":"logo.eps"})).unwrap();
|
||||
let aid = AssetId(placed["asset"].as_u64().unwrap());
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let html = s.execute("file.exportHtml", &json!({})).unwrap();
|
||||
let encoded = html["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap();
|
||||
let png = super::super::file::base64_decode(encoded);
|
||||
assert!(png.starts_with(b"\x89PNG"));
|
||||
assert_eq!(designcraft_render::image_size(&png), Some((2, 2)));
|
||||
assert_eq!(designcraft_render::decode_pixmap(&png).unwrap().data()[0], designcraft_render::decode_pixmap(&bytes).unwrap().data()[0]);
|
||||
let result = s.execute("file.exportEpub", &json!({})).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(result["base64"].as_str().unwrap()))).unwrap();
|
||||
let mut image = Vec::new();
|
||||
z.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap();
|
||||
assert_eq!(image, png);
|
||||
let mut opf = String::new();
|
||||
z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
|
||||
assert!(opf.contains("media-type=\"image/png\""));
|
||||
assert!(!opf.contains("image/tiff"));
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
s.edit(|d, _| {
|
||||
Arc::make_mut(d.assets.get_mut(&aid).unwrap()).data = Arc::new(b"%!PS-Adobe-3.0 EPSF-3.0\n%%EOF\n".to_vec());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for command in ["file.exportHtml", "file.exportEpub"] {
|
||||
let result = s.execute(command, &json!({})).unwrap();
|
||||
assert_eq!(result["warnings"].as_array().unwrap().len(), 1, "{command}");
|
||||
if command == "file.exportHtml" {
|
||||
assert!(!result["text"].as_str().unwrap().contains("<img "));
|
||||
}
|
||||
}
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.assets.contains_key(&aid));
|
||||
for command in ["file.exportHtml", "file.exportEpub"] {
|
||||
let result = s.execute(command, &json!({})).unwrap();
|
||||
assert!(result["warnings"].as_array().unwrap().is_empty(), "unused EPS: {command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_status_tracks_source_bytes_across_save_update_relink_and_embed() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-eps-status-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("logo.eps");
|
||||
let first = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n0 0 0 setrgbcolor\n%%EOF\n";
|
||||
let changed = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n1 0 0 setrgbcolor\n%%EOF\n";
|
||||
assert_eq!(first.len(), changed.len());
|
||||
assert_eq!(designcraft_images::eps_proxy(first), designcraft_images::eps_proxy(changed), "same preview, different source");
|
||||
std::fs::write(&path, first).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap());
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 0);
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before), "unchanged EPS must not create an edit");
|
||||
let saved = designcraft_format::save(&before).unwrap();
|
||||
let reopened = designcraft_format::load(&saved).unwrap();
|
||||
assert_eq!(reopened.assets[&aid].data.as_slice(), first);
|
||||
let mut s = Session::new();
|
||||
s.add_document(crate::DocState::new(reopened, None));
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
|
||||
std::fs::write(&path, changed).unwrap();
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "modified");
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
|
||||
assert_eq!(s.doc().unwrap().doc.assets[&aid].data.as_slice(), changed);
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "missing");
|
||||
let replacement = dir.join("replacement.eps");
|
||||
std::fs::write(&replacement, changed).unwrap();
|
||||
s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "missing");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
|
||||
s.execute("links.embed", &json!({"asset": aid.0})).unwrap();
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "embedded");
|
||||
std::fs::remove_file(&replacement).unwrap();
|
||||
let embedded = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
// The asset is embedded, so no link is followed and the document is irrelevant.
|
||||
assert_eq!(status(&embedded.assets[&aid], None), "embedded");
|
||||
assert_eq!(embedded.assets[&aid].data.as_slice(), changed);
|
||||
assert!(designcraft_render::decode_pixmap(&embedded.assets[&aid].data).is_some());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_legacy_preview_document_is_upgraded_by_update() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-eps-legacy-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("logo.eps");
|
||||
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n";
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let aid = AssetId(s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap()["asset"].as_u64().unwrap());
|
||||
let proxy = designcraft_images::eps_proxy(source).unwrap().0;
|
||||
s.edit(|d, _| {
|
||||
let a = Arc::make_mut(d.assets.get_mut(&aid).unwrap());
|
||||
a.data = Arc::new(proxy);
|
||||
a.mime = "image/png".into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let old = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.add_document(crate::DocState::new(old, None));
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
|
||||
assert_eq!(s.doc().unwrap().doc.assets[&aid].data.as_slice(), source);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(designcraft_render::decode_pixmap(&s.doc().unwrap().doc.assets[&aid].data).is_some(), "old preview remains renderable");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_source_exports_preview_to_pdf_html_and_epub_without_editing_document() {
|
||||
use hayro_syntax::object::Name;
|
||||
use std::io::Read;
|
||||
|
||||
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n";
|
||||
let proxy = designcraft_images::eps_proxy(source).unwrap().0;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let aid = AssetId(
|
||||
s.execute("file.place", &json!({"base64": super::super::file::base64_encode(source), "name": "logo.eps"})).unwrap()["asset"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
// A document retaining the original EPS rather than the legacy preview-only asset.
|
||||
s.edit(|d, _| {
|
||||
let a = Arc::make_mut(d.assets.get_mut(&aid).unwrap());
|
||||
a.data = Arc::new(source.to_vec());
|
||||
a.mime = "application/postscript".into();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let result = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
assert!(result["warnings"].as_array().unwrap().is_empty(), "{:?}", result["warnings"]);
|
||||
let pdf = hayro_syntax::Pdf::new(super::super::file::base64_decode(result["base64"].as_str().unwrap())).unwrap();
|
||||
assert_eq!(
|
||||
pdf.objects()
|
||||
.into_iter()
|
||||
.filter_map(|o| o.into_stream())
|
||||
.filter(|st| st.dict().get::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let result = s.execute("file.exportHtml", &json!({})).unwrap();
|
||||
let encoded = result["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap();
|
||||
assert_eq!(super::super::file::base64_decode(encoded), proxy);
|
||||
let result = s.execute("file.exportEpub", &json!({})).unwrap();
|
||||
let bytes = super::super::file::base64_decode(result["base64"].as_str().unwrap());
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut image = Vec::new();
|
||||
archive.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap();
|
||||
assert_eq!(image, proxy);
|
||||
let mut opf = String::new();
|
||||
archive.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap();
|
||||
assert!(opf.contains("media-type=\"image/png\""));
|
||||
assert!(!opf.contains("application/postscript"));
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before), "exports must leave the source document intact");
|
||||
assert_eq!(before.assets[&aid].data.as_slice(), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_relink_update_and_folder_relink_retain_source() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-links-eps-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("new")).unwrap();
|
||||
let eps = |w: u32| format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 {w} 10\n%%EOF\n");
|
||||
let original = dir.join("original.eps");
|
||||
let replacement = dir.join("replacement.eps");
|
||||
std::fs::write(&original, eps(10)).unwrap();
|
||||
std::fs::write(&replacement, eps(30)).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"path": original.to_string_lossy()})).unwrap();
|
||||
let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap();
|
||||
std::fs::write(&original, eps(20)).unwrap();
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
|
||||
assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((40, 20)));
|
||||
s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap();
|
||||
let a = &s.doc().unwrap().doc.assets[&aid];
|
||||
assert_eq!(a.pixels, Some((60, 20)));
|
||||
assert_eq!(a.mime, "application/postscript");
|
||||
assert_eq!(a.data.as_slice(), eps(30).as_bytes());
|
||||
let moved = dir.join("new/replacement.eps");
|
||||
std::fs::rename(&replacement, &moved).unwrap();
|
||||
assert_eq!(s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap()["relinked"], 1);
|
||||
assert_eq!(s.doc().unwrap().doc.assets[&aid].link.as_deref(), moved.to_str());
|
||||
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_relink_and_update_preserve_selected_page_size() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-links-pdf-size-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("original.pdf");
|
||||
let replacement = dir.join("replacement.pdf");
|
||||
std::fs::write(&original, pdf(&[(10, 10), (20, 30)])).unwrap();
|
||||
std::fs::write(&replacement, pdf(&[(40, 40), (50, 60)])).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap();
|
||||
let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap();
|
||||
s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap();
|
||||
let a = &s.doc().unwrap().doc.assets[&aid];
|
||||
assert_eq!(a.page, 1);
|
||||
assert_eq!(a.pixels, Some((50, 60)));
|
||||
assert!(designcraft_render::render_pdf_page(&a.data, a.page as usize, 64).is_some());
|
||||
std::fs::write(&replacement, pdf(&[(70, 70), (80, 90)])).unwrap();
|
||||
assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1);
|
||||
assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((80, 90)));
|
||||
let raster = dir.join("replacement.png");
|
||||
std::fs::write(&raster, png(4, 5)).unwrap();
|
||||
s.execute("links.relink", &json!({"asset": aid.0, "path": raster.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.assets[&aid].page, 0);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_reload_rejects_missing_page_without_edit() {
|
||||
let full = pdf(&[(10, 10), (20, 30)]);
|
||||
let short = pdf(&[(10, 10)]);
|
||||
let dir = std::env::temp_dir().join(format!("dc-links-pdf-page-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("new")).unwrap();
|
||||
let original = dir.join("book.pdf");
|
||||
let replacement = dir.join("new/book.pdf");
|
||||
std::fs::write(&replacement, short.clone()).unwrap();
|
||||
for command in ["links.relink", "links.relinkFolder", "links.update"] {
|
||||
std::fs::write(&original, &full).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap();
|
||||
let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap();
|
||||
if command == "links.update" {
|
||||
std::fs::write(&original, &short).unwrap();
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
let before = st.doc.clone();
|
||||
let revision = st.revision;
|
||||
let undo = st.history.undo.len();
|
||||
let journal = s.journal.len();
|
||||
let params = match command {
|
||||
"links.relinkFolder" => json!({"dir": dir.join("new").to_string_lossy(), "missingOnly": false}),
|
||||
"links.update" => json!({"asset": aid.0}),
|
||||
_ => json!({"asset": aid.0, "path": replacement.to_string_lossy()}),
|
||||
};
|
||||
let err = s.execute(command, ¶ms).unwrap_err();
|
||||
assert!(err.to_string().contains("PDF page 2"), "{command}: {err}");
|
||||
let st = s.doc().unwrap();
|
||||
assert!(Arc::ptr_eq(&st.doc, &before), "{command}");
|
||||
assert_eq!(st.revision, revision);
|
||||
assert_eq!(st.history.undo.len(), undo);
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
assert!(designcraft_render::render_pdf_page(&st.doc.assets[&aid].data, 1, 64).is_some());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_status_relink_update_embed() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-links-{}", std::process::id()));
|
||||
@@ -491,10 +919,78 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod relink_folder_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn two_missing_links(case: &str) -> (Session, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("dc-relink-{case}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("new")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for name in ["a.svg", "b.svg"] {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#).unwrap();
|
||||
s.doc_mut().unwrap().selection = Default::default();
|
||||
s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
std::fs::rename(&path, dir.join("new").join(name)).unwrap();
|
||||
}
|
||||
(s, dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_relink_failure_keeps_document_and_history() {
|
||||
let (mut s, dir) = two_missing_links("atomic");
|
||||
std::fs::write(dir.join("new/b.svg"), b"not an image").unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let before = st.doc.clone();
|
||||
let revision = st.revision;
|
||||
let undo = st.history.undo.len();
|
||||
let journal = s.journal.len();
|
||||
let err = s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap_err();
|
||||
assert!(err.to_string().contains("unsupported or corrupt image"), "{err}");
|
||||
let st = s.doc().unwrap();
|
||||
assert!(Arc::ptr_eq(&st.doc, &before), "a failed batch must not leave its first link changed");
|
||||
assert_eq!(st.revision, revision);
|
||||
assert_eq!(st.history.undo.len(), undo);
|
||||
assert_eq!(s.journal.len(), journal);
|
||||
s.execute("frame.create", &json!({"rect": [0, 0, 20, 20]})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_relink_is_one_undo_step() {
|
||||
let (mut s, dir) = two_missing_links("undo");
|
||||
let st = s.doc().unwrap();
|
||||
let before = st.doc.clone();
|
||||
let revision = st.revision;
|
||||
let undo = st.history.undo.len();
|
||||
let r = s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["relinked"], 2);
|
||||
let st = s.doc().unwrap();
|
||||
// `revision` is drawn from a process-wide counter, so a test running beside this one
|
||||
// can take the next value: that the batch commits *once* is what the single undo step is.
|
||||
assert_ne!(st.revision, revision, "the batch commits");
|
||||
assert_eq!(st.history.undo.len(), undo + 1, "exactly one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relink_extension_rejects_path_separators_without_panicking() {
|
||||
let (mut s, dir) = two_missing_links("extension");
|
||||
for extension in ["png/other", "png\\other", "png\0other"] {
|
||||
// Call directly so the command guard cannot hide a panic in Path::with_extension.
|
||||
let err = super::relink_folder(&mut s, &json!({"extension": extension})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relink_missing_links_to_a_folder_and_by_extension() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-relink-{}", std::process::id()));
|
||||
@@ -834,7 +1330,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 +1469,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> {
|
||||
|
||||
@@ -61,7 +61,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(id) = super::id_param(p, "id") {
|
||||
return s.edit(|d, _| {
|
||||
let old = d.item(id).ok_or_else(|| bad("math.insert", "no such object"))?;
|
||||
let at = old.bounds();
|
||||
// The path and graphic live in the item's inner space, which `xf` maps to the spread:
|
||||
// build the new ones at the old inner corner and keep `xf`.
|
||||
let at = old.path.bounds().ok_or_else(|| bad("math.insert", "equation has no geometry"))?;
|
||||
let it = math_item(d, &latex, size, lid, (at.x0, at.y0));
|
||||
let target = d.item_mut(id).ok_or_else(|| bad("math.insert", "no such object"))?;
|
||||
target.path = it.path;
|
||||
@@ -129,4 +131,46 @@ mod tests {
|
||||
let st = &s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(f["story"].as_u64().unwrap())];
|
||||
assert_eq!(st.objects.len(), 1);
|
||||
}
|
||||
|
||||
/// The item's top-left corner in spread space (inner path corner through its transform).
|
||||
fn origin(s: &Session, id: designcraft_doc::ItemId) -> designcraft_geom::Point {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let it = d.item(id).unwrap();
|
||||
let r = it.path.bounds().unwrap();
|
||||
it.xf * designcraft_geom::Point::new(r.x0, r.y0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_transformed_equation_keeps_its_place() {
|
||||
for step in ["move", "rotate", "scale"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("math.insert", &json!({"latex": r"\frac{a}{b}", "x": 100, "y": 100})).unwrap();
|
||||
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
|
||||
match step {
|
||||
"move" => s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})),
|
||||
"rotate" => s.execute("transform.rotate", &json!({"ids": [id.0], "angle": 30})),
|
||||
_ => s.execute("transform.scale", &json!({"ids": [id.0], "sx": 2.0, "sy": 1.5})),
|
||||
}
|
||||
.unwrap();
|
||||
let before = origin(&s, id);
|
||||
s.execute("math.insert", &json!({"id": id.0, "latex": r"\frac{a}{b}"})).unwrap();
|
||||
let after = origin(&s, id);
|
||||
assert!((before.x - after.x).abs() < 1e-6 && (before.y - after.y).abs() < 1e-6, "{step}: {before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_moved_equation_keeps_its_bounds() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("math.insert", &json!({"latex": "x", "x": 100, "y": 100})).unwrap();
|
||||
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
|
||||
s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})).unwrap();
|
||||
let before = s.doc().unwrap().doc.item(id).unwrap().bounds();
|
||||
s.execute("math.insert", &json!({"id": id.0, "latex": "x"})).unwrap();
|
||||
let after = s.doc().unwrap().doc.item(id).unwrap().bounds();
|
||||
assert_eq!((before.x0, before.y0), (after.x0, after.y0));
|
||||
assert_eq!((after.x0, after.y0), (150.0, 140.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,9 +259,14 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
}
|
||||
/// Plain text from a parameter, with CR LF and lone CR (classic Mac and InDesign line ends) as
|
||||
/// `\n`, the paragraph separator, as edit.paste and text import already do. Kept as they were,
|
||||
/// the CRs joined the paragraphs into one.
|
||||
/// the CRs joined the paragraphs into one. A vertical tab (Word's and PowerPoint's Shift+Enter,
|
||||
/// Chr(11)) is a forced line break: kept, it was drawn as a missing-glyph box.
|
||||
pub(crate) fn text_param(p: &Value, key: &str) -> String {
|
||||
str_param(p, key).unwrap_or("").replace("\r\n", "\n").replace('\r', "\n")
|
||||
str_param(p, key)
|
||||
.unwrap_or("")
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', "\n")
|
||||
.replace('\u{B}', designcraft_doc::story::FORCED_LINE_BREAK.encode_utf8(&mut [0; 4]))
|
||||
}
|
||||
pub(crate) fn id_param(p: &Value, key: &str) -> Option<ItemId> {
|
||||
p.get(key).and_then(Value::as_u64).map(ItemId)
|
||||
|
||||
@@ -297,7 +297,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.swatch = v.into();
|
||||
}
|
||||
if let Some(v) = p2.get("tint").and_then(Value::as_f64) {
|
||||
st.tint = v as f32;
|
||||
st.tint = v.clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
if let Some(v) = p2.get("weight").and_then(Value::as_f64) {
|
||||
st.weight = v.max(0.0);
|
||||
@@ -309,7 +309,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.gap_swatch = v.into();
|
||||
}
|
||||
if let Some(v) = p2.get("gapTint").and_then(Value::as_f64) {
|
||||
st.gap_tint = v as f32;
|
||||
st.gap_tint = v.clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
if let Some(v) = p2.get("gapOverprint").and_then(Value::as_bool) {
|
||||
st.gap_overprint = v;
|
||||
@@ -1201,11 +1201,10 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let to = rect_param(p, "to").ok_or_else(|| bad("transform.resize", "missing to"))?;
|
||||
let scale_content = bool_or(p, "content", false);
|
||||
let distribute = bool_or(p, "distribute", false);
|
||||
if from.width().abs() < 1e-9 || from.height().abs() < 1e-9 {
|
||||
return Err(bad("transform.resize", "degenerate source rect"));
|
||||
}
|
||||
let sx = to.width() / from.width();
|
||||
let sy = to.height() / from.height();
|
||||
// A flat source (a horizontal or vertical line) has no size to scale on its flat axis: that axis only moves.
|
||||
let ratio = |new: f64, old: f64| if old.abs() < 1e-9 { 1.0 } else { new / old };
|
||||
let sx = ratio(to.width(), from.width());
|
||||
let sy = ratio(to.height(), from.height());
|
||||
let sx = if sx.abs() < 1e-3 { 1e-3f64.copysign(sx) } else { sx };
|
||||
let sy = if sy.abs() < 1e-3 { 1e-3f64.copysign(sy) } else { sy };
|
||||
let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0));
|
||||
@@ -4189,6 +4188,16 @@ mod fitting_tests {
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("/Subtype/Image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_line_resizes_along_its_length() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s.execute("line.create", &json!({"a": [100, 100], "b": [200, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("transform.resize", &json!({"ids": [id], "from": [100, 100, 200, 100], "to": [100, 100, 300, 100]})).unwrap();
|
||||
let r = s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds();
|
||||
assert!((r.x0 - 100.0).abs() < 1e-9 && (r.x1 - 300.0).abs() < 1e-9 && (r.y0 - 100.0).abs() < 1e-9 && r.height().abs() < 1e-9, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_distribute_spreads_objects_without_resizing() {
|
||||
let mut s = Session::new();
|
||||
@@ -4369,6 +4378,22 @@ mod line_end_tests {
|
||||
s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap();
|
||||
assert_eq!(note(&mut s), "Three\nFour");
|
||||
}
|
||||
|
||||
/// "One\u{B}Two" kept the vertical tab, drawn as a missing-glyph box, where Word and
|
||||
/// PowerPoint text (Chr(11)) means a forced line break.
|
||||
#[test]
|
||||
fn vertical_tabs_are_forced_line_breaks() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\u{B}Two"})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
let story = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().clone();
|
||||
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two", 1));
|
||||
s.execute("text.insert", &json!({"text": "\u{B}Three", "raw": true})).unwrap();
|
||||
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two\u{2028}Three", 1));
|
||||
s.execute("story.setText", &json!({"story": sid.0, "text": "A\u{B}B\rC"})).unwrap();
|
||||
assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\u{2028}B\nC", 2));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -236,7 +236,8 @@ fn same_rect(a: Rect, b: Rect) -> bool {
|
||||
/// on its page (when it sits in one), then one frame per column on each page it adds. With
|
||||
/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the
|
||||
/// live area with the page's columns instead. Empty frames it made at the end of the thread are
|
||||
/// removed again, except the first on a page. Returns pages added.
|
||||
/// removed again, except the first on a page it didn't add; a page it added that holds only such
|
||||
/// a frame goes with it. Returns pages added.
|
||||
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
|
||||
/// name `cmd`, the command that autoflows.
|
||||
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result<usize> {
|
||||
@@ -249,6 +250,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
let vertical = story_vertical || primary;
|
||||
let mut added = 0;
|
||||
let mut made = Vec::new();
|
||||
let mut new_pages = Vec::new();
|
||||
let mut first = true;
|
||||
loop {
|
||||
let (overset, held, full) = fit(d, sid);
|
||||
@@ -278,7 +280,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.columns.count.max(1) as usize) };
|
||||
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
|
||||
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
|
||||
d.insert_pages(Some(page), pages, parent)?;
|
||||
new_pages.extend(d.insert_pages(Some(page), pages, parent)?);
|
||||
for pg in page + 1..=page + pages {
|
||||
let (si, pi) = d.page_loc(pg).ok_or_else(|| bad(cmd, "page insert failed"))?;
|
||||
let columns = d.spreads[si].pages[pi].columns.clone();
|
||||
@@ -297,10 +299,24 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
|
||||
while let Some(&f) = made.last() {
|
||||
let frames = d.story(sid).map_or(&[][..], |s| s.frames.as_slice());
|
||||
let [.., before, tail] = frames else { break };
|
||||
if *tail != f || cs.frame(f).is_some_and(|ft| !ft.range.is_empty()) || d.page_of_item(*before) != d.page_of_item(f) {
|
||||
if *tail != f || cs.frame(f).is_some_and(|ft| !ft.range.is_empty()) {
|
||||
break;
|
||||
}
|
||||
d.remove_item(f)?;
|
||||
let page = d.page_of_item(f);
|
||||
if d.page_of_item(*before) != page {
|
||||
// The first frame on its page: the estimate added more pages than the text needs.
|
||||
// Drop the page too when autoflow added it and the frame is all it holds.
|
||||
let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break };
|
||||
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
|
||||
if !alone {
|
||||
break;
|
||||
}
|
||||
d.remove_item(f)?;
|
||||
d.delete_pages(&[pg])?;
|
||||
added = added.saturating_sub(1);
|
||||
} else {
|
||||
d.remove_item(f)?;
|
||||
}
|
||||
made.pop();
|
||||
}
|
||||
Ok(added)
|
||||
@@ -366,7 +382,14 @@ pub(super) fn place_text(
|
||||
let story = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
|
||||
let r = t.range();
|
||||
story.delete(r.clone());
|
||||
// An insertion point in an empty paragraph takes the first imported paragraph's style
|
||||
// (insert_story would keep the empty paragraph's own, losing the styleMap result).
|
||||
let p0 = story.para_at(r.start);
|
||||
let blank = story.para_ranges().get(p0).is_some_and(|x| x.is_empty());
|
||||
let end = story.insert_story(r.start, &imp.story);
|
||||
if blank && let (Some(first), Some(p)) = (imp.story.paras.first(), story.paras.get_mut(p0)) {
|
||||
*p = ParaFormat { table: p.table, ..first.clone() };
|
||||
}
|
||||
sel.text = Some(TextSel { anchor: end, focus: end, ..t });
|
||||
if link.is_some() {
|
||||
warnings.push("not linked: a linked text file fills a whole story, not an insertion point".into());
|
||||
@@ -686,6 +709,38 @@ mod reflow_tests {
|
||||
s.execute("text.delete", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.page_count(), 1);
|
||||
}
|
||||
|
||||
/// Placing text into the primary frame adds only the pages the text needs, also when a wrap
|
||||
/// leaves page 1 little room or the text ends at a page boundary (#1165).
|
||||
#[test]
|
||||
fn smart_text_reflow_place_adds_no_empty_pages() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-reflow-place-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (n, wrap, want) in [(280, true, 5), (250, false, 3)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "primaryTextFrame": true})).unwrap();
|
||||
if wrap {
|
||||
let id = s.execute("frame.create", &json!({"rect": [36, 36, 576, 600]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.textWrap", &json!({"ids": [id], "mode": "boundingBox"})).unwrap();
|
||||
}
|
||||
let sid = s.doc().unwrap().doc.settings.primary_story.expect("primary story");
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
|
||||
let path = dir.join(format!("words-{n}.txt"));
|
||||
std::fs::write(&path, "Words keep coming and the page fills up quickly with them. ".repeat(n)).unwrap();
|
||||
s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert_eq!(d.page_count(), want, "{n} lines");
|
||||
let frames = &d.story(sid).unwrap().frames;
|
||||
assert_eq!(frames.len(), d.page_count(), "{n} lines");
|
||||
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
|
||||
assert!(cs.overset_at.is_none(), "{n} lines");
|
||||
for f in frames {
|
||||
assert!(cs.frame(*f).is_some_and(|ft| !ft.range.is_empty()), "{n} lines: empty frame {f:?}");
|
||||
}
|
||||
d.check().unwrap();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -767,6 +822,20 @@ mod pdf_page_tests {
|
||||
assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().chars.size, Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_first_paragraph_into_empty_caret_keeps_mapped_style() {
|
||||
// Regression (#1208): placing into a new text frame (which leaves its caret selected) left the first paragraph unstyled.
|
||||
let b64 = crate::cmd::base64_encode(&word_file());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("style.paragraph.create", &json!({"name": "Headline"})).unwrap();
|
||||
let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text"})).unwrap();
|
||||
let sid = fr["story"].as_u64().unwrap();
|
||||
s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "frame": fr["id"], "styleMap": {"Title": "Headline"}})).unwrap();
|
||||
let st = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
|
||||
assert_eq!(st.paras.iter().map(|p| p.style.as_str()).collect::<Vec<_>>(), ["Headline", "Body"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_normal_comes_in_as_its_own_style() {
|
||||
// Regression (#722): Normal's formatting (over Word's document defaults) was dropped.
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -30,7 +30,7 @@ fn style_specs() -> Vec<CommandSpec> {
|
||||
"New Cell Style…",
|
||||
[],
|
||||
None,
|
||||
"{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}",
|
||||
"{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}",
|
||||
has_doc,
|
||||
|s, p| cell_style_create(s, p)
|
||||
),
|
||||
@@ -297,7 +297,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Cell Options",
|
||||
[],
|
||||
None,
|
||||
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
|
||||
"{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
|
||||
in_table,
|
||||
set_cell
|
||||
),
|
||||
@@ -932,6 +932,22 @@ fn drawn_side(t: &Table, owners: &[(usize, usize)], (r, c): (usize, usize), side
|
||||
drawn.or_else(|| t.cell(r, c).and_then(|cell| cell.strokes.get(side).cloned()))
|
||||
}
|
||||
|
||||
/// Cell Options › Text › First Baseline: `firstBaseline` (offset method) and `firstBaselineMin` (pt).
|
||||
fn first_baseline_params(p: &Value, id: &str) -> Result<(Option<designcraft_doc::FirstBaseline>, Option<f64>)> {
|
||||
let fb = match p.get("firstBaseline").filter(|v| !v.is_null()) {
|
||||
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(id, format!("firstBaseline: {e}")))?),
|
||||
None => None,
|
||||
};
|
||||
let min = match p.get("firstBaselineMin").filter(|v| !v.is_null()) {
|
||||
Some(v) => match v.as_f64().filter(|m| m.is_finite() && (0.0..=8640.0).contains(m)) {
|
||||
Some(m) => Some(m),
|
||||
None => return Err(bad(id, "firstBaselineMin: a number of points from 0 to 8640")),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Ok((fb, min))
|
||||
}
|
||||
|
||||
fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "table.setCell";
|
||||
let g = target(s, p, ID)?;
|
||||
@@ -954,6 +970,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))),
|
||||
None => None,
|
||||
};
|
||||
let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
|
||||
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
|
||||
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
|
||||
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
|
||||
@@ -998,6 +1015,12 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = vj {
|
||||
cell.vj = v;
|
||||
}
|
||||
if let Some(v) = first_baseline {
|
||||
cell.first_baseline = v;
|
||||
}
|
||||
if let Some(v) = first_baseline_min {
|
||||
cell.first_baseline_min = v;
|
||||
}
|
||||
if let Some(tx) = &text {
|
||||
let len = cell.text.len();
|
||||
cell.text.replace(0..len, tx);
|
||||
@@ -1525,7 +1548,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter_map(|(r, c)| {
|
||||
let cell = t.cell(r, c)?;
|
||||
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
|
||||
"insets": cell.insets, "vj": cell.vj, "style": cell.style}))
|
||||
"insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
@@ -1572,6 +1595,13 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Opt
|
||||
if let Some(v) = p.get("vj") {
|
||||
cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?);
|
||||
}
|
||||
let (first_baseline, first_baseline_min) = first_baseline_params(p, cmd)?;
|
||||
if first_baseline.is_some() {
|
||||
cs.first_baseline = first_baseline;
|
||||
}
|
||||
if first_baseline_min.is_some() {
|
||||
cs.first_baseline_min = first_baseline_min;
|
||||
}
|
||||
if let Some(st) = stroke {
|
||||
cs.stroke = Some(st);
|
||||
cs.strokes = Default::default();
|
||||
@@ -1600,6 +1630,8 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cs.fill_tint = Some(cell.fill_tint);
|
||||
cs.insets = Some(cell.insets);
|
||||
cs.vj = Some(cell.vj);
|
||||
cs.first_baseline = Some(cell.first_baseline);
|
||||
cs.first_baseline_min = Some(cell.first_baseline_min);
|
||||
cs.stroke = Some(cell.strokes[0].clone());
|
||||
cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone());
|
||||
}
|
||||
@@ -1787,6 +1819,28 @@ mod style_tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn cell_first_baseline_is_a_cell_option_and_a_cell_style_attribute() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "capHeight", "firstBaselineMin": 3})).unwrap();
|
||||
let got = s.execute("table.get", &json!({})).unwrap();
|
||||
let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
|
||||
assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
|
||||
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
|
||||
assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
|
||||
s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
|
||||
s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let t = d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap();
|
||||
assert_eq!(t.cell(1, 1).unwrap().first_baseline, designcraft_doc::FirstBaseline::Leading);
|
||||
assert_eq!(t.cell(1, 0).unwrap().first_baseline, designcraft_doc::FirstBaseline::Ascent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_and_table_styles_apply_and_follow_edits() {
|
||||
let mut s = Session::new();
|
||||
@@ -1897,7 +1951,20 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, ID)?;
|
||||
let (bytes, name, link) = super::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 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"))?;
|
||||
let (r, c) = t.owner(g.range.r0, g.range.c0);
|
||||
let cell = t.cell(r, c).ok_or_else(|| bad(ID, "no cell"))?;
|
||||
let (rs, cs) = (cell.row_span.max(1) as usize, cell.col_span.max(1) as usize);
|
||||
let w: f64 = t.columns.get(c..c.saturating_add(cs).min(t.ncols())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.width).sum();
|
||||
let h: f64 = t.rows.get(r..r.saturating_add(rs).min(t.nrows())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.height).sum();
|
||||
let ins = cell.insets;
|
||||
let (bw, bh) = ((w - ins[1] - ins[3]).max(1.0), (h - ins[0] - ins[2]).max(1.0));
|
||||
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);
|
||||
super::file::validate_placement((nw, nh), designcraft_geom::Rect::new(0.0, 0.0, bw, bh), xf, ID)?;
|
||||
s.edit(|d, sel| {
|
||||
let fresh = designcraft_doc::AssetId(d.alloc());
|
||||
let mime = designcraft_render::image_mime(&bytes).to_string();
|
||||
@@ -1912,16 +1979,6 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
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 (r, c) = t.owner(g.range.r0, g.range.c0);
|
||||
let cell = t.cell(r, c).ok_or_else(|| bad(ID, "no cell"))?;
|
||||
let (rs, cs) = (cell.row_span.max(1) as usize, cell.col_span.max(1) as usize);
|
||||
let w: f64 = t.columns[c..(c + cs).min(t.ncols())].iter().map(|x| x.width).sum();
|
||||
let h: f64 = t.rows[r..(r + rs).min(t.nrows())].iter().map(|x| x.height).sum();
|
||||
let ins = cell.insets;
|
||||
let (bw, bh) = ((w - ins[1] - ins[3]).max(1.0), (h - ins[0] - ins[2]).max(1.0));
|
||||
let (nw, nh) = (pw as f64, ph as f64);
|
||||
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);
|
||||
let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?;
|
||||
cell.graphic = Some(designcraft_doc::Graphic {
|
||||
asset: aid,
|
||||
@@ -2188,6 +2245,31 @@ mod sort_tests {
|
||||
assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_row_cannot_move_a_header_row_out_of_place() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap();
|
||||
assert!(s.execute("table.moveRow", &json!({"from": 0, "to": 2})).is_err());
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
// Dragging the selected header row onto the last body row is refused too.
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap();
|
||||
let fid = d.stories[&sid].frames[0];
|
||||
let cs = s.cache.get(&d, sid, None);
|
||||
let cells = &cs.frames[0].tables[0].cells;
|
||||
let centre = |r: usize| {
|
||||
let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center();
|
||||
d.text_xf(d.item(fid).unwrap()) * c
|
||||
};
|
||||
let (from, to) = (centre(0), centre(2));
|
||||
s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap();
|
||||
assert!(s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).is_err());
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_options_limits_header_and_footer_rows() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -125,12 +125,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?);
|
||||
let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a);
|
||||
let offset = |v: u64| usize::try_from(v).map_err(|_| bad("story.replaceRange", format!("offset {v} is out of range")));
|
||||
let a = offset(p.get("start").and_then(Value::as_u64).unwrap_or(0))?;
|
||||
let b = match p.get("end").and_then(Value::as_u64) {
|
||||
Some(v) => offset(v)?,
|
||||
None => a,
|
||||
};
|
||||
let text = super::text_param(p, "text");
|
||||
s.edit(|d, sel| {
|
||||
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||||
let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len())));
|
||||
let n = st.len();
|
||||
if a > b || b > n || !st.text.is_char_boundary(a) || !st.text.is_char_boundary(b) {
|
||||
let msg = format!("range {a}..{b} must be UTF-8 byte offsets on character boundaries, start <= end; the story is {n} bytes");
|
||||
return Err(bad("story.replaceRange", msg));
|
||||
}
|
||||
st.replace(a..b, &text);
|
||||
let len = st.len();
|
||||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
|
||||
@@ -2292,9 +2300,14 @@ mod open_type_tests {
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap();
|
||||
let sid = r["story"].clone();
|
||||
// Offsets inside 漢 (2..5) and 🙂 (5..9) snap back to the character starts: 漢 is replaced.
|
||||
s.execute("story.replaceRange", &json!({"story": sid, "start": 3, "end": 6, "text": "字"})).unwrap();
|
||||
let story = designcraft_doc::StoryId(sid.as_u64().unwrap());
|
||||
// Offsets inside 漢 (2..5) and 🙂 (5..9), past the end, or reversed are refused and change nothing.
|
||||
for (start, end) in [(3, 6), (2, 4), (0, 99), (5, 2)] {
|
||||
let r = s.execute("story.replaceRange", &json!({"story": sid, "start": start, "end": end, "text": "字"}));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("10 bytes")), "{start}..{end}");
|
||||
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é漢🙂x");
|
||||
}
|
||||
s.execute("story.replaceRange", &json!({"story": sid, "start": 2, "end": 5, "text": "字"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é字🙂x");
|
||||
// A caret before 🙂 (byte 5) would land inside it once é (2 bytes) is gone.
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user