From b050343a6ca790d7a3e0c7167827260c114affb9 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 8 Oct 2026 11:13:17 -0600 Subject: [PATCH 01/45] links.relink: make replacements atomic and retain EPS sources --- Cargo.lock | 2 + apps/designcraft/src/main.rs | 14 + crates/engine/src/cmd/datamerge/fill.rs | 33 +- crates/engine/src/cmd/export.rs | 52 ++- crates/engine/src/cmd/file.rs | 382 ++++++++++++++++----- crates/engine/src/cmd/links.rs | 424 ++++++++++++++++++++++-- crates/engine/src/cmd/table.rs | 23 +- crates/format/Cargo.toml | 1 + crates/format/src/lib.rs | 62 +++- crates/idml/Cargo.toml | 1 + crates/idml/src/export.rs | 13 +- crates/idml/src/import.rs | 1 + crates/idml/src/lib.rs | 5 +- crates/images/src/eps.rs | 142 +++++++- crates/images/src/lib.rs | 5 +- crates/pdf/src/export.rs | 79 ++++- crates/render/src/lib.rs | 39 +++ 17 files changed, 1110 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67134162..6d85efbd 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 101a5f5f..60155add 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 deb0948b..eb3c42c7 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 bf986ced..70d94a33 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 e4000009..71d685da 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 c2f925c0..01bf6e86 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 c3b8ac45..b7634f1c 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 f80ad0a6..e58277e0 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 9ee69053..f516a615 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 919991eb..a31abd71 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 3e1c17e2..f12add2a 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 c82f0c74..b9cfdd7c 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 01d95929..025ce9db 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 b770140f..0e0d204c 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 0c77e69b..4713f0df 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 c1b288de..4a85ec32 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 From 54305b11eb93e913addf96b0d1bdcd6bd4557d17 Mon Sep 17 00:00:00 2001 From: Sean Hammond Date: Sat, 10 Oct 2026 18:45:56 +0000 Subject: [PATCH 02/45] Drop caps: scale with the cap's point size, as InDesign does A drop cap came out the same size whatever its point size: the fit (drop + body cap height) / (the cap's own cap height) cancels the cap's size, since its own cap height grows with it. InDesign fits the cap as if it were set at the body text's size and scales that by the cap's size over the body's, keeping its baseline: a larger cap rises above the first line, a smaller one falls short of it. Measured against an InDesign capture (Palatino, 14 pt body, 18 pt italic T, 2 lines): cap height 2.04x the line gap there, 2.03x predicted. The cap's box (selection highlight, caret) now reaches the top of a cap that rises above its line; selected glyphs are drawn inverted, so the part outside the box would vanish. A one-line cap still keeps its own size when that is smaller. Test: drop_cap_scales_with_its_point_size (a 15 pt cap over 10 pt text is 1.5x the fitted height on the same baseline, 7.5 pt is 0.75x, the body lines don't move, the box covers the cap). Before the fix the 15 pt cap was 30.7 pt, the same as at 10 pt; with it, 46.05 pt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/compose/src/lib.rs | 23 +++++++++++++++++------ crates/compose/src/tests.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f..bfd29b39 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1133,7 +1133,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C drop_box = Some(DropCapBox { end: dc.end, baseline: baseline + dc.drop, - rect: Rect::new(at, baseline - asc, at + dc.width, baseline + dc.drop + desc), + // From its line's top, or its own top when it rises above that line. + rect: Rect::new(at, (baseline - asc).min(baseline + dc.drop - dc.cap), at + dc.width, baseline + dc.drop + desc), }); } ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback); @@ -2923,11 +2924,15 @@ struct DropCap { /// Its baseline below the first line's: `lines − 1` leadings of the text after it (grid steps /// on a baseline grid), wherever line `lines` is set. drop: f64, + /// Its cap height as set (how far its top sits above its baseline). + cap: f64, } /// Take the drop cap (the glyphs before byte `end`) off the front of a paragraph's glyphs and scale /// it so its cap height reaches from the first line's cap height down to line `lines`' baseline, -/// measured in the leading and cap height of the text after it. A one-line drop cap keeps its own +/// measured in the leading and cap height of the text after it: the size of a cap set at that text's +/// size. As in InDesign, a cap set larger (or smaller) is scaled by its size over that text's, +/// keeping its baseline, so it rises above the first line (or falls short of it). A one-line drop cap keeps its own /// size when that is smaller. On a baseline grid of increment `grid`, lines are that many grid /// steps apart. With `align_left` its ink starts at the indent (see [`DropCap::lsb`]). Tabs inside /// it advance to the stops of `tabs` (stops and the start-side indent; in right-to-left paragraphs @@ -2955,14 +2960,19 @@ fn split_drop_cap( let mut after: Vec = glyphs.drain(..spaces).collect(); let end = after.last().map_or(end, |g| g.byte.saturating_add(g.len).max(end)); let body = glyphs.iter().find(|g| g.len > 0 && g.cap > 0.0); - let (lead, cap) = body.map_or((base_leading, base_cap), |g| (g.leading, g.cap)); + let (lead, cap, body_size) = body.map_or((base_leading, base_cap, None), |g| (g.leading, g.cap, Some(g.size))); let lead = match grid.map(|inc| (lead / inc - 1e-6).ceil().max(1.0) * inc) { Some(pitch) if pitch.is_finite() && pitch > 0.0 => pitch, _ => lead, }; let drop = (lines - 1) as f64 * lead; - let own = dc.iter().map(|g| g.cap).fold(0.0, f64::max); - let k = if own > 0.0 { (drop + cap) / own } else { 1.0 }; + // The cap's tallest glyph sets its height; its point size over the body's scales the fit. + let (own, own_size) = dc.iter().fold((0.0, 0.0), |(c, s), g| if g.cap > c { (g.cap, g.size) } else { (c, s) }); + let ratio = match body_size { + Some(b) if b > 0.0 && own_size > 0.0 && (own_size / b).is_finite() => own_size / b, + _ => 1.0, + }; + let k = if own > 0.0 { (drop + cap) / own * ratio } else { 1.0 }; let k = if lines == 1 { k.min(1.0) } else { k }; let k = if k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 }; for g in dc.iter_mut().chain(after.iter_mut()) { @@ -3003,7 +3013,8 @@ fn split_drop_cap( } let width = dc.iter().map(|g| g.adv).sum(); dc.append(&mut after); - Some(DropCap { glyphs: dc, end, lines, width, drop, lsb }) + let cap = dc.iter().map(|g| g.cap).fold(0.0, f64::max); + Some(DropCap { glyphs: dc, end, lines, width, drop, lsb, cap }) } impl DropCap { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a20ec78b..7f8918a1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2493,6 +2493,37 @@ fn right_to_left_drop_cap_sets_its_tabs_from_the_right_edge() { assert!(right <= 260.0 + 1e-6 && right > 255.0, "text ends at {right}"); } +/// The cap's point size scales it, as in InDesign: at the body's size (10 pt) it fills its lines +/// exactly; set larger or smaller it is scaled by its size over the body's, keeping its baseline, +/// so it rises above the first line (or falls short of it). The body's lines don't move, and the +/// cap's box (highlight, caret) reaches the top of a cap that rises above its line. +#[test] +fn drop_cap_scales_with_its_point_size() { + let set = |size: f64| { + let (mut d, sid, _) = drop_doc(&[LOREM; 2].join(" "), drop_cap(3, 1)); + d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(size)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).into_iter().cloned().collect::>() + }; + let base = set(10.0); + let (b0, b2) = (base[0].baseline, base[2].baseline); + let body = base[0].glyphs.iter().find(|g| g.byte == 1).unwrap(); + let fitted = cap_of(&base[0].glyphs[0]); + assert!((b2 - fitted - (b0 - cap_of(body))).abs() < 0.01, "at the body's size the cap fills its lines"); + for (size, scale) in [(15.0, 1.5), (7.5, 0.75)] { + let l = set(size); + let dc = &l[0].glyphs[0]; + for (a, b) in l.iter().zip(&base) { + assert!((a.baseline - b.baseline).abs() < 1e-6, "{size} pt: the lines stay where they were"); + } + assert!((l[0].baseline + dc.y - b2).abs() < 1e-6, "{size} pt: the cap sits on line 3's baseline"); + assert!((cap_of(dc) - fitted * scale).abs() < 0.01, "{size} pt: cap height {} vs {}", cap_of(dc), fitted * scale); + let top = (l[0].baseline - l[0].ascent).min(b2 - cap_of(dc)); + assert!((l[0].drop_cap.unwrap().rect.y0 - top).abs() < 1e-6, "{size} pt: the box starts at the line's or the cap's top"); + } + assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line"); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] From ed218b9817e79cfafa3277a123de5ef03859f8e9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 15 Oct 2026 21:45:11 +0200 Subject: [PATCH 03/45] Text parameters: a vertical tab is a forced line break frame.create, story.setText, text.insert and the other commands that take text kept U+000B as a character, drawn as a missing-glyph box. It is the forced line break of Word and PowerPoint text (Chr(11), Shift+Enter), and what people porting scripts try first, so text_param now reads it as U+2028, the forced line break, next to the CR handling it already has. script --help and docs/agents.md say so. --- apps/designcraft-cli/src/main.rs | 1 + crates/engine/src/cmd/mod.rs | 9 +++++++-- crates/engine/src/cmd/object.rs | 16 ++++++++++++++++ docs/agents.md | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index c4373ffb..d768bc34 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -62,6 +62,7 @@ References to earlier results (in string parameters): "$$" a literal `$` anywhere: "$$1" passes `$1` (a GREP group), "$${0.id}" passes `${0.id}`. Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab. +"\u000b" (Word's line break, Chr(11)) is read as a forced line break too. Most commands ignore parameters they do not know instead of rejecting them: check names with `designcraft-cli describe COMMAND`. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 0bfb9727..9b0acfe5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -240,9 +240,14 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { } /// Plain text from a parameter, with CR LF and lone CR (classic Mac and InDesign line ends) as /// `\n`, the paragraph separator, as edit.paste and text import already do. Kept as they were, -/// the CRs joined the paragraphs into one. +/// the CRs joined the paragraphs into one. A vertical tab (Word's and PowerPoint's Shift+Enter, +/// Chr(11)) is a forced line break: kept, it was drawn as a missing-glyph box. pub(crate) fn text_param(p: &Value, key: &str) -> String { - str_param(p, key).unwrap_or("").replace("\r\n", "\n").replace('\r', "\n") + str_param(p, key) + .unwrap_or("") + .replace("\r\n", "\n") + .replace('\r', "\n") + .replace('\u{B}', designcraft_doc::story::FORCED_LINE_BREAK.encode_utf8(&mut [0; 4])) } pub(crate) fn id_param(p: &Value, key: &str) -> Option { p.get(key).and_then(Value::as_u64).map(ItemId) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index bbc0a36a..fb014353 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -3124,6 +3124,22 @@ mod line_end_tests { s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap(); assert_eq!(note(&mut s), "Three\nFour"); } + + /// "One\u{B}Two" kept the vertical tab, drawn as a missing-glyph box, where Word and + /// PowerPoint text (Chr(11)) means a forced line break. + #[test] + fn vertical_tabs_are_forced_line_breaks() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\u{B}Two"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let story = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two", 1)); + s.execute("text.insert", &json!({"text": "\u{B}Three", "raw": true})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two\u{2028}Three", 1)); + s.execute("story.setText", &json!({"story": sid.0, "text": "A\u{B}B\rC"})).unwrap(); + assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\u{2028}B\nC", 2)); + } } #[cfg(test)] diff --git a/docs/agents.md b/docs/agents.md index b737ebb8..49ff7d5b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -26,7 +26,7 @@ of step N's result: N is the 0-based step index (comments and blank lines are no step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"` interpolates inside longer strings. Any other string starting with `$` stays as written (`"$1-$2"`, `"$5 off"`), and `$$` writes a literal `$` anywhere (`"$$1"` passes `$1`, a GREP group). In text parameters `\n` starts a new paragraph and `\u2028` is a forced line -break. Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`. +break (`\u000b`, Word's line break, is read as one too). Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`. `designcraft-cli script --help` prints this reference. ```sh From 230b29be60d6506f91c0f3ee7d4fa792750196f0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:54:41 +0000 Subject: [PATCH 04/45] Refuse table row moves across the header/footer boundary Found while testing crates/doc/src/table.rs:882 (Table::move_row, called from crates/engine/src/cmd/table.rs:1437 and :1369) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/table.rs | 17 ++++++++++++++++- crates/engine/src/cmd/table.rs | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 9e5f9c16..ae7bb1ef 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -878,7 +878,8 @@ impl Table { } /// Move row `from` to position `to` (indices before the move). Refused when merged cells span - /// rows (the move would cut them). + /// rows (the move would cut them) or when the move would take a row across a header/footer + /// boundary (`from` and `to` must be the same kind of row). pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> { let (nr, nc) = (self.nrows(), self.ncols()); if from >= nr || to >= nr { @@ -887,6 +888,9 @@ impl Table { if self.regions().iter().any(|r| r.2 > 1) { return Err("unmerge cells that span rows first".into()); } + if self.rows[from].kind != self.rows[to].kind { + return Err("header and footer rows must stay first and last".into()); + } let row = self.rows.remove(from); self.rows.insert(to, row); let cells: Vec = self.cells.drain(from * nc..(from + 1) * nc).collect(); @@ -1195,6 +1199,17 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + #[test] + fn move_row_keeps_header_and_footer_rows_in_place() { + let mut t = Table::new(1, 3, 2, 1, 1, 120.0); + for (from, to) in [(0, 2), (0, 4), (2, 0), (2, 4), (4, 2), (4, 0)] { + assert!(t.move_row(from, to).is_err(), "move_row({from}, {to}) should be refused"); + t.check().unwrap(); + } + t.move_row(1, 3).unwrap(); + t.check().unwrap(); + } + #[test] fn selected_owners_include_covered_cells_without_expanding_range() { for column in [true, false] { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 4b2962b4..a8aaadb4 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1677,6 +1677,31 @@ mod sort_tests { assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string())); } + #[test] + fn move_row_cannot_move_a_header_row_out_of_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + assert!(s.execute("table.moveRow", &json!({"from": 0, "to": 2})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + // Dragging the selected header row onto the last body row is refused too. + let d = s.doc().unwrap().doc.clone(); + let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap(); + let fid = d.stories[&sid].frames[0]; + let cs = s.cache.get(&d, sid, None); + let cells = &cs.frames[0].tables[0].cells; + let centre = |r: usize| { + let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); + d.text_xf(d.item(fid).unwrap()) * c + }; + let (from, to) = (centre(0), centre(2)); + s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); + assert!(s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + } + #[test] fn table_options_limits_header_and_footer_rows() { let mut s = Session::new(); From ec86a8fd3c29f2fa0061c4bf0a104333e6c7bb8e Mon Sep 17 00:00:00 2001 From: Youssef Azriouil Date: Wed, 14 Oct 2026 18:57:44 +0200 Subject: [PATCH 05/45] Breaker: keep a page number's digits together The emergency break inside a word wider than the measure could fall between glyphs of one source character: an automatic page number wider than its frame came out as "13" over "4". Glyphs sharing a cluster (the generated digits of a page number, multi-glyph clusters) now stay together and the number hangs out of its frame, as in InDesign. --- crates/compose/src/breaker.rs | 4 ++++ crates/compose/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index c2db91fb..d5742f1d 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -462,7 +462,11 @@ fn overlong_words(items: &[Item], ig: &[usize], glyphs: &[Glyph], measure: f64) let marks = out.get_or_insert_with(|| vec![false; glyphs.len()]); for i in a..b { let (Some(g), Some(next)) = (glyphs.get(i), glyphs.get(i + 1)) else { continue }; + // Glyphs of one source character (an automatic page number's digits, a + // multi-glyph cluster) stay together, as InDesign keeps them. + let one_char = next.byte == g.byte && (next.len == 0 || next.len == g.len); let ok = !g.no_break + && !one_char && g.break_after != Some(false) && next.ch != SOFT_HYPHEN && !is_forced(next.ch) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 97eeb8cd..e1710cb8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -358,6 +358,23 @@ fn jump_object_keeps_grid_aligned_lines_on_the_grid() { assert!((l.baseline - want).abs() < 1e-6, "{} vs {want}", l.baseline); } +/// An automatic page number wider than its frame stays on one line and hangs out, as in InDesign +/// ("136" in a frame made for two digits); breaking it put "13" over "4". +#[test] +fn page_number_wider_than_its_frame_stays_whole() { + let text = designcraft_doc::story::PAGE_NUMBER.to_string(); + let (d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 10.0, 40.0), ParaAttrs::default()); + let mut specs = frame_specs(&d, sid); + specs[0].page_name = Some("136".into()); + let cs = compose(&d, d.story(sid).unwrap(), &specs, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 1, "{lines:?}"); + assert!(!cs.is_overset()); + // A word of ordinary letters that doesn't fit still breaks where it must. + let (d, sid, _) = doc_with("Wordwiderthanframe", Rect::new(0.0, 0.0, 30.0, 200.0), ParaAttrs::default()); + assert!(all_lines(&compose_story(&d, sid, &ComposeOptions::default())).len() > 1); +} + #[test] fn hostile_wrap_geometry_terminates() { let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, &[]); From e6878edac280597e06274fa7dd04d29e96a36bd3 Mon Sep 17 00:00:00 2001 From: Youssef Azriouil Date: Thu, 15 Oct 2026 08:37:40 +0200 Subject: [PATCH 06/45] Shaping: Arabic keeps contextual alternates Contextual alternates are part of how many Arabic fonts join (connecting strokes, tatweel and letter variants). InDesign keeps them in Arabic text when Contextual Alternates is off, so `calt` off is now dropped for Arabic runs. --- crates/fonts/src/lib.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index f31f5942..64b21d68 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -195,6 +195,20 @@ pub fn shape_with_context( out } +/// `features` as a run in `script` takes them. Contextual alternates are part of how Arabic joins +/// (connecting strokes, tatweel and letter variants): InDesign keeps them in Arabic text with +/// Contextual Alternates off, so `calt` off is dropped there. +fn script_features(features: &[Feature], script: unicode_script::Script) -> std::borrow::Cow<'_, [Feature]> { + use unicode_script::Script; + let calt = Tag::new(b"calt"); + let off = |f: &Feature| f.tag == calt && f.value == 0; + if script == Script::Arabic && features.iter().any(off) { + features.iter().filter(|f| !off(f)).copied().collect::>().into() + } else { + features.into() + } +} + fn shape_dir( face: &FontFace, text: &str, @@ -205,6 +219,8 @@ fn shape_dir( context: ShapeContext<'_>, ) -> Vec { let mut out = Vec::with_capacity(text.len()); + let features = script_features(features, script); + let features = &features[..]; let shaped = face.hb().map(|hb| { let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); let mut buf = UnicodeBuffer::new(); @@ -332,6 +348,16 @@ pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { mod tests { use super::*; + #[test] + fn arabic_keeps_contextual_alternates() { + use unicode_script::Script; + let off = feature("-calt").unwrap(); + let liga = feature("-liga").unwrap(); + let tags = |s: Script| script_features(&[off, liga], s).iter().map(|f| (f.tag, f.value)).collect::>(); + assert_eq!(tags(Script::Arabic), vec![(liga.tag, 0)]); + assert_eq!(tags(Script::Latin), vec![(off.tag, 0), (liga.tag, 0)]); + } + #[test] fn arabic_script_runs_do_not_inherit_the_hebrew_shaper() { use unicode_script::Script; From 53160594050288d5e6f256ef1b5f2ffc74446458 Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 16 Oct 2026 20:23:31 +0500 Subject: [PATCH 07/45] Graphics: keep a failed OpenGL out of the instance (macOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `choose` pruned the failed list to this platform's candidates, which dropped an OpenGL entry on macOS, where Metal is the only candidate. But OpenGL is not only a candidate: off Windows it is the in-process fallback that `instance_backends` adds whenever it has not failed. So a start that recorded OpenGL and never showed a frame had the entry pruned a line later, and OpenGL went straight back into the instance — the backend that had just failed was offered again on every start, with nothing able to retire it. An entry that can still be reached is kept now. A non-candidate entry still cannot be chosen as a backend, since `pick` only looks at candidates, so which backend starts is unchanged; it only stops a failed OpenGL being handed back to the instance. This is what `a_start_records_its_backend_until_a_frame_is_presented` asserts, and that test failed on macOS: `cargo xtask ci` stopped in the `designcraft` bin before the rest of the workspace ran, so the gate could not go green there at all. --- apps/designcraft/src/gpu/backend.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs index 7a944b35..3cac6089 100644 --- a/apps/designcraft/src/gpu/backend.rs +++ b/apps/designcraft/src/gpu/backend.rs @@ -144,7 +144,11 @@ pub fn choose(candidates: &[Backend], previous: &Record, version: &str, skipped: { failed.push(b); } - failed.retain(|b| candidates.contains(b)); + // A stale entry for a backend this platform cannot use is dropped, but one that can still + // be reached stays: OpenGL is the in-process fallback off Windows (`instance_backends`) + // without being a candidate there, so forgetting it would offer it again after it failed + // to present — on macOS, where Metal is the only candidate, it never got left out at all. + failed.retain(|b| candidates.contains(b) || (!cfg!(windows) && *b == Backend::Gl)); let pick = |failed: &[Backend]| candidates.iter().copied().find(|b| !failed.contains(b) && !skipped.contains(b)); let backend = match pick(&failed) { Some(b) => b, From 4b520066e3a7d502f46fc4c042b46fb21f00fa33 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:58:01 +0200 Subject: [PATCH 08/45] Remove empty pages autoflow adds when placing into the primary frame Fixes #1165 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/place_text.rs | 56 ++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 5343f54f..ccdca38b 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -236,7 +236,8 @@ fn same_rect(a: Rect, b: Rect) -> bool { /// on its page (when it sits in one), then one frame per column on each page it adds. With /// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the /// live area with the page's columns instead. Empty frames it made at the end of the thread are -/// removed again, except the first on a page. Returns pages added. +/// removed again, except the first on a page it didn't add; a page it added that holds only such +/// a frame goes with it. Returns pages added. /// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors /// name `cmd`, the command that autoflows. pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result { @@ -249,6 +250,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s let vertical = story_vertical || primary; let mut added = 0; let mut made = Vec::new(); + let mut new_pages = Vec::new(); let mut first = true; loop { let (overset, held, full) = fit(d, sid); @@ -278,7 +280,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.columns.count.max(1) as usize) }; let pages = want.div_ceil(per_page).clamp(1, max_pages - added); let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); - d.insert_pages(Some(page), pages, parent)?; + new_pages.extend(d.insert_pages(Some(page), pages, parent)?); for pg in page + 1..=page + pages { let (si, pi) = d.page_loc(pg).ok_or_else(|| bad(cmd, "page insert failed"))?; let columns = d.spreads[si].pages[pi].columns.clone(); @@ -297,10 +299,24 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s while let Some(&f) = made.last() { let frames = d.story(sid).map_or(&[][..], |s| s.frames.as_slice()); let [.., before, tail] = frames else { break }; - if *tail != f || cs.frame(f).is_some_and(|ft| !ft.range.is_empty()) || d.page_of_item(*before) != d.page_of_item(f) { + if *tail != f || cs.frame(f).is_some_and(|ft| !ft.range.is_empty()) { break; } - d.remove_item(f)?; + let page = d.page_of_item(f); + if d.page_of_item(*before) != page { + // The first frame on its page: the estimate added more pages than the text needs. + // Drop the page too when autoflow added it and the frame is all it holds. + let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break }; + let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg)); + if !alone { + break; + } + d.remove_item(f)?; + d.delete_pages(&[pg])?; + added = added.saturating_sub(1); + } else { + d.remove_item(f)?; + } made.pop(); } Ok(added) @@ -686,6 +702,38 @@ mod reflow_tests { s.execute("text.delete", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.page_count(), 1); } + + /// Placing text into the primary frame adds only the pages the text needs, also when a wrap + /// leaves page 1 little room or the text ends at a page boundary (#1165). + #[test] + fn smart_text_reflow_place_adds_no_empty_pages() { + let dir = std::env::temp_dir().join(format!("dc-reflow-place-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (n, wrap, want) in [(280, true, 5), (250, false, 3)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "primaryTextFrame": true})).unwrap(); + if wrap { + let id = s.execute("frame.create", &json!({"rect": [36, 36, 576, 600]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.textWrap", &json!({"ids": [id], "mode": "boundingBox"})).unwrap(); + } + let sid = s.doc().unwrap().doc.settings.primary_story.expect("primary story"); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + let path = dir.join(format!("words-{n}.txt")); + std::fs::write(&path, "Words keep coming and the page fills up quickly with them. ".repeat(n)).unwrap(); + s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.page_count(), want, "{n} lines"); + let frames = &d.story(sid).unwrap().frames; + assert_eq!(frames.len(), d.page_count(), "{n} lines"); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + assert!(cs.overset_at.is_none(), "{n} lines"); + for f in frames { + assert!(cs.frame(*f).is_some_and(|ft| !ft.range.is_empty()), "{n} lines: empty frame {f:?}"); + } + d.check().unwrap(); + } + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] From 248e10908bdf82dcdf09db50c01710dd0b16bc47 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:48:00 +0000 Subject: [PATCH 09/45] Follow paths stored in documents only on this computer or the document's share Links, data sources, IDML package links and book chapters go through io::document_path (craft-docpath) before they reach the file system: relative paths resolve beside the document, and paths to other computers, devices and URLs are missing, with the reason in links.list and Preflight. IDML and INDD packages read a linked file where its link points on this computer, then from the link's trailing names beside the package, only when it is a regular file up to 256 MB holding an image or a graphic. A file URI that names a host keeps it as a network path. --- Cargo.lock | 5 + Cargo.toml | 2 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/book.rs | 27 ++- crates/engine/src/cmd/datamerge/fill.rs | 18 ++ crates/engine/src/cmd/datamerge/mod.rs | 62 +++--- crates/engine/src/cmd/datamerge/parse.rs | 29 +-- crates/engine/src/cmd/file.rs | 6 +- crates/engine/src/cmd/interchange.rs | 229 ++++++++++++++++------- crates/engine/src/cmd/links.rs | 134 ++++++++----- crates/engine/src/cmd/preflight.rs | 8 +- crates/engine/src/io.rs | 58 +++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_linked_paths.rs | 107 +++++++++++ crates/engine/src/tests_roots.rs | 5 +- crates/idml/src/import.rs | 18 +- crates/idml/src/tests.rs | 4 + crates/ui-egui/src/panels/properties.rs | 6 +- docs/target-app-parity.md | 1 + 19 files changed, 545 insertions(+), 177 deletions(-) create mode 100644 crates/engine/src/tests_linked_paths.rs diff --git a/Cargo.lock b/Cargo.lock index bc2369d1..35dc59da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -824,6 +824,10 @@ dependencies = [ "rayon", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" + [[package]] name = "craft-pdf" version = "0.1.0" @@ -1010,6 +1014,7 @@ version = "0.8.0" dependencies = [ "caseless", "craft-atomic", + "craft-docpath", "craft-print", "designcraft-color", "designcraft-compose", diff --git a/Cargo.toml b/Cargo.toml index a9aeccd3..71442d84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,8 @@ zip = { version = "3", default-features = false, features = ["deflate"] } # (MIT OR Apache-2.0, no dependencies, builds for wasm). indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } +# Which paths stored in documents may be followed (craft-libs). +craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } # Atomic file replacement for saves and exports (craft-libs). craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" } # File › Print through the system print spooler (craft-libs). diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 8982ddfd..69e0e8d5 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] craft-print = { workspace = true } +craft-docpath = { workspace = true } designcraft-images = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index 34a385ab..4a65137c 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -36,6 +36,19 @@ fn save(_: &Book) -> Result<()> { Err(EngineError::Other("books need a file system".into())) } +/// A chapter's file, resolved against the book's file. A chapter on another computer (other than +/// the book's own network share) or a device is not opened ([`crate::io::document_path`]). +fn chapter(b: &Book, path: &str) -> Result { + crate::io::document_path(path, Some(std::path::Path::new(&b.path))) + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|why| EngineError::Other(format!("{path}: {why}"))) +} + +/// Every chapter's file ([`chapter`]), in order. +fn chapters(b: &Book) -> Result> { + b.documents.iter().map(|path| chapter(b, path)).collect() +} + fn load(path: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; @@ -45,7 +58,8 @@ fn load(path: &str) -> Result { vec![] }; if path.to_lowercase().ends_with(".idml") { - return super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + return super::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(path))) + .map_err(|e| EngineError::Other(format!("{path}: {e}"))); } super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -144,7 +158,7 @@ fn paginate(s: &Session) -> Result { let mut next = 1u32; let mut chapter = 0u32; let mut chapters = Vec::with_capacity(b.documents.len()); - for path in &b.documents { + for path in &self::chapters(b)? { writable_chapter(s, path, "book.paginate")?; let mut d = load(path)?; start_at(&mut d, next); @@ -167,13 +181,14 @@ fn paginate(s: &Session) -> Result { fn sync_styles(s: &Session) -> Result { let b = s.book.as_ref().ok_or_else(no_book)?; let mut batch = ChapterBatch::new(b.documents.len(), "book.syncStyles")?; - let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?; + let paths = chapters(b)?; + let src_path = paths.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?; let src = load(src_path)?; let styles = src.styles.clone(); let swatches = src.swatches.clone(); drop(src); let mut n = 0; - for (i, path) in b.documents.iter().enumerate() { + for (i, path) in paths.iter().enumerate() { if i == b.style_source { continue; } @@ -304,7 +319,7 @@ pub fn specs() -> Vec { let mut first = 1usize; let mut docs = Vec::new(); for p in &b.documents { - let pages = load(p).map(|d| d.page_count()).unwrap_or(0); + let pages = chapter(b, p).and_then(|p| load(&p)).map(|d| d.page_count()).unwrap_or(0); docs.push(json!({"path": p, "pages": pages, "firstPage": first})); first = first.saturating_add(pages); } @@ -319,7 +334,7 @@ pub fn specs() -> Vec { cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| { let b = s.book.clone().ok_or_else(no_book)?; let mut parts = Vec::new(); - for path in &b.documents { + for path in &chapters(&b)? { let mut d = load(path)?; // The chapter's own fonts, for this export (skipped font files are logged). let _fonts = super::file::load_document_fonts(&mut d, path).0; diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 55db5d2a..2bf2ca92 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -489,3 +489,21 @@ mod link_tests { } } } + +#[cfg(all(test, unix))] +mod image_path_tests { + use std::path::Path; + + use super::image_candidates; + + #[test] + fn image_cells_resolve_beside_the_data_and_never_to_other_computers() { + let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc")); + for cell in [r"\\192.0.2.1\share\x.png", "file://192.0.2.1/s/x.png", "//192.0.2.1/s/x.png", "https://example.com/x.png"] { + assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}"); + } + let found = image_candidates("img/x.png", Some(data), Some(doc)); + assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]); + assert!(image_candidates("img/x.png", None, None).is_empty(), "not beside the working directory"); + } +} diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 38ec1832..b29ae3b3 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -98,11 +98,20 @@ fn source_select(s: &mut Session, p: &Value) -> Result { }) } +/// Where a data source's file is, resolved against the document's file (`document`); `None` when +/// it has none or the path may not be followed ([`crate::io::document_path`]). +fn source_file(src: &DataSource, document: Option<&Path>) -> Option { + crate::io::document_path(src.path.as_deref()?, document).ok() +} + fn source_update(s: &mut Session, _p: &Value) -> Result { let (path, name, delimiter, sheet) = { - let src = s.doc()?.doc.data_merge.sources.first().ok_or_else(|| bad("data.source.update", "select a data source"))?; + let st = s.doc()?; + let src = st.doc.data_merge.sources.first().ok_or_else(|| bad("data.source.update", "select a data source"))?; let path = src.path.clone().ok_or_else(|| bad("data.source.update", "this source has no file to update"))?; - (path, src.name.clone(), src.delimiter, src.sheet.clone()) + let at = + crate::io::document_path(&path, st.path.as_deref().map(Path::new)).map_err(|why| bad("data.source.update", format!("{path}: {why}")))?; + (at.to_string_lossy().into_owned(), src.name.clone(), src.delimiter, src.sheet.clone()) }; let bytes = read_path(&path).map_err(|e| bad("data.source.update", e))?; let table = parse_file(&bytes, &name, Some(delimiter), sheet.as_deref()).map_err(|e| bad("data.source.update", e))?; @@ -199,7 +208,7 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result { let options = doc.data_merge.options.clone(); let maps = identity_map(&doc); let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default(); - let data_dir = source.path.as_deref().and_then(parent_dir); + let data_dir = source_file(&source, st.path.as_deref().map(Path::new)).as_deref().and_then(Path::parent).map(Path::to_path_buf); let doc_dir = st.path.as_deref().and_then(parent_dir); fill_row(&mut doc, &source.fields, &row, record as u32, &options, &maps, data_dir.as_deref(), doc_dir.as_deref()); st.doc = Arc::new(doc); @@ -218,7 +227,7 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result { let (table, dir) = load_inline(p).map_err(|e| bad("data.merge", e))?; (table, Vec::new(), dir, None) } else { - let (table, warnings, dir, status) = linked_table(&st.doc).map_err(|e| bad("data.merge", e))?; + let (table, warnings, dir, status) = linked_table(&st.doc, st.path.as_deref().map(Path::new)).map_err(|e| bad("data.merge", e))?; (table, warnings, dir, Some(status)) }; let mut options = st.doc.data_merge.options.clone(); @@ -624,28 +633,24 @@ pub(crate) fn refresh_relative_paths(doc: &mut Document, doc_path: &Path) { } /// On open, compare the absolute path with its fingerprint. A missing absolute path is tried -/// again as `relative_path` beside the document. +/// again as `relative_path` beside the document. Paths that may not be followed ([`crate::io::document_path`]) are missing. pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path>) { for src in &mut doc.data_merge.sources { - let abs_exists = src.path.as_deref().is_some_and(crate::io::is_file); - if abs_exists { - if let Some(path) = src.path.clone() { - src.status = status_of(Path::new(&path), src.fingerprint); - } + if let Some(path) = source_file(src, doc_path).filter(|p| crate::io::is_file(p)) { + src.status = status_of(&path, src.fingerprint); continue; } if let (Some(doc_path), Some(rel)) = (doc_path, src.relative_path.clone()) && let Some(dir) = doc_path.parent() + && let Ok(candidate) = crate::io::document_path(&rel, Some(doc_path)) + && crate::io::is_file(&candidate) { - let candidate = dir.join(&rel); - if crate::io::is_file(&candidate) { - let saved = candidate.to_string_lossy().into_owned(); - src.fingerprint = fingerprint_of(Path::new(&saved)); - src.status = SourceStatus::Ok; - src.relative_path = Some(relative_between(dir, Path::new(&saved))); - src.path = Some(saved); - continue; - } + let saved = candidate.to_string_lossy().into_owned(); + src.fingerprint = fingerprint_of(Path::new(&saved)); + src.status = SourceStatus::Ok; + src.relative_path = Some(relative_between(dir, Path::new(&saved))); + src.path = Some(saved); + continue; } if src.path.is_some() { src.status = SourceStatus::Missing; @@ -680,11 +685,17 @@ fn read_source(p: &Value) -> std::result::Result<(DataSource, Value), String> { Ok((source, report)) } -fn linked_table(doc: &Document) -> std::result::Result<(Table, Vec, Option, SourceStatus), String> { +fn linked_table(doc: &Document, document: Option<&Path>) -> std::result::Result<(Table, Vec, Option, SourceStatus), String> { let src = doc.data_merge.sources.first().ok_or_else(|| "select a data source or pass csv or rows".to_string())?; let mut warnings = Vec::new(); let status = if let Some(path) = &src.path { - let status = status_of(Path::new(path), src.fingerprint); + let status = match crate::io::document_path(path, document) { + Ok(path) => status_of(&path, src.fingerprint), + Err(why) => { + warnings.push(format!("The data file is not read ({why}). This merge used the cached rows.")); + return Ok((cached_table(src), warnings, None, SourceStatus::Missing)); + } + }; match status { SourceStatus::Modified => warnings.push("The data file changed since it was read. This merge used the cached rows.".into()), SourceStatus::Missing => warnings.push("The data file is missing. This merge used the cached rows.".into()), @@ -694,9 +705,12 @@ fn linked_table(doc: &Document) -> std::result::Result<(Table, Vec, Opti } else { src.status }; - let data_dir = src.path.as_deref().and_then(parent_dir); - let table = Table { fields: src.fields.clone(), rows: src.rows.clone(), warnings: src.warnings.clone(), delimiter: src.delimiter }; - Ok((table, warnings, data_dir, status)) + let data_dir = source_file(src, document).as_deref().and_then(Path::parent).map(Path::to_path_buf); + Ok((cached_table(src), warnings, data_dir, status)) +} + +fn cached_table(src: &DataSource) -> Table { + Table { fields: src.fields.clone(), rows: src.rows.clone(), warnings: src.warnings.clone(), delimiter: src.delimiter } } fn set_source_status(doc: &mut Document, status: SourceStatus) -> bool { diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index e7c9be6e..61ad6224 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -277,32 +277,23 @@ fn next_slot(col: i32, row: i32, rows_first: bool, fits_col: &impl Fn(i32) -> bo (0, 0, true) } -/// Files to try for an image cell. `http` and `https` are not candidates. Relative paths are -/// not resolved against the process working directory. +/// Files to try for an image cell: an absolute path as it is, a relative one beside the data +/// file, then beside the document. Relative paths are not resolved against the process working +/// directory, and paths that may not be followed ([`crate::io::document_path`]: URLs, other +/// computers, devices) are not candidates. pub fn image_candidates(cell: &str, data_dir: Option<&Path>, doc_dir: Option<&Path>) -> Vec { let cell = cell.trim(); if cell.is_empty() { return Vec::new(); } - let lower = cell.to_ascii_lowercase(); - if lower.starts_with("http://") || lower.starts_with("https://") { - return Vec::new(); - } - let path = Path::new(cell); - let mut out = Vec::new(); - let mut push = |p: PathBuf| { - if !out.iter().any(|e| e == &p) { + let found = std::iter::once(crate::io::document_path(cell, None)) + .chain(data_dir.map(|dir| crate::io::folder_path(cell, dir))) + .chain(doc_dir.map(|dir| crate::io::folder_path(cell, dir))); + let mut out: Vec = Vec::new(); + for p in found.flatten() { + if !out.contains(&p) { out.push(p); } - }; - if path.is_absolute() { - push(path.to_path_buf()); - } - if let Some(dir) = data_dir { - push(dir.join(cell)); - } - if let Some(dir) = doc_dir { - push(dir.join(cell)); } out } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ace74562..49cbec1c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -478,7 +478,7 @@ fn open_path(s: &mut Session, p: &Value) -> Result { return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file"))); } let (mut d, mut warnings) = load_bytes(&bytes)?; - super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent()); + super::interchange::resolve_packaged_links(&mut d, Some(std::path::Path::new(path))); super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); let (fonts, faces, font_warnings) = load_document_fonts(&mut d, path); warnings.extend(font_warnings); @@ -926,7 +926,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result { fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { const ID: &str = "file.place"; let src = if name.to_lowercase().ends_with(".idml") { - super::interchange::import_idml_confined(bytes).map_err(|e| bad(ID, e.to_string()))? + super::interchange::import_idml_confined(bytes, str_param(p, "path").map(std::path::Path::new)).map_err(|e| bad(ID, e.to_string()))? } else { from_bytes(bytes)? }; @@ -1051,7 +1051,7 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { { let mut d = if path.to_ascii_lowercase().ends_with(".idml") { let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?; - super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(e.to_string()))? + super::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(&path))).map_err(|e| EngineError::Other(e.to_string()))? } else { from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)? }; diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index cf127f34..a33525aa 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -1,5 +1,11 @@ //! Interchange formats: IDML (InDesign Markup Language) import and export, and INDD/INDT //! import (converted to IDML in memory by indd-utils). +//! +//! A package's linked files are read from where the link points on this computer, then from the +//! link's trailing names beside the package (where a package moved from its author's computer +//! keeps them). Links to other computers and devices are not followed +//! ([`crate::io::document_path`]). Only regular files that hold an image or a graphic format are +//! read, and only up to [`MAX_LINKED_FILE_BYTES`]. use designcraft_doc::Document; use serde_json::{Value, json}; @@ -33,28 +39,40 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { } } -/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`], so the -/// automation read root applies to the paths the package names, and its PDF crops resolved. -pub(crate) fn import_idml_confined(bytes: &[u8]) -> designcraft_idml::Result { - let mut d = designcraft_idml::import_idml_with(bytes, &|path| crate::io::read_file(path).ok())?; +/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`] from +/// beside `package` (the package's file, if it has one), so the automation read root applies to +/// the paths the package names, and its PDF crops resolved. +pub(crate) fn import_idml_confined(bytes: &[u8], package: Option<&std::path::Path>) -> designcraft_idml::Result { + let read = |link: &str| -> Option> { + #[cfg(not(target_arch = "wasm32"))] + { + read_packaged_link(link, package).map(|(_, data)| data) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (link, package); + None + } + }; + let mut d = designcraft_idml::import_idml_with(bytes, &read)?; resolve_pdf_crops(&mut d); Ok(d) } -/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup). -pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { - import_report(bytes, dir).map(|(document, _)| document) +/// Import IDML bytes; `package` is the package's file (its folder is where links are looked for). +pub fn import(bytes: &[u8], package: Option<&std::path::Path>) -> Result { + import_report(bytes, package).map(|(document, _)| document) } /// [`import`], with the problems that did not stop it (parts missing from the package, …). -pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Document, Vec)> { - import_report_with_previews(bytes, dir, &std::collections::HashMap::new()) +pub fn import_report(bytes: &[u8], package: Option<&std::path::Path>) -> Result<(Document, Vec)> { + import_report_with_previews(bytes, package, &std::collections::HashMap::new()) } /// [`import_report`], showing `previews` (graphic `Self` → image) for linked files that can't be read. fn import_report_with_previews( bytes: &[u8], - dir: Option<&std::path::Path>, + package: Option<&std::path::Path>, previews: &std::collections::HashMap>>, ) -> Result<(Document, Vec)> { #[cfg(not(target_arch = "wasm32"))] @@ -62,13 +80,13 @@ fn import_report_with_previews( let read = |link: &str| -> Option> { #[cfg(not(target_arch = "wasm32"))] { - let (path, data) = read_packaged_link(link, dir)?; + let (path, data) = read_packaged_link(link, package)?; resolved.borrow_mut().insert(link.to_string(), path); Some(data) } #[cfg(target_arch = "wasm32")] { - let _ = (link, &dir); + let _ = (link, &package); None } }; @@ -84,7 +102,7 @@ fn import_report_with_previews( std::sync::Arc::make_mut(asset).link = Some(path); } } - resolve_packaged_links(&mut document, dir); + resolve_packaged_links(&mut document, package); document }; Ok((document, warnings)) @@ -173,77 +191,97 @@ const MAX_LINK_PATH_BYTES: usize = 128 * 1024; #[cfg(not(target_arch = "wasm32"))] const MAX_LINK_SUFFIX_PARTS: usize = 8; +/// Largest linked file read when a package is opened (bytes); bigger files stay missing. #[cfg(not(target_arch = "wasm32"))] -fn packaged_link_candidates(link: &str, dir: Option<&std::path::Path>) -> Vec { +pub(crate) const MAX_LINKED_FILE_BYTES: u64 = 256 * 1024 * 1024; + +/// The files to try for `link` in the package at `package`: the link itself, then its trailing +/// names beside the package (without a package file, only an absolute link). Each is a path +/// [`crate::io::document_path`] allows. +#[cfg(not(target_arch = "wasm32"))] +fn packaged_link_candidates(link: &str, package: Option<&std::path::Path>) -> Vec { // Enough for a maximum-length Windows path even with four-byte UTF-8 characters. // Bound splitting/allocation for link strings supplied by imported documents. if link.len() > MAX_LINK_PATH_BYTES { return Vec::new(); } - let mut candidates = vec![std::path::PathBuf::from(link)]; - if let Some(dir) = dir { - // IDML may retain Windows paths even when the package is opened on another platform. - let parts: Vec<&str> = link.split(['/', '\\']).filter(|part| !part.is_empty()).collect(); - let safe_part = |part: &&str| *part != "." && *part != ".." && !part.contains(':'); - if !link.starts_with(['/', '\\']) && parts.iter().all(safe_part) { - candidates.push(dir.join(parts.iter().collect::())); + let mut candidates: Vec = crate::io::document_path(link, package).into_iter().collect(); + let Some(package) = package else { return candidates }; + // Names relative to the package, resolved beside it. + let mut beside = |names: &[&str]| { + if let Ok(path) = crate::io::document_path(&names.join("/"), Some(package)) + && !candidates.contains(&path) + { + candidates.push(path); } - // Packagers can keep subfolders under Links (for example, Links/illustrations/logo.ai). - // Preserve that suffix instead of flattening every resource to its basename. - if let Some(index) = parts.iter().rposition(|part| part.eq_ignore_ascii_case("Links")) { - let suffix = &parts[index + 1..]; - if !suffix.is_empty() && suffix.iter().all(safe_part) { - candidates.push(dir.join("Links").join(suffix.iter().collect::())); - } - } - // A package moved off its author's machine keeps its own subfolders (`/images/logo.png` - // beside the .idml as `images/logo.png`): try the link's trailing folders, longest first. - let first = parts.len().saturating_sub(MAX_LINK_SUFFIX_PARTS); - for start in first..parts.len().saturating_sub(1) { - if let Some(suffix) = parts.get(start..).filter(|s| s.iter().all(safe_part)) { - candidates.push(dir.join(suffix.iter().collect::())); - } - } - if let Some(name) = parts.last().filter(|part| safe_part(part)) { - candidates.push(dir.join(name)); - candidates.push(dir.join("Links").join(name)); + }; + // IDML may retain Windows paths even when the package is opened on another platform. + let parts: Vec<&str> = link.split(['/', '\\']).filter(|part| !part.is_empty()).collect(); + let safe_part = |part: &&str| *part != "." && *part != ".." && !part.contains(':'); + // Packagers can keep subfolders under Links (for example, Links/illustrations/logo.ai). + // Preserve that suffix instead of flattening every resource to its basename. + if let Some(index) = parts.iter().rposition(|part| part.eq_ignore_ascii_case("Links")) { + let suffix = parts.get(index + 1..).unwrap_or_default(); + if !suffix.is_empty() && suffix.iter().all(safe_part) { + beside(&[&["Links"][..], suffix].concat()); } } + // A package moved off its author's machine keeps its own subfolders (`/images/logo.png` + // beside the .idml as `images/logo.png`): try the link's trailing folders, longest first. + let first = parts.len().saturating_sub(MAX_LINK_SUFFIX_PARTS); + for start in first..parts.len().saturating_sub(1) { + if let Some(suffix) = parts.get(start..).filter(|s| s.iter().all(safe_part)) { + beside(suffix); + } + } + if let Some(name) = parts.last().filter(|part| safe_part(part)) { + beside(&[name]); + beside(&["Links", name]); + } candidates } +/// Bytes an image or a graphic can be placed from (raster formats, PDF, SVG, PSD, EPS). +#[cfg(not(target_arch = "wasm32"))] +fn is_graphic(bytes: &[u8]) -> bool { + designcraft_images::mime(bytes) != "application/octet-stream" || designcraft_images::is_eps(bytes) +} + /// Embedded images do not call the IDML resource loader. Rebind their missing links /// beside the opened document too, without replacing their embedded image bytes. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::path::Path>) { - let Some(dir) = dir else { return }; +pub(crate) fn resolve_packaged_links(document: &mut Document, package: Option<&std::path::Path>) { + let Some(package) = package else { return }; for asset in document.assets.values_mut() { let Some(link) = asset.link.as_deref() else { continue }; - if link.len() > MAX_LINK_PATH_BYTES || crate::io::is_file(link) { + if link.len() > MAX_LINK_PATH_BYTES || crate::io::document_path(link, Some(package)).is_ok_and(crate::io::is_file) { continue; } - let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| crate::io::is_file(p)) else { continue }; - // Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI. - let path = std::path::absolute(&path).unwrap_or(path); + let Some(path) = packaged_link_candidates(link, Some(package)).into_iter().find(|p| crate::io::is_file(p)) else { continue }; std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned()); } } +/// The first candidate for `link` ([`packaged_link_candidates`]) that is a regular file no larger +/// than [`MAX_LINKED_FILE_BYTES`] holding an image or a graphic, with its path. #[cfg(not(target_arch = "wasm32"))] -fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> { - for path in packaged_link_candidates(link, dir) { - if let Ok(bytes) = crate::io::read_file(&path) { - // Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize - // would introduce a verbatim prefix that is not an IDML file URI. - let path = std::path::absolute(&path).unwrap_or(path); - return Some((path.to_string_lossy().into_owned(), bytes)); +fn read_packaged_link(link: &str, package: Option<&std::path::Path>) -> Option<(String, Vec)> { + for path in packaged_link_candidates(link, package) { + if !crate::io::is_file(&path) || !crate::io::metadata(&path).is_ok_and(|m| m.len() <= MAX_LINKED_FILE_BYTES) { + continue; } + // At most one byte over the limit, in case the file grew since. + let Ok(bytes) = crate::io::read_capped(&path, MAX_LINKED_FILE_BYTES) else { continue }; + if bytes.len() as u64 > MAX_LINKED_FILE_BYTES || !is_graphic(&bytes) { + continue; + } + return Some((path.to_string_lossy().into_owned(), bytes)); } None } pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { - let (bytes, dir, file, title) = if let Some(b) = str_param(p, "base64") { + let (bytes, package, file, title) = if let Some(b) = str_param(p, "base64") { let name = str_param(p, "name").unwrap_or_default(); (base64_decode(b), None, name.to_string(), without_document_extension(name).to_string()) } else if let Some(path) = str_param(p, "path") { @@ -253,7 +291,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { let b: Vec = Vec::new(); let pp = std::path::Path::new(path); let file = pp.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()); - (b, pp.parent().map(|d| d.to_path_buf()), file, pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()) + (b, Some(pp.to_path_buf()), file, pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()) } else { return Err(bad("file.openIdml", "missing `path` or `base64`")); }; @@ -280,7 +318,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { } else { bytes }; - let (mut d, import_warnings) = import_report_with_previews(&bytes, dir.as_deref(), &previews)?; + let (mut d, import_warnings) = import_report_with_previews(&bytes, package.as_deref(), &previews)?; warnings.extend(import_warnings); if !title.is_empty() { d.title = title; @@ -346,12 +384,12 @@ mod tests { std::fs::write(&path, &png).unwrap(); std::fs::write(dir.join("logo.png"), b"a different file with the same basename").unwrap(); - let doc = import(&idml, Some(&dir)).unwrap(); + let doc = import(&idml, Some(&dir.join("x.idml"))).unwrap(); let asset = doc.assets.values().next().unwrap(); assert_eq!(*asset.data, png, "the packaged graphic must render even when its author-machine path no longer exists"); let resolved = std::path::absolute(&path).unwrap(); assert_eq!(asset.link.as_deref(), resolved.to_str(), "remember the resolved link so update and preflight use the packaged file"); - assert_eq!(super::super::links::status(asset), "ok"); + assert_eq!(super::super::links::status(asset, None), "ok"); std::fs::remove_dir_all(dir).unwrap(); } @@ -364,11 +402,11 @@ mod tests { let (idml, png) = linked_idml("/images/logo.png", false).unwrap(); std::fs::write(&path, &png).unwrap(); - let doc = import(&idml, Some(&dir)).unwrap(); + let doc = import(&idml, Some(&dir.join("x.idml"))).unwrap(); let asset = doc.assets.values().next().unwrap(); assert_eq!(*asset.data, png); assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str()); - assert_eq!(super::super::links::status(asset), "ok"); + assert_eq!(super::super::links::status(asset, None), "ok"); std::fs::remove_dir_all(dir).unwrap(); } @@ -380,11 +418,11 @@ mod tests { let (idml, png) = linked_idml("/original-computer/project/logo.png", false).unwrap(); std::fs::write(&path, &png).unwrap(); - let doc = import(&idml, Some(&dir)).unwrap(); + let doc = import(&idml, Some(&dir.join("x.idml"))).unwrap(); let asset = doc.assets.values().next().unwrap(); assert_eq!(*asset.data, png); assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str()); - assert_eq!(super::super::links::status(asset), "ok"); + assert_eq!(super::super::links::status(asset, None), "ok"); std::fs::remove_dir_all(dir).unwrap(); } @@ -400,39 +438,90 @@ mod tests { let (idml, png) = linked_idml(original.to_str().unwrap(), true).unwrap(); std::fs::write(&original, b"modified original").unwrap(); std::fs::write(©, &png).unwrap(); - let doc = import(&idml, Some(&package)).unwrap(); + let doc = import(&idml, Some(&package.join("x.idml"))).unwrap(); let asset = doc.assets.values().next().unwrap(); assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative"); assert_eq!(*asset.data, png); - assert_eq!(super::super::links::status(asset), "modified"); + assert_eq!(super::super::links::status(asset, None), "modified"); std::fs::remove_file(&original).unwrap(); std::fs::write(©, b"modified packaged copy").unwrap(); - let doc = import(&idml, Some(&package)).unwrap(); + let doc = import(&idml, Some(&package.join("x.idml"))).unwrap(); let asset = doc.assets.values().next().unwrap(); assert_eq!(asset.link.as_deref(), copy.to_str()); assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels"); - assert_eq!(super::super::links::status(asset), "modified"); + assert_eq!(super::super::links::status(asset, None), "modified"); std::fs::remove_file(©).unwrap(); - for folder in [Some(package.as_path()), None] { + let packaged = package.join("x.idml"); + for folder in [Some(packaged.as_path()), None] { let doc = import(&idml, folder).unwrap(); let asset = doc.assets.values().next().unwrap(); assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate"); assert_eq!(*asset.data, png); - assert_eq!(super::super::links::status(asset), "missing"); + assert_eq!(super::super::links::status(asset, None), "missing"); } let oversized = "a/".repeat(MAX_LINK_PATH_BYTES); - assert!(packaged_link_candidates(&oversized, Some(&package)).is_empty()); + assert!(packaged_link_candidates(&oversized, Some(&packaged)).is_empty()); let mut doc = import(&idml, None).unwrap(); Arc::make_mut(doc.assets.values_mut().next().unwrap()).link = Some(oversized.clone()); - resolve_packaged_links(&mut doc, Some(&package)); + resolve_packaged_links(&mut doc, Some(&packaged)); let asset = doc.assets.values().next().unwrap(); assert_eq!(asset.link.as_deref(), Some(oversized.as_str())); assert_eq!(*asset.data, png); std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn only_local_graphic_files_are_read_from_links() { + let base = std::env::temp_dir().join(format!("dc-idml-link-reach-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (dir, outside) = (base.join("package"), base.join("outside")); + std::fs::create_dir_all(dir.join("Links")).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let package = dir.join("x.idml"); + let png = designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png(); + std::fs::write(dir.join("Links").join("notes.png"), b"not an image").unwrap(); + std::fs::write(outside.join("notes.txt"), b"not an image").unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink("/dev/zero", dir.join("Links").join("zero.png")).unwrap(); + std::os::unix::fs::symlink("/dev/zero", outside.join("zero.png")).unwrap(); + } + let (text, zero) = (outside.join("notes.txt"), outside.join("zero.png")); + let links = [ + r"\\192.0.2.1\share\x.png", + "file://192.0.2.1/s/x.png", + text.to_str().unwrap(), + zero.to_str().unwrap(), + "/dev/zero", + "Links/zero.png", + "Links/notes.png", + ]; + for link in links { + let (idml, _) = linked_idml(link, false).unwrap(); + let (doc, seen) = crate::io::touched(|| import(&idml, Some(&package)).unwrap()); + // Beside the package, and the link itself where it is on this computer. + assert!(seen.iter().all(|p| p.starts_with(&dir) || p == std::path::Path::new(link)), "{link}: {seen:?}"); + let asset = doc.assets.values().next().unwrap(); + assert!(asset.data.is_empty(), "{link}"); + assert_eq!(super::super::links::status(asset, None), "missing", "{link}"); + } + // An image elsewhere on this computer is read, and so is one beside the package. + let away = outside.join("logo.png"); + std::fs::write(&away, &png).unwrap(); + let (idml, _) = linked_idml(away.to_str().unwrap(), false).unwrap(); + let doc = import(&idml, Some(&package)).unwrap(); + let asset = doc.assets.values().next().unwrap(); + assert_eq!((&*asset.data, asset.link.as_deref()), (&png, away.to_str())); + assert_eq!(super::super::links::status(asset, None), "ok"); + std::fs::write(dir.join("Links").join("logo.png"), &png).unwrap(); + let (idml, _) = linked_idml("Links/logo.png", false).unwrap(); + let doc = import(&idml, Some(&package)).unwrap(); + assert_eq!(*doc.assets.values().next().unwrap().data, png); + std::fs::remove_dir_all(base).unwrap(); + } + /// INDD routing without an InDesign file: bytes with the INDD signature (or an .indd/.indt /// name) go to the converter, and what it can't read is an open error that adds no document. #[test] diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 9e06a566..9aa23cbf 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -7,7 +7,11 @@ //! //! A linked text file (File › Place with Create Links) remembers its file's size and hash on the //! story instead; updating it imports the file again into the story. +//! +//! A link is followed only where [`crate::io::document_path`] allows it, resolved against the +//! document's own file; a refused link is missing, with the reason in `links.list`. +use std::path::Path; use std::sync::Arc; use designcraft_doc::{AssetId, Content, Document, Fingerprint, ItemId, Selection, Story, StoryId, TextFileLink}; @@ -18,10 +22,10 @@ use crate::{EngineError, Result, Session}; pub fn specs() -> Vec { vec![ - cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { + cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { // Taken out for the call so the document needn't be cloned on every poll. let mut checks = std::mem::take(&mut s.text_link_checks); - let links = s.doc().map(|d| list(&d.doc, &mut checks)); + let links = s.doc().map(|d| list(&d.doc, d.path.as_deref().map(Path::new), &mut checks)); s.text_link_checks = checks; Ok(Value::Array(links?)) }), @@ -126,31 +130,47 @@ fn uses(d: &Document, aid: AssetId) -> Vec { out } +/// Why a link stored in the document at `document` isn't followed, if it isn't. +pub fn refused(link: &str, document: Option<&Path>) -> Option { + crate::io::document_path(link, document).err() +} + /// Whether an asset's linked file is gone ([`status`] "missing" without reading the file). An -/// asset can hold data while its file is missing: the document's preview of it. -pub fn link_missing(a: &designcraft_doc::Asset) -> bool { - let Some(path) = &a.link else { return false }; +/// asset can hold data while its file is missing: the document's preview of it. `document` is +/// the document's file, which relative links are resolved against. +pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool { + let Some(link) = &a.link else { return false }; #[cfg(not(target_arch = "wasm32"))] { - !crate::io::exists(path) + crate::io::document_path(link, document).map_or(true, |path| !crate::io::exists(path)) } #[cfg(target_arch = "wasm32")] { - let _ = path; + let _ = (link, document); false } } -/// Link status of an asset. -pub fn status(a: &designcraft_doc::Asset) -> &'static str { - let Some(path) = &a.link else { return "embedded" }; +/// Link status of an asset in the document at `document`. Only a regular file is compared, so a +/// link to a device (`/dev/zero`) or a FIFO is missing rather than read. +pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static str { + let Some(link) = &a.link else { return "embedded" }; + // Nothing was read from the file (a package's link that wasn't followed): missing, as in + // Preflight. + if a.data.is_empty() { + return "missing"; + } #[cfg(not(target_arch = "wasm32"))] { - match crate::io::metadata(path) { + let Ok(path) = crate::io::document_path(link, document) else { return "missing" }; + if !crate::io::is_file(&path) { + return "missing"; + } + match crate::io::metadata(&path) { Err(_) => "missing", Ok(m) if m.len() as usize != a.data.len() => "modified", // Same size: compare the bytes (cheap for the sizes involved, and exact). - Ok(_) => match crate::io::read_file(path) { + Ok(_) => match crate::io::read_capped(&path, a.data.len() as u64) { Ok(b) if b == *a.data => "ok", Ok(_) => "modified", Err(_) => "missing", @@ -159,7 +179,7 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { } #[cfg(target_arch = "wasm32")] { - let _ = path; + let _ = (link, document); "ok" } } @@ -196,14 +216,15 @@ pub(crate) type TextChecks = std::collections::HashMap &'static str { +/// or synced again without changes stays OK. `document` is the document's file. +pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks, document: Option<&Path>) -> &'static str { #[cfg(not(target_arch = "wasm32"))] { - if !crate::io::is_file(&l.path) { + let Ok(path) = crate::io::document_path(&l.path, document) else { return "missing" }; + if !crate::io::is_file(&path) { return "missing"; } - let now = match crate::io::metadata(&l.path) { + let now = match crate::io::metadata(&path) { Err(_) => return "missing", Ok(m) => super::datamerge::fingerprint_from(&m), }; @@ -216,7 +237,7 @@ pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks) -> &'static let hash = match checks.get(&l.path) { Some((at, hash)) if *at == now => hash.clone(), _ if now.size > MAX_TEXT_LINK_BYTES => return "tooLarge", - _ => match read_text(&l.path) { + _ => match read_text(&path) { Ok((b, at)) => { let hash = fnv_hex(&b); // The time from before the read: a change during it is seen next time. @@ -232,7 +253,7 @@ pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks) -> &'static } #[cfg(target_arch = "wasm32")] { - let _ = (l, checks); + let _ = (l, checks, document); "ok" } } @@ -241,11 +262,11 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) } -fn list(d: &Document, checks: &mut TextChecks) -> Vec { +fn list(d: &Document, document: Option<&Path>, checks: &mut TextChecks) -> Vec { let texts = d.stories.values().filter_map(|st| { let l = st.file_link.as_ref()?; let uses: Vec = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect(); - Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l, checks), "edited": story_hash(st) != l.story_hash, "uses": uses})) + Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l, checks, document), "refused": refused(&l.path, document).map(|r| r.to_string()), "edited": story_hash(st) != l.story_hash, "uses": uses})) }); let used = d.used_assets(); d.assets @@ -274,7 +295,8 @@ fn list(d: &Document, checks: &mut TextChecks) -> Vec { }) .collect(); let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name()); - json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses}) + let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string()); + json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a, document), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses}) }) .chain(texts) .collect() @@ -282,15 +304,16 @@ fn list(d: &Document, checks: &mut TextChecks) -> Vec { /// A linked text file's bytes, with its size and time from before they were read. #[cfg(not(target_arch = "wasm32"))] -fn read_text(path: &str) -> Result<(Vec, Fingerprint)> { - let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}")); +fn read_text(path: impl AsRef) -> Result<(Vec, Fingerprint)> { + let path = path.as_ref(); + let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display())); // The roots' own message when they refuse the path. crate::file_access::check_read(path).map_err(EngineError::Other)?; let m = crate::io::metadata(path).map_err(err)?; if !crate::io::is_file(path) { - return Err(EngineError::Other(format!("{path}: not a file"))); + return Err(EngineError::Other(format!("{}: not a file", path.display()))); } - let too_large = || EngineError::Other(format!("{path}: larger than {} MiB", MAX_TEXT_LINK_BYTES / (1024 * 1024))); + let too_large = || EngineError::Other(format!("{}: larger than {} MiB", path.display(), MAX_TEXT_LINK_BYTES / (1024 * 1024))); if m.len() > MAX_TEXT_LINK_BYTES { return Err(too_large()); } @@ -303,8 +326,8 @@ fn read_text(path: &str) -> Result<(Vec, Fingerprint)> { } #[cfg(target_arch = "wasm32")] -fn read_text(path: &str) -> Result<(Vec, Fingerprint)> { - Err(EngineError::Other(format!("{path}: files can't be read on the web"))) +fn read_text(path: impl AsRef) -> Result<(Vec, Fingerprint)> { + Err(EngineError::Other(format!("{}: files can't be read on the web", path.as_ref().display()))) } /// Import a linked text file again; a warning says when edits made in the layout were replaced. @@ -318,15 +341,16 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi } #[cfg(not(target_arch = "wasm32"))] -fn reload(path: &str) -> Result<(Vec, (u32, u32))> { +fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> { + let path = path.as_ref(); let b = crate::io::read_file(path).map_err(EngineError::Other)?; - let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{path}: unsupported or corrupt image")))?; + let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{}: unsupported or corrupt image", path.display())))?; Ok((b, px)) } #[cfg(target_arch = "wasm32")] -fn reload(path: &str) -> Result<(Vec, (u32, u32))> { - Err(EngineError::Other(format!("{path}: files can't be read on the web"))) +fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> { + Err(EngineError::Other(format!("{}: files can't be read on the web", path.as_ref().display()))) } fn relink(s: &mut Session, p: &Value) -> Result { @@ -368,11 +392,12 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { } let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none()); let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()); - let d = &s.doc()?.doc; + let st = s.doc()?; + let (d, document) = (&st.doc, st.path.as_deref().map(Path::new)); let mut todo: Vec<(AssetId, std::path::PathBuf)> = Vec::new(); let mut not_found = Vec::new(); for a in d.assets.values() { - if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a) != "missing") { + if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a, document) != "missing") { continue; } let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone())); @@ -380,9 +405,15 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { if let Some(e) = &ext { name = std::path::Path::new(&name).with_extension(e).into_os_string(); } - let folder = dir.clone().or_else(|| old.parent().map(|p| p.to_path_buf())).unwrap_or_default(); - let cand = folder.join(&name); - if crate::io::exists(&cand) { + // The link's own folder only where the link may be followed; the name only as a plain + // file name (not a device). + let folder = match &dir { + Some(dir) => Some(dir.clone()), + None => a.link.as_deref().and_then(|l| crate::io::document_path(l, document).ok()).and_then(|p| p.parent().map(Path::to_path_buf)), + }; + let plain_name = name.to_str().is_some_and(|n| !n.contains(['/', '\\']) && craft_docpath::is_local(n)); + let cand = folder.filter(|_| plain_name).map(|f| f.join(&name)); + if let Some(cand) = cand.filter(|c| crate::io::exists(c)) { todo.push((a.id, cand)); } else { not_found.push(name.to_string_lossy().to_string()); @@ -397,30 +428,39 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { } fn update(s: &mut Session, p: &Value) -> Result { - let d = s.doc()?.doc.clone(); + let (d, document) = { + let st = s.doc()?; + (st.doc.clone(), st.path.clone()) + }; + let document = document.as_deref().map(Path::new); let checks = &mut s.text_link_checks; let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p)); let all = only_asset.is_none() && only_story.is_none(); - let which: Vec<(AssetId, String)> = d + // "modified" means the link resolved: refused links are missing. + let resolved = |link: &str| crate::io::document_path(link, document).ok(); + let which: Vec<(AssetId, std::path::PathBuf)> = d .assets .values() .filter(|a| all || only_asset == Some(a.id.0)) - .filter(|a| status(a) == "modified") - .filter_map(|a| a.link.clone().map(|l| (a.id, l))) + .filter(|a| status(a, document) == "modified") + .filter_map(|a| a.link.as_deref().and_then(resolved).map(|l| (a.id, l))) .collect(); - let texts: Vec<(StoryId, String)> = d + let texts: Vec<(StoryId, String, std::path::PathBuf)> = d .stories .values() .filter(|st| all || only_story == Some(st.id)) - .filter_map(|st| st.file_link.as_ref().filter(|l| text_status(l, checks) == "modified").map(|l| (st.id, l.path.clone()))) + .filter_map(|st| { + let l = st.file_link.as_ref().filter(|l| text_status(l, checks, document) == "modified")?; + Some((st.id, l.path.clone(), resolved(&l.path)?)) + }) .collect(); let mut fresh = Vec::new(); for (aid, path) in which { fresh.push((aid, reload(&path)?)); } let mut fresh_texts = Vec::new(); - for (sid, path) in texts { - let (bytes, before) = read_text(&path)?; + for (sid, path, at) in texts { + let (bytes, before) = read_text(&at)?; fresh_texts.push((sid, path, bytes, before)); } let n = fresh.len() + fresh_texts.len(); @@ -834,7 +874,7 @@ mod text_link_tests { // Saved and opened again: the link and its options are kept. let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); assert_eq!(back.story(sid).unwrap().file_link, story(&s, sid).file_link); - assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default()), "ok"); + assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default(), None), "ok"); // The file moves: Missing; Relink to it imports it into the story. let moved = dir.join("moved").join("chapter.docx"); @@ -973,7 +1013,7 @@ mod text_link_read_tests { fn status_of(path: &std::path::Path, size: u64) -> &'static str { let l = TextFileLink { path: path.to_string_lossy().to_string(), size, hash: "0".into(), ..Default::default() }; - promptly(move || text_status(&l, &mut Default::default())) + promptly(move || text_status(&l, &mut Default::default(), None)) } fn read(path: &std::path::Path) -> Result { diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 7ae80a1b..19d64c1f 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -116,6 +116,7 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des pub fn check(s: &Session, min_ppi: f64) -> Vec { let Some(st) = s.active() else { return vec![] }; let d = &st.doc; + let document = st.path.as_deref().map(std::path::Path::new); let mut out = Vec::new(); // Stories: overset and fonts. let mut missing_fonts: Vec = Vec::new(); @@ -251,10 +252,13 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { match &it.content { Content::Graphic(g) => match d.assets.get(&g.asset) { None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }), - Some(a) if a.data.is_empty() || super::links::link_missing(a) => out.push(Issue { + Some(a) if a.data.is_empty() || super::links::link_missing(a, document) => out.push(Issue { severity: "error", kind: "missingLink", - message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), + message: match a.link.as_deref().and_then(|l| super::links::refused(l, document)) { + Some(why) => format!("Missing link: {} ({why})", a.link.as_deref().unwrap_or(&a.name)), + None => format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)), + }, item: Some(it.id.0), page, }), diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 9eb8850a..5378a411 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -8,12 +8,58 @@ //! it is ([`is_file`], [`is_dir`], [`exists`], [`metadata`]), and as the roots' own message where //! it asks for the bytes. +pub use craft_docpath::Refused; pub use std::fs::Metadata; -use std::path::Path; +use std::path::{Path, PathBuf}; + +/// The path to open for a path stored in a document (a link, a data source, a book chapter), +/// resolved against `document`, the path of the file it came from, when there is one. +/// +/// Paths to other computers (other than the document's own network share), devices and URLs are +/// refused, and so is a result that isn't absolute: without a document, a relative path would +/// depend on the working directory, and a Windows path names no file on other platforms. A +/// refused path is never handed to the file system; callers treat it as a missing file. +pub fn document_path(stored: &str, document: Option<&Path>) -> Result { + let document = document.map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf())); + let path = craft_docpath::resolve(stored, document.as_deref())?; + if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) } +} + +/// [`document_path`] for a path stored in a file inside `folder` (a data file's image names). +pub fn folder_path(stored: &str, folder: &Path) -> Result { + // Resolved against a document in the folder: only the document's folder is used. + document_path(stored, Some(&folder.join("-"))) +} + +#[cfg(test)] +thread_local! { + static TOUCHED: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// Run `f` and list every path it handed to this module, in order (on this thread). +#[cfg(test)] +pub(crate) fn touched(f: impl FnOnce() -> T) -> (T, Vec) { + let previous = TOUCHED.with(|t| t.replace(Some(Vec::new()))); + let out = f(); + let seen = TOUCHED.with(|t| t.replace(previous)).unwrap_or_default(); + (out, seen) +} + +fn touch(path: &Path) { + #[cfg(test)] + TOUCHED.with(|t| { + if let Some(seen) = t.borrow_mut().as_mut() { + seen.push(path.to_path_buf()); + } + }); + #[cfg(not(test))] + let _ = path; +} /// The bytes of the file at `path`, inside the automation read root when there is one. pub fn read_file(path: impl AsRef) -> Result, String> { let path = path.as_ref(); + touch(path); crate::file_access::check_read(path)?; std::fs::read(path).map_err(|e| format!("{}: {e}", path.display())) } @@ -22,6 +68,7 @@ pub fn read_file(path: impl AsRef) -> Result, String> { /// failures. A refused path is a `PermissionDenied` error carrying the roots' message. pub fn read(path: impl AsRef) -> std::io::Result> { let path = path.as_ref(); + touch(path); crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; std::fs::read(path) } @@ -31,6 +78,7 @@ pub fn read(path: impl AsRef) -> std::io::Result> { pub fn read_capped(path: impl AsRef, cap: u64) -> std::io::Result> { use std::io::Read; let path = path.as_ref(); + touch(path); crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; let mut b = Vec::new(); std::fs::File::open(path)?.take(cap.saturating_add(1)).read_to_end(&mut b)?; @@ -42,6 +90,7 @@ pub fn read_capped(path: impl AsRef, cap: u64) -> std::io::Result> /// roots' message. pub fn open(path: impl AsRef) -> std::io::Result { let path = path.as_ref(); + touch(path); crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; std::fs::File::open(path) } @@ -49,6 +98,7 @@ pub fn open(path: impl AsRef) -> std::io::Result { /// The text of the file at `path`, inside the automation read root when there is one. pub fn read_to_string(path: impl AsRef) -> Result { let path = path.as_ref(); + touch(path); crate::file_access::check_read(path)?; std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display())) } @@ -56,6 +106,7 @@ pub fn read_to_string(path: impl AsRef) -> Result { /// Write `bytes` to the file at `path`, inside the automation write root when there is one. pub fn write_file(path: impl AsRef, bytes: &[u8]) -> Result<(), String> { let path = path.as_ref(); + touch(path); crate::file_access::check_write(path)?; // Replaced atomically where there is a real file system (see `atomic`). #[cfg(not(target_arch = "wasm32"))] @@ -71,6 +122,7 @@ pub fn write_file(path: impl AsRef, bytes: &[u8]) -> Result<(), String> { #[cfg(not(target_arch = "wasm32"))] pub fn replace_with(path: impl AsRef, staged: impl AsRef) -> std::io::Result<()> { let path = path.as_ref(); + touch(path); crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; let mut input = std::fs::File::open(staged)?; crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ())) @@ -80,6 +132,7 @@ pub fn replace_with(path: impl AsRef, staged: impl AsRef) -> std::io /// write root when there is one. pub fn writable(path: impl AsRef) -> std::io::Result<()> { let path = path.as_ref(); + touch(path); crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; std::fs::OpenOptions::new().write(true).open(path).map(drop) } @@ -88,6 +141,7 @@ pub fn writable(path: impl AsRef) -> std::io::Result<()> { /// one. pub fn canonicalize(path: impl AsRef) -> std::io::Result { let path = path.as_ref(); + touch(path); crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; std::fs::canonicalize(path) } @@ -96,6 +150,7 @@ pub fn canonicalize(path: impl AsRef) -> std::io::Result) -> std::io::Result { let path = path.as_ref(); + touch(path); crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; std::fs::metadata(path) } @@ -118,6 +173,7 @@ pub fn exists(path: impl AsRef) -> bool { /// The entries of the folder at `path`, inside the automation read root when there is one. pub fn read_dir(path: impl AsRef) -> std::io::Result { let path = path.as_ref(); + touch(path); crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; std::fs::read_dir(path) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 95e822fd..183b81c5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -744,6 +744,8 @@ mod tests_idml; #[cfg(test)] mod tests_ime; #[cfg(test)] +mod tests_linked_paths; +#[cfg(test)] mod tests_params; #[cfg(test)] mod tests_roots; diff --git a/crates/engine/src/tests_linked_paths.rs b/crates/engine/src/tests_linked_paths.rs new file mode 100644 index 00000000..eb4c953e --- /dev/null +++ b/crates/engine/src/tests_linked_paths.rs @@ -0,0 +1,107 @@ +//! Paths stored in documents (links, data sources, book chapters) are followed only where +//! [`crate::io::document_path`] allows: never to another computer, and resolved beside the +//! document. Every path the engine hands the file system is recorded by [`crate::io::touched`]. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::Session; + +const UNC: &str = r"\\192.0.2.1\share\x.png"; +const FILE_URL: &str = "file://192.0.2.1/s/x.png"; + +fn png() -> Vec { + designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png() +} + +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dc-linked-paths-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A document saved as `doc` with one placed graphic per link, linked to it. +fn document_with_links(doc: &Path, links: &[&str]) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (i, link) in links.iter().enumerate() { + s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png()), "name": format!("{i}.png"), "x": 72 * i, "y": 72})).unwrap(); + let link = link.to_string(); + s.edit(move |d, _| { + let a = d.assets.values_mut().find(|a| a.link.is_none() && a.name == format!("{i}.png")).unwrap(); + std::sync::Arc::make_mut(a).link = Some(link); + Ok(Value::Null) + }) + .unwrap(); + } + s.doc_mut().unwrap().path = Some(doc.to_string_lossy().into_owned()); + s +} + +#[test] +fn links_to_other_computers_never_reach_the_file_system() { + let dir = folder("remote"); + let doc = dir.join("doc.designcraft"); + let mut s = document_with_links(&doc, &[UNC, FILE_URL]); + let (rows, seen) = crate::io::touched(|| { + let rows = s.execute("links.list", &json!({})).unwrap(); + let issues = s.execute("preflight.run", &json!({})).unwrap(); + s.execute("links.relinkFolder", &json!({"extension": "jpg"})).unwrap(); + s.execute("links.update", &json!({})).unwrap(); + (rows, issues) + }); + assert!(seen.is_empty(), "{seen:?}"); + let (rows, issues) = rows; + for row in rows.as_array().unwrap() { + assert_eq!(row["status"], "missing", "{row}"); + assert_eq!(row["refused"], "the path is on another computer", "{row}"); + } + assert_eq!(issues.to_string().matches("Missing link").count(), 2, "{issues}"); + + // Data sources and book chapters stored the same way. + let mut d = s.doc().unwrap().doc.as_ref().clone(); + d.data_merge.sources.push(designcraft_doc::DataSource { + id: 1, + path: Some(UNC.replace("x.png", "data.csv")), + relative_path: Some(FILE_URL.replace("x.png", "data.csv")), + name: "data.csv".into(), + delimiter: designcraft_doc::Delimiter::Comma, + sheet: None, + fields: vec![], + rows: vec![], + fingerprint: None, + status: designcraft_doc::SourceStatus::Ok, + warnings: vec![], + }); + std::fs::write(&doc, designcraft_format::save(&d).unwrap()).unwrap(); + let mut opened = Session::new(); + let ((), seen) = crate::io::touched(|| { + opened.execute("file.open", &json!({"path": doc.to_string_lossy()})).unwrap(); + opened.execute("links.list", &json!({})).unwrap(); + }); + // The document itself and the folders beside it, nothing else. + assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}"); + assert_eq!(opened.doc().unwrap().doc.data_merge.sources[0].status, designcraft_doc::SourceStatus::Missing); + let book = dir.join("b.dcbook"); + std::fs::write(&book, json!({"documents": [UNC.replace("x.png", "c.designcraft"), FILE_URL.replace("x.png", "c.designcraft")]}).to_string()) + .unwrap(); + s.execute("book.open", &json!({"path": book.to_string_lossy()})).unwrap(); + let (list, seen) = crate::io::touched(|| s.execute("book.list", &json!({})).unwrap()); + assert!(seen.is_empty(), "{seen:?}"); + assert_eq!(list["documents"][0]["pages"], 0); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_relative_link_resolves_beside_the_document() { + let dir = folder("relative"); + std::fs::write(dir.join("logo.png"), png()).unwrap(); + let mut s = document_with_links(&dir.join("doc.designcraft"), &["logo.png"]); + let (rows, seen) = crate::io::touched(|| s.execute("links.list", &json!({})).unwrap()); + assert_eq!(rows[0]["status"], "ok", "{rows}"); + assert!(rows[0]["refused"].is_null()); + assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests_roots.rs index a8720cd4..39ab5e57 100644 --- a/crates/engine/src/tests_roots.rs +++ b/crates/engine/src/tests_roots.rs @@ -71,9 +71,10 @@ fn idml_links_are_read_through_the_roots() { // Linked, not embedded: opening the package has to read the file its link names. let idml = base64_idml(&mut s); let linked = |doc: &designcraft_doc::Document| doc.assets.values().filter(|a| !a.data.is_empty()).count(); - let unconfined = cmd::interchange::import_idml_confined(&idml).unwrap(); + let package = base.join("outside/x.idml"); + let unconfined = cmd::interchange::import_idml_confined(&idml, Some(&package)).unwrap(); assert_eq!(linked(&unconfined), 1, "without roots the link is followed"); - let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml)).unwrap(); + let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml, Some(&package))).unwrap(); assert_eq!(linked(&confined), 0, "with roots a link outside the read root is not read"); // The same through the commands that open or place a package. let b64 = cmd::base64_encode(&idml); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 1e727bcb..4c85d78b 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -3710,9 +3710,23 @@ fn wrap_path_of(pg: &El) -> std::result::Result { Ok(PathData::new(subs)) } -/// IDML link URI → file system path (`file:/a%20b` → `/a b`, `file:///C:/x` → `C:/x`). +/// IDML link URI → file system path (`file:/a%20b` → `/a b`, `file:///C:/x` → `C:/x`). A URI +/// naming another computer keeps it as a network path (`file://host/s/x` → `//host/s/x`). pub(crate) fn uri_to_path(uri: &str) -> String { - let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri); + let rest = match uri.strip_prefix("file://") { + Some(rest) => { + let host_end = rest.find(['/', '\\']).unwrap_or(rest.len()); + let host = rest.get(..host_end).unwrap_or_default(); + if host.is_empty() || host.eq_ignore_ascii_case("localhost") { + rest.get(host_end..).unwrap_or(rest) + } else if matches!(host.as_bytes(), [letter, b':'] if letter.is_ascii_alphabetic()) { + rest + } else { + return format!("//{}", percent_decode(rest)); + } + } + None => uri.strip_prefix("file:").unwrap_or(uri), + }; // `file:///C:/…` → `/C:/…` → `C:/…` let rest = match rest.as_bytes() { [b'/', _, b':', _, ..] => rest.get(1..).unwrap_or(rest), diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 3f5b7977..2c48673c 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1022,6 +1022,10 @@ fn round_trips_overridden_parent_items() { fn uri_and_base64_helpers() { assert_eq!(import::uri_to_path("file:/a%20b/c.png"), "/a b/c.png"); assert_eq!(import::uri_to_path("file:///C:/x/y.jpg"), "C:/x/y.jpg"); + assert_eq!(import::uri_to_path("file://localhost/a/b.png"), "/a/b.png"); + assert_eq!(import::uri_to_path("file://C:/x/y.jpg"), "C:/x/y.jpg"); + // Another computer stays one: a network path, not a folder named after it. + assert_eq!(import::uri_to_path("file://192.0.2.1/s/x%20y.png"), "//192.0.2.1/s/x y.png"); assert_eq!(export::path_to_uri("/a b/c.png"), "file:/a%20b/c.png"); let data: Vec = (0..=255u8).cycle().take(1000).collect(); assert_eq!(base64_decode(&base64_encode(&data)), data); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index a9f4aeed..fded030b 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -2360,7 +2360,11 @@ pub fn links_panel(app: &mut DesignApp, ui: &mut egui::Ui) { "tooLarge" => ("Too large to check", egui::Color32::from_rgb(0xe8, 0xb3, 0x2c)), _ => ("OK", t.text_dim), }; - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, txt)).color(col).size(11.0)); + let status = crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, txt)).color(col).size(11.0)); + // Why a link isn't followed (a path on another computer, a device). + if let Some(why) = r["refused"].as_str() { + status.on_hover_text(why); + } let uses = r["uses"].as_array().cloned().unwrap_or_default(); let info = match uses.first() { Some(u) => { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c6991ed..4a7e9b99 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -422,6 +422,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. - Underline / Strikethrough Options: weight, offset, colour and tint (Character panel; screen, PDF and IDML). - Relink to Folder and Relink File Extension (`links.relinkFolder`; missing links found by name). +- Paths a document names (links, data sources, book chapters) are followed only on this computer or on the document's own network share, relative ones beside the document; a link to another computer or a device is Missing, with the reason in `links.list` (`refused`) and Preflight. An IDML or INDD package's linked files are read from where the link points on this computer, then from the link's trailing names beside the package, and only regular files up to 256 MB that hold an image or a graphic. Images inside an SVG come only from `data:` URLs. - File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). - Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read. - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. From 8c3bbdc09f6b8f40e43795397abfc413ebd72ed1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 16 Oct 2026 16:48:00 +0000 Subject: [PATCH 10/45] Read images inside SVGs only from data: URLs --- crates/images/src/lib.rs | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 24eeedb5..2bed1c48 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -72,7 +72,11 @@ fn svg_options() -> &'static usvg::Options<'static> { db.set_sans_serif_family("Source Sans 3"); db.set_monospace_family("JetBrains Mono"); let font_resolver = usvg::FontResolver { select_font: select_font(), select_fallback: select_fallback() }; - usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, ..Default::default() } + // Images inside an SVG come only from `data:` URLs: an `href` naming a file is not read + // (it is left out, as usvg leaves out an image it can't load). + let image_href_resolver = + usvg::ImageHrefResolver { resolve_data: usvg::ImageHrefResolver::default_data_resolver(), resolve_string: Box::new(|_, _| None) }; + usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, image_href_resolver, ..Default::default() } }) } @@ -510,6 +514,39 @@ mod tests { assert!(!is_svg(b"\x89PNG")); } + #[test] + fn svg_images_come_only_from_data_urls() { + let dir = std::env::temp_dir().join(format!("dc-svg-href-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("red.png"); + image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).save(&file).unwrap(); + let blue = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Crect width='10' height='10' fill='blue'/%3E%3C/svg%3E"; + let svg = format!( + r#" + + + +"#, + file.display() + ); + // The image nodes usvg kept: only the data URL's (PDF export embeds every one). + fn images(g: &usvg::Group) -> usize { + g.children() + .iter() + .map(|n| match n { + usvg::Node::Group(g) => images(g), + usvg::Node::Image(_) => 1, + _ => 0, + }) + .sum() + } + assert_eq!(images(svg_tree(svg.as_bytes()).unwrap().root()), 1, "files the SVG names are not read"); + let (px, w, _) = render_svg(svg.as_bytes(), 30).unwrap(); + let at = |x: usize, y: usize| px[(y * w as usize + x) * 4..][..4].to_vec(); + assert_eq!(at(15, 5), [0, 0, 255, 255], "a data URL is drawn"); + let _ = std::fs::remove_dir_all(&dir); + } + /// The family of the font that sets each text glyph of `svg`, and the glyph's id. fn glyph_families(svg: &str) -> Vec<(String, u16)> { fn walk(g: &usvg::Group, tree: &usvg::Tree, out: &mut Vec<(String, u16)>) { From 81cc1404b8c7335d5838e5a694d1815bfdec7dbb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:22:58 +0200 Subject: [PATCH 11/45] Skip phonetic guide text when importing XLSX strings Found while testing crates/textimport/src/xlsx.rs:62 and :112 (shared strings are read with El::text, which includes children; inline strings use the same call, crates/textimport/src/docx.rs:37) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/xlsx.rs | 43 ++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs index d0795699..3f20f3de 100644 --- a/crates/textimport/src/xlsx.rs +++ b/crates/textimport/src/xlsx.rs @@ -35,11 +35,23 @@ fn first_sheet_target(wb: &str, rels: &str) -> Option { Some(if target.starts_with("xl/") { target } else { format!("xl/{target}") }) } +/// The text of a shared string (``) or inline string (``): its own `` and the `` of its +/// `` runs. Phonetic guides (``, ``) are the reading, not the cell text. +fn string_item(si: &El) -> String { + si.els() + .map(|e| match e.name.as_str() { + "t" => e.text(), + "r" => e.els().filter(|t| t.name == "t").map(El::text).collect(), + _ => String::new(), + }) + .collect() +} + fn value(c: &El, shared: &[String]) -> String { let v = || c.child("v").map(El::text).unwrap_or_default(); match c.attr("t") { Some("s") => v().trim().parse::().ok().and_then(|i| shared.get(i).cloned()).unwrap_or_default(), - Some("inlineStr") => c.child("is").map(El::text).unwrap_or_default(), + Some("inlineStr") => c.child("is").map(string_item).unwrap_or_default(), // A cached formula string is text even when it looks like a number. Some("str") => v(), Some("b") => { @@ -90,7 +102,7 @@ pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result>, Im let shared: Vec = part(&mut zip, "xl/sharedStrings.xml")? .map(|x| parse(&x)) .transpose()? - .map(|sst| sst.els().filter(|e| e.name == "si").map(El::text).collect()) + .map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect()) .unwrap_or_default(); let mut cells: Vec<(usize, usize, String)> = Vec::new(); if let Some(data) = sheet_xml.child("sheetData") { @@ -198,7 +210,7 @@ pub fn import(bytes: &[u8]) -> Result { let shared: Vec = part(&mut zip, "xl/sharedStrings.xml")? .map(|x| parse(&x)) .transpose()? - .map(|sst| sst.els().filter(|e| e.name == "si").map(El::text).collect()) + .map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect()) .unwrap_or_default(); let mut cells: Vec<((usize, usize), String)> = Vec::new(); if let Some(data) = sheet.child("sheetData") { @@ -355,6 +367,31 @@ mod tests { assert!(records(&book, Some("Missing")).is_err()); } + #[test] + fn phonetic_guide_text_is_not_cell_text() { + let book = sheets(&[ + ( + "xl/workbook.xml", + r#""#, + ), + ("xl/_rels/workbook.xml.rels", r#""#), + ( + "xl/sharedStrings.xml", + r#"日本ニホン東京トウキョウ"#, + ), + ( + "xl/worksheets/sheet1.xml", + r#"01大阪オオサカ"#, + ), + ]); + let want = ["日本", "東京", "大阪"].map(String::from).to_vec(); + assert_eq!(records(&book, None).unwrap(), vec![want.clone()]); + let imp = import(&book).unwrap(); + let t = imp.story.tables.values().next().expect("a table"); + let got: Vec = (0..3).map(|c| t.cell(0, c).unwrap().text.text.clone()).collect(); + assert_eq!(got, want); + } + fn one_value(cell: &str) -> Vec { workbook_with(&format!( r#"Value{cell}"# From 61387304b66fca571d16f40de32c89c32db1cf06 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:26:08 +0200 Subject: [PATCH 12/45] Ignore non-finite values in Tagged Text import Found while testing crates/textimport/src/tagged.rs:173 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/tagged.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs index b84577ea..9bc84b76 100644 --- a/crates/textimport/src/tagged.rs +++ b/crates/textimport/src/tagged.rs @@ -170,7 +170,7 @@ pub fn import(bytes: &[u8]) -> Result { } "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()), "cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()), - "cSize" => fmt.over.size = value.trim().parse().ok(), + "cSize" => fmt.over.size = value.trim().parse().ok().filter(|s: &f64| s.is_finite()), "DefineParaStyle" | "DefineCharStyle" => { let nm = value.split('=').next().unwrap_or("").trim().to_string(); let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles }; @@ -245,6 +245,17 @@ mod tests { assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC"); } + /// `f64::from_str` accepts "NaN" and "inf"; a non-finite size must not reach the story. + #[test] + fn non_finite_char_size_is_ignored() { + for v in ["NaN", "inf", "infinity", "-inf"] { + let src = format!("\r\nbig"); + let i = import(src.as_bytes()).unwrap(); + let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size; + assert!(size.is_none_or(f64::is_finite), "{v}: size {size:?}"); + } + } + #[test] fn tagged_text_keeps_a_leading_empty_paragraph() { let doc = Document::new(&designcraft_doc::build::NewDocument::default()); From 3823d7d9420b74c661578db75c0ff2168ef4190d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:22:17 +0000 Subject: [PATCH 13/45] Ignore out-of-range font sizes in RTF import Found while testing crates/textimport/src/rtf.rs:219 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/rtf.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs index f336b7f3..baf065c3 100644 --- a/crates/textimport/src/rtf.rs +++ b/crates/textimport/src/rtf.rs @@ -216,7 +216,10 @@ pub fn import(bytes: &[u8]) -> Result { "ul" => restyle(&|s| s.underline = on), "ulnone" => restyle(&|s| s.underline = false), "strike" => restyle(&|s| s.strike = on), - "fs" => restyle(&|s| s.size = param.map(|p| p as f64 / 2.0)), + // Half-points; sizes outside the engine's 0.1–1296 pt range fall back to the default. + "fs" => restyle(&|s| { + s.size = param.map(|p| p as f64 / 2.0).filter(|pt| (0.1..=1296.0).contains(pt)); + }), "plain" => restyle(&|s| { s.bold = false; s.italic = false; @@ -297,6 +300,18 @@ Caf\'e9 \u8364? price\tab tab\line next\par assert!(!st.text.contains("Times"), "font table skipped"); } + /// `\fs` sizes outside the engine's 0.1–1296 pt character size range are not stored. + #[test] + fn font_size_out_of_range_is_not_stored() { + let i = import(br"{\rtf1\ansi\fs0 A\fs-8 B\fs999999999 C\fs24 D}").unwrap(); + let st = &i.story; + for ch in ["A", "B", "C"] { + let size = st.format_after(st.text.find(ch).unwrap()).over.size; + assert!(size.is_none_or(|s| (0.1..=1296.0).contains(&s)), "{ch}: {size:?}"); + } + assert_eq!(st.format_after(st.text.find('D').unwrap()).over.size, Some(12.0)); + } + /// A '-' right after a control word is text unless a digit follows it (then it is a negative /// parameter). It used to be swallowed. #[test] From 1977a678860ef6476fa7f6fa552559c2861a7d31 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 17 Oct 2026 02:20:16 +0000 Subject: [PATCH 14/45] Build: use craft-docpath from craft-libs --- Cargo.lock | 1 + Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 35dc59da..f1f4ecdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,6 +827,7 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=12281d07fedff5c955bb68344fb287aa5d87971e#12281d07fedff5c955bb68344fb287aa5d87971e" [[package]] name = "craft-pdf" diff --git a/Cargo.toml b/Cargo.toml index 71442d84..6725c5c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ zip = { version = "3", default-features = false, features = ["deflate"] } indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } # Which paths stored in documents may be followed (craft-libs). -craft-docpath = { path = "../secfix-craft-libs/crates/docpath" } +craft-docpath = { git = "https://github.com/aiongg/craft-libs", version = "0.1.0", rev = "12281d07fedff5c955bb68344fb287aa5d87971e" } # Atomic file replacement for saves and exports (craft-libs). craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" } # File › Print through the system print spooler (craft-libs). From bc840130a4e07468dc6090a3877ecf431e005d13 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 16 Oct 2026 20:20:36 -0600 Subject: [PATCH 15/45] Satisfy clippy's nonminimal_bool on rustc 1.96 Four sites already on main that `cargo xtask ci` cannot get past with the current toolchain; each rewrite is the one clippy suggests and changes no behaviour. `!x.is_some_and(f)` becomes `x.is_none_or(!f)`, and pdf_bounds folds `!(a && b) || !c` into `!(a && b && c)` by De Morgan. Unrelated to the merge they are committed beside; separated so that merge stays a merge. --- crates/engine/src/tests_file_guard.rs | 2 +- crates/render/src/pdf_bounds.rs | 2 +- crates/ui-egui/src/lib.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests_file_guard.rs b/crates/engine/src/tests_file_guard.rs index 21c8f301..9f2af3c3 100644 --- a/crates/engine/src/tests_file_guard.rs +++ b/crates/engine/src/tests_file_guard.rs @@ -158,7 +158,7 @@ fn offenders(crates_dir: &Path) -> (Vec, usize) { for path in files { let rel = path.strip_prefix(crates_dir).unwrap_or(&path).to_string_lossy().replace('\\', "/"); // `crates//src/...` only: build scripts, benches and `tests/` are not shipped code. - if !rel.split('/').nth(1).is_some_and(|d| d == "src") || is_test_file(&rel) || ALLOWED.iter().any(|(allowed, _)| *allowed == rel) { + if rel.split('/').nth(1).is_none_or(|d| d != "src") || is_test_file(&rel) || ALLOWED.iter().any(|(allowed, _)| *allowed == rel) { continue; } let Ok(text) = std::fs::read_to_string(&path) else { continue }; diff --git a/crates/render/src/pdf_bounds.rs b/crates/render/src/pdf_bounds.rs index 47a02152..74665ae3 100644 --- a/crates/render/src/pdf_bounds.rs +++ b/crates/render/src/pdf_bounds.rs @@ -48,7 +48,7 @@ impl Bounds { } let Some(clip) = self.clips.last() else { return }; let r = r.intersect(*clip); - if !(r.width() > 0.0 && r.height() > 0.0) || ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + if !(r.width() > 0.0 && r.height() > 0.0 && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())) { return; } self.out = Some(self.out.map_or(r, |o| o.union(r))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79eb2c44..fcb764c5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -950,7 +950,7 @@ impl DesignApp { } } // Release only when the request actually replaces Find, including a native PDF picker. - if was_find && !self.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") { + if was_find && self.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { self.session.reset_find_scope(); } } @@ -1437,7 +1437,7 @@ impl DesignApp { self.status(format!("{file}: {}", warnings.join("; "))); // Find Font, offered for the document's missing fonts, stays up: the status line // keeps the warnings. - if !self.ui.dialog.as_ref().is_some_and(|d| d.id == "findFont") { + if self.ui.dialog.as_ref().is_none_or(|d| d.id != "findFont") { self.ui.dialog = Some(dialogs::Dialog::new("alert", json!({"title": "Import Warnings", "file": file, "message": warnings.join("\n")}))); } From c43a63f71e850d349e4a96911c10a87ce058d9f3 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 16 Oct 2026 20:20:53 -0600 Subject: [PATCH 16/45] Reconcile this branch's EPS work with main's placement and format changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge compiled, but six tests showed where the two branches meant different things. Fixes, in order of consequence: - Format version: both branches had independently defined version 2 — here for retained EPS sources, on main for pinned Keep Lines Together and drop cap defaults, with an `upgrade` that runs below 2. Writing 1 for documents without EPS then re-ran that upgrade on load and rewrote their paragraph styles, so saving and reloading changed the document. EPS now asks for version 3 and everything else stays at 2. - place.drop into a frame: main turned Auto-Fit off so that resizing a frame crops rather than refits, and this branch had carried the older FillProportionally along while moving the geometry out of the edit. Main's behaviour is the intended one. - save() compacts unreferenced assets now, so the format test has to place its EPS for the version sniff to see it at all. - EPUB entries are named from the asset's file stem (asset_path), not its bare id: images/logo-.png. - Command parameters are validated strictly, so the PDF page test sends each command only the parameters it takes. - The relink batch's "commits once" check read a process-wide revision counter, which a test running beside it can advance; the single undo step is what actually shows it committed once. The EPS place tests move to cmd/file/eps_place_tests.rs: they write real files, and main's file-system guard reads src files without seeing inline #[cfg(test)] modules, but exempts _tests.rs ones. --- crates/engine/src/cmd/file.rs | 220 +----------------- crates/engine/src/cmd/file/eps_place_tests.rs | 215 +++++++++++++++++ crates/engine/src/cmd/links.rs | 17 +- crates/format/src/lib.rs | 19 +- 4 files changed, 240 insertions(+), 231 deletions(-) create mode 100644 crates/engine/src/cmd/file/eps_place_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 56d5c0cd..0c80ffed 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -874,7 +874,8 @@ fn place_drop(s: &mut Session, p: &Value) -> Result { 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) + // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. + (r, xf, Default::default()) } else { if d.spread(sr).is_none() { return Err(bad("place.drop", "no such spread")); @@ -1460,222 +1461,7 @@ mod new_document_tests { } #[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 { + 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(" json!({"dir": dir.join("new").to_string_lossy(), "missingOnly": false}), + "links.update" => json!({"asset": aid.0}), + _ => json!({"asset": aid.0, "path": replacement.to_string_lossy()}), + }; let err = s.execute(command, ¶ms).unwrap_err(); assert!(err.to_string().contains("PDF page 2"), "{command}: {err}"); let st = s.doc().unwrap(); @@ -843,8 +846,10 @@ mod relink_folder_tests { 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); + // `revision` is drawn from a process-wide counter, so a test running beside this one + // can take the next value: that the batch commits *once* is what the single undo step is. + assert_ne!(st.revision, revision, "the batch commits"); + assert_eq!(st.history.undo.len(), undo + 1, "exactly one undo step"); s.execute("edit.undo", &json!({})).unwrap(); assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before)); let _ = std::fs::remove_dir_all(dir); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 8490fcdd..5e87c540 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -21,8 +21,9 @@ use designcraft_doc::{AssetId, Document, ParaAttrs, Story}; use serde_json::{Value, json}; pub const MIME: &str = "application/vnd.designcraft+zip"; -/// Latest reader capability. Version 2 adds rendering of retained EPS source assets. -pub const VERSION: u32 = 2; +/// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults; +/// version 3 adds rendering of retained EPS source assets. +pub const VERSION: u32 = 3; #[derive(Debug, thiserror::Error)] pub enum FormatError { @@ -84,8 +85,9 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?; z.start_file("meta.json", deflate).map_err(io)?; // 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 }; + // documents without EPS stay at 2 so their existing readers keep working; writing 1 + // would make a reader re-run the version 2 paragraph upgrade over current defaults. + let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } else { 2 }; 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()))?; if let Some(png) = preview { @@ -439,7 +441,7 @@ mod tests { } #[test] - fn eps_source_requires_new_reader_but_preview_documents_keep_version_one() { + fn eps_source_requires_new_reader_but_preview_documents_keep_the_older_one() { let mut d = Document::new(&NewDocument::default()); let version = |d: &Document| { let bytes = save(d).unwrap(); @@ -448,7 +450,7 @@ mod tests { 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); + assert_eq!(version(&d), 2); 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( @@ -464,10 +466,11 @@ mod tests { ..Default::default() }), ); - assert_eq!(version(&d), 2, "sniff source even when legacy metadata says PNG"); + place(&mut d, aid, 0.0); + assert_eq!(version(&d), 3, "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"); + assert_eq!(version(&d), 2, "legacy EPS previews stay compatible"); } #[test] From 653f83b8cb21f8ce2cdc873ca712a58c73628352 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 16 Oct 2026 20:46:40 -0600 Subject: [PATCH 17/45] Startup: keep the backend a start was on, whichever it is, and cover it Builds on the fix in this branch, which spotted that a failed OpenGL was being forgotten because `failed` is filtered to this platform's candidates and OpenGL is no candidate off Windows. Rather than spare OpenGL from the filter, run the filter before the previous start's backend is added. A stored entry that is no candidate here came from another machine and still says nothing, so it is dropped as before; the backend a start was actually running on is added afterwards and stays, whichever backend it is. That keeps the same crash out of the next instance without naming OpenGL or the platform, and without holding a failure recorded on someone else's machine against this one. Adds the regression test: on macOS, where Metal is the only candidate, a start left on OpenGL has to keep it out of the instance, while a stored OpenGL entry that never ran here is dropped. --- apps/designcraft/src/gpu/backend.rs | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/designcraft/src/gpu/backend.rs b/apps/designcraft/src/gpu/backend.rs index 3cac6089..0a3cda61 100644 --- a/apps/designcraft/src/gpu/backend.rs +++ b/apps/designcraft/src/gpu/backend.rs @@ -138,17 +138,18 @@ pub fn choose(candidates: &[Backend], previous: &Record, version: &str, skipped: let first = *candidates.first()?; let same_version = previous.version == version; let mut failed: Vec = if same_version { previous.failed.clone() } else { Vec::new() }; + // A stored entry that is no candidate here came from another machine and says nothing. The + // backend the previous start was still on is added after this and stays, candidate or not: + // the instance can load a backend that is no candidate — everywhere but Windows OpenGL rides + // along beside the chosen one (`instance_backends`) — and a start that one took down must + // keep it out of the next instance. + failed.retain(|b| candidates.contains(b)); let unfinished = previous.trying.filter(|_| same_version); if let Some(b) = unfinished && !failed.contains(&b) { failed.push(b); } - // A stale entry for a backend this platform cannot use is dropped, but one that can still - // be reached stays: OpenGL is the in-process fallback off Windows (`instance_backends`) - // without being a candidate there, so forgetting it would offer it again after it failed - // to present — on macOS, where Metal is the only candidate, it never got left out at all. - failed.retain(|b| candidates.contains(b) || (!cfg!(windows) && *b == Backend::Gl)); let pick = |failed: &[Backend]| candidates.iter().copied().find(|b| !failed.contains(b) && !skipped.contains(b)); let backend = match pick(&failed) { Some(b) => b, @@ -391,6 +392,23 @@ mod tests { assert_eq!(c.record.failed, &[Backend::Vulkan]); } + /// The backend the previous start was still on stays in `failed` even where it is no + /// candidate: on macOS Metal is the only candidate, but the instance loads OpenGL beside it, + /// so a start OpenGL took down has to keep it out of the next one. Only the stored list is + /// filtered to this platform's candidates — those entries came from another machine. + #[test] + fn the_backend_a_start_was_on_counts_as_failed_even_where_it_is_no_candidate() { + const MAC: &[Backend] = &[Backend::Metal]; + let c = choose(MAC, &record(Some(Backend::Gl), &[]), V, &[]).unwrap(); + assert_eq!(c.backend, Backend::Metal); + assert_eq!(c.record.failed, &[Backend::Gl], "OpenGL ran here, so it failed here"); + assert_eq!(c.switched_from, Some(Backend::Gl)); + assert!(!instance_backends(c.backend, &c.record.failed).contains(wgpu::Backends::GL)); + // Stored on another machine, never run here: dropped, and OpenGL may ride along again. + let c = choose(MAC, &record(None, &[Backend::Gl]), V, &[]).unwrap(); + assert_eq!(c.record.failed, &[]); + } + /// A restart that found no adapter of DirectX 12 names the backend in its skip list: that start /// takes the next candidate, with or without a record, and the record doesn't keep it as failed /// (a later launch tries it again). From 2cc21e19b2a0ea6ac48b0ca4f0fa098ccd5fd727 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:55:36 +0200 Subject: [PATCH 18/45] Ignore non-finite w:sz, w:spacing and w:ind values in DOCX import Found while testing crates/textimport/src/docx.rs:183 (run_attrs, w:sz) and :206-214 (para_attrs, w:spacing/w:ind via the `tw` closure) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/textimport/src/docx.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index c4375669..783b010c 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -180,7 +180,7 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs { if let Some(s) = r.on("strike") { a.strikethrough = Some(s); } - if let Some(sz) = r.val("sz").and_then(|v| v.parse::().ok()) { + if let Some(sz) = r.val("sz").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { a.size = Some(sz / 2.0); } if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) { @@ -203,7 +203,7 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs { fn para_attrs(ppr: Option<&El>) -> ParaAttrs { let mut a = ParaAttrs::default(); let Some(p) = ppr else { return a }; - let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).map(|v| v / 20.0); + let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0); if let Some(sp) = p.child("spacing") { a.space_before = tw(sp.attr("before")); a.space_after = tw(sp.attr("after")); @@ -680,6 +680,28 @@ mod tests { assert_eq!(st.text, "Date: 1 May 2026.\nLink"); } + /// `str::parse::` accepts "NaN"/"inf"; sizes, spacing and indents must ignore them. + #[test] + fn non_finite_sizes_spacing_and_indents_are_ignored() { + let document = format!( + r#" + + ab + c + "# + ); + let st = import(&docx(&document, "", "")).unwrap().story; + let fin = |v: Option| v.is_none_or(f64::is_finite); + for p in &st.paras { + let p = &p.para; + assert!(fin(p.space_before) && fin(p.space_after), "{p:?}"); + assert!(fin(p.left_indent) && fin(p.right_indent) && fin(p.first_line_indent), "{p:?}"); + } + for at in [0, 1] { + assert!(fin(st.format_after(at).over.size), "{:?}", st.format_after(at).over.size); + } + } + #[test] fn tracked_changes_import_as_accepted_including_moves() { // Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import. From fc36957fe05f38a6976df4734d52b9250cd54a2c Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 16 Oct 2026 22:00:52 -0600 Subject: [PATCH 19/45] Ignore the local outputs/ folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scratch exports from running the app and the CLI land in outputs/. It is not ignored, so `cargo xtask assets` — which scans tracked and untracked files alike — reports every PNG and PDF in it as an asset with no row in ASSETS.md, and the check cannot pass on a machine that has ever run the app with an output directory. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ab320279..d47b8f37 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ plan/ /dist /corpus /log +/outputs/ .DS_Store /.claude/ /target-*/ From ec4fbb270fe7555a55c06141a8139fb89fae81bf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:20:21 +0200 Subject: [PATCH 20/45] Ignore NaN and infinite numbers when importing IDML Found while testing crates/idml/src/import.rs:2748 (stroke_from: StrokeWeight, MiterLimit), also El::num in crates/idml/src/xml.rs:96 and the plain `.parse().ok()` calls around import.rs:1833, 2823 and 2832 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_idml.rs | 97 +++++++++++++++++++++++++++++++++ crates/idml/src/import.rs | 35 ++++++++---- crates/idml/src/names.rs | 2 +- crates/idml/src/xml.rs | 11 +++- 4 files changed, 133 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index 0701052d..3a49168b 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -575,3 +575,100 @@ fn table_of_contents_character_styles_and_sort_round_trip_through_idml() { let e = &after.entries[1]; assert_eq!((e.page_number_style.as_deref(), e.separator_style.as_deref(), e.sort), (None, None, false)); } + +/// `text` with the value after every `open` (up to the next `close`) set to `value`, and how +/// many values were replaced. +fn replace_values(text: &str, open: &str, close: char, value: &str) -> (String, usize) { + let mut rest = text; + let mut new = String::new(); + let mut count = 0; + while let Some(at) = rest.find(open) { + let start = at + open.len(); + new.push_str(&rest[..start]); + new.push_str(value); + let end = rest[start..].find(close).map_or(rest.len(), |e| start + e); + rest = &rest[end..]; + count += 1; + } + new.push_str(rest); + (new, count) +} + +/// `package` with every `{name}="…"` attribute and `<{name}>…` / `<{name} type="…">…` +/// property element in its XML parts set to `value`, and how many values were replaced. +fn with_attr_set(package: &[u8], name: &str, value: &str) -> (Vec, usize) { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(package)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let mut count = 0; + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + let mut body = Vec::new(); + std::io::Read::read_to_end(&mut f, &mut body).unwrap(); + if f.name().ends_with(".xml") + && let Ok(text) = String::from_utf8(body.clone()) + { + let (text, a) = replace_values(&text, &format!(" {name}=\""), '"', value); + let (text, b) = replace_values(&text, &format!("<{name}>"), '<', value); + let (text, c) = replace_values(&text, &format!("<{name} type=\"unit\">"), '<', value); + let (text, d) = replace_values(&text, &format!("<{name} type=\"double\">"), '<', value); + count += a + b + c + d; + body = text.into_bytes(); + } + out.start_file(f.name(), zip::write::SimpleFileOptions::default()).unwrap(); + std::io::Write::write_all(&mut out, &body).unwrap(); + } + (out.finish().unwrap().into_inner(), count) +} + +/// Non-finite numbers in an IDML package never reach the model: the imported document saves +/// and reopens. Every listed name occurs in the exported sample, so each case really rewrites +/// values; names the sample lacks (feather `Width`, gradient start/length) go through `El::num`, +/// covered in `xml.rs`. +#[test] +fn idml_non_finite_numbers_import_to_a_document_that_reopens() { + let package = designcraft_idml::export_idml(&crate::sample::magazine()); + let attrs = [ + "StrokeWeight", + "MiterLimit", + "Angle", + "FirstBaselineOffset", + "MinimumFirstBaselineOffset", + "SpaceBefore", + "SpaceAfter", + "LeftIndent", + "RightIndent", + "FirstLineIndent", + "PointSize", + "Tracking", + "Leading", + "FillTint", + "StrokeTint", + "GapTint", + "Position", + "BaselineShift", + "Skew", + ]; + let mut failures = Vec::new(); + for attr in attrs { + for value in ["NaN", "inf", "-inf"] { + let (bytes, replaced) = with_attr_set(&package, attr, value); + if replaced == 0 { + failures.push(format!("{attr}: not present in the exported package")); + continue; + } + let doc = match designcraft_idml::import_idml(&bytes) { + Ok(doc) => doc, + Err(e) => { + failures.push(format!("{attr}={value}: import failed: {e}")); + continue; + } + }; + match designcraft_format::save(&doc).map(|saved| designcraft_format::load(&saved)) { + Ok(Ok(_)) => {} + Ok(Err(e)) => failures.push(format!("{attr}={value}: saved but load failed: {e}")), + Err(e) => failures.push(format!("{attr}={value}: save failed: {e}")), + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 5cd3746a..ae4d9f89 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -391,6 +391,12 @@ fn nums(s: &str) -> Vec { s.split_whitespace().filter_map(|v| v.parse().ok()).collect() } +/// Like [`nums`], but empty when any number is NaN or infinite. +fn finite_nums(s: &str) -> Vec { + let v = nums(s); + if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() } +} + /// The viewBox size of an SVG whose root has a viewBox but no width/height (percentages count as /// none): InDesign places such a file at one viewBox unit to the point. fn svg_viewbox_size(data: &[u8]) -> Option<(f64, f64)> { @@ -936,12 +942,16 @@ impl<'r> Importer<'r> { // Custom stroke styles. for e in top.iter().filter(|e| matches!(e.local(), "StripedStrokeStyle" | "DashedStrokeStyle" | "DottedStrokeStyle")) { let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue }; - let nums = |k: &str| e.get(k).unwrap_or("").split_whitespace().filter_map(|x| x.parse::().ok()).collect::>(); let kind = match e.local() { "StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes { - bands: nums("StripeArray").as_chunks::<2>().0.iter().map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0)).collect(), + bands: finite_nums(e.get("StripeArray").unwrap_or("")) + .as_chunks::<2>() + .0 + .iter() + .map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0)) + .collect(), }, - "DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums("DashArray") }, + "DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: finite_nums(e.get("DashArray").unwrap_or("")) }, _ => designcraft_doc::StrokeType::Dotted, }; let _ = id; @@ -1830,7 +1840,7 @@ impl<'r> Importer<'r> { for li in tl.find_all("ListItem") { let get = |k: &str| li.find(k).map(|x| x.text_content()); tabs.push(TabStop { - position: get("Position").and_then(|v| v.trim().parse().ok()).unwrap_or(0.0), + position: get("Position").and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).unwrap_or(0.0), align: names::tab_align_in(get("Alignment").unwrap_or_default().trim()), leader: get("Leader").unwrap_or_default(), align_on: get("AlignmentCharacter").filter(|c| c != ".").unwrap_or_default(), @@ -2189,8 +2199,8 @@ impl<'r> Importer<'r> { if r >= nr || c >= nc { continue; } - let rs = (ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize).min(nr - r); - let cs = (ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize).min(nc - c); + let rs = (span(ce, "RowSpan").max(1.0) as usize).min(nr - r); + let cs = (span(ce, "ColumnSpan").max(1.0) as usize).min(nc - c); let mut cell = t.cell(r, c).cloned().unwrap_or_default(); let cell_style = ce.get("AppliedCellStyle").map(|r| self.cell_style_ref(r)).unwrap_or_else(|| designcraft_doc::NO_CELL_STYLE.into()); if cell_style != designcraft_doc::NO_CELL_STYLE { @@ -2745,7 +2755,7 @@ impl<'r> Importer<'r> { if let Some(c) = color { s.swatch = self.swatch_ref(&c); } - if let Some(w) = g(self, "StrokeWeight").and_then(|v| v.parse().ok()) { + if let Some(w) = g(self, "StrokeWeight").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { s.weight = w; } if let Some(t) = g(self, "StrokeTint").and_then(|v| v.parse().ok()) { @@ -2763,7 +2773,7 @@ impl<'r> Importer<'r> { if let Some(v) = g(self, "EndJoin") { s.join = names::join_in(&v); } - if let Some(v) = g(self, "MiterLimit").and_then(|v| v.parse().ok()) { + if let Some(v) = g(self, "MiterLimit").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) { s.miter_limit = v; } if let Some(v) = g(self, "LeftLineEnd") { @@ -2829,7 +2839,7 @@ impl<'r> Importer<'r> { { it.fill.gradient_angle = Some(a); } - let start: Option> = e.prop("GradientFillStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect()); + let start: Option> = e.prop("GradientFillStart").map(|s| finite_nums(&s)); if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength")) && len > 0.0 { @@ -2847,7 +2857,7 @@ impl<'r> Importer<'r> { { it.stroke.gradient_angle = Some(a); } - let start: Option> = e.prop("GradientStrokeStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect()); + let start: Option> = e.prop("GradientStrokeStart").map(|s| finite_nums(&s)); if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength")) && len > 0.0 { @@ -3301,6 +3311,11 @@ fn overlay(base: &mut El, top: &El, depth: usize) { } } +/// A cell span count; an infinite span is kept so it clamps to the table edge. +fn span(e: &El, k: &str) -> f64 { + e.prop(k).and_then(|v| v.trim().parse::().ok()).unwrap_or(1.0) +} + fn finite(v: Option) -> Option { v.filter(|v| v.is_finite()) } diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 70d02e7c..56924447 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -263,7 +263,7 @@ pub fn leading_out(l: Leading) -> (String, &'static str) { } } pub fn leading_in(s: &str) -> Option { - if s == "Auto" { Some(Leading::Auto) } else { s.trim().parse().ok().map(Leading::Points) } + if s == "Auto" { Some(Leading::Auto) } else { s.trim().parse::().ok().filter(|v| v.is_finite()).map(Leading::Points) } } pub fn stroke_align_out(a: StrokeAlign) -> &'static str { diff --git a/crates/idml/src/xml.rs b/crates/idml/src/xml.rs index b3f1ef0d..374e70c7 100644 --- a/crates/idml/src/xml.rs +++ b/crates/idml/src/xml.rs @@ -93,8 +93,9 @@ impl El { pub fn prop_el(&self, k: &str) -> Option<&El> { self.find("Properties").and_then(|p| p.find(k)) } + /// A finite numeric property; `NaN` and infinities count as absent. pub fn num(&self, k: &str) -> Option { - self.prop(k).and_then(|v| v.trim().parse().ok()) + self.prop(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()) } pub fn boolean(&self, k: &str) -> Option { match self.prop(k)?.trim() { @@ -337,4 +338,12 @@ mod tests { assert_eq!(e.num("Leading"), Some(14.0)); assert_eq!(e.prop("Missing"), None); } + + /// `NaN` and infinities parse as `f64` but are not usable numbers. + #[test] + fn non_finite_numbers_count_as_absent() { + let x = br#"-inf2.5"#; + let e = parse(x).unwrap(); + assert_eq!((e.num("W"), e.num("X"), e.num("Y"), e.num("Z")), (None, None, None, Some(2.5))); + } } From 3eb7b5085b18f6080db38dcd6e04833df6d29584 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 06:27:34 +0000 Subject: [PATCH 21/45] Tools: readouts measure in the document's ruler units Tool readouts (the Page tool's size while dragging, smart-guide distances, the resize readout) took their unit from the UI's own setting, which nothing changes from picas, so a document set to inches read in picas. The tool context now takes the active document's horizontal ruler unit, and the Page tool's W/H use the horizontal and vertical units, as the Control and Properties fields do. Test: tool_readouts_measure_in_the_documents_units (picas before). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/tools/src/nav.rs | 4 ++-- crates/ui-egui/src/lib.rs | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index f371f267..f77c5d0b 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -434,8 +434,8 @@ impl Tool for PageTool { p: d.cur, text: format!( "W: {}\nH: {}", - designcraft_geom::format_measure(pg.width, cx.unit), - designcraft_geom::format_measure(pg.height, cx.unit) + designcraft_geom::format_measure(pg.width, cx.doc.settings.horizontal_units), + designcraft_geom::format_measure(pg.height, cx.doc.settings.vertical_units) ), }); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fcb764c5..70685206 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -737,7 +737,9 @@ impl DesignApp { ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), snap: self.ui.snap_view(), - unit: self.ui.units, + // Tool readouts (sizes, smart-guide distances) measure in the document's ruler units, + // as its Control and Properties fields do. + unit: self.session.active().map_or(self.ui.units, |st| st.doc.settings.horizontal_units), stroke_in_front: self.ui.stroke_front, } } @@ -1843,6 +1845,17 @@ mod tests { assert!(!accurate.rich_black, "a saved choice is kept"); } + /// Tool readouts measured in picas whatever the document's units (the UI's unit is never set): + /// they follow the active document's ruler units. + #[test] + fn tool_readouts_measure_in_the_documents_units() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); + assert_eq!(app.view_info().unit, Unit::Picas, "no document: the UI's default"); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "millimeters"})).unwrap(); + assert_eq!(app.view_info().unit, Unit::Inches); + } + /// An app with one document that has an unsaved edit. fn app_with_unsaved_document() -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default()); From 485a964e94e24f846b6fefa3063750bd9694a20c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:51:07 +0000 Subject: [PATCH 22/45] Reject a non-finite or non-positive --scale in designcraft-cli run Found while testing apps/designcraft-cli/src/main.rs:361 (`--scale` parse) and :386 (export); crates/render/src/lib.rs:422-423 (render_page) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/designcraft-cli/src/main.rs | 5 ++++- apps/designcraft-cli/tests/cli.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 20e8509e..51bf67f0 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -330,7 +330,10 @@ fn run(args: &[String]) -> Result<(), String> { } } "--page" => page = val()?.parse().map_err(|_| "bad --page")?, - "--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?, + "--scale" => { + // 0, a negative value and NaN made a 1×1 image; infinity, "no such page". + scale = val()?.parse().ok().filter(|s: &f64| s.is_finite() && *s > 0.0).ok_or("bad --scale: expected a positive number")?; + } "--pdf-options" => { // The export adds its path to them. pdf_opts = match serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))? { diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs index 0cb9be85..173a605f 100644 --- a/apps/designcraft-cli/tests/cli.rs +++ b/apps/designcraft-cli/tests/cli.rs @@ -252,6 +252,22 @@ fn run_export_options_come_before_their_export() { let _ = std::fs::remove_dir_all(&dir); } +/// `--scale 0`, a negative value or `nan` wrote a 1×1 PNG and exited 0; `inf` said "no such page". +#[test] +fn run_scale_that_is_not_finite_and_positive_is_an_error() { + let dir = std::env::temp_dir().join(format!("dc-cli-scale-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("a.png"); + for scale in ["nan", "0", "-2", "inf", "-inf"] { + let out = cli().args(["run", "--sample", "--scale", scale, "--export", png.to_str().unwrap()]).output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "--scale {scale}: {err}"); + assert!(err.contains("bad --scale"), "--scale {scale}: {err}"); + } + assert!(!png.exists(), "nothing is exported when the scale is wrong"); + let _ = std::fs::remove_dir_all(&dir); +} + /// `--pdf-options` must be a JSON object; any other JSON value used to panic when the PDF path was set on it. #[test] fn pdf_options_that_are_not_an_object_are_an_error_not_a_panic() { From 1b1a66ee7740f06101ccf4625d7de24ced693e8f Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:33:22 +0200 Subject: [PATCH 23/45] Make the dock collapse and expand chevrons clickable (#1203) --- crates/ui-egui/src/dock.rs | 62 +++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index fb9a7cf1..544f0072 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -68,7 +68,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { egui::Panel::right("dock").default_size(253.0).size_range(220.0..=420.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel)).show( ui, |ui| { - dock_header(ui, &t, "»"); + if dock_header(ui, &t, "»").clicked() { + app.ui.dock_expanded = false; + } // Tab strip (27 pt). let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.tab_strip); @@ -127,7 +129,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // (Essentials Classic) show their names. let groups = app.ui.icon_groups.clone(); egui::Panel::right("icon_column").exact_size(icon_column_width(app)).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { - dock_header(ui, &t, "«"); + if dock_header(ui, &t, "«").clicked() { + app.ui.dock_expanded = true; + } ui.add_space(4.0); match &groups { Some(groups) => { @@ -194,12 +198,13 @@ fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egu crate::panel_docking::legacy_tab(app, ui, id, response); } -/// 11 pt dock header strip with a collapse chevron. -fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::hover()); +/// 11 pt dock header strip with a collapse chevron; clicking it reports a click on the response. +fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) -> egui::Response { + let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::click()); ui.painter().rect_filled(r, 0.0, t.tab_strip); ui.painter().text(egui::pos2(r.max.x - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, chevron, egui::FontId::proportional(9.0), t.text_dim); ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); + response } /// A panel opened from the icon column, shown as a floating flyout next to the dock. @@ -518,3 +523,50 @@ mod visual_regressions { } } } + +#[cfg(test)] +mod chevron_tests { + use super::*; + use egui_kittest::Harness; + + fn click(harness: &mut Harness<'_, DesignApp>, at: egui::Pos2) { + harness.hover_at(at); + harness.run_steps(1); + harness.drag_at(at); + harness.run_steps(1); + harness.drop_at(at); + harness.run_steps(3); + } + + /// The » / « chevron painted at the right end of a panel's 11 pt header. + fn chevron(harness: &Harness<'_, DesignApp>, panel: &str) -> egui::Pos2 { + let r = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(panel)).expect("the panel is laid out").outer_rect; + egui::pos2(r.max.x - 10.0, r.min.y + 5.0) + } + + /// Issue #1203: the header chevrons were painted text; clicking them did nothing. + #[test] + fn the_dock_chevron_collapses_and_expands_the_dock() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui.dock_expanded = true; + let mut ready = false; + let mut harness = Harness::builder().with_size(vec2(1280.0, 640.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), "en"); + ready = true; + return; + } + show(app, ui); + }, + app, + ); + harness.run_steps(4); + let at = chevron(&harness, "dock"); + click(&mut harness, at); + assert!(!harness.state().ui.dock_expanded, "» collapses the dock to icons"); + let at = chevron(&harness, "icon_column"); + click(&mut harness, at); + assert!(harness.state().ui.dock_expanded, "« expands the dock again"); + } +} From 77625733445b99760df3773faa8a48318479b6ea Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:39:38 +0000 Subject: [PATCH 24/45] Keep a moved equation in place when editing its LaTeX Found while testing crates/engine/src/cmd/mathexpr.rs:73-83 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/mathexpr.rs | 46 ++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index e0ec7a56..b684f245 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -61,7 +61,9 @@ fn insert(s: &mut Session, p: &Value) -> Result { if let Some(id) = super::id_param(p, "id") { return s.edit(|d, _| { let old = d.item(id).ok_or_else(|| bad("math.insert", "no such object"))?; - let at = old.bounds(); + // The path and graphic live in the item's inner space, which `xf` maps to the spread: + // build the new ones at the old inner corner and keep `xf`. + let at = old.path.bounds().ok_or_else(|| bad("math.insert", "equation has no geometry"))?; let it = math_item(d, &latex, size, lid, (at.x0, at.y0)); let target = d.item_mut(id).ok_or_else(|| bad("math.insert", "no such object"))?; target.path = it.path; @@ -129,4 +131,46 @@ mod tests { let st = &s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(f["story"].as_u64().unwrap())]; assert_eq!(st.objects.len(), 1); } + + /// The item's top-left corner in spread space (inner path corner through its transform). + fn origin(s: &Session, id: designcraft_doc::ItemId) -> designcraft_geom::Point { + let d = &s.doc().unwrap().doc; + let it = d.item(id).unwrap(); + let r = it.path.bounds().unwrap(); + it.xf * designcraft_geom::Point::new(r.x0, r.y0) + } + + #[test] + fn editing_a_transformed_equation_keeps_its_place() { + for step in ["move", "rotate", "scale"] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("math.insert", &json!({"latex": r"\frac{a}{b}", "x": 100, "y": 100})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + match step { + "move" => s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})), + "rotate" => s.execute("transform.rotate", &json!({"ids": [id.0], "angle": 30})), + _ => s.execute("transform.scale", &json!({"ids": [id.0], "sx": 2.0, "sy": 1.5})), + } + .unwrap(); + let before = origin(&s, id); + s.execute("math.insert", &json!({"id": id.0, "latex": r"\frac{a}{b}"})).unwrap(); + let after = origin(&s, id); + assert!((before.x - after.x).abs() < 1e-6 && (before.y - after.y).abs() < 1e-6, "{step}: {before:?} -> {after:?}"); + } + } + + #[test] + fn editing_a_moved_equation_keeps_its_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("math.insert", &json!({"latex": "x", "x": 100, "y": 100})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})).unwrap(); + let before = s.doc().unwrap().doc.item(id).unwrap().bounds(); + s.execute("math.insert", &json!({"id": id.0, "latex": "x"})).unwrap(); + let after = s.doc().unwrap().doc.item(id).unwrap().bounds(); + assert_eq!((before.x0, before.y0), (after.x0, after.y0)); + assert_eq!((after.x0, after.y0), (150.0, 140.0)); + } } From b13b1ec40860efe2487a0a34d22e945fb49c185b Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:03:26 +0200 Subject: [PATCH 25/45] Keep the mapped style of the first paragraph when placing text at an empty caret (#1208) --- crates/engine/src/cmd/place_text.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 5343f54f..a3b9e5ef 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -366,7 +366,14 @@ pub(super) fn place_text( let story = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); story.delete(r.clone()); + // An insertion point in an empty paragraph takes the first imported paragraph's style + // (insert_story would keep the empty paragraph's own, losing the styleMap result). + let p0 = story.para_at(r.start); + let blank = story.para_ranges().get(p0).is_some_and(|x| x.is_empty()); let end = story.insert_story(r.start, &imp.story); + if blank && let (Some(first), Some(p)) = (imp.story.paras.first(), story.paras.get_mut(p0)) { + *p = ParaFormat { table: p.table, ..first.clone() }; + } sel.text = Some(TextSel { anchor: end, focus: end, ..t }); if link.is_some() { warnings.push("not linked: a linked text file fills a whole story, not an insertion point".into()); @@ -767,6 +774,20 @@ mod pdf_page_tests { assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().chars.size, Some(10.0)); } + #[test] + fn word_first_paragraph_into_empty_caret_keeps_mapped_style() { + // Regression (#1208): placing into a new text frame (which leaves its caret selected) left the first paragraph unstyled. + let b64 = crate::cmd::base64_encode(&word_file()); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Headline"})).unwrap(); + let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text"})).unwrap(); + let sid = fr["story"].as_u64().unwrap(); + s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "frame": fr["id"], "styleMap": {"Title": "Headline"}})).unwrap(); + let st = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + assert_eq!(st.paras.iter().map(|p| p.style.as_str()).collect::>(), ["Headline", "Body"]); + } + #[test] fn word_normal_comes_in_as_its_own_style() { // Regression (#722): Normal's formatting (over Word's document defaults) was dropped. From f633cf54ddc0e72ba1b6f964d482c9688bcad12a Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:05:48 +0200 Subject: [PATCH 26/45] PDF/X-4 export: add xmp:MetadataDate and xmpMM:VersionID to the XMP (#1207) --- crates/pdf/src/export.rs | 2 +- crates/pdf/src/pdfx.rs | 25 ++++++++++++++++++++----- crates/pdf/src/tests.rs | 14 ++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 3afe2201..4e7627e9 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -254,7 +254,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions } if opts.standard == Standard::PdfX4 { // Output intent and PDF/X identification, then check the result. - match crate::pdfx::make_pdfx4(&bytes, &title) { + match crate::pdfx::make_pdfx4(&bytes, &title, created) { Some(b) => bytes = b, None => warnings.push("PDF/X-4: the output intent couldn't be added".into()), } diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 75d661cf..534886f1 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -23,9 +23,11 @@ fn lit(s: &str) -> String { o } -/// Turn krilla's output into PDF/X-4. `None` when the file isn't the simple kind this -/// understands (a classic cross-reference table and plain dictionaries). -pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> { +/// Turn krilla's output into PDF/X-4. `date` (Unix seconds, UTC) is the XMP metadata date; `None` +/// (no clock, as on wasm) writes the epoch, because PDF/X-4 requires the entry. `None` back when +/// the file isn't the simple kind this understands (a classic cross-reference table and plain +/// dictionaries). +pub fn make_pdfx4(pdf: &[u8], title: &str, date: Option) -> Option> { let t_at = rfind(pdf, b"trailer")?; let trailer = String::from_utf8_lossy(&pdf[t_at..]).to_string(); let size = int_after(&trailer, "/Size")?; @@ -84,7 +86,8 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> { i } }; - // XMP: the PDF/X identification schema, and the trapping state that Info also states. + // XMP: the PDF/X identification schema, the trapping state that Info also states, and the + // metadata date and version ID that PDF/X-4 requires. if let Some(meta) = int_after(&cat, "/Metadata") { let r = object(pdf, meta)?; let body = &pdf[r]; @@ -96,11 +99,17 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> { if dict.contains("/Filter") { return None; } + let (y, mo, d, h, mi, se) = crate::export::civil(date.unwrap_or(0)); + let stamp = format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z"); let add = concat!( "PDF/X-4", "False" ); - let xml = xml.replacen("", &format!("{add}"), 1); + let dates = format!( + "\ + {stamp}1" + ); + let xml = xml.replacen("", &format!("{add}{dates}"), 1); let dict = { let i = dict.find("/Length")?; let rest = &dict[i + 7..]; @@ -216,6 +225,12 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec { if !text.contains("pdfxid:GTS_PDFXVersion>PDF/X-4<") { issues.push("the XMP metadata doesn't name PDF/X-4".into()); } + if !text.contains("") { + issues.push("the XMP metadata has no xmp:MetadataDate".into()); + } + if !text.contains("") { + issues.push("the XMP metadata has no xmpMM:VersionID".into()); + } if text.contains("/Interpolate true") { issues.push("an image asks for interpolation".into()); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index ed6a1ac1..ee02e219 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -163,6 +163,20 @@ fn spreads_and_ranges() { assert_eq!(export_pdf(&d, &Cache::new(), &PdfOptions { pages: Some(vec![9]), ..Default::default() }), Err(PdfError::BadPage(10))); } +#[test] +fn pdfx4_xmp_has_metadata_date_and_version_id() { + let d = doc_with_text("Print me"); + let opts = PdfOptions { standard: Standard::PdfX4, created: Some(1_700_000_000), ..Default::default() }; + let r = export_pdf_with_report(&d, &Cache::new(), &opts).unwrap(); + let text = String::from_utf8_lossy(&r.bytes).to_string(); + assert!(text.contains("2023-11-14T22:13:20Z"), "metadata date"); + assert!(text.contains("1"), "version id"); + assert!(crate::check_pdfx4(&r.bytes).is_empty()); + // Without the two entries the check says so. + let stripped = text.replace("xmp:MetadataDate", "xmp:Other").replace("xmpMM:VersionID", "xmpMM:Other"); + assert_eq!(crate::check_pdfx4(stripped.as_bytes()).iter().filter(|i| i.contains("xmp")).count(), 2); +} + #[test] fn standards() { let d = doc_with_text("Archive me"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c6991ed..3ebe08f3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -377,7 +377,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel and Character Style Options; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default; IDML Kashidas): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left. - Interface language (Preferences ▸ General ▸ Interface Language, `app.language`; each language listed by its own name): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage. Polish covers 1,717 catalog keys with the same category-first count captions and English fallback. - Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update. -- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with the working CMYK space's profile (by default craft-cms's Coated FOGRA39 profile, identifier FOGRA39; Generic CMYK and Device CMYK embed our Generic CMYK profile, lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result. +- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with the working CMYK space's profile (by default craft-cms's Coated FOGRA39 profile, identifier FOGRA39; Generic CMYK and Device CMYK embed our Generic CMYK profile, lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, `xmp:MetadataDate` and `xmpMM:VersionID` in XMP, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result. - EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted. - Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections. - Colour management exposed (ICC through the craft-libs `craft-cms` crate): Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, default CMYK Coated FOGRA39 with CMYK swatches shown relative colorimetric with black-point compensation, Generic CMYK and Device CMYK still selectable, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas. Placed images are shown through their embedded ICC profiles, and CMYK images through their own profile or the working CMYK space, with the settings' intent and black-point compensation. From 636277c69fb86047468025b4300f09f747576ce2 Mon Sep 17 00:00:00 2001 From: ghanashyam03 Date: Sat, 17 Oct 2026 20:35:40 +0530 Subject: [PATCH 27/45] docs: fix broken parity checklist link --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274e..75f2f01e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -186,7 +186,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope ## How far from full parity (estimate, 2026-10-04) **Breadth: ~99% weighted** (P0 core 99%, P1 100%, P2 97%) over the 275 features of the InDesign catalogue, scored -row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still +row by row in [docs/parity-checklist.md](docs/parity-checklist.md) (`cargo xtask parity` recomputes it). Many features scored done still lack some of InDesign's options or dialog details, so **overall parity including depth is about 83%**. **Remaining work: about 170 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 60–85 hours with From d149123cd3d9eb80961f4b5129293c165095ff02 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 17 Oct 2026 12:02:18 -0600 Subject: [PATCH 28/45] Build: take craft-docpath from the merged craft-libs rev craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to storytold/craft-libs 82a24b00 (main after the merge) rather than the older 12281d07, which predates resolve_absolute and the Windows colon refusal. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1f4ecdd..f99120ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,7 +827,7 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=12281d07fedff5c955bb68344fb287aa5d87971e#12281d07fedff5c955bb68344fb287aa5d87971e" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" [[package]] name = "craft-pdf" diff --git a/Cargo.toml b/Cargo.toml index 6725c5c7..34efc4ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ zip = { version = "3", default-features = false, features = ["deflate"] } indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } # Which paths stored in documents may be followed (craft-libs). -craft-docpath = { git = "https://github.com/aiongg/craft-libs", version = "0.1.0", rev = "12281d07fedff5c955bb68344fb287aa5d87971e" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # Atomic file replacement for saves and exports (craft-libs). craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" } # File › Print through the system print spooler (craft-libs). From 08ce444054898810961d63732164b466d7812f8d Mon Sep 17 00:00:00 2001 From: Aykut Firat Date: Sat, 17 Oct 2026 15:07:46 -0400 Subject: [PATCH 29/45] =?UTF-8?q?Tables:=20cell=20First=20Baseline=20offse?= =?UTF-8?q?t=20(Cell=20Options=20=E2=80=BA=20Text=20=E2=80=BA=20First=20Ba?= =?UTF-8?q?seline)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cells and cell styles had no first-baseline setting, so every cell set its first line by the Ascent offset (the font's typographic ascender). IDML documents that set FirstBaselineOffset on a cell style (CapHeight is common, often through a table style's body region) composed every row taller than the document asked for. With a font whose typographic ascender is a full em (Avenir), each row grew about 2.6 pt and long tables overset, losing their last rows. - doc: Cell.first_baseline / first_baseline_min; CellStyle.first_baseline / first_baseline_min, applied by CellStyle::apply_to and carried through resolve_cell_style's BasedOn chain. - idml: FirstBaselineOffset and MinimumFirstBaselineOffset read and written on cells and cell styles. - compose: a cell's text frame uses the cell's first baseline offset and minimum. - engine: table.setCell, style.cell.create/edit take firstBaseline / firstBaselineMin; table.get reports them; style.cell.create fromSelection captures them. - tests: import through BasedOn and a table style's body region, a cell's own override, IDML round trip; composer cap height vs ascent and a fixed minimum; command parameters and validation. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/table.rs | 19 ++++++++++- crates/doc/src/styles.rs | 17 ++++++++++ crates/doc/src/table.rs | 16 ++++++++- crates/engine/src/cmd/table.rs | 60 ++++++++++++++++++++++++++++++++-- crates/idml/src/export.rs | 8 +++++ crates/idml/src/import.rs | 8 +++++ crates/idml/src/tests.rs | 41 +++++++++++++++++++++++ 7 files changed, 164 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index d70ff074..940e0aa7 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -125,7 +125,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left let spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), - opts: TextFrameOptions::default(), + opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() }, vertical: false, exclusions: vec![], outline: None, @@ -835,6 +835,23 @@ mod height_tests { AnchoredObject::new(designcraft_doc::Item::new(ItemId(0), LayerId(0), designcraft_doc::Shape::Rectangle, path), position) } + #[test] + fn cell_first_baseline_option_sets_the_first_line_and_the_row_height() { + // Cell Options › Text › First Baseline: Cap Height sets the first line by the cap height, + // not the typographic ascent, so a row sized for one line of caps keeps its height. + let ascent = text_cell("Hd", 4.0); + let mut cap = ascent.clone(); + cap.first_baseline = designcraft_doc::FirstBaseline::CapHeight; + let (a, c) = (measure(&ascent, 92.0), measure(&cap, 92.0)); + let (la, lc) = (&a.text.frames[0].lines[0], &c.text.frames[0].lines[0]); + assert!(lc.baseline < la.baseline, "cap height {} should sit above ascent {}", lc.baseline, la.baseline); + assert!(c.content_h < a.content_h); + let mut fixed = ascent.clone(); + fixed.first_baseline = designcraft_doc::FirstBaseline::Fixed; + fixed.first_baseline_min = 6.0; + assert!((measure(&fixed, 92.0).text.frames[0].lines[0].baseline - 6.0).abs() < 1e-6); + } + #[test] fn cell_bottom_inset_contains_descenders_without_growing_every_row() { let cell = text_cell("gyp", 8.5); diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 72590d47..5c747fdb 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -454,6 +454,11 @@ pub struct CellStyle { pub inset_overrides: [Option; 4], #[serde(skip_serializing_if = "Option::is_none")] pub vj: Option, + /// Cell Options › Text › First Baseline, and its minimum. + #[serde(skip_serializing_if = "Option::is_none")] + pub first_baseline: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_baseline_min: Option, /// All four edges. #[serde(skip_serializing_if = "Option::is_none")] pub stroke: Option, @@ -529,6 +534,12 @@ impl CellStyle { if let Some(v) = self.vj { cell.vj = v; } + if let Some(v) = self.first_baseline { + cell.first_baseline = v; + } + if let Some(v) = self.first_baseline_min { + cell.first_baseline_min = v; + } if let Some(v) = &self.stroke { cell.strokes = [v.clone(), v.clone(), v.clone(), v.clone()]; cell.border_overrides = [true; 4]; @@ -698,6 +709,12 @@ pub fn resolve_cell_style(cells: &[CellStyle], name: &str) -> CellStyle { if style.vj.is_some() { out.vj = style.vj; } + if style.first_baseline.is_some() { + out.first_baseline = style.first_baseline; + } + if style.first_baseline_min.is_some() { + out.first_baseline_min = style.first_baseline_min; + } if let Some(stroke) = &style.stroke { out.strokes = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(stroke)); } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 14e02a7c..5d109d16 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use crate::ids::StoryId; -use crate::item::{StrokeType, VerticalJustification}; +use crate::item::{FirstBaseline, StrokeType, VerticalJustification}; use crate::story::{CharFormat, ParaFormat, Story, TABLE_ANCHOR}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -159,6 +159,12 @@ fn full() -> f32 { fn no_fill() -> String { designcraft_color::swatch::NONE.into() } +fn is_ascent(f: &FirstBaseline) -> bool { + *f == FirstBaseline::Ascent +} +fn is_zero_min(v: &f64) -> bool { + *v == 0.0 +} fn default_insets() -> [f64; 4] { [4.0; 4] } @@ -181,6 +187,12 @@ pub struct Cell { pub insets: [f64; 4], #[serde(default)] pub vj: VerticalJustification, + /// Cell Options › Text › First Baseline: how far the first line's baseline sits below the + /// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`). + #[serde(default, skip_serializing_if = "is_ascent")] + pub first_baseline: FirstBaseline, + #[serde(default, skip_serializing_if = "is_zero_min")] + pub first_baseline_min: f64, /// Text rotation in degrees (only 0 is composed for now). #[serde(default)] pub rotation: f64, @@ -216,6 +228,8 @@ impl Default for Cell { fill_tint: 1.0, insets: default_insets(), vj: VerticalJustification::Top, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, rotation: 0.0, strokes: Default::default(), border_overrides: [false; 4], diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index af322105..7cf7c271 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -30,7 +30,7 @@ fn style_specs() -> Vec { "New Cell Style…", [], None, - "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}", + "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}", has_doc, |s, p| cell_style_create(s, p) ), @@ -297,7 +297,7 @@ fn table_specs() -> Vec { "Cell Options", [], None, - "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", + "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", in_table, set_cell ), @@ -932,6 +932,22 @@ fn drawn_side(t: &Table, owners: &[(usize, usize)], (r, c): (usize, usize), side drawn.or_else(|| t.cell(r, c).and_then(|cell| cell.strokes.get(side).cloned())) } +/// Cell Options › Text › First Baseline: `firstBaseline` (offset method) and `firstBaselineMin` (pt). +fn first_baseline_params(p: &Value, id: &str) -> Result<(Option, Option)> { + let fb = match p.get("firstBaseline").filter(|v| !v.is_null()) { + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(id, format!("firstBaseline: {e}")))?), + None => None, + }; + let min = match p.get("firstBaselineMin").filter(|v| !v.is_null()) { + Some(v) => match v.as_f64().filter(|m| m.is_finite() && (0.0..=8640.0).contains(m)) { + Some(m) => Some(m), + None => return Err(bad(id, "firstBaselineMin: a number of points from 0 to 8640")), + }, + None => None, + }; + Ok((fb, min)) +} + fn set_cell(s: &mut Session, p: &Value) -> Result { const ID: &str = "table.setCell"; let g = target(s, p, ID)?; @@ -954,6 +970,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))), None => None, }; + let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?; let text = str_param(p, "text").map(|_| super::text_param(p, "text")); let stroke = match p.get("stroke").filter(|v| !v.is_null()) { Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)), @@ -998,6 +1015,12 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { if let Some(v) = vj { cell.vj = v; } + if let Some(v) = first_baseline { + cell.first_baseline = v; + } + if let Some(v) = first_baseline_min { + cell.first_baseline_min = v; + } if let Some(tx) = &text { let len = cell.text.len(); cell.text.replace(0..len, tx); @@ -1525,7 +1548,7 @@ fn get(s: &mut Session, p: &Value) -> Result { .filter_map(|(r, c)| { let cell = t.cell(r, c)?; Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, - "insets": cell.insets, "vj": cell.vj, "style": cell.style})) + "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style})) }) .collect(); Ok(json!({ @@ -1572,6 +1595,13 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Opt if let Some(v) = p.get("vj") { cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?); } + let (first_baseline, first_baseline_min) = first_baseline_params(p, cmd)?; + if first_baseline.is_some() { + cs.first_baseline = first_baseline; + } + if first_baseline_min.is_some() { + cs.first_baseline_min = first_baseline_min; + } if let Some(st) = stroke { cs.stroke = Some(st); cs.strokes = Default::default(); @@ -1600,6 +1630,8 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result { cs.fill_tint = Some(cell.fill_tint); cs.insets = Some(cell.insets); cs.vj = Some(cell.vj); + cs.first_baseline = Some(cell.first_baseline); + cs.first_baseline_min = Some(cell.first_baseline_min); cs.stroke = Some(cell.strokes[0].clone()); cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone()); } @@ -1787,6 +1819,28 @@ mod style_tests { use crate::Session; + #[test] + fn cell_first_baseline_is_a_cell_option_and_a_cell_style_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "capHeight", "firstBaselineMin": 3})).unwrap(); + let got = s.execute("table.get", &json!({})).unwrap(); + let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone(); + assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0))); + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err()); + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err()); + s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap(); + let d = &s.doc().unwrap().doc; + let t = d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap(); + assert_eq!(t.cell(1, 1).unwrap().first_baseline, designcraft_doc::FirstBaseline::Leading); + assert_eq!(t.cell(1, 0).unwrap().first_baseline, designcraft_doc::FirstBaseline::Ascent); + } + #[test] fn cell_and_table_styles_apply_and_follow_edits() { let mut s = Session::new(); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index a773ec9e..86fef8e4 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -923,6 +923,12 @@ impl<'a> Ex<'a> { if let Some(vj) = cs.vj { el.set("VerticalJustification", names::vj_out(vj)); } + if let Some(fb) = cs.first_baseline { + el.set("FirstBaselineOffset", names::first_baseline_out(fb)); + } + if let Some(min) = cs.first_baseline_min { + el.set("MinimumFirstBaselineOffset", num(min)); + } if let Some(st) = &cs.stroke { for side in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"] { self.cell_stroke_attrs(&mut el, side, st); @@ -2770,6 +2776,8 @@ impl<'a> Ex<'a> { .attr("BottomInset", num(cell.insets[2])) .attr("RightInset", num(cell.insets[3])) .attr("VerticalJustification", vj) + .attr("FirstBaselineOffset", names::first_baseline_out(cell.first_baseline)) + .attr("MinimumFirstBaselineOffset", num(cell.first_baseline_min)) .attr("RotationAngle", num(cell.rotation)); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { // Do not turn absent native defaults into explicit shared-edge candidates. diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 5cd3746a..3493d60a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1189,6 +1189,8 @@ impl<'r> Importer<'r> { cs.inset_overrides = [None; 4]; } cs.vj = e.get("VerticalJustification").map(names::vj_in); + cs.first_baseline = e.get("FirstBaselineOffset").map(names::first_baseline_in); + cs.first_baseline_min = e.num("MinimumFirstBaselineOffset").map(|v| v.max(0.0)); cs.strokes = ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.cell_stroke_attrs(e, side)); if let Some(stroke) = uniform_cell_stroke(&cs.strokes) { cs.stroke = Some(stroke); @@ -2213,6 +2215,12 @@ impl<'r> Importer<'r> { if let Some(vj) = ce.get("VerticalJustification") { cell.vj = names::vj_in(vj); } + if let Some(fb) = ce.get("FirstBaselineOffset") { + cell.first_baseline = names::first_baseline_in(fb); + } + if let Some(min) = ce.num("MinimumFirstBaselineOffset") { + cell.first_baseline_min = min.max(0.0); + } cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { let attrs = self.cell_stroke_attrs(ce, side); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 3f5b7977..c5d7ec78 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -4189,3 +4189,44 @@ mod liquid_page_rules { } } } + +/// Cell Options › Text › First Baseline, set by a cell style that a table style applies to its body +/// region (cells with `[None]` take it from there) and overridden on one cell. +const FIRST_BASELINE_STYLES: &str = r#" + + + + cs-cap + + + + +"#; + +const FIRST_BASELINE_STORY: &str = r#" + + + +Region +Local +
"#; + +#[test] +fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_trips() { + use designcraft_doc::FirstBaseline; + let d = import_idml(&fixture_with_table_styles(FIRST_BASELINE_STYLES, FIRST_BASELINE_STORY)).unwrap(); + d.check().unwrap(); + let style = d.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); + assert_eq!((style.first_baseline, style.first_baseline_min), (Some(FirstBaseline::CapHeight), Some(2.0))); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let region = table.cell(0, 0).unwrap(); + assert_eq!((region.first_baseline, region.first_baseline_min), (FirstBaseline::CapHeight, 2.0), "inherited through BasedOn and the body region"); + assert_eq!(table.cell(0, 1).unwrap().first_baseline, FirstBaseline::Leading, "a cell's own attribute wins"); + let back = import_idml(&export_idml(&d)).unwrap(); + let t2 = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + assert_eq!(t2.cell(0, 0).unwrap().first_baseline, FirstBaseline::CapHeight); + assert_eq!(t2.cell(0, 0).unwrap().first_baseline_min, 2.0); + assert_eq!(t2.cell(0, 1).unwrap().first_baseline, FirstBaseline::Leading); + let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap(); + assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight)); +} From 7cb8b12507def48fb9346e5004340b5e8e4cfcb7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 17 Oct 2026 21:43:04 -0400 Subject: [PATCH 30/45] Release 0.9.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8322e342..3db05625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -929,7 +929,7 @@ dependencies = [ [[package]] name = "designcraft" -version = "0.8.0" +version = "0.9.0" dependencies = [ "arboard", "designcraft-engine", @@ -951,7 +951,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -967,7 +967,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-cms", "kurbo", @@ -979,7 +979,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -999,7 +999,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-color", "designcraft-geom", @@ -1011,7 +1011,7 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.8.0" +version = "0.9.0" dependencies = [ "caseless", "craft-atomic", @@ -1050,7 +1050,7 @@ dependencies = [ [[package]] name = "designcraft-epub" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "designcraft-fonts" -version = "0.8.0" +version = "0.9.0" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1072,7 +1072,7 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-doc", "designcraft-images", @@ -1084,7 +1084,7 @@ dependencies = [ [[package]] name = "designcraft-geom" -version = "0.8.0" +version = "0.9.0" dependencies = [ "flo_curves", "kurbo", @@ -1097,7 +1097,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1112,7 +1112,7 @@ dependencies = [ [[package]] name = "designcraft-images" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-cms", "designcraft-color", @@ -1127,7 +1127,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1140,7 +1140,7 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1162,7 +1162,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1179,7 +1179,7 @@ dependencies = [ [[package]] name = "designcraft-render" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1197,7 +1197,7 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-doc", "quick-xml", @@ -1206,7 +1206,7 @@ dependencies = [ [[package]] name = "designcraft-tools" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1218,7 +1218,7 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.8.0" +version = "0.9.0" dependencies = [ "craft-ui", "designcraft-color", @@ -1245,7 +1245,7 @@ dependencies = [ [[package]] name = "designcraft-web" -version = "0.8.0" +version = "0.9.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -5796,7 +5796,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.8.0" +version = "0.9.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 34efc4ce..ca5d0baa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 7141a68f1850255e079e3089c448c499cd45e896 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 10:39:57 -0400 Subject: [PATCH 31/45] docs: drop references to competing commercial software (#1248) --- README.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 613f5102..880433e3 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@

DesignCraft

- Page layout and publishing; an open-source, clean-room reimplementation of Adobe InDesign, rebuilt in pure Rust. + Page layout and publishing; an open-source, modern desktop publishing and page layout studio, built in pure Rust.

- A fast, open-source, clean-room take on the Adobe InDesign workflow. It runs natively on macOS, + A fast, open-source desktop publishing and page layout workflow. It runs natively on macOS, Windows and Linux, and in the browser via WebAssembly.
By the ArtCraft team.

@@ -78,9 +78,9 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or ## Why DesignCraft -- **Familiar.** InDesign's layout, tools, menus, panels and shortcuts: spreads and parent pages, - frames and threaded stories, the Control panel, paragraph and character styles, swatches, text - wrap and more. If you know InDesign, you already know how to use it. +- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: spreads and parent pages, + master pages, text frames, threaded text, paragraph and character styles, tables, text wrap and + more. Everything is right where you expect it. - **Beautiful type.** A Knuth–Plass paragraph composer (plus single-line), dictionary hyphenation (the public-domain Moby word list plus our own trained patterns), word, letter and glyph-scaling justification, keeps, optical margin alignment, columns, baseline grid, tabs, rules and shading. @@ -88,7 +88,7 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or - **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo snapshots, and cached composition. - **Open.** A documented native format, IDML import and export, INDD import (through - [indd-utils](https://github.com/storytold/indd-utils)), editable PowerPoint export, and PNG, PDF + [indd-utils](https://github.com/storytold/indd-utils)), editable .pptx export, and PNG, PDF (PDF/X-4, PDF/A-2b) and EPUB export. No subscription, no licence server, no telemetry. @@ -247,10 +247,10 @@ egui frontend is a separate crate, so the UI can be swapped without touching the | L3 | `render` (vello_cpu) | | L4 | `tools` (pointer events → commands + overlays) | | L5 | `engine` (session, history, command registry) | -| L6 | `ui-egui` (InDesign-style UI, control channel) | +| L6 | `ui-egui` (publishing UI, control channel) | | L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` | -- **Status and milestones:** [ROADMAP.md](ROADMAP.md) (stage: pre-alpha); how close to InDesign: [`docs/target-app-parity.md`](docs/target-app-parity.md); known gaps: [`docs/gaps.md`](docs/gaps.md) +- **Status and milestones:** [ROADMAP.md](ROADMAP.md) (stage: pre-alpha); feature assessment: [`docs/target-app-parity.md`](docs/target-app-parity.md); known gaps: [`docs/gaps.md`](docs/gaps.md) - **Contributor and agent rules** (clean-room, asset policy, quality gates): [`AGENTS.md`](AGENTS.md) - **Bundled assets:** every one is listed with its licence in [`ASSETS.md`](ASSETS.md) - **App icon and colour:** a calico cat in a polka-dot scarf on DesignCraft green `#7bb51c`; see [`assets/app-icon/`](assets/app-icon/README.md) @@ -373,8 +373,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and DesignCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DesignCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

From 526eeda545c205014783cbb22d924f67d14b0e18 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sun, 18 Oct 2026 18:04:22 +0200 Subject: [PATCH 32/45] Document Setup: read a number without a unit in the document's units (fixes #1247) --- crates/ui-egui/src/dialogs.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 358416a4..8b404378 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1236,7 +1236,9 @@ pub fn open_document_setup(app: &mut DesignApp) { } // The margins as shown: OK applies them to every page only when they were edited. f["marginsShown"] = json!(SETUP_EDGES.map(|e| f[format!("margin{e}")].clone())); - app.ui.dialog = Some(Dialog::new("documentSetup", f)); + let mut d = Dialog::new("documentSetup", f); + d.unit = units; + app.ui.dialog = Some(d); } /// The edges of Document Setup's margins, bleed and slug (field suffixes). @@ -7076,6 +7078,17 @@ mod tests { assert!((doc.settings.page_width - 612.0).abs() < 1e-6 && doc.settings.bleed[0] == 0.0, "one undo step"); } + #[test] + fn document_setup_reads_a_bare_number_in_the_documents_units() { + let mut app = document_setup_app("millimeters"); + setup_fields(&mut app, json!({"bleedTop": "5", "bleedBottom": "5", "bleedInside": "5", "bleedOutside": "5", "width": "210"})); + confirm(&mut app).unwrap(); + let mm = designcraft_geom::units::PT_PER_MM; + let settings = &app.session.active().unwrap().doc.settings; + assert!(settings.bleed.iter().all(|b| (b - 5.0 * mm).abs() < 1e-6), "5 mm, not 5 picas: {:?}", settings.bleed); + assert!((settings.page_width - 210.0 * mm).abs() < 1e-6, "{}", settings.page_width); + } + #[test] fn document_setup_keeps_each_pages_margins_unless_they_are_edited() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From ecd7e5d063eb12fa5e61fe61c9b8a77bb3c9110a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 14:31:22 -0400 Subject: [PATCH 33/45] ci: configure Dependabot for Cargo and GitHub Actions Co-authored-by: Dylan M. Taylor --- .github/dependabot.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..2c4b8cb1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" From 103d7eb3ff87f04fc42fe346c101fcd7e624cd28 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:59:59 +0000 Subject: [PATCH 34/45] Clamp object.stroke tint and gapTint to 0..1 Found while testing crates/engine/src/cmd/object.rs:299-313 (object.stroke: `st.tint = v as f32`, `st.gap_tint = v as f32`; the no-selection path at :273 clamps, this one doesn't) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/object.rs | 4 ++-- crates/engine/tests/stroke_tint_range.rs | 25 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/stroke_tint_range.rs diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 2d581760..e766eeea 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -297,7 +297,7 @@ pub fn specs() -> Vec { st.swatch = v.into(); } if let Some(v) = p2.get("tint").and_then(Value::as_f64) { - st.tint = v as f32; + st.tint = v.clamp(0.0, 1.0) as f32; } if let Some(v) = p2.get("weight").and_then(Value::as_f64) { st.weight = v.max(0.0); @@ -309,7 +309,7 @@ pub fn specs() -> Vec { st.gap_swatch = v.into(); } if let Some(v) = p2.get("gapTint").and_then(Value::as_f64) { - st.gap_tint = v as f32; + st.gap_tint = v.clamp(0.0, 1.0) as f32; } if let Some(v) = p2.get("gapOverprint").and_then(Value::as_bool) { st.gap_overprint = v; diff --git a/crates/engine/tests/stroke_tint_range.rs b/crates/engine/tests/stroke_tint_range.rs new file mode 100644 index 00000000..40aca49c --- /dev/null +++ b/crates/engine/tests/stroke_tint_range.rs @@ -0,0 +1,25 @@ +//! `object.stroke` keeps `tint` and `gapTint` within 0..1, so a saved document always reloads. +use designcraft_doc::ItemId; +use designcraft_engine::Session; +use serde_json::json; + +fn stroked(tint: f64, gap_tint: f64) -> (Session, ItemId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let created = s.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap(); + let id = ItemId(created["id"].as_u64().unwrap()); + s.execute("object.stroke", &json!({"ids": [id.0], "swatch": "[Black]", "tint": tint, "gapTint": gap_tint})).unwrap(); + (s, id) +} + +#[test] +fn out_of_range_stroke_tints_are_clamped_and_the_saved_file_reloads() { + for (tint, gap, want_tint, want_gap) in [(1e300, 1e300, 1.0, 1.0), (2.0, -1.0, 1.0, 0.0), (-1e300, 0.5, 0.0, 0.5)] { + let (s, id) = stroked(tint, gap); + let doc = &s.doc().unwrap().doc; + let st = &doc.item(id).unwrap().stroke; + assert_eq!((st.tint, st.gap_tint), (want_tint, want_gap), "tint {tint}, gapTint {gap}"); + let bytes = designcraft_format::save(doc).unwrap(); + designcraft_format::load(&bytes).expect("reload what we just saved"); + } +} From 6fc7172628cc7e97862bd05f1f1af10d789ab462 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:35:56 +0200 Subject: [PATCH 35/45] Let single-line paths be resized along their length (#1245) --- crates/engine/src/cmd/object.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 2d581760..bea26c59 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1201,11 +1201,10 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result { let to = rect_param(p, "to").ok_or_else(|| bad("transform.resize", "missing to"))?; let scale_content = bool_or(p, "content", false); let distribute = bool_or(p, "distribute", false); - if from.width().abs() < 1e-9 || from.height().abs() < 1e-9 { - return Err(bad("transform.resize", "degenerate source rect")); - } - let sx = to.width() / from.width(); - let sy = to.height() / from.height(); + // A flat source (a horizontal or vertical line) has no size to scale on its flat axis: that axis only moves. + let ratio = |new: f64, old: f64| if old.abs() < 1e-9 { 1.0 } else { new / old }; + let sx = ratio(to.width(), from.width()); + let sy = ratio(to.height(), from.height()); let sx = if sx.abs() < 1e-3 { 1e-3f64.copysign(sx) } else { sx }; let sy = if sy.abs() < 1e-3 { 1e-3f64.copysign(sy) } else { sy }; let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0)); @@ -4189,6 +4188,16 @@ mod fitting_tests { assert!(String::from_utf8_lossy(&bytes).contains("/Subtype/Image")); } + #[test] + fn a_flat_line_resizes_along_its_length() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("line.create", &json!({"a": [100, 100], "b": [200, 100]})).unwrap()["id"].as_u64().unwrap(); + s.execute("transform.resize", &json!({"ids": [id], "from": [100, 100, 200, 100], "to": [100, 100, 300, 100]})).unwrap(); + let r = s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds(); + assert!((r.x0 - 100.0).abs() < 1e-9 && (r.x1 - 300.0).abs() < 1e-9 && (r.y0 - 100.0).abs() < 1e-9 && r.height().abs() < 1e-9, "{r:?}"); + } + #[test] fn live_distribute_spreads_objects_without_resizing() { let mut s = Session::new(); From 6e0b50a819ca24ac21808753d7656aafa971dbd1 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:35:57 +0200 Subject: [PATCH 36/45] Show New Document preset sizes in the selected units (#1260) --- crates/ui-egui/src/new_document.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs index ad415c83..ee59de80 100644 --- a/crates/ui-egui/src/new_document.rs +++ b/crates/ui-egui/src/new_document.rs @@ -360,9 +360,8 @@ fn cards(app: &DesignApp, tab: &str) -> Vec { } } -/// `210 × 297 mm`, `51p0 × 66p0`. -fn size_text(nd: &NewDocument) -> String { - let u = nd.units; +/// `210 × 297 mm`, `51p0 × 66p0`: the size of `nd` in the dialog's units `u`. +fn size_text(nd: &NewDocument, u: Unit) -> String { let (w, h) = (format_measure(nd.width, u), format_measure(nd.height, u)); let suffix = u.suffix(); if suffix.starts_with(' ') @@ -391,7 +390,7 @@ fn paint_page(p: &egui::Painter, area: Rect, nd: &NewDocument, custom: bool, col } } -fn card(ui: &mut Ui, c: &Card, selected: bool) -> egui::Response { +fn card(ui: &mut Ui, c: &Card, units: Unit, selected: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(CARD, Sense::click()); if selected { @@ -406,7 +405,7 @@ fn card(ui: &mut Ui, c: &Card, selected: bool) -> egui::Response { ui.painter(), pos2(r.center().x, r.top() + 132.0), Align2::CENTER_CENTER, - &size_text(&c.nd), + &size_text(&c.nd, units), FontId::proportional(11.0), t.text_dim, ); @@ -494,13 +493,14 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); ui.add_space(10.0); let current = d.s("preset"); + let card_units = units(d); let mut create = false; let mut delete = None; let layout = if rtl { Layout::right_to_left(Align::TOP) } else { Layout::left_to_right(Align::TOP) }.with_main_wrap(true); ui.with_layout(layout, |ui| { ui.spacing_mut().item_spacing = vec2(10.0, 10.0); for c in &list { - let resp = card(ui, c, current == c.key); + let resp = card(ui, c, card_units, current == c.key); if resp.clicked() || resp.double_clicked() { select(d, c); } @@ -1031,6 +1031,13 @@ mod tests { assert!(!doc.settings.facing_pages); } + #[test] + fn card_sizes_follow_the_dialog_units() { + let card = NewDocument::from_preset("Business Card").unwrap(); + assert_eq!(size_text(&card, Unit::Millimeters), "88.9 × 50.8 mm"); + assert_ne!(size_text(&card, Unit::Millimeters), size_text(&card, Unit::Picas)); + } + #[test] fn units_reformat_the_measures_and_linked_edges_follow() { // InDesign's Units list. From 251ceda1e018691de0686106660957698052df16 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:35:55 +0200 Subject: [PATCH 37/45] Reject invalid story.replaceRange offsets with an error (#1267) --- crates/engine/src/cmd/text.rs | 15 ++++++++++++--- docs/mcp.md | 4 +++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 24ff0384..32cbfd0f 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -130,7 +130,11 @@ pub fn specs() -> Vec { let text = super::text_param(p, "text"); s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len()))); + let n = st.len(); + if a > b || b > n || !st.text.is_char_boundary(a) || !st.text.is_char_boundary(b) { + let msg = format!("range {a}..{b} must be UTF-8 byte offsets on character boundaries, start <= end; the story is {n} bytes"); + return Err(bad("story.replaceRange", msg)); + } st.replace(a..b, &text); let len = st.len(); if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) { @@ -2292,9 +2296,14 @@ mod open_type_tests { s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap(); let sid = r["story"].clone(); - // Offsets inside 漢 (2..5) and 🙂 (5..9) snap back to the character starts: 漢 is replaced. - s.execute("story.replaceRange", &json!({"story": sid, "start": 3, "end": 6, "text": "字"})).unwrap(); let story = designcraft_doc::StoryId(sid.as_u64().unwrap()); + // Offsets inside 漢 (2..5) and 🙂 (5..9), past the end, or reversed are refused and change nothing. + for (start, end) in [(3, 6), (2, 4), (0, 99), (5, 2)] { + let r = s.execute("story.replaceRange", &json!({"story": sid, "start": start, "end": end, "text": "字"})); + assert!(r.is_err_and(|e| e.to_string().contains("10 bytes")), "{start}..{end}"); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é漢🙂x"); + } + s.execute("story.replaceRange", &json!({"story": sid, "start": 2, "end": 5, "text": "字"})).unwrap(); assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é字🙂x"); // A caret before 🙂 (byte 5) would land inside it once é (2 bytes) is gone. s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index bba394e3..83e8bdf3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -137,7 +137,9 @@ Positions in a story's text are **UTF-8 byte offsets**, not character counts: `a `start` / `end` of `story.replaceRange` and of `find.find` matches, the `pos` that `text.placeCaret`, `text.insert` and `text.move` return, a story's `length` in `get_story` and `inspect_document`, and its `overset` position in `get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15 -and its second character starts at 3. An offset inside a character snaps back to the start of that character. +and its second character starts at 3. An offset inside a character snaps back to the start of that character, except +in `story.replaceRange`: there an offset inside a character, an `end` past the end of the story or a `start` after `end` +is an error that gives the story length in bytes, and the story is left unchanged. ## Text and image fitting From 551886c133030f5c2878b8114e49f7198d8f8cee Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:36:18 +0200 Subject: [PATCH 38/45] Show text stroke options in the Stroke panel when text is selected (#1265) --- crates/ui-egui/src/panels/properties.rs | 51 +++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fded030b..44acf6e2 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1864,6 +1864,12 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { super::cell_stroke::panel(app, ui); return; } + if super::colors_affect_text(app) + && let Some(a) = text_attrs(app) + { + text_stroke_panel(app, ui, &a["chars"]); + return; + } let Some(i) = sel_info(app) else { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select an object.")); return; @@ -1985,6 +1991,27 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// Stroke panel while text is selected: the text's outline colour, weight and whether it sits behind the fill. +fn text_stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui, c: &Value) { + let swatch = c["stroke"].as_str().unwrap_or(designcraft_color::swatch::NONE).to_string(); + egui::Grid::new("text_stroke_panel").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + caption(ui, crate::i18n::tr(&app.ui.language, "Weight:")); + if let Some(v) = number(ui, "tsw_panel", c["strokeWeight"].as_f64(), " pt", 60.0, 3) { + let _ = app.run("type.char", json!({"attrs": {"strokeWeight": v}})); + } + ui.end_row(); + caption(ui, crate::i18n::tr(&app.ui.language, "Color:")); + super::swatch_picker(app, ui, "tspick", Some(swatch), |app, n| { + let _ = super::apply_swatch(app, true, &n, None); + }); + ui.end_row(); + }); + let mut behind = c["strokeBehindFill"].as_bool().unwrap_or(false); + if ui.checkbox(&mut behind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stroke Behind Fill"))).changed() { + let _ = app.run("type.char", json!({"attrs": {"strokeBehindFill": behind}})); + } +} + /// A short line ending in arrowhead `a` (pointing left for Start, right for End). fn arrow_preview(ui: &mut egui::Ui, a: Arrowhead, start: bool, color: egui::Color32) { use designcraft_geom::kurbo; @@ -2915,6 +2942,30 @@ mod tests { h } + /// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel. + #[test] + fn stroke_panel_shows_the_text_stroke_options_while_text_is_selected() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap(); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(320.0, 400.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), ""); + crate::theme::apply(ui.ctx(), &Tokens::for_brightness(Default::default())); + ready = true; + return; + } + stroke_panel(app, ui); + }, + app, + ); + h.run_steps(3); + assert!(h.query_by_label("Select an object.").is_none()); + h.get_by_label("Stroke Behind Fill"); + } + /// As InDesign's: Edit Page takes the Page tool to the page in view, and Properties sets that /// page alone; Back returns to the document. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4a7e9b99..9e9ec6c3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -439,7 +439,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel (InDesign's layout: thumbnail, Type, Location, Angle, Reverse) sets type, angle, reverse and edits stops and midpoints on a ramp (drag, add, drag off to remove, location; drop a swatch from the Swatches panel on a stop or the ramp; stops keep their CMYK), and follows the Fill/Stroke proxy. Edited gradients are unnamed swatches until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from colour swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF). - New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it. - Document Setup (⌥⌘P), laid out like InDesign's: intent, number of pages, start page # (an even start begins with a left page), facing pages, binding, Page Size (presets keeping the orientation, width and height with arrows, orientation), margins for every page and parent page, bleed and slug per edge (each group linked or not), and Adjust Layout (objects follow the new page size and margins), all in one undo step; measures are read in the document's units, and a value that can't be read keeps the dialog open; `layout.documentSetup {}` reports the setup, margins included. -- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. +- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots; with text selected (or text frames selected with Formatting Affects Text on) it shows the text's stroke weight, colour and Stroke Behind Fill. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. - Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu. - Based on Parent (`layout.parents.options {parent, basedOn}`): a parent shows its base parent's items under its own, on its pages and on the parent itself; chains work, cycles are refused, and items from any parent in the chain can be overridden. IDML carries it as the parent pages' `AppliedMaster`; a cycle or missing base in a file is cleared with a warning on open. The Pages panel has no Parent Options dialog yet. From 4df5d1639cff3b88ce85145b56daa870051989aa Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:40:40 +0200 Subject: [PATCH 39/45] Align Story Editor paragraph style labels with the wrapped text rows (#1251) --- crates/ui-egui/src/story_editor.rs | 59 ++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/story_editor.rs b/crates/ui-egui/src/story_editor.rs index fd7e618b..2bd481d8 100644 --- a/crates/ui-egui/src/story_editor.rs +++ b/crates/ui-egui/src/story_editor.rs @@ -51,27 +51,26 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { .show(ctx, |ui| { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.horizontal_top(|ui| { - // Style column. - ui.vertical(|ui| { - ui.set_width(130.0); - for (i, r) in story.para_ranges().iter().enumerate() { - let lines = story.text[r.clone()].len() / 60 + 1; - let style = &story.paras[i].style; - crate::rtl::label(ui, egui::RichText::new(crate::i18n::style_name(&app.ui.language, style)).size(11.0).color(t.text_dim)); - for _ in 1..lines { - ui.label(egui::RichText::new(" ").size(11.0)); - } - } - }); + // Style column: the labels are painted after the text, at the rows the text really wraps to. + let (column, _) = ui.allocate_exact_size(vec2(130.0, 0.0), egui::Sense::hover()); ui.separator(); let mut text = story.text.clone(); - let r = ui.add( - egui::TextEdit::multiline(&mut text) - .font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0))) - .desired_width(f32::INFINITY) - .desired_rows(20) - .frame(egui::Frame::NONE), - ); + let out = egui::TextEdit::multiline(&mut text) + .font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0))) + .desired_width(f32::INFINITY) + .desired_rows(20) + .frame(egui::Frame::NONE) + .show(ui); + let r = out.response; + let starts = paragraph_start_offsets(out.galley.rows.iter().map(|row| (row.pos.y, row.ends_with_newline))); + for (i, y) in starts.into_iter().enumerate() { + let Some(para) = story.paras.get(i) else { break }; + let rect = egui::Rect::from_min_size(egui::pos2(column.min.x, out.galley_pos.y + y), vec2(130.0, 14.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let name = crate::i18n::style_name(&app.ui.language, ¶.style); + crate::rtl::label(ui, egui::RichText::new(name).size(11.0).color(t.text_dim)); + }); + } if r.changed() { // Minimal diff: common prefix and suffix. let old = &story.text; @@ -111,6 +110,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } +/// Vertical offset of the first laid-out row of each paragraph, from `(row y, row ends with a newline)` +/// in layout order. A paragraph that wraps spans several rows, so counting characters per line +/// drifts from what the editor draws; the rows themselves are the truth. +fn paragraph_start_offsets(rows: impl Iterator) -> Vec { + let mut starts = Vec::new(); + let mut at_start = true; + for (y, ends_with_newline) in rows { + if at_start { + starts.push(y); + } + at_start = ends_with_newline; + } + starts +} + #[cfg(test)] mod tests { use egui_kittest::kittest::Queryable; @@ -123,6 +137,13 @@ mod tests { st.doc.stories.values().next().unwrap().text.clone() } + /// A wrapped paragraph takes several rows; the next paragraph's label goes to the row after them. + #[test] + fn labels_follow_wrapped_rows() { + let rows = [(0.0, false), (20.0, false), (40.0, true), (60.0, true), (80.0, false)]; + assert_eq!(super::paragraph_start_offsets(rows.into_iter()), vec![0.0, 60.0, 80.0]); + } + /// Story ids are counted per document: typing in the editor after switching documents edited /// the other document's story with the same id. #[test] From 3b3544477d19f385b3c78a100e233f41cc3ff3a5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:04:40 +0000 Subject: [PATCH 40/45] Show page and resolution of anchored images in the Links panel Fixes #1269 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/links.rs | 99 ++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index d77db3ea..c6e9e756 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -14,7 +14,9 @@ use std::path::Path; use std::sync::Arc; -use designcraft_doc::{AssetId, Content, Document, Fingerprint, ItemId, Selection, Story, StoryId, TextFileLink}; +use designcraft_compose::Cache; +use designcraft_doc::{AssetId, Content, Document, Fingerprint, Item, ItemId, Selection, Story, StoryId, TextFileLink}; +use designcraft_geom::Affine; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; @@ -25,7 +27,7 @@ pub fn specs() -> Vec { cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| { // Taken out for the call so the document needn't be cloned on every poll. let mut checks = std::mem::take(&mut s.text_link_checks); - let links = s.doc().map(|d| list(&d.doc, d.path.as_deref().map(Path::new), &mut checks)); + let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks)); s.text_link_checks = checks; Ok(Value::Array(links?)) }), @@ -262,7 +264,60 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) } -fn list(d: &Document, document: Option<&Path>, checks: &mut TextChecks) -> Vec { +/// A graphic showing an asset: the item, its page, and the transform from its parent's space to +/// the spread. +struct Shown<'a> { + item: &'a Item, + page: Option, + parent_xf: Affine, +} + +/// Where an asset shows: frames on spreads (see [`uses`]) and anchored or inline objects, whose +/// page and position come from the frame their story lays them out in. +fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec> { + fn walk<'a>(it: &'a Item, xf: Affine, aid: AssetId, page: Option, out: &mut Vec>) { + if let Content::Graphic(g) = &it.content + && g.asset == aid + { + out.push(Shown { item: it, page, parent_xf: xf }); + } + for c in it.children() { + walk(c, xf * it.xf, aid, page, out); + } + } + let mut out: Vec = uses(d, aid) + .into_iter() + .filter_map(|id| { + let loc = d.find(id)?; + Some(Shown { item: d.item_at(&loc)?, page: d.page_of_item(id), parent_xf: d.parent_xf(&loc) }) + }) + .collect(); + for st in d.stories.values() { + let mut composed = None; + for (k, o) in st.objects.iter().enumerate() { + let mut found = false; + o.item.walk(&mut |i| found |= matches!(&i.content, Content::Graphic(g) if g.asset == aid)); + if !found { + continue; + } + let cs = composed.get_or_insert_with(|| cache.get(d, st.id, None)); + let placed = cs.frames.iter().find_map(|ft| ft.objects.iter().find(|p| p.index == k).map(|p| (ft.frame, p.origin))); + // Overset: no page; the resolution of the object as it would show. + let (page, xf) = match placed { + Some((fid, origin)) => { + let frame_xf = + d.find(fid).map_or(Affine::IDENTITY, |loc| d.parent_xf(&loc)) * d.item(fid).map_or(Affine::IDENTITY, |i| d.text_xf(i)); + (d.page_of_item(fid), frame_xf * Affine::translate(origin.to_vec2())) + } + None => (None, Affine::IDENTITY), + }; + walk(&o.item, xf, aid, page, &mut out); + } + } + out +} + +fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks) -> Vec { let texts = d.stories.values().filter_map(|st| { let l = st.file_link.as_ref()?; let uses: Vec = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect(); @@ -273,25 +328,24 @@ fn list(d: &Document, document: Option<&Path>, checks: &mut TextChecks) -> Vec = uses(d, a.id) + let uses: Vec = shown(d, cache, a.id) .into_iter() - .map(|id| { + .map(|u| { + let it = u.item; // Effective resolution: image pixels over the size it is shown at. let ppi = (|| { - let loc = d.find(id)?; - let it = d.item_at(&loc)?; let Content::Graphic(g) = &it.content else { return None }; // Vector (placed PDF or SVG): no effective resolution. if a.mime == "application/pdf" || a.mime == "image/svg+xml" { return None; } let (pw, _) = a.pixels?; - let xf = d.parent_xf(&loc) * it.xf * g.xf; + let xf = u.parent_xf * it.xf * g.xf; let shown_w = xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)).width(); (shown_w > 0.0).then(|| (pw as f64 / (shown_w / 72.0)).round()) })(); - let page = d.page_of_item(id).map(|p| d.page_name(p)); - json!({"id": id.0, "page": page, "ppi": ppi}) + let page = u.page.map(|p| d.page_name(p)); + json!({"id": it.id.0, "page": page, "ppi": ppi}) }) .collect(); let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name()); @@ -527,6 +581,31 @@ mod tests { super::super::file::base64_decode(result["base64"].as_str().unwrap()) } + #[test] + fn anchored_image_lists_its_page_and_resolution() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let r = s + .execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let placed = s + .execute( + "file.place", + &json!({"base64": super::super::file::base64_encode(&png(10, 10)), "name": "dot.png", "x": 450, "y": 450, "width": 36}), + ) + .unwrap(); + let id = placed["id"].as_u64().unwrap(); + let before = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(before[0]["uses"][0]["ppi"], 20.0); + s.execute("anchored.insert", &json!({"ids": [id], "story": sid.0, "pos": 7})).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + let uses = list[0]["uses"].as_array().unwrap(); + assert_eq!(uses.len(), 1, "anchored image must have one use: {list}"); + assert_eq!(uses[0]["page"], "2"); + assert_eq!(uses[0]["ppi"], 20.0); + } + #[test] fn eps_web_exports_convert_tiff_and_skip_bad_used_assets() { use std::io::Read; From 07b4956e0f05e39630d4ebe45bf49b94db2351a5 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:30:00 -0600 Subject: [PATCH 41/45] Docs: install the CLI and register it with MCP clients --- ROADMAP.md | 2 ++ docs/mcp.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9bd0274e..292a5c55 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change. + - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/docs/mcp.md b/docs/mcp.md index bba394e3..1aba6b22 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7,21 +7,64 @@ the result as PNG images. It speaks newline-delimited JSON-RPC 2.0 on stdin/stdo ## Register it -Build the CLI (`cargo build --release -p designcraft-cli`), then: +### From source (development) + +With Rust and Cargo installed, run this from the repository root: + +```sh +cargo install --path apps/designcraft-cli --locked +``` + +Cargo copies the executable into its installation root's `bin` directory, normally +`~/.cargo/bin` (Windows `%USERPROFILE%\.cargo\bin`). If you set `CARGO_INSTALL_ROOT`, +Cargo's `install.root` configuration, or `CARGO_HOME`, use that installation's `bin` +directory instead. Unlike a symlink into `target/release`, this copy survives +`cargo clean`. Repeat the install command after source changes to update it. + +Ensure that directory is on `PATH`. For a default install in a POSIX shell: + +```sh +export PATH="$HOME/.cargo/bin:$PATH" +designcraft-cli --help +``` + +Persist the PATH setting in your shell configuration (on Windows, add the directory +to your user Path). The **MCP client process** must inherit that PATH too; restart +the client after changing it. A GUI-launched client may not read shell configuration. +If it still reports `ENOENT` or "executable not found", use the absolute path to the +installed binary in its registration instead of the bare name. + +### Headless and connected registration + +After installation, register the CLI by name: ```sh # Headless: an in-process engine, no window -claude mcp add designcraft -- /path/to/target/release/designcraft-cli mcp +claude mcp add designcraft -- designcraft-cli mcp -# Drive the running desktop app (start it first with a control port) +# Drive the running desktop app (install/start the desktop app separately) designcraft --control 7979 -claude mcp add designcraft-app -- /path/to/target/release/designcraft-cli mcp --connect 7979 +claude mcp add designcraft-app -- designcraft-cli mcp --connect 7979 ``` Other MCP clients use the same command line, e.g. in a JSON config: ```json -{"mcpServers": {"designcraft": {"command": "/path/to/designcraft-cli", "args": ["mcp"]}}} +{ + "mcpServers": { + "designcraft": { + "command": "designcraft-cli", + "args": ["mcp"] + } + } +} +``` + +If the client cannot find the CLI on PATH, substitute the absolute installed path +(not a path under `target/`), for example: + +```sh +claude mcp add designcraft -- /absolute/install/root/bin/designcraft-cli mcp ``` ### From an installed release From 49fc2c120924b88e3d087adece26c57dbf32a12a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:43:14 +0200 Subject: [PATCH 42/45] Clamp RGB, CMYK and Gray components on ASE import Found while testing crates/color/src/ase.rs:100-103 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/ase.rs | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs index c20c249e..6cfd7203 100644 --- a/crates/color/src/ase.rs +++ b/crates/color/src/ase.rs @@ -98,9 +98,11 @@ pub fn read(bytes: &[u8]) -> Result, AseError> { } } let color = match &model[..] { - b"CMYK" => Color::Cmyk { c: b.f32()?, m: b.f32()?, y: b.f32()?, k: b.f32()? }, - b"RGB " => Color::Rgb { r: b.f32()?, g: b.f32()?, b: b.f32()? }, - b"Gray" => Color::Gray { k: 1.0 - b.f32()? }, + b"CMYK" => { + Color::Cmyk { c: b.f32()?.clamp(0.0, 1.0), m: b.f32()?.clamp(0.0, 1.0), y: b.f32()?.clamp(0.0, 1.0), k: b.f32()?.clamp(0.0, 1.0) } + } + b"RGB " => Color::Rgb { r: b.f32()?.clamp(0.0, 1.0), g: b.f32()?.clamp(0.0, 1.0), b: b.f32()?.clamp(0.0, 1.0) }, + b"Gray" => Color::Gray { k: (1.0 - b.f32()?).clamp(0.0, 1.0) }, b"LAB " => { let (l, a, bb) = (b.f32()?, b.f32()?, b.f32()?); let [r, g, bl] = cms::lab::lab_to_srgb(cms::Lab { l: l * 100.0, a, b: bb }); @@ -231,6 +233,36 @@ mod tests { assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit"); } + #[test] + fn out_of_range_components_are_clamped() { + let mut f = Vec::new(); + f.extend(b"ASEF\x00\x01\x00\x00"); + f.extend(3u32.to_be_bytes()); + let cases: [(&[u8; 4], &[f32]); 3] = [(b"RGB ", &[5.0, -2.0, 0.5]), (b"CMYK", &[2.0, -1.0, 0.5, 9.0]), (b"Gray", &[-1.0])]; + for (model, vals) in cases { + let mut c = Vec::new(); + c.extend(2u16.to_be_bytes()); + c.extend([0, b'X', 0, 0]); + c.extend(model); + for v in vals { + c.extend(v.to_bits().to_be_bytes()); + } + c.extend(2u16.to_be_bytes()); + f.extend(COLOR.to_be_bytes()); + f.extend((c.len() as u32).to_be_bytes()); + f.extend(&c); + } + let colors: Vec = read(&f) + .unwrap() + .into_iter() + .filter_map(|s| match s.value { + SwatchValue::Color { color, .. } => Some(color), + _ => None, + }) + .collect(); + assert_eq!(colors, [Color::Rgb { r: 1.0, g: 0.0, b: 0.5 }, Color::Cmyk { c: 1.0, m: 0.0, y: 0.5, k: 1.0 }, Color::Gray { k: 1.0 },]); + } + #[test] fn non_finite_components_are_skipped() { let sw = |name: &str, color: Color| Swatch { From 0d0fa585a0e49d333dc1d8f8fc92b0a0fbb6af20 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:23:07 -0400 Subject: [PATCH 43/45] story.replaceRange: convert offsets with usize::try_from (wasm32-safe) Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 32cbfd0f..12bd540b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -125,8 +125,12 @@ pub fn specs() -> Vec { has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?); - let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize; - let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a); + let offset = |v: u64| usize::try_from(v).map_err(|_| bad("story.replaceRange", format!("offset {v} is out of range"))); + let a = offset(p.get("start").and_then(Value::as_u64).unwrap_or(0))?; + let b = match p.get("end").and_then(Value::as_u64) { + Some(v) => offset(v)?, + None => a, + }; let text = super::text_param(p, "text"); s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; From 8981d5e4cdfd58a471db32fd2f817543e5b59a70 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:31:31 -0400 Subject: [PATCH 44/45] IDML import: ignore non-finite Minimum First Baseline offsets, clamp to 0..8640 Co-Authored-By: Claude Opus 5.5 --- crates/idml/src/import.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3493d60a..7490218c 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1190,7 +1190,7 @@ impl<'r> Importer<'r> { } cs.vj = e.get("VerticalJustification").map(names::vj_in); cs.first_baseline = e.get("FirstBaselineOffset").map(names::first_baseline_in); - cs.first_baseline_min = e.num("MinimumFirstBaselineOffset").map(|v| v.max(0.0)); + cs.first_baseline_min = e.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 8640.0)); cs.strokes = ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.cell_stroke_attrs(e, side)); if let Some(stroke) = uniform_cell_stroke(&cs.strokes) { cs.stroke = Some(stroke); @@ -2218,8 +2218,8 @@ impl<'r> Importer<'r> { if let Some(fb) = ce.get("FirstBaselineOffset") { cell.first_baseline = names::first_baseline_in(fb); } - if let Some(min) = ce.num("MinimumFirstBaselineOffset") { - cell.first_baseline_min = min.max(0.0); + if let Some(min) = ce.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()) { + cell.first_baseline_min = min.clamp(0.0, 8640.0); } cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation); for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() { From dcb0417b63f58fbbc845adde61c27db0cfcd8720 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 02:38:32 -0400 Subject: [PATCH 45/45] =?UTF-8?q?Dock:=20the=20=C2=BB=20chevron=20runs=20w?= =?UTF-8?q?indow.collapseToIcons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 544f0072..d4551e81 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -69,7 +69,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui, |ui| { if dock_header(ui, &t, "»").clicked() { - app.ui.dock_expanded = false; + let _ = app.run("window.collapseToIcons", serde_json::json!({})); } // Tab strip (27 pt). let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover());