mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 02:37:00 -06:00
links.relink: make replacements atomic and retain EPS sources
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
"{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<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.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<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 { 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<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, 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})),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+302
-80
@@ -260,13 +260,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);
|
||||
@@ -314,50 +309,39 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<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 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<Value> {
|
||||
);
|
||||
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<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);
|
||||
(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<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod eps_place_tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pdf_crop_tests {
|
||||
use serde_json::json;
|
||||
|
||||
+395
-29
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<u8>, (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<Arc<designcraft_doc::Asset>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<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 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("<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();
|
||||
assert_eq!(status(&embedded.assets[&aid]), "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/{}.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#"<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();
|
||||
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()));
|
||||
|
||||
@@ -1219,7 +1219,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::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<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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+50
-12
@@ -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<Vec<u8>, 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<Document, FormatError> {
|
||||
if let Some(meta) = read(&mut z, "meta.json")
|
||||
&& let Ok(m) = serde_json::from_slice::<Value>(&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::<Value>(&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))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -117,8 +117,11 @@ pub(crate) fn base64_decode(s: &str) -> Vec<u8> {
|
||||
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)));
|
||||
|
||||
+130
-12
@@ -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<f64> = 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<Vec<u8>> {
|
||||
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<u8>, (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<u8>, (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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
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::<tiff::encoder::colortype::CMYK8>(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::<Name>("Subtype").is_some_and(|n| n.as_str() == "Image"))
|
||||
.map(|s| (s.dict().get::<Name>("ColorSpace").unwrap().as_str().to_string(), s.decoded().unwrap().into_owned()))
|
||||
.collect();
|
||||
assert_eq!(images, vec![("DeviceCMYK".to_string(), pixels.clone())], "{standard:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -951,6 +951,9 @@ pub fn decode_pixmap(bytes: &[u8]) -> Option<Pixmap> {
|
||||
|
||||
/// [`decode_pixmap`] for page `page` of a PDF (other formats have one page).
|
||||
pub fn decode_pixmap_page(bytes: &[u8], page: u32) -> Option<Pixmap> {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user