diff --git a/Cargo.lock b/Cargo.lock index 671341623..6d85efbdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -981,6 +981,7 @@ name = "designcraft-format" version = "0.4.0" dependencies = [ "designcraft-doc", + "designcraft-images", "serde", "serde_json", "thiserror 2.0.21", @@ -1007,6 +1008,7 @@ dependencies = [ "designcraft-color", "designcraft-doc", "designcraft-geom", + "designcraft-images", "quick-xml 0.38.4", "thiserror 2.0.21", "zip", diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 101a5f5f5..60155addb 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -103,6 +103,10 @@ fn open_filters(purpose: &str) -> &'static [OpenFilter] { "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], "library" => &[OpenFilter { name: "Object Library", extensions: &["dclib"] }], + "relink" => &[OpenFilter { + name: "Graphics", + extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"], + }], "dataMerge" => &[OpenFilter { name: "Data source (CSV, TSV, text, Excel)", extensions: &["csv", "tsv", "tab", "txt", "xlsx"] }], "place" => &[ OpenFilter { @@ -260,6 +264,16 @@ fn main() -> eframe::Result { #[cfg(test)] mod tests { + #[test] + fn relink_open_dialog_lists_graphics() { + let filters = super::open_filters("relink"); + let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); + for ext in ["png", "jpg", "tif", "psd", "svg", "pdf", "ai", "eps"] { + assert!(exts.contains(&ext), "{ext} is missing from the relink dialog: {exts:?}"); + } + assert!(!exts.contains(&"designcraft") && !exts.contains(&"idml")); + } + #[test] fn data_merge_open_dialog_lists_table_extensions() { let filters = super::open_filters("dataMerge"); diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index deb0948bc..eb3c42c7b 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -340,7 +340,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::image_natural_size(&bytes)) else { if let Some(it) = doc.item_mut(item) { it.content = Content::Unassigned; } @@ -353,8 +353,26 @@ 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 mut g = + Graphic { asset: AssetId(0), size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] }; + if let Some(xf) = g.fitted(inner, mode) { + g.xf = xf; + } + if options.center && nw > 0.0 && nh > 0.0 { + let bb = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh)); + let c = inner.center(); + let m = bb.center(); + g.xf = Affine::translate((c.x - m.x, c.y - m.y)) * g.xf; + } + 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 aid = 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()); @@ -370,16 +388,7 @@ fn place_image( page: 0, }), ); - let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] }; - if let Some(xf) = g.fitted(inner, mode) { - g.xf = xf; - } - if options.center && nw > 0.0 && nh > 0.0 { - let bb = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh)); - let c = inner.center(); - let m = bb.center(); - g.xf = Affine::translate((c.x - m.x, c.y - m.y)) * g.xf; - } + g.asset = aid; if let Some(it) = doc.item_mut(item) { it.content = Content::Graphic(g); } diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index bf986ced7..70d94a332 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -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}; @@ -12,7 +13,7 @@ pub fn specs() -> Vec { "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})", has_doc, export_pdf), cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes} (no path: {base64, bytes})", + "{path?, title?, author?, language?: \"en\", cover?: bool (the first page as the cover image), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings})", has_doc, export_epub), cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", @@ -23,7 +24,7 @@ pub fn specs() -> Vec { "{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.exportText", "Export Text…", ["File"], None, "{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})", @@ -51,18 +52,52 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result { } } +/// 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) { + // 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 { let st = s.doc()?; let opts = designcraft_epub::HtmlOptions { title: str_param(p, "title").map(str::to_string), language: str_param(p, "language").map(str::to_string) }; - 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"))] std::fs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - 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})), } } @@ -156,14 +191,15 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { 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, warnings) = eps_previews(epub_doc); + let bytes = designcraft_epub::export_epub(&epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] std::fs::write(path, &bytes).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) } - None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), + None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e40000095..71d685da3 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -260,13 +260,8 @@ fn file_place(s: &mut Session, p: &Value) -> Result { 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); @@ -314,50 +309,39 @@ fn file_place(s: &mut Session, p: &Value) -> Result { let px = p.get("x").and_then(Value::as_f64); let py = p.get("y").and_then(Value::as_f64); let want_w = p.get("width").and_then(Value::as_f64); + // Prepare and validate all geometry before allocating an asset or changing a frame. + let d = &s.doc()?.doc; + let (frame_rect, xf, auto_fit) = if let Some(fid) = target_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); + (r, xf, designcraft_doc::Fitting::FillProportionally) + } else { + let sp = d.spread(spread).ok_or_else(|| bad("file.place", "no such spread"))?; + let pr = sp.pages.first().map(|pg| pg.margin_rect()).unwrap_or(Rect::new(36.0, 36.0, 300.0, 300.0)); + let (x, y) = (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0)); + let w = want_w.unwrap_or_else(|| nw.min(d.settings.page_width * 0.6)); + let k = w / nw; + let (bx, by, bw, bh) = crop_box.unwrap_or((0.0, 0.0, nw, nh)); + let r = Rect::new(x, y, x + bw * k, y + bh * k); + let xf = Affine::translate((x - bx * k, y - by * k)) * Affine::scale(k); + (r, xf, Default::default()) + }; + validate_placement((nw, nh), frame_rect, xf, "file.place")?; s.edit(|d, sel| { let aid = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); d.assets.insert(aid, Arc::new(Asset { page: pdf_page, id: aid, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)) })); - // The width the whole page (or image) gets; the height follows the proportions. - let w = match want_w { - Some(w) => w, - None => { - let page_w = d.settings.page_width * 0.6; - nw.min(page_w) - } - }; + let graphic = Content::Graphic(Graphic { asset: aid, size: (nw, nh), xf, auto_fit, fit_align: 4, crop: [0.0; 4] }); let id = if let Some(fid) = target_frame { let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; - let r = it.inner_bounds(); - // Fill frame proportionally. - let k = (r.width() / nw).max(r.height() / nh); - let (gw, gh) = (nw * k, nh * k); - it.content = Content::Graphic(Graphic { - asset: aid, - size: (nw, nh), - xf: Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k), - auto_fit: designcraft_doc::Fitting::FillProportionally, - fit_align: 4, - crop: [0.0; 4], - }); + it.content = graphic; fid } else { - let sp = d.spread(spread).ok_or_else(|| bad("file.place", "no such spread"))?; - let pr = sp.pages.first().map(|pg| pg.margin_rect()).unwrap_or(Rect::new(36.0, 36.0, 300.0, 300.0)); - let (x, y) = (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0)); let id = ItemId(d.alloc()); - let k = w / nw; - let (bx, by, bw, bh) = crop_box.unwrap_or((0.0, 0.0, nw, nh)); - let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x, y, x + bw * k, y + bh * k))); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_rect)); it.object_style = d.styles.default_graphic_frame.clone(); - it.content = Content::Graphic(Graphic { - asset: aid, - size: (nw, nh), - xf: Affine::translate((x - bx * k, y - by * k)) * Affine::scale(k), - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - }); + it.content = graphic; d.insert_item(spread, it, None)?; id }; @@ -416,9 +400,35 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option) Ok((b, name, Some(path.to_string()))) } +fn eps_size(bytes: &[u8], command: &str) -> Result> { + 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 { 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 size = eps_size(&bytes, "place.load")?.unwrap_or((pw as f64, ph as f64)); let aid = s.edit(|d, _| { let aid = AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); @@ -428,7 +438,7 @@ fn place_load(s: &mut Session, p: &Value) -> Result { ); Ok(aid) })?; - s.loaded = Some((aid, (pw as f64, ph as f64))); + s.loaded = Some((aid, size)); s.set_tool("placeGun"); Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph})) } @@ -440,45 +450,39 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { 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); + (r, xf, designcraft_doc::Fitting::FillProportionally) + } 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), - auto_fit: designcraft_doc::Fitting::FillProportionally, - fit_align: 4, - crop: [0.0; 4], - }); - 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 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(); - it.content = Content::Graphic(Graphic { - asset: aid, - size: (nw, nh), - xf: Affine::translate((frame_r.x0, frame_r.y0)) * Affine::scale(k), - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - }); - d.insert_item(sr, it, None)?; - id - } + let graphic = Content::Graphic(Graphic { asset: aid, size: (nw, nh), xf, auto_fit, fit_align: 4, crop: [0.0; 4] }); + 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(); + it.content = graphic; + d.insert_item(sr, it, None)?; + id }; *sel = Selection::items(vec![id]); Ok(json!({"id": id.0})) @@ -642,6 +646,224 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { } } +#[cfg(test)] +pub(crate) mod eps_place_tests { + use super::*; + + pub(crate) fn tiff_eps() -> Vec { + 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(" Vec { "Relink…", ["Window", "Links"], None, - "{asset, path} — use another file for every placement of the graphic", + "{asset, path} — use another file for every placement of the graphic; keeps the selected PDF page (error if missing)", has_doc, relink ), @@ -153,19 +153,34 @@ fn reload(path: &str) -> Result<(Vec, (u32, u32))> { Err(EngineError::Other(format!("{path}: files can't be read on the web"))) } +fn relinked_asset(a: &designcraft_doc::Asset, path: &str) -> Result> { + let (bytes, px) = reload(path)?; + 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!("{path}: can't read PDF page {}", 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 = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); + a.link = Some(path.to_string()); + Ok(Arc::new(a)) +} + fn relink(s: &mut Session, p: &Value) -> Result { let aid = asset_param(p, "links.relink")?; - let path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?.to_string(); - 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 path = str_param(p, "path").ok_or_else(|| bad("links.relink", "missing path"))?; + let a = s.doc()?.doc.assets.get(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?; + let fresh = relinked_asset(a, 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()); + let name = fresh.name.clone(); + d.assets.insert(aid, fresh); Ok(json!({"name": name})) }) } @@ -173,6 +188,9 @@ fn relink(s: &mut Session, p: &Value) -> Result { fn relink_folder(s: &mut Session, p: &Value) -> Result { let dir = str_param(p, "dir").map(std::path::PathBuf::from); 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`")); } @@ -198,38 +216,40 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { 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.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 { let d = &s.doc()?.doc; - let which: Vec<(AssetId, String)> = d - .assets - .values() - .filter(|a| p.get("asset").and_then(Value::as_u64).is_none_or(|x| x == a.id.0)) - .filter(|a| status(a) == "modified") - .filter_map(|a| a.link.clone().map(|l| (a.id, l))) - .collect(); let mut fresh = Vec::new(); - for (aid, path) in which { - fresh.push((aid, reload(&path)?)); + for a in d.assets.values().filter(|a| p.get("asset").and_then(Value::as_u64).is_none_or(|x| x == a.id.0)).filter(|a| status(a) == "modified") { + if let Some(path) = &a.link { + fresh.push((a.id, relinked_asset(a, path)?)); + } } let n = fresh.len(); if n == 0 { 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); - } + for (aid, a) in fresh { + d.assets.insert(aid, a); } Ok(json!({"updated": n})) }) @@ -243,6 +263,286 @@ 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 { + 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 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/{}.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("("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/{}.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 = + json!({"asset": aid.0, "path": replacement.to_string_lossy(), "dir": dir.join("new").to_string_lossy(), "missingOnly": false}); + 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())); @@ -282,10 +582,76 @@ 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#""#).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(); + assert_eq!(st.revision, revision + 1, "the batch commits once"); + assert_eq!(st.history.undo.len(), undo + 1); + 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())); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index c2f925c09..01bf6e86f 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1219,7 +1219,20 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result { 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::image_natural_size(&bytes).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 aid = designcraft_doc::AssetId(d.alloc()); let mime = designcraft_render::image_mime(&bytes).to_string(); @@ -1237,16 +1250,6 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result { ); 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, diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index c3b8ac459..b7634f1c8 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] designcraft-doc = { workspace = true } +designcraft-images = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } zip = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f80ad0a65..e58277e0b 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -17,14 +17,15 @@ use designcraft_doc::{AssetId, Document}; use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; -pub const VERSION: u32 = 1; +/// Latest reader capability. Version 2 adds rendering of retained EPS source assets. +pub const VERSION: u32 = 2; #[derive(Debug, thiserror::Error)] pub enum FormatError { #[error("not a DesignCraft document: {0}")] NotOurs(String), #[error("document is from a newer DesignCraft (format {0}); please update")] - TooNew(u32), + TooNew(u64), #[error("{0}")] Io(String), } @@ -37,6 +38,7 @@ fn ext_for(mime: &str) -> &'static str { "image/webp" => "webp", "image/tiff" => "tif", "application/pdf" => "pdf", + "application/postscript" => "eps", "image/svg+xml" => "svg", _ => "bin", } @@ -53,7 +55,10 @@ pub fn save(doc: &Document) -> Result, FormatError> { z.start_file("mimetype", stored).map_err(io)?; z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("meta.json", deflate).map_err(io)?; - let meta = json!({"format": "designcraft", "version": VERSION, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); + // Older readers would silently omit EPS artwork. Preview-only legacy assets and + // documents without EPS still use version 1 so their existing readers keep working. + let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } else { 1 }; + let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))}); z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("document.json", deflate).map_err(io)?; let json = serde_json::to_vec_pretty(doc).map_err(|e| FormatError::Io(e.to_string()))?; @@ -91,9 +96,9 @@ fn load_zip(bytes: &[u8]) -> Result { if let Some(meta) = read(&mut z, "meta.json") && let Ok(m) = serde_json::from_slice::(&meta) && let Some(v) = m.get("version").and_then(Value::as_u64) - && v as u32 > VERSION + && v > u64::from(VERSION) { - return Err(FormatError::TooNew(v as u32)); + return Err(FormatError::TooNew(v)); } let json = read(&mut z, "document.json").ok_or_else(|| FormatError::NotOurs("missing document.json".into()))?; let mut v: Value = serde_json::from_slice(&json).map_err(|e| FormatError::NotOurs(e.to_string()))?; @@ -205,6 +210,37 @@ mod tests { use designcraft_doc::geom::Rect; use designcraft_doc::{Asset, ParaFormat, SpreadRef}; + #[test] + fn eps_source_requires_new_reader_but_preview_documents_keep_version_one() { + let mut d = Document::new(&NewDocument::default()); + let version = |d: &Document| { + let bytes = save(d).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut meta = String::new(); + z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); + serde_json::from_str::(&meta).unwrap()["version"].as_u64().unwrap() + }; + assert_eq!(version(&d), 1); + let aid = AssetId(d.alloc()); + let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec(); + d.assets.insert( + aid, + Arc::new(Asset { + id: aid, + name: "logo.eps".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(source.clone()), + pixels: Some((40, 20)), + page: 0, + }), + ); + assert_eq!(version(&d), 2, "sniff source even when legacy metadata says PNG"); + assert_eq!(load(&save(&d).unwrap()).unwrap().assets[&aid].data.as_slice(), source); + Arc::make_mut(d.assets.get_mut(&aid).unwrap()).data = Arc::new(b"\x89PNG\r\n\x1a\npreview".to_vec()); + assert_eq!(version(&d), 1, "legacy EPS previews stay compatible"); + } + #[test] fn roundtrip_with_assets() { let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); @@ -290,13 +326,15 @@ mod tests { #[test] fn rejects_garbage_and_newer_versions() { assert!(load(b"nope").is_err()); - let mut buf = Cursor::new(Vec::new()); - { - let mut z = zip::ZipWriter::new(&mut buf); - z.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap(); - z.write_all(br#"{"version": 99}"#).unwrap(); - z.finish().unwrap(); + for version in [99u64, u64::from(u32::MAX) + 2] { + let mut buf = Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + z.start_file("meta.json", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(json!({"version": version}).to_string().as_bytes()).unwrap(); + z.finish().unwrap(); + } + assert!(matches!(load(&buf.into_inner()), Err(FormatError::TooNew(v)) if v == version)); } - assert!(matches!(load(&buf.into_inner()), Err(FormatError::TooNew(99)))); } } diff --git a/crates/idml/Cargo.toml b/crates/idml/Cargo.toml index 9ee690531..f516a6150 100644 --- a/crates/idml/Cargo.toml +++ b/crates/idml/Cargo.toml @@ -12,6 +12,7 @@ workspace = true designcraft-geom = { workspace = true } designcraft-color = { workspace = true } designcraft-doc = { workspace = true } +designcraft-images = { workspace = true } thiserror = { workspace = true } quick-xml = { workspace = true } zip = { workspace = true } diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 919991eb0..a31abd710 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1683,7 +1683,15 @@ impl<'a> Ex<'a> { fn image_el(&mut self, asset: &designcraft_doc::Asset, g: &designcraft_doc::Graphic) -> El { let is_pdf = asset.mime == "application/pdf"; - let mut el = El::new(if is_pdf { "PDF" } else { "Image" }).attr("Self", self.fresh()); + let is_eps = asset.mime == "application/postscript" || designcraft_images::is_eps(&asset.data); + let mut el = El::new(if is_eps { + "EPS" + } else if is_pdf { + "PDF" + } else { + "Image" + }) + .attr("Self", self.fresh()); if let Some((pw, ph)) = asset.pixels && g.size.0 > 0.0 && g.size.1 > 0.0 @@ -1710,7 +1718,7 @@ impl<'a> Ex<'a> { .attr("AssetURL", "$ID/") .attr("AssetID", "$ID/") .attr("LinkResourceURI", uri) - .attr("LinkResourceFormat", format!("$ID/{}", format_name(&asset.mime))) + .attr("LinkResourceFormat", format!("$ID/{}", format_name(if is_eps { "application/postscript" } else { &asset.mime }))) .attr("StoredState", if embed { "Embedded" } else { "Normal" }) .attr("LinkClassID", "35906") .attr("LinkClientID", "257") @@ -2358,6 +2366,7 @@ fn format_name(mime: &str) -> &'static str { "image/gif" => "GIF", "image/tiff" => "TIFF", "application/pdf" => "Adobe Portable Document Format (PDF)", + "application/postscript" => "Encapsulated PostScript (EPS)", _ => "Portable Network Graphics (PNG)", } } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3e1c17e28..f12add2a9 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2118,6 +2118,7 @@ impl<'r> Importer<'r> { } let (mime, px) = sniff_image(&data); let mime = mime.map(str::to_string).unwrap_or_else(|| match link.and_then(|k| k.get("LinkResourceFormat")).unwrap_or("") { + f if f.contains("EPS") || g.local() == "EPS" => "application/postscript".into(), f if f.contains("JPEG") => "image/jpeg".into(), f if f.contains("TIFF") => "image/tiff".into(), f if f.contains("PDF") || g.local() == "PDF" => "application/pdf".into(), diff --git a/crates/idml/src/lib.rs b/crates/idml/src/lib.rs index c82f0c74d..b9cfdd7c2 100644 --- a/crates/idml/src/lib.rs +++ b/crates/idml/src/lib.rs @@ -117,8 +117,11 @@ pub(crate) fn base64_decode(s: &str) -> Vec { out } -/// Sniff an image's MIME type and pixel size from its header (PNG, JPEG, GIF, WebP, TIFF). +/// Sniff an image's MIME type and pixel size (PNG, JPEG, GIF, WebP, TIFF, PDF, EPS). pub(crate) fn sniff_image(b: &[u8]) -> (Option<&'static str>, Option<(u32, u32)>) { + if designcraft_images::is_eps(b) { + return (Some("application/postscript"), designcraft_images::eps_pixel_size(b)); + } let be32 = |i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])); if b.starts_with(b"\x89PNG\r\n\x1a\n") { return (Some("image/png"), be32(16).zip(be32(20))); diff --git a/crates/images/src/eps.rs b/crates/images/src/eps.rs index 01d959292..025ce9db6 100644 --- a/crates/images/src/eps.rs +++ b/crates/images/src/eps.rs @@ -26,26 +26,113 @@ fn sections(bytes: &[u8]) -> (&[u8], Option<&[u8]>) { pub fn bounding_box(bytes: &[u8]) -> Option<[f64; 4]> { let (ps, _) = sections(bytes); let head = String::from_utf8_lossy(&ps[..ps.len().min(64 * 1024)]); - let read = |key: &str| { - head.lines().find_map(|l| { - let v: Vec = l.strip_prefix(key)?.split_whitespace().filter_map(|t| t.parse().ok()).collect(); - (v.len() == 4 && v[2] > v[0] && v[3] > v[1]).then(|| [v[0], v[1], v[2], v[3]]) - }) + // DSC allows (atend): scan a bounded trailer as well, without reading the whole program. + let tail = String::from_utf8_lossy(&ps[ps.len().saturating_sub(64 * 1024)..]); + let header_value = |key: &str| { + head.lines() + .take_while(|l| { + (l.starts_with('%') || l.trim().is_empty()) + && ![ + "%%EndComments", + "%%BeginDocument", + "%%BeginProlog", + "%%BeginSetup", + "%%BeginData", + "%%BeginBinary", + "%%BeginResource", + "%%Page:", + ] + .iter() + .any(|k| l.starts_with(k)) + }) + .find_map(|l| l.strip_prefix(key)) }; - read("%%HiResBoundingBox:").or_else(|| read("%%BoundingBox:")) + let hires = header_value("%%HiResBoundingBox:"); + let bbox = header_value("%%BoundingBox:"); + let at_end = |v: Option<&str>| v.is_some_and(|v| v.trim() == "(atend)"); + // An outer header box must not be replaced by a nested document's trailer comments. + // Inspect the trailer only when the outer header delegates its bounds there. + hires + .and_then(parse_box) + .or_else(|| (at_end(hires) || at_end(bbox)).then(|| trailer_box(&tail, "%%HiResBoundingBox:")).flatten()) + .or_else(|| bbox.and_then(parse_box)) + .or_else(|| at_end(bbox).then(|| trailer_box(&tail, "%%BoundingBox:")).flatten()) +} + +fn parse_box(value: &str) -> Option<[f64; 4]> { + let mut words = value.split_whitespace(); + let mut bb = [0.0f64; 4]; + for n in &mut bb { + *n = words.next()?.parse().ok()?; + } + (words.next().is_none() && bb.iter().all(|n| n.is_finite()) && bb[2] > bb[0] && bb[3] > bb[1]).then_some(bb) +} + +fn trailer_box(tail: &str, key: &str) -> Option<[f64; 4]> { + // Work backwards from the outer EOF so nesting remains known even when the bounded + // window begins inside a child document. Opaque DSC data/resource blocks are skipped too. + let mut nested = 0usize; + for line in tail.lines().rev() { + if ["%%EndDocument", "%%EndData", "%%EndBinary", "%%EndResource"].iter().any(|k| line.starts_with(k)) { + nested = nested.checked_add(1)?; + } else if ["%%BeginDocument", "%%BeginData", "%%BeginBinary", "%%BeginResource"].iter().any(|k| line.starts_with(k)) { + nested = nested.checked_sub(1)?; + } else if nested == 0 + && let Some(bb) = line.strip_prefix(key).and_then(parse_box) + { + return Some(bb); + } + } + None +} + +/// Natural EPS dimensions in points. Bound sizes before fitting or allocating a proxy: +/// sub-thousandth-point artwork and dimensions over 20,000 points aren't usable layouts. +pub fn eps_size(bytes: &[u8]) -> Option<(f64, f64)> { + let bb = bounding_box(bytes)?; + let (w, h) = (bb[2] - bb[0], bb[3] - bb[1]); + ((0.001..=20_000.0).contains(&w) && (0.001..=20_000.0).contains(&h)).then_some((w, h)) +} + +fn preview(bytes: &[u8]) -> Option<(&[u8], (u32, u32))> { + let (_, tiff) = sections(bytes); + let tiff = tiff?; + let (w, h) = crate::pixel_size(tiff)?; + // Match the decoder's allocation budget, including conversion to RGBA. + (w > 0 && h > 0 && u64::from(w) * u64::from(h) <= 256 * 1024 * 1024 / 4).then_some((tiff, (w, h))) +} + +/// Proxy pixel dimensions, without encoding the placeholder just to read its size. +pub fn eps_pixel_size(bytes: &[u8]) -> Option<(u32, u32)> { + let (w, h) = eps_size(bytes)?; + Some( + preview(bytes) + .map(|(_, px)| px) + .unwrap_or_else(|| ((w * 2.0).round().clamp(2.0, 4000.0) as u32, (h * 2.0).round().clamp(2.0, 4000.0) as u32)), + ) +} + +/// A browser-readable EPS preview. Keep the original TIFF for print exports (CMYK inks), +/// but HTML and EPUB need PNG rather than a TIFF data URI or a mislabeled .png file. +pub fn eps_png(bytes: &[u8]) -> Option> { + let (proxy, _) = eps_proxy(bytes)?; + if proxy.starts_with(b"\x89PNG\r\n\x1a\n") { + return Some(proxy); + } + let rgba = crate::decode_rgba(&proxy)?; + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(rgba).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) } /// The proxy for a placed EPS: (image bytes, its size in points). The TIFF preview when present, /// else a light grey placeholder with a cross, at 2 pixels per point. pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec, (f64, f64))> { - let bb = bounding_box(bytes)?; - let (w, h) = (bb[2] - bb[0], bb[3] - bb[1]); - if let (_, Some(tiff)) = sections(bytes) - && crate::pixel_size(tiff).is_some() - { + let (w, h) = eps_size(bytes)?; + if let Some((tiff, _)) = preview(bytes) { return Some((tiff.to_vec(), (w, h))); } - let (pw, ph) = ((w * 2.0).round().clamp(2.0, 4000.0) as u32, (h * 2.0).round().clamp(2.0, 4000.0) as u32); + let (pw, ph) = eps_pixel_size(bytes)?; let mut img = image::RgbaImage::from_pixel(pw, ph, image::Rgba([228, 228, 228, 255])); // Diagonals and a border mark it as a stand-in. let n = pw.max(ph); @@ -72,6 +159,37 @@ pub fn eps_proxy(bytes: &[u8]) -> Option<(Vec, (f64, f64))> { mod tests { use super::*; + #[test] + fn eps_rejects_unsafe_bounds_and_accepts_trailer_bounds() { + for bounds in ["0 0 1e-320 1e-320", "0 0 1e308 1e308", "0 0 inf 10"] { + let eps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: {bounds}\n%%EOF\n"); + assert!(eps_proxy(eps.as_bytes()).is_none(), "{bounds}"); + } + let mut eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: (atend)\n".to_vec(); + eps.extend(vec![b' '; 70_000]); + eps.extend(b"\n%%Trailer\n%%BoundingBox: 0 0 20 10\n%%EOF\n"); + assert_eq!(eps_proxy(&eps).unwrap().1, (20.0, 10.0)); + } + + #[test] + fn eps_nested_document_bounds_do_not_override_outer_artwork() { + for (deferred, padding, child_padding) in [(false, 0, 0), (false, 8_000, 0), (true, 8_000, 0), (true, 0, 8_000)] { + let mut eps = + format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: {}\n%%EndComments\n", if deferred { "(atend)" } else { "0 0 100 50" }).into_bytes(); + eps.extend(b"% padding\n".repeat(padding)); + eps.extend(b"%%BeginDocument: child.eps\n%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: (atend)\n%%EndComments\n"); + eps.extend(b"% child padding\n".repeat(child_padding)); + eps.extend(b"%%Trailer\n%%BoundingBox: 0 0 10 20\n%%HiResBoundingBox: 0 0 10 20\n%%EOF\n%%EndDocument\n"); + if deferred { + eps.extend(b"%%Trailer\n%%BoundingBox: 0 0 100 50\n"); + } + eps.extend(b"%%EOF\n"); + assert_eq!(eps_size(&eps), Some((100.0, 50.0)), "deferred={deferred}, padding={padding}, child_padding={child_padding}"); + } + let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: (atend)\n%%HiResBoundingBox: (atend)\n%%EndComments\nnewpath\n%%Trailer\n%%BoundingBox: 0 0 101 51\n%%HiResBoundingBox: 0 0 100.5 50.25\n%%EOF\n"; + assert_eq!(eps_size(eps), Some((100.5, 50.25))); + } + #[test] fn eps_bounding_box_and_proxies() { let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 50\n%%HiResBoundingBox: 0 0 100.5 50.25\nnewpath\n%%EOF\n"; diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index b770140fd..0e0d204c7 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -10,7 +10,7 @@ pub use resvg::usvg; mod eps; mod psd; -pub use eps::{bounding_box as eps_bounding_box, eps_proxy, is_eps}; +pub use eps::{bounding_box as eps_bounding_box, eps_pixel_size, eps_png, eps_proxy, eps_size, is_eps}; /// CSS pixels (SVG user units) → points. pub const PT_PER_PX: f64 = 0.75; @@ -33,6 +33,9 @@ pub fn is_svg(bytes: &[u8]) -> bool { /// MIME type of a placed graphic. pub fn mime(bytes: &[u8]) -> &'static str { + if is_eps(bytes) { + return "application/postscript"; + } if is_pdf(bytes) { return "application/pdf"; } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0c77e69b9..4713f0df5 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1165,7 +1165,17 @@ impl Exporter<'_> { return i.clone(); } let asset = self.doc.assets.get(&id)?.clone(); - let data = asset.data.clone(); + // Use the same EPS preview as the renderer, preserving CMYK TIFF inks below. + let data = if designcraft_images::is_eps(&asset.data) { + let Some((proxy, _)) = designcraft_images::eps_proxy(&asset.data) else { + self.warn(format!("image `{}` could not be decoded and was skipped", asset.name)); + self.images.insert(id, None); + return None; + }; + Arc::new(proxy) + } else { + asset.data.clone() + }; let fmt = image::guess_format(&data).ok(); let img = match fmt { Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), true).ok(), @@ -1342,3 +1352,70 @@ fn recompress(data: &[u8]) -> Option { image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?; Image::from_jpeg(buf.into(), true).ok() } + +#[cfg(test)] +mod eps_tests { + use super::*; + use hayro_syntax::object::Name; + + #[test] + fn eps_tiff_preview_keeps_cmyk_inks_in_pdf() { + let pixels = vec![0u8, 0, 0, 255, 0, 255, 255, 0]; + let mut tiff = Vec::new(); + tiff::encoder::TiffEncoder::new(std::io::Cursor::new(&mut tiff)) + .unwrap() + .write_image::(2, 1, &pixels) + .unwrap(); + let ps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 2 1\n%%EOF\n"; + let mut eps = vec![0xC5, 0xD0, 0xD3, 0xC6]; + for value in [30usize, ps.len(), 0, 0, 30 + ps.len(), tiff.len()] { + eps.extend((value as u32).to_le_bytes()); + } + eps.extend([0xFF, 0xFF]); + eps.extend_from_slice(ps); + eps.extend_from_slice(&tiff); + assert_eq!(designcraft_images::eps_proxy(&eps).unwrap().0, tiff); + let mut doc = Document::new(&designcraft_doc::build::NewDocument::default()); + let aid = AssetId(doc.alloc()); + doc.assets.insert( + aid, + Arc::new(designcraft_doc::Asset { + id: aid, + name: "cmyk.eps".into(), + mime: "application/postscript".into(), + link: None, + data: Arc::new(eps), + pixels: Some((2, 1)), + page: 0, + }), + ); + let mut item = Item::new( + designcraft_doc::ItemId(doc.alloc()), + doc.default_layer(), + designcraft_doc::Shape::Rectangle, + designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)), + ); + item.content = Content::Graphic(designcraft_doc::Graphic { + asset: aid, + size: (2.0, 1.0), + xf: Affine::translate((72.0, 72.0)) * Affine::scale(100.0), + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + }); + doc.insert_item(designcraft_doc::SpreadRef::Doc(0), item, None).unwrap(); + for standard in [Standard::None, Standard::PdfX4] { + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + let pdf = hayro_syntax::Pdf::new(report.bytes).unwrap(); + let images: Vec<_> = pdf + .objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .filter(|s| s.dict().get::("Subtype").is_some_and(|n| n.as_str() == "Image")) + .map(|s| (s.dict().get::("ColorSpace").unwrap().as_str().to_string(), s.decoded().unwrap().into_owned())) + .collect(); + assert_eq!(images, vec![("DeviceCMYK".to_string(), pixels.clone())], "{standard:?}"); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index c1b288def..4a85ec326 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -951,6 +951,9 @@ pub fn decode_pixmap(bytes: &[u8]) -> Option { /// [`decode_pixmap`] for page `page` of a PDF (other formats have one page). pub fn decode_pixmap_page(bytes: &[u8], page: u32) -> Option { + // Documents retain the EPS source; only display its TIFF preview or placeholder. + let proxy = if designcraft_images::is_eps(bytes) { Some(designcraft_images::eps_proxy(bytes)?.0) } else { None }; + let bytes = proxy.as_deref().unwrap_or(bytes); if is_pdf(bytes) { return render_pdf_page(bytes, page as usize, 3000); } @@ -1011,6 +1014,9 @@ fn now() -> u64 { /// Pixel size of an encoded image without decoding it fully. pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> { + if designcraft_images::is_eps(bytes) { + return designcraft_images::eps_pixel_size(bytes); + } if is_pdf(bytes) { // Placed PDFs are sized by their page (crop box) in points. return pdf_page_size(bytes, 0).map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)); @@ -1022,6 +1028,11 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> { designcraft_images::pixel_size(bytes) } +/// Natural graphic size in points; EPS uses its bounding box, independently of preview pixels. +pub fn image_natural_size(bytes: &[u8]) -> Option<(f64, f64)> { + if designcraft_images::is_eps(bytes) { designcraft_images::eps_size(bytes) } else { image_size(bytes).map(|(w, h)| (w as f64, h as f64)) } +} + /// MIME type guess for encoded image bytes. /// Is this a PDF file (placed PDFs are graphics)? pub fn is_pdf(bytes: &[u8]) -> bool { @@ -1169,6 +1180,34 @@ mod tests { use designcraft_doc::build::NewDocument; use designcraft_doc::{Fill, ParaFormat}; + #[test] + fn eps_source_decodes_like_its_preview() { + let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n"; + let mut tiff = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(3, 2, image::Rgba([255, 0, 0, 128]))) + .write_to(&mut std::io::Cursor::new(&mut tiff), image::ImageFormat::Tiff) + .unwrap(); + let mut dos = vec![0xC5, 0xD0, 0xD3, 0xC6]; + for value in [30usize, source.len(), 0, 0, 30 + source.len(), tiff.len()] { + dos.extend((value as u32).to_le_bytes()); + } + dos.extend([0xFF, 0xFF]); + dos.extend_from_slice(source); + dos.extend_from_slice(&tiff); + for source in [source.as_slice(), dos.as_slice()] { + let proxy = designcraft_images::eps_proxy(source).unwrap().0; + let actual = decode_pixmap(source).unwrap(); + let expected = decode_pixmap(&proxy).unwrap(); + assert_eq!((actual.width(), actual.height()), (expected.width(), expected.height())); + assert_eq!(actual.data(), expected.data()); + assert_eq!(image_size(source), image_size(&proxy)); + assert_eq!(image_mime(source), "application/postscript"); + } + let corrupt = b"%!PS-Adobe-3.0 EPSF-3.0\nno bounding box\n"; + assert!(decode_pixmap(corrupt).is_none()); + assert!(image_size(corrupt).is_none()); + } + /// `Rendered` has public fields: a buffer that doesn't match the stated size used to panic /// in `to_png`. /// A canvas 65535 pixels wide (any render wider than that was clamped to it) panicked inside