From b2f1ad1867db9d49b7ed8dac5193afbc49e9cdc6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:44:53 +0000 Subject: [PATCH] refactor(engine): split cmd/file.rs into cmd/file/ --- crates/engine/src/cmd/file.rs | 2167 +----------------------- crates/engine/src/cmd/file/bytes.rs | 163 ++ crates/engine/src/cmd/file/new.rs | 338 ++++ crates/engine/src/cmd/file/open.rs | 528 ++++++ crates/engine/src/cmd/file/place.rs | 753 ++++++++ crates/engine/src/cmd/file/registry.rs | 139 ++ crates/engine/src/cmd/file/save.rs | 210 +++ crates/engine/src/cmd/file/snippets.rs | 54 + 8 files changed, 2199 insertions(+), 2153 deletions(-) create mode 100644 crates/engine/src/cmd/file/bytes.rs create mode 100644 crates/engine/src/cmd/file/new.rs create mode 100644 crates/engine/src/cmd/file/open.rs create mode 100644 crates/engine/src/cmd/file/place.rs create mode 100644 crates/engine/src/cmd/file/registry.rs create mode 100644 crates/engine/src/cmd/file/save.rs create mode 100644 crates/engine/src/cmd/file/snippets.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 646ae4f8c..44aaa85b7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -10,137 +10,20 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, f64_or, has_doc, ok, str_param}; use crate::{DocState, EngineError, Result, Session}; -pub fn specs() -> Vec { - vec![ - cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"), - "{preset?: \"Letter\"|\"A4\"|… or a saved preset (file.savePreset), intent?: print|web|mobile, units?: points|picas|millimeters|…, width?, height?, pages?, startPage?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, slug?: n | [top, bottom, inside, outside], title?, primaryTextFrame?: bool}", - always, file_new), - cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), - cmd!(query "file.presets", "Document Presets", [], None, "{} → the built-in presets [{name, intent, width, height, units}], then the saved ones (with `saved: true`)", always, |s, _| { - let mut presets: Vec = PRESETS.iter().map(|p| serde_json::to_value(p).unwrap_or_default()).collect(); - presets.extend( - s.prefs - .document_presets - .iter() - .map(|p| json!({"name": p.title, "intent": p.intent, "width": p.width, "height": p.height, "units": p.units, "saved": true})), - ); - Ok(Value::Array(presets)) - }), - cmd!(noundo "file.savePreset", "Save Document Preset", [], None, - "{name, …the settings as file.new} — kept with the preferences; `file.new {preset: name}` uses it. A saved preset of that name is replaced; built-in names are refused", - always, save_preset), - cmd!(noundo "file.deletePreset", "Delete Document Preset", [], None, "{name} — a saved document preset", always, |s, p| { - let name = str_param(p, "name").unwrap_or_default().trim().to_string(); - let before = s.prefs.document_presets.len(); - s.prefs.document_presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); - if s.prefs.document_presets.len() == before { - return Err(bad("file.deletePreset", format!("no saved preset `{name}`"))); - } - ok() - }), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), - "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}", - always, file_open), - cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes), - cmd!(noundo "file.openRecent", "Open Recent File", [], None, - "{index} — reopen entry `index` of File › Open Recent (as file.open); a file that is gone is an error and stays listed", - has_recent, file_open_recent), - cmd!(query "file.recentFiles", "Recent Files", [], None, "{} → [{path, name, exists}] File › Open Recent, most recent first", always, |s, _| { - Ok(Value::Array(s.prefs.recent_files.iter().map(|p| recent_entry(p)).collect())) - }), - cmd!(noundo "file.clearRecent", "Clear Recent Files", ["File", "Open Recent"], None, "{} — empty File › Open Recent", has_recent, |s, _| { - s.prefs.recent_files.clear(); - ok() - }), - cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} — a `path` without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), - cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} — a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), - cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state; a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, |s, p| { - let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?; - let path = with_document_extension(path); - let mut copy = (*s.doc()?.doc).clone(); - super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); - let bytes = to_bytes_with_preview(©, &s.cache)?; - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": bytes.len()})) - }), - cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), - cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| { - let dir = recovery_dir(s, p, FolderAccess::Read)?; - Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect())) - }), - cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { - let dir = recovery_dir(s, p, FolderAccess::Write)?; - Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) - }), - cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { - // Reopening removes the entries it read. - let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?; - Ok(json!({"opened": crate::recovery::open(s, &dir)?})) - }), - cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { - let st = s.doc()?; - // Preview fill is session state. Serialize the unfilled template. - let doc = st.preview_stash.as_deref().unwrap_or(st.doc.as_ref()); - let bytes = to_bytes(doc)?; - Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})) - }), - cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, |s, p| { - let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).unwrap_or(0); - s.close_document(i); - ok() - }), - cmd!(noundo "file.activate", "Activate Document", [], None, "{index}", has_doc, |s, p| { - s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize); - ok() - }), - cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None, - "{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", super::has_selection, snippet_export), - cmd!(query "place.styles", "Import Options: Styles", [], None, - "{path | base64, name} → {paragraph: [name], character: [name], conflicts: [name]} — the styles a Word/RTF file brings (for styleMap / styleConflicts)", - has_doc, |s, p| { - let (bytes, name, _) = read_source(p)?; - let imp = designcraft_textimport::import(&name, &bytes).map_err(|e| bad("place.styles", e.to_string()))?; - let st = &s.doc()?.doc.styles; - let conflicts: Vec<&str> = imp - .para_styles - .iter() - .filter(|x| st.para(&x.name).is_some()) - .chain(imp.char_styles.iter().filter(|x| st.char_style(&x.name).is_some())) - .map(|x| x.name.as_str()) - .collect(); - Ok(json!({"paragraph": imp.para_styles.iter().map(|x| &x.name).collect::>(), "character": imp.char_styles.iter().map(|x| &x.name).collect::>(), "conflicts": conflicts})) - }), - cmd!( - "snippet.place", - "Place Snippet", - [], - None, - "{path | base64, spread?, x?, y?} — items keep their positions unless x/y given (top-left)", - has_doc, - snippet_place - ), - cmd!( - "place.load", - "Load Place Cursor", - [], - None, - "{path | base64, name?} — load a graphic into the place cursor (then click/drag with the placeGun tool)", - has_doc, - place_load - ), - cmd!("place.drop", "Place Loaded Graphic", [], None, "{spread?, x, y, rect?: [x0,y0,x1,y1], frame?: id}", has_doc, place_drop), - cmd!( - "file.place", - "Place…", - ["File"], - Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`", - has_doc, - file_place - ), - ] -} +mod bytes; +mod new; +mod open; +mod place; +mod registry; +mod save; +mod snippets; + +pub use bytes::{DOCUMENT_SNIFF_BYTES, base64_decode, base64_encode, from_bytes, is_document, to_bytes, with_document_extension}; +pub(crate) use open::load_document_fonts; +pub use open::{RecentPreview, recent_preview}; +pub(crate) use place::{crop_pdf, read_source, validate_placement}; +pub use registry::specs; +pub(crate) use snippets::{snippet_bytes, snippet_place}; #[cfg(test)] thread_local! { @@ -148,2027 +31,5 @@ thread_local! { pub(crate) static FAIL_TO_BYTES: std::cell::Cell = const { std::cell::Cell::new(false) }; } -/// The document as `.designcraft` bytes, or an error when it can't be written; never an empty file. -pub fn to_bytes(d: &Document) -> Result> { - save_bytes(d, None) -} - -/// [`to_bytes`] with `preview` (PNG bytes) stored as the file's first-page picture. -fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result> { - #[cfg(test)] - if FAIL_TO_BYTES.with(std::cell::Cell::get) { - return Err(EngineError::Other("could not write the document: injected failure".into())); - } - designcraft_format::save_with_preview(d, preview).map_err(|e| EngineError::Other(format!("could not write the document: {e}"))) -} - -/// `path` ending in `.designcraft`: the extension is added when it is missing or another one, so -/// the file opens from the file manager. A path without a file name is left for the write to fail. -pub fn with_document_extension(path: &str) -> String { - let p = std::path::Path::new(path); - let ext = p.extension().and_then(std::ffi::OsStr::to_str); - if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) { - return path.to_string(); - } - format!("{path}.{}", designcraft_format::EXTENSION) -} - -/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package. -/// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their -/// `mimetype` entry, as both formats write them. -pub fn is_document(bytes: &[u8]) -> bool { - designcraft_idml::is_idml(bytes) || designcraft_format::starts_designcraft(bytes) || designcraft_format::is_designcraft(bytes) -} - -/// How much of a file [`is_document`] needs to recognise a package by its `mimetype` entry. -pub const DOCUMENT_SNIFF_BYTES: u64 = 64 << 10; - -/// The longer side of the first-page picture saved in the file, in pixels. -const PREVIEW_PX: f64 = 320.0; - -/// [`to_bytes`] with a picture of the first page, which the start screen shows next to the file -/// in its recent files. A page that can't be drawn just leaves the picture out. -fn to_bytes_with_preview(d: &Document, cache: &crate::Cache) -> Result> { - let preview = d.page_loc(0).and_then(|(si, pi)| { - let b = d.spreads.get(si)?.pages.get(pi)?.bounds(); - let side = b.width().max(b.height()); - (side.is_finite() && side > 0.0).then_some(PREVIEW_PX / side) - }); - let png = - preview.and_then(|scale| designcraft_render::Renderer::new().render_page(d, cache, 0, scale, false, &Default::default())).map(|r| r.to_png()); - save_bytes(d, png.as_deref()) -} - -pub fn from_bytes(b: &[u8]) -> Result { - load_bytes(b).map(|(d, _)| d) -} - -/// Read `.designcraft` bytes and repair what a hand-edited file can get wrong without failing the -/// open, with a warning for each repair. -fn load_bytes(b: &[u8]) -> Result<(Document, Vec)> { - let mut d = designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))?; - let warnings = d.fix_parent_bases(); - Ok((d, warnings)) -} - -/// The longest saved preset name, and how many presets are kept. -const MAX_PRESET_NAME: usize = 100; -const MAX_PRESETS: usize = 500; - -/// A built-in document preset or a saved one, by name (case ignored). -fn find_preset(s: &Session, name: &str) -> Option { - NewDocument::from_preset(name).or_else(|| s.prefs.document_presets.iter().find(|p| p.title.eq_ignore_ascii_case(name.trim())).cloned()) -} - -/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. -fn new_document_settings(s: &Session, p: &Value) -> Result { - let mut nd = match str_param(p, "preset") { - Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, - None => NewDocument::default(), - }; - apply_settings(&mut nd, p); - check_size("file.new", &nd)?; - Ok(nd) -} - -/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a -/// paragraph's last word or across a column. Opened and imported files keep their own values, -/// which is why `ParaProps` keeps the IDML defaults (on). -fn new_document(nd: &NewDocument) -> Document { - let mut d = Document::new(nd); - if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) { - st.para.hyph_capitalized = Some(false); - st.para.hyph_last_word = Some(false); - st.para.hyph_across_column = Some(false); - } - d -} - -fn file_new(s: &mut Session, p: &Value) -> Result { - let mut nd = new_document_settings(s, p)?; - s.untitled += 1; - nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); - let d = new_document(&nd); - let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i})) -} - -impl Session { - /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while - /// the New Document window is open. It replaces the previous preview document in its tab and - /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses - /// leave the previous preview as it was. Returns the preview document's uid. - pub fn preview_new_document(&mut self, p: &Value) -> Result { - let mut nd = new_document_settings(self, p)?; - let previous = match self.new_document_preview { - Some((_, previous)) => previous, - None => self.active().map(|d| d.uid), - }; - let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old)); - // A preview edited or saved by a menu or script while the window is open has become a - // document of its own: it stays, and the preview gets a tab of its own. - let replace = shown.filter(|&i| self.docs.get(i).is_some_and(|d| !d.is_dirty() && d.path.is_none())); - if replace.is_none() && shown.is_some() { - self.untitled += 1; - } - nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); - let st = DocState::new(new_document(&nd), None); - let uid = st.uid; - match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { - Some((i, d)) => { - *d = st; - self.active = Some(i); - } - None => { - // Pushed without `add_document`: the document that was active stays as it is. - self.docs.push(st); - self.active = Some(self.docs.len() - 1); - } - } - self.new_document_preview = Some((uid, previous)); - Ok(uid) - } - - /// The uid of New Document's preview document, while one is shown. - pub fn new_document_preview(&self) -> Option { - self.new_document_preview.map(|(uid, _)| uid) - } - - /// End New Document's preview. Its document goes, and if it was still the active document, - /// the one that was active before it is active again. A preview that a menu or script has - /// edited or saved stays open as a document, with an Untitled number. Returns the uid of the - /// document removed. Without a preview it does nothing. - pub fn end_new_document_preview(&mut self) -> Option { - let (uid, previous) = self.new_document_preview.take()?; - let i = self.docs.iter().position(|d| d.uid == uid)?; - let d = self.docs.get(i)?; - if d.is_dirty() || d.path.is_some() { - self.untitled += 1; - return None; - } - self.docs.remove(i); - self.active = match self.active { - Some(a) if a == i => previous.and_then(|p| self.docs.iter().position(|d| d.uid == p)).or_else(|| self.docs.len().checked_sub(1)), - Some(a) if a > i => Some(a - 1), - a => a, - }; - Some(uid) - } - - /// Create in the New Document window, with the settings the preview shows: the preview - /// document becomes the new document, as `file.new` would make it (the next Untitled number, - /// no undo history). Returns its index, or `None` when there is no unedited preview to keep. - pub fn keep_new_document_preview(&mut self) -> Option { - let (uid, _) = self.new_document_preview?; - let i = self.docs.iter().position(|d| d.uid == uid && !d.is_dirty() && d.path.is_none())?; - self.new_document_preview = None; - self.untitled += 1; - if let Some(d) = self.docs.get_mut(i) { - d.history.undo.clear(); - d.history.redo.clear(); - } - self.active = Some(i); - Some(i) - } -} - -fn save_preset(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("file.savePreset", "missing `name`"))?.to_string(); - if name.chars().count() > MAX_PRESET_NAME { - return Err(bad("file.savePreset", format!("a preset name has at most {MAX_PRESET_NAME} characters"))); - } - if NewDocument::from_preset(&name).is_some() { - return Err(bad("file.savePreset", format!("`{name}` is a built-in preset"))); - } - let mut nd = NewDocument::default(); - apply_settings(&mut nd, p); - check_size("file.savePreset", &nd)?; - nd.title = name.clone(); - let presets = &mut s.prefs.document_presets; - presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); - if presets.len() >= MAX_PRESETS { - return Err(bad("file.savePreset", format!("at most {MAX_PRESETS} saved presets"))); - } - presets.push(nd); - Ok(json!({"name": name})) -} - -fn check_size(cmd: &str, nd: &NewDocument) -> Result<()> { - if !(nd.width > 0.0 && nd.height > 0.0 && nd.width <= 15552.0 && nd.height <= 15552.0) { - return Err(bad(cmd, "page size out of range (0 < size ≤ 216 in)")); - } - Ok(()) -} - -/// The document settings in `p` (as file.new takes them) over `nd`. -fn apply_settings(nd: &mut NewDocument, p: &Value) { - if let Some(intent) = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()) { - nd.intent = intent; - } - if let Some(units) = p.get("units").and_then(|v| serde_json::from_value(v.clone()).ok()) { - nd.units = units; - } - nd.width = f64_or(p, "width", nd.width); - nd.height = f64_or(p, "height", nd.height); - nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999); - nd.start_page = p.get("startPage").and_then(Value::as_u64).map(|v| v.clamp(1, 99_999) as u32).unwrap_or(nd.start_page); - nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages); - nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns); - nd.gutter = f64_or(p, "gutter", nd.gutter); - nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(nd.primary_text_frame); - match p.get("margins") { - Some(Value::Number(n)) => nd.margins = designcraft_doc::Margins::uniform(n.as_f64().unwrap_or(36.0)), - Some(v @ Value::Object(_)) => { - if let Ok(m) = serde_json::from_value(v.clone()) { - nd.margins = m; - } - } - _ => {} - } - if let Some(b) = edges(p, "bleed") { - nd.bleed = b; - } - if let Some(b) = edges(p, "slug") { - nd.slug = b; - } -} - -/// A bleed or slug parameter: one width for every edge, or [top, bottom, inside, outside]. -fn edges(p: &Value, key: &str) -> Option<[f64; 4]> { - let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1296.0) } else { 0.0 }; - match p.get(key)? { - Value::Array(a) => match a.iter().map(Value::as_f64).collect::>>()?.as_slice() { - [t, b, i, o] => Some([clean(*t), clean(*b), clean(*i), clean(*o)]), - _ => None, - }, - v => v.as_f64().map(|b| [clean(b); 4]), - } -} - -fn file_sample(s: &mut Session, _p: &Value) -> Result { - let d = crate::sample::magazine(); - let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i})) -} - -fn has_recent(s: &Session) -> std::result::Result<(), String> { - if s.prefs.recent_files.is_empty() { Err("no recent files".into()) } else { Ok(()) } -} - -/// A recent file's first-page picture as straight RGBA, for the start screen. -pub struct RecentPreview { - pub width: u32, - pub height: u32, - pub rgba: Vec, -} - -/// The picture a `.designcraft` file saved of its first page; None when the file is gone, isn't -/// ours, or was saved before files had one (it gets one when saved again). -pub fn recent_preview(path: &str) -> Option { - #[cfg(not(target_arch = "wasm32"))] - { - // Only the zip directory and the preview entry are read, not the whole document. - let png = designcraft_format::read_preview(std::io::BufReader::new(crate::io::open(path).ok()?))?; - let img = designcraft_images::decode_rgba(&png)?; - let (width, height) = img.dimensions(); - Some(RecentPreview { width, height, rgba: img.into_raw() }) - } - #[cfg(target_arch = "wasm32")] - { - let _ = path; - None - } -} - -/// One File › Open Recent row: the path, its file name and whether it is still there. -fn recent_entry(path: &str) -> Value { - let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()); - #[cfg(not(target_arch = "wasm32"))] - let exists = crate::io::is_file(path); - #[cfg(target_arch = "wasm32")] - let exists = false; - json!({"path": path, "name": name, "exists": exists}) -} - -fn file_open_recent(s: &mut Session, p: &Value) -> Result { - let index = - p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("file.openRecent", "missing `index`"))?; - let path = s - .prefs - .recent_files - .get(index) - .cloned() - .ok_or_else(|| bad("file.openRecent", format!("no recent file {index} (the list has {})", s.prefs.recent_files.len())))?; - #[cfg(not(target_arch = "wasm32"))] - if !crate::io::is_file(&path) { - return Err(EngineError::Other(format!("{path} can't be found: it was moved, renamed or deleted, or its disk isn't connected"))); - } - file_open(s, &json!({"path": path})) -} - -/// `file.open`: the document opens and moves to the front of File › Open Recent. -fn file_open(s: &mut Session, p: &Value) -> Result { - let r = open_path(s, p)?; - if let Some(path) = str_param(p, "path") { - s.prefs.note_recent_file(path); - } - Ok(r) -} - -fn open_path(s: &mut Session, p: &Value) -> Result { - let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; - #[cfg(not(target_arch = "wasm32"))] - { - let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; - // The content decides first: a file's extension may be missing or wrong. The extension - // then routes files whose content isn't recognised to the reader that explains why. - let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); - let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{"); - if designcraft_idml::is_idml(&bytes) || (!native && (super::interchange::is_indd(path, &bytes) || ext == "idml")) { - return super::interchange::open_idml(s, p); - } - if !native && ext != designcraft_format::EXTENSION { - 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, 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); - warnings.extend(super::style::grep_style_warnings(&d)); - let mut st = DocState::new(d, Some(path.to_string())); - st.fonts = fonts; - let i = s.add_document(st); - Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) - } - #[cfg(target_arch = "wasm32")] - { - let _ = (s, path); - Err(EngineError::Other("use file.openBytes on the web".into())) - } -} - -/// Load the fonts in the `Document Fonts` folder beside the document file at `path` for `d` alone -/// (`d.font_scope`) → (the scope, for the document's state to hold; faces; a warning for each -/// font file skipped). Nothing on the web (no folders). -pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option>, usize, Vec) { - #[cfg(not(target_arch = "wasm32"))] - { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) }; - // A document's folder is the agent's to name (a recovery entry or a book lists paths too): - // the fonts crate asks the automation roots about the folder and each font file, so fonts - // outside the read root stay unloaded. - let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|p| crate::file_access::check_read(p)); - d.font_scope = r.scope; - let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope))); - (scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect()) - } - #[cfg(target_arch = "wasm32")] - { - let _ = (d, path); - (None, 0, Vec::new()) - } -} - -/// The `Document Fonts` folder in `dir`, matched without regard to case: packages name it -/// `Document fonts`, and file systems on Linux are case-sensitive. The exact name wins. -#[cfg(not(target_arch = "wasm32"))] -fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - let exact = dir.join(DOCUMENT_FONTS_FOLDER); - if crate::io::is_dir(&exact) { - return exact; - } - crate::io::read_dir(dir) - .into_iter() - .flatten() - .flatten() - .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path())) - .map_or(exact, |e| e.path()) -} - -fn file_open_bytes(s: &mut Session, p: &Value) -> Result { - let b = base64_decode(str_param(p, "base64").unwrap_or("")); - // IDML packages are recognised by their stored `mimetype` first entry, INDD files by their - // signature (or their name). - if designcraft_idml::is_idml(&b) || super::interchange::is_indd(str_param(p, "name").unwrap_or_default(), &b) { - return super::interchange::open_idml(s, p); - } - let (mut d, warnings) = load_bytes(&b)?; - super::datamerge::resolve_sources_on_open(&mut d, None); - if let Some(n) = str_param(p, "name") { - d.title = n.to_string(); - } - let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i, "warnings": warnings})) -} - -fn file_save(s: &mut Session, p: &Value) -> Result { - let st = s.doc_mut()?; - // Saving to the document's own file keeps its name, even one opened without the extension. - let path = str_param(p, "path") - .map(with_document_extension) - .or_else(|| st.path.clone()) - .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; - // Before anything about the document changes: a refused save leaves it as it was. - crate::file_access::check_write(&path).map_err(EngineError::Other)?; - // Prepare paths for the destination without changing the live document until persistence succeeds. - let mut saved = (*st.doc).clone(); - super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); - let paths_changed = saved.data_merge != st.doc.data_merge; - let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; - let bytes = to_bytes_with_preview(&saved, &s.cache)?; - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; - let st = s.doc_mut()?; - if paths_changed { - st.doc = saved; - st.bump_revision(); - } - st.path = Some(path.clone()); - st.saved_revision = st.revision; - st.saved_doc = st.doc.clone(); - #[cfg(not(target_arch = "wasm32"))] - { - let recovery_dir = s.recovery_dir.clone(); - let index = s.active_index().ok_or(EngineError::NoDocument)?; - crate::recovery::discard_document(s, index, recovery_dir.as_deref()); - s.prefs.note_recent_file(&path); - } - Ok(json!({"path": path, "bytes": bytes.len()})) -} - -fn file_place(s: &mut Session, p: &Value) -> Result { - // The file's size and time before it is read (a linked text file remembers them). - let mut before = None; - let (bytes, name, link) = if let Some(b) = str_param(p, "base64") { - (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None) - } else if let Some(path) = str_param(p, "path") { - before = super::datamerge::fingerprint_from_path(path); - #[cfg(not(target_arch = "wasm32"))] - let b = crate::io::read_file(path).map_err(EngineError::Other)?; - #[cfg(target_arch = "wasm32")] - let b: Vec = vec![]; - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); - (b, name, Some(path.to_string())) - } else { - s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) }); - return ok(); - }; - // A page of another layout (IDML or DesignCraft): its objects, as one group. - let lname = name.to_lowercase(); - if lname.ends_with(".idml") || lname.ends_with(".designcraft") { - return place_layout_page(s, p, &name, &bytes); - } - // 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); - } - // Text files (plain, Word, RTF) flow into frames. - if designcraft_textimport::is_text_file(&name) { - return super::place_text::place_text(s, p, &name, &bytes, link, before); - } - let want_w = p - .get("width") - .filter(|value| !value.is_null()) - .map(|value| { - value - .as_f64() - .filter(|width| width.is_finite() && *width > 0.0) - .ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero")) - }) - .transpose()?; - // Image Import Options: which page of a PDF (1-based). - if designcraft_render::is_pdf(&bytes) { - designcraft_render::check_pdf_input_size(bytes.len()).map_err(|e| bad("file.place", e.to_string()))?; - } - let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { - Some(n) if designcraft_render::is_pdf(&bytes) => { - let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1); - let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)")); - let one_based = usize::try_from(n).map_err(|_| invalid_page())?; - if one_based == 0 || one_based > count { - return Err(invalid_page()); - } - let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?; - u32::try_from(zero_based).map_err(|_| invalid_page())? - } - _ => 0, - }; - let (pw, ph) = match pdf_page { - 0 => designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?, - n => designcraft_render::pdf_page_size(&bytes, n as usize) - .map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)) - .ok_or_else(|| bad("file.place", "can't read that PDF page"))?, - }; - // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages - // and SVGs keep their exact size: rounding it to whole points would stretch them unevenly. - let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64)); - // Image Import Options › Crop to: the graphic spans that box of the PDF page. - let is_pdf = designcraft_render::is_pdf(&bytes); - let pdf_crop = match str_param(p, "pdfCrop") { - Some(k) if is_pdf => PdfCrop::from_name(k) - .ok_or_else(|| bad("file.place", format!("unknown pdfCrop `{k}` (crop, art, trim, bleed, media, contentVisible, contentAll)")))?, - _ => PdfCrop::Crop, - }; - let mime = designcraft_render::image_mime(&bytes).to_string(); - let mut asset = Asset { page: pdf_page, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), pdf_crop, ..Default::default() }; - // The graphic's natural size: the box in points (the whole page when the box can't be found, - // and then the document says so). - let (bw, bh) = match crop_pdf(&mut asset) { - Some(size) => size, - None => { - asset.pdf_crop = PdfCrop::Crop; - (nw, nh) - } - }; - let target_frame = super::id_param(p, "frame").or_else(|| { - if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { - return None; - } - let st = s.active()?; - st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) - }); - // `"spread": null` counts as omitted, like the other placement parameters. - let spread = match p.get("spread") { - Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0), - _ => super::spread_param(p, "file.place")?, - }; - let lid = s.doc()?.active_layer; - let px = p.get("x").and_then(Value::as_f64); - let py = p.get("y").and_then(Value::as_f64); - if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { - return load_cursor(s, asset, (bw, bh)); - } - s.edit(|d, sel| { - let fresh = AssetId(d.alloc()); - let aid = d.add_asset(Asset { id: fresh, ..asset }); - // 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 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 the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. - let k = (r.width() / bw).max(r.height() / bh); - let (gw, gh) = (bw * k, bh * k); - let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); - validate_placement((bw, bh), r, xf, "file.place")?; - it.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (bw, bh), - xf, - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - 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 r = super::finite_rect("file.place", Rect::new(x, y, x + bw * k, y + bh * k))?; - let xf = Affine::translate((x, y)) * Affine::scale(k); - validate_placement((bw, bh), r, xf, "file.place")?; - let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); - it.object_style = d.styles.default_graphic_frame.clone(); - if let Some(e) = d.styles.object_effects(&it.object_style) { - it.effects = e.clone(); - } - it.content = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (bw, bh), - xf, - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - d.insert_item(spread, it, None)?; - id - }; - *sel = Selection::items(vec![id]); - Ok(json!({"id": id.0, "asset": aid.0})) - }) -} - -const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -pub fn base64_encode(data: &[u8]) -> String { - let mut out = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - out.push(B64[(n >> 18) as usize & 63] as char); - out.push(B64[(n >> 12) as usize & 63] as char); - out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); - out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' }); - } - out -} - -pub fn base64_decode(s: &str) -> Vec { - let mut out = Vec::with_capacity(s.len() * 3 / 4); - let mut buf = 0u32; - let mut bits = 0; - for b in s.bytes() { - let v = match b { - b'A'..=b'Z' => b - b'A', - b'a'..=b'z' => b - b'a' + 26, - b'0'..=b'9' => b - b'0' + 52, - b'+' | b'-' => 62, - b'/' | b'_' => 63, - _ => continue, - } as u32; - buf = buf << 6 | v; - bits += 6; - if bits >= 8 { - bits -= 8; - out.push((buf >> bits) as u8); - } - } - out -} - -/// Sets `a.pdf_box` to where its `pdf_crop` box sits on its page and returns the box's size in -/// points. The crop box is the whole page and has none; nor has a page that can't be read, which -/// keeps its crop choice (a missing link relinked later gets its box then). -pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> { - a.pdf_box = None; - if a.pdf_crop == PdfCrop::Crop || !designcraft_render::is_pdf(&a.data) { - return None; - } - let (frac, size) = designcraft_render::pdf_crop_box(&a.data, a.page as usize, a.pdf_crop)?; - a.pdf_box = Some(frac); - Some(size) -} - -pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option)> { - if let Some(b) = str_param(p, "base64") { - return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)); - } - let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; - #[cfg(not(target_arch = "wasm32"))] - let b = crate::io::read_file(path).map_err(EngineError::Other)?; - #[cfg(target_arch = "wasm32")] - let b: Vec = vec![]; - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); - 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 natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); - let mime = designcraft_render::image_mime(&bytes).to_string(); - let asset = Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }; - load_cursor(s, asset, natural) -} - -/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor. -fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64)) -> Result { - let name = asset.name.clone(); - let (pw, ph) = asset.pixels.unwrap_or_default(); - let aid = s.edit(|d, _| { - asset.id = AssetId(d.alloc()); - Ok(d.add_asset(asset)) - })?; - s.loaded = Some(crate::LoadedGraphic { asset: aid, size }); - s.set_tool("placeGun"); - Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true})) -} - -fn place_drop(s: &mut Session, p: &Value) -> Result { - let crate::LoadedGraphic { asset: aid, size: (nw, nh) } = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; - // Undo can remove the loaded graphic's asset. - if !s.doc()?.doc.assets.contains_key(&aid) { - s.loaded = None; - return Err(bad("place.drop", "the loaded graphic is no longer in the document")); - } - let sr = super::spread_param(p, "place.drop")?; - let lid = s.doc()?.active_layer; - let frame = super::id_param(p, "frame"); - let rect = super::rect_param(p, "rect"); - let (x, y) = (super::f64_or(p, "x", 0.0), super::f64_or(p, "y", 0.0)); - let d = &s.doc()?.doc; - let (frame_rect, xf, auto_fit) = if let Some(fid) = frame { - let r = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.inner_bounds(); - let k = (r.width() / nw).max(r.height() / nh); - let xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k); - // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. - (r, xf, Default::default()) - } else { - if d.spread(sr).is_none() { - return Err(bad("place.drop", "no such spread")); - } - // Drag: fit proportionally; click: actual size at the point. - let (r, k) = match rect { - Some(r) if r.width() > 2.0 && r.height() > 2.0 => { - let k = (r.width() / nw).min(r.height() / nh); - (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) - } - _ => (Rect::new(x, y, x + nw, y + nh), 1.0), - }; - (r, Affine::translate((r.x0, r.y0)) * Affine::scale(k), Default::default()) - }; - validate_placement((nw, nh), frame_rect, xf, "place.drop")?; - let r = s.edit(|d, sel| { - let graphic = Content::Graphic(Graphic { - layer_options: None, - asset: aid, - size: (nw, nh), - xf, - clip: None, - auto_fit, - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - let id = if let Some(fid) = frame { - d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.content = graphic; - fid - } else { - let id = ItemId(d.alloc()); - let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_rect)); - it.object_style = d.styles.default_graphic_frame.clone(); - if let Some(e) = d.styles.object_effects(&it.object_style) { - it.effects = e.clone(); - } - it.content = graphic; - d.insert_item(sr, it, None)?; - id - }; - *sel = Selection::items(vec![id]); - Ok(json!({"id": id.0})) - })?; - s.loaded = None; - s.set_tool("selection"); - Ok(r) -} - -/// The selection as `.designcraft` snippet bytes. -pub(crate) fn snippet_bytes(s: &mut Session) -> Result> { - to_bytes(&super::edit::clip_doc(s)?) -} - -fn snippet_export(s: &mut Session, p: &Value) -> Result { - let d = super::edit::clip_doc(s)?; - let bytes = to_bytes(&d)?; - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) - } - None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), - } -} - -/// File › Place of an IDML or DesignCraft document: the objects of one page (`layoutPage`, -/// 1-based) grouped, top-left at x/y (default: the source position on the first page). -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, str_param(p, "path").map(std::path::Path::new)).map_err(|e| bad(ID, e.to_string()))? - } else { - from_bytes(bytes)? - }; - let n = src.page_count(); - let page = p.get("layoutPage").and_then(Value::as_u64).unwrap_or(1) as usize; - if page == 0 || page > n { - return Err(bad(ID, format!("the document has {n} page(s)"))); - } - let (si, pi) = src.page_loc(page - 1).ok_or_else(|| bad(ID, "no such page"))?; - let sp = &src.spreads[si]; - let pr = sp.pages[pi].bounds(); - let ids: Vec = sp.items.iter().filter(|it| !it.hidden && sp.page_at_x(it.bounds().center().x) == Some(pi)).map(|it| it.id).collect(); - if ids.is_empty() { - return Err(bad(ID, format!("page {page} has no objects"))); - } - let to = super::spread_param(p, ID)?; - let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), - _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), - }; - let placed = s.edit(|d, sel| { - for sw in &src.swatches { - if d.swatch(&sw.name).is_none() { - d.swatches.push(sw.clone()); - } - } - let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); - let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); - if !missing_p.is_empty() || !missing_c.is_empty() { - let st = d.styles_mut(); - st.paragraph.extend(missing_p); - st.character.extend(missing_c); - } - let new = super::object::duplicate_from(d, &src, &ids, to, off)?; - *sel = Selection::items(new.clone()); - Ok(new) - })?; - if placed.len() > 1 { - let g = s.execute("object.group", &json!({"ids": placed.iter().map(|i| i.0).collect::>()}))?; - return Ok(json!({"id": g["id"], "items": placed.len()})); - } - Ok(json!({"id": placed[0].0, "items": 1})) -} - -pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { - let (bytes, _, _) = read_source(p)?; - let src = from_bytes(&bytes)?; - let ids: Vec = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); - if ids.is_empty() { - return Err(bad("snippet.place", "the snippet is empty")); - } - let sr = super::spread_param(p, "snippet.place")?; - let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); - let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), - _ => designcraft_geom::Vec2::ZERO, - }; - s.edit(|d, sel| { - // Bring over styles and swatches the snippet uses that this document lacks. - for sw in &src.swatches { - if d.swatch(&sw.name).is_none() { - d.swatches.push(sw.clone()); - } - } - let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); - let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); - if !missing_p.is_empty() || !missing_c.is_empty() { - let st = d.styles_mut(); - st.paragraph.extend(missing_p); - st.character.extend(missing_c); - } - let new = super::object::duplicate_from(d, &src, &ids, sr, off)?; - *sel = Selection::items(new.clone()); - Ok(json!({"ids": new.iter().map(|i| i.0).collect::>()})) - }) -} - -/// Which access a recovery command needs to the folder it is given. -#[derive(Clone, Copy)] -enum FolderAccess { - Read, - Write, - ReadWrite, -} - -/// The recovery folder: `dir` when the command names one, else the session's own (set by the -/// host application, so not the automation roots' business). A named folder is checked against -/// the roots for `access`. -fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result { - #[cfg(target_arch = "wasm32")] - { - let _ = (s, p, access); - Err(EngineError::Other("no recovery folder on the web".into())) - } - #[cfg(not(target_arch = "wasm32"))] - { - let Some(named) = str_param(p, "dir") else { - return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")); - }; - if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) { - crate::file_access::check_read(named).map_err(EngineError::Other)?; - } - if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) { - crate::file_access::check_write(named).map_err(EngineError::Other)?; - } - Ok(std::path::PathBuf::from(named)) - } -} - -fn can_revert(s: &Session) -> std::result::Result<(), String> { - match s.active() { - Some(d) if d.path.is_some() && d.is_dirty() => Ok(()), - Some(d) if d.path.is_none() => Err("the document has never been saved".into()), - Some(_) => Err("no changes since the last save".into()), - None => Err("no document open".into()), - } -} - -fn file_revert(s: &mut Session, _: &Value) -> Result { - let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - #[cfg(not(target_arch = "wasm32"))] - { - 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, 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)?)? - }; - // As opened again: the fonts beside it now (unchanged files are shared, not read again). - let (fonts, _, _) = load_document_fonts(&mut d, &path); - let uid = s.doc()?.uid; - let recovery_dir = s.recovery_dir.clone(); - let index = s.active_index().ok_or(EngineError::NoDocument)?; - let mut fresh = DocState::new(d, Some(path.clone())); - fresh.fonts = fonts; - fresh.uid = uid; - // A new state has a revision no state had before, so caches don't show the old document. - crate::recovery::discard_document(s, index, recovery_dir.as_deref()); - *s.doc_mut()? = fresh; - Ok(json!({"path": path})) - } - #[cfg(target_arch = "wasm32")] - { - let _ = (s, path); - Err(EngineError::Other("revert isn't available on the web".into())) - } -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod image_place_tests { - use designcraft_doc::{Content, ItemId}; - use designcraft_geom::Rect; - use serde_json::{Value, json}; - - use crate::Session; - - pub(super) fn image_file() -> std::io::Result { - image_file_with_color(30) - } - - fn image_file_with_color(red: u8) -> std::io::Result { - let file = tempfile::Builder::new().suffix(".png").tempfile()?; - let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png(); - std::fs::write(file.path(), png)?; - Ok(file) - } - - #[test] - fn consecutive_path_placements_create_distinct_graphics() { - let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()]; - let mut session = Session::new(); - session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); - let mut placements = Vec::new(); - let mut originals = Vec::new(); - - for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) { - let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap(); - let id = ItemId(placed["id"].as_u64().unwrap()); - assert!( - placements.iter().all(|previous: &Value| previous["id"] != placed["id"]), - "each explicit placement needs a distinct frame: {placed}" - ); - let document = &session.doc().unwrap().doc; - let item = document.item(id).unwrap(); - assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0)); - assert!(document.spreads[spread].items.iter().any(|item| item.id == id)); - originals.push(serde_json::to_value(item).unwrap()); - placements.push(placed); - } - - let document = &session.doc().unwrap().doc; - assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3); - assert_eq!(document.assets.len(), 3); - for (placed, original) in placements.iter().zip(originals) { - assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original); - } - let links = session.execute("links.list", &json!({})).unwrap(); - assert_eq!(links.as_array().unwrap().len(), 3); - for (placed, image) in placements.into_iter().zip(&images) { - let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap(); - assert_eq!(row["path"], json!(image.path())); - assert_eq!(row["uses"].as_array().unwrap().len(), 1); - assert_eq!(row["uses"][0]["id"], placed["id"]); - } - } - - #[test] - fn individual_placement_parameters_bypass_selected_frames() { - let image = image_file().unwrap(); - for graphic in [false, true] { - for (key, value, expected) in [ - ("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)), - ("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)), - ("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)), - ("width", 0, Rect::ZERO), - ("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)), - ] { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); - if graphic { - session.execute("file.place", &json!({"path": image.path()})).unwrap(); - } - let id = ItemId(selected["id"].as_u64().unwrap()); - let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap(); - let mut params = json!({"path": image.path()}); - params[key] = json!(value); - - let result = session.execute("file.place", ¶ms); - if key == "width" && value == 0 { - assert!(result.is_err(), "zero width must be rejected, graphic={graphic}"); - continue; - } - let placed = result.unwrap(); - - assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}"); - let document = &session.doc().unwrap().doc; - assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original); - let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); - assert_eq!(item.bounds(), expected); - let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; - let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs(); - assert!(scale_x > 0.0 && scale_y > 0.0); - } - } - } - - #[test] - fn explicit_frame_wins_over_placement_and_selection() { - let image = image_file().unwrap(); - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); - let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap(); - - let placed = - session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap(); - - assert_eq!(placed["id"], target["id"]); - let document = &session.doc().unwrap().doc; - let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap(); - assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); - assert!(matches!(item.content, Content::Graphic(_))); - assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned)); - assert_eq!(document.spreads[0].items.len(), 2); - } - - fn assert_width_rejected(width: Value) -> std::result::Result<(), Box> { - let image = image_file()?; - for graphic in [false, true] { - for explicit_frame in [false, true] { - let mut session = Session::new(); - session.execute("file.new", &json!({}))?; - let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; - if graphic { - session.execute("file.place", &json!({"path": image.path()}))?; - } - let mut params = json!({"path": image.path(), "width": width}); - if explicit_frame { - params["frame"] = target["id"].clone(); - session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?; - } - let before = session.doc()?; - let document = before.doc.clone(); - let selection = before.selection.clone(); - let revision = before.revision; - let assets = document.assets.len(); - - let result = session.execute("file.place", ¶ms); - - assert!( - matches!(&result, Err(crate::EngineError::BadParams { .. })), - "width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}" - ); - let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?; - let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) }; - assert_eq!(cmd, "file.place"); - assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}"); - let after = session.doc()?; - assert!(std::sync::Arc::ptr_eq(&document, &after.doc)); - assert_eq!(after.selection, selection); - assert_eq!(after.revision, revision); - assert_eq!(after.doc.assets.len(), assets); - } - } - Ok(()) - } - - #[test] - fn width_rejected_when_zero() { - assert_width_rejected(json!(0)).unwrap(); - } - - #[test] - fn width_rejected_when_negative() { - assert_width_rejected(json!(-80)).unwrap(); - } - - #[test] - fn width_rejected_when_non_numeric() { - for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] { - assert_width_rejected(width).unwrap(); - } - } - - fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box> { - let image = image_file()?; - let previous_image = image_file_with_color(31)?; - for graphic in [false, true] { - let mut session = Session::new(); - session.execute("file.new", &json!({}))?; - let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; - let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?; - let mut params = json!({"path": image.path()}); - for key in keys { - params[*key] = Value::Null; - } - - let placed = session.execute("file.place", ¶ms)?; - - assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}"); - if let Some(previous) = previous { - assert_ne!(placed["asset"], previous["asset"]); - } - let document = &session.doc()?.doc; - let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?); - let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); - assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic"); - let Content::Graphic(content) = &item.content else { - return Err(crate::EngineError::Other("placed frame must be graphic".into()).into()); - }; - assert_eq!(json!(content.asset.0), placed["asset"]); - assert_eq!(document.spreads[0].items.len(), 1); - } - Ok(()) - } - - #[test] - fn selection_used_when_x_null() { - assert_null_placement_uses_selection(&["x"]).unwrap(); - } - - #[test] - fn selection_used_when_y_null() { - assert_null_placement_uses_selection(&["y"]).unwrap(); - } - - #[test] - fn selection_used_when_spread_null() { - assert_null_placement_uses_selection(&["spread"]).unwrap(); - } - - #[test] - fn selection_used_when_width_null() { - assert_null_placement_uses_selection(&["width"]).unwrap(); - } - - #[test] - fn selection_used_when_all_placement_parameters_null() { - assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap(); - } - - #[test] - fn implicit_placement_fills_empty_and_replaces_graphic_selection() { - let image = image_file().unwrap(); - let previous_image = image_file_with_color(31).unwrap(); - for graphic in [false, true] { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); - let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap()); - - let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap(); - - assert_eq!(placed["id"], selected["id"]); - if let Some(previous) = previous { - assert_ne!(placed["asset"], previous["asset"]); - } - let document = &session.doc().unwrap().doc; - let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); - assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); - let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; - assert_eq!(json!(content.asset.0), placed["asset"]); - assert_eq!(document.spreads[0].items.len(), 1); - } - } -} - -#[cfg(test)] -mod new_document_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn saved_presets_make_documents_by_name() { - let mut s = Session::new(); - let newsletter = json!({"name": "Newsletter", "units": "millimeters", "width": 595.2755905511812, "height": 841.8897637795277, - "pages": 8, "facingPages": false, "columns": 3, "margins": {"top": 20, "bottom": 30, "inside": 40, "outside": 50}, - "bleed": [9, 9, 0, 9], "primaryTextFrame": true}); - assert_eq!(s.execute("file.savePreset", &newsletter).unwrap()["name"], "Newsletter"); - let listed = s.execute("file.presets", &json!({})).unwrap(); - let saved: Vec<_> = listed.as_array().unwrap().iter().filter(|p| p["saved"] == true).collect(); - assert_eq!(saved.len(), 1); - assert_eq!((&saved[0]["name"], &saved[0]["units"]), (&json!("Newsletter"), &json!("millimeters"))); - // By name, case ignored; parameters still override the preset. - s.execute("file.new", &json!({"preset": "newsletter", "pages": 4})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.page_count(), 4); - assert!(!d.settings.facing_pages && d.settings.primary_text_frame); - assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Millimeters); - assert_eq!(d.settings.bleed, [9.0, 9.0, 0.0, 9.0]); - assert_eq!(d.page(0).unwrap().margins.outside, 50.0); - assert!(d.title.starts_with("Untitled-"), "the preset's name isn't the document's"); - // The same name replaces it. - s.execute("file.savePreset", &json!({"name": "NEWSLETTER", "pages": 2})).unwrap(); - assert_eq!(s.prefs.document_presets.len(), 1); - s.execute("file.new", &json!({"preset": "Newsletter"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.page_count(), 2); - // Built-in names, empty and overlong names and impossible sizes are refused. - for bad in [json!({"name": "a4"}), json!({"name": " "}), json!({}), json!({"name": "x".repeat(101)}), json!({"name": "Huge", "width": 1e9})] - { - assert!(s.execute("file.savePreset", &bad).is_err(), "{bad}"); - } - s.execute("file.deletePreset", &json!({"name": "newsletter"})).unwrap(); - assert!(s.execute("file.deletePreset", &json!({"name": "newsletter"})).is_err()); - assert!(s.execute("file.new", &json!({"preset": "Newsletter"})).is_err()); - } - - #[test] - fn new_document_takes_intent_units_start_page_bleed_and_slug() { - let mut s = Session::new(); - let p = json!({"preset": "A4", "intent": "web", "units": "pixels", "startPage": 4, "bleed": [9, 0, 3, 6], "slug": 18}); - s.execute("file.new", &p).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.settings.intent, designcraft_doc::Intent::Web); - assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Pixels); - assert_eq!(d.settings.bleed, [9.0, 0.0, 3.0, 6.0]); - assert_eq!(d.settings.slug, [18.0; 4]); - assert_eq!(d.page_name(0), "4"); - // Malformed edges are ignored, hostile ones bounded. - s.execute("file.new", &json!({"bleed": [1, 2], "slug": [-5, 1e12, 0, 0]})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.settings.bleed, [0.0; 4]); - assert_eq!(d.settings.slug, [0.0, 1296.0, 0.0, 0.0]); - } - - #[test] - fn a_new_document_preview_replaces_itself_and_leaves_the_session_as_it_was() { - let dir = std::env::temp_dir().join(format!("dc-new-doc-preview-{}", std::process::id())); - let mut s = Session::new(); - s.recovery_dir = Some(dir.clone()); - s.execute("file.new", &json!({})).unwrap(); - let first = s.doc().unwrap().uid; - let a = s.preview_new_document(&json!({"width": 300})).unwrap(); - assert_eq!((s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (a, "Untitled-2")); - let b = s.preview_new_document(&json!({"width": 400, "columns": 3})).unwrap(); - assert_eq!(s.documents().len(), 2, "the preview replaces itself"); - assert!(s.preview_new_document(&json!({"width": 0})).is_err()); - let d = s.doc().unwrap(); - assert_eq!((d.uid, d.doc.settings.page_width, d.doc.page(0).unwrap().columns.count), (b, 400.0, 3), "a refused size keeps it"); - let listed = s.execute("document.list", &json!({})).unwrap(); - assert_eq!((listed[0].get("preview"), &listed[1]["preview"]), (None, &json!(true))); - assert_eq!(s.end_new_document_preview(), Some(b)); - let d = s.doc().unwrap(); - assert_eq!((s.documents().len(), d.uid, d.is_dirty(), d.history.undo.len()), (1, first, false, 0)); - assert_eq!(s.end_new_document_preview(), None, "without a preview it does nothing"); - // Edited by a script while shown, it writes no recovery data while it is a preview, and - // stays as a document when the window closes, with its Untitled number. - let c = s.preview_new_document(&json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); - assert_eq!(crate::recovery::save(&s, &dir).unwrap(), 0); - // The app's timer saves through the command. - assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"], 0); - assert!(crate::recovery::list(&dir).is_empty()); - assert_eq!(s.end_new_document_preview(), None); - assert_eq!((s.documents().len(), s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (2, c, "Untitled-2")); - s.execute("file.new", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.title, "Untitled-3"); - // Create with the previewed settings keeps the preview as the document. - let d = s.preview_new_document(&json!({"pages": 2})).unwrap(); - assert_eq!(s.keep_new_document_preview(), Some(3)); - let st = s.doc().unwrap(); - assert_eq!((st.uid, st.doc.title.as_str(), s.new_document_preview()), (d, "Untitled-4", None)); - s.execute("file.new", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.title, "Untitled-5"); - let _ = std::fs::remove_dir_all(&dir); - } -} - #[cfg(test)] pub(crate) mod eps_place_tests; - -#[cfg(test)] -mod pdf_crop_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn place_pdf_cropped_to_its_trim_box() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); - let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); - let mut t = Session::new(); - t.execute("file.new", &json!({})).unwrap(); - let size = |t: &mut Session, crop: &str| { - t.execute("edit.deselectAll", &json!({})).unwrap(); - let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": crop, "width": 630, "x": 0, "y": 0})).unwrap(); - let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - t.doc().unwrap().doc.item(id).unwrap().bounds() - }; - let full = size(&mut t, "crop"); - let trim = size(&mut t, "trim"); - assert!((full.width() - 630.0).abs() < 1e-6, "{full:?}"); - assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}"); - assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); - } - - /// Placing with `trim` stores the choice; the graphic spans the trim box and draws only it, - /// even in a frame larger than the graphic. - #[test] - fn a_pdf_placed_with_its_trim_box_stores_and_draws_that_box() { - // Letter with a 9 pt bleed: black in the bleed's top-left corner and a black square inside. - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); - for rect in [[-9, -9, 0, 0], [10, 10, 30, 30]] { - let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); - } - let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); - - let mut t = Session::new(); - t.execute("file.new", &json!({})).unwrap(); - // `width` is the whole page's (630 pt with its bleed): half size. - let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "trim", "width": 315, "x": 100, "y": 100})).unwrap(); - let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - let d = t.doc().unwrap().doc.clone(); - let it = d.item(id).unwrap(); - let g = it.graphic().unwrap().clone(); - let a = &d.assets[&g.asset]; - assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Trim); - let b = a.shown_box().unwrap(); - let want = [9.0 / 630.0, 9.0 / 810.0, 621.0 / 630.0, 801.0 / 810.0]; - assert!(b.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-4), "{b:?}"); - assert!((g.size.0 - 612.0).abs() < 1e-3 && (g.size.1 - 792.0).abs() < 1e-3, "{:?}", g.size); - let shown = (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); - let fb = it.bounds(); - for (x, y) in [(shown.x0, 100.0), (shown.y0, 100.0), (shown.x1, 406.0), (shown.y1, 496.0), (fb.x1, 406.0), (fb.y1, 496.0)] { - assert!((x - y).abs() < 1e-3, "{shown:?} {fb:?}"); - } - - // A frame larger than the graphic still shows only the trim box. - t.edit(|d, _| { - d.item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(90.0, 90.0, 416.0, 506.0)); - Ok(()) - }) - .unwrap(); - let d = t.doc().unwrap().doc.clone(); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - let img = rr.render_page(&d, &t.cache, 0, 1.0, false, &Default::default()).unwrap(); - let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 60; - // The page sits at 95.5: its bleed corner (95.5–100) is cut off, the square (105–115) shows - // where it is on the page. - assert!(!dark(97, 97), "the bleed is outside the trim box"); - assert!(!dark(103, 103), "the page is offset by its bleed"); - assert!(dark(110, 110), "the square inside the trim box"); - } - - #[test] - fn placed_pdf_keeps_its_exact_page_size() { - // A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points. - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap(); - let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); - let mut t = Session::new(); - t.execute("file.new", &json!({})).unwrap(); - let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap(); - let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - let d = &t.doc().unwrap().doc; - let it = d.item(id).unwrap(); - let b = it.bounds(); - assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}"); - let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") }; - assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size); - } -} - -#[cfg(test)] -mod layout_place_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn place_a_page_of_an_idml_file_as_a_group() { - let mut a = Session::new(); - a.execute("file.new", &json!({"pages": 2})).unwrap(); - a.execute("frame.create", &json!({"rect": [100, 100, 200, 150], "content": "text", "text": "From page one", "caret": false})).unwrap(); - a.execute("frame.create", &json!({"rect": [300, 400, 350, 450]})).unwrap(); - let idml = designcraft_idml::export_idml(&a.doc().unwrap().doc); - let b64 = super::base64_encode(&idml); - let mut b = Session::new(); - b.execute("file.new", &json!({})).unwrap(); - let r = b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap(); - assert_eq!(r["items"], 2); - let d = &b.doc().unwrap().doc; - let g = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap(); - assert_eq!(g.children().len(), 2, "one group"); - let bb = g.bounds(); - assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.y0 - 100.0).abs() < 1e-6, "page top-left at 0,0: {bb:?}"); - assert!(d.stories.values().any(|st| st.text == "From page one"), "stories come along"); - assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty"); - } -} - -#[cfg(test)] -mod save_tests { - use serde_json::json; - - use crate::Session; - - /// Saving over an existing document replaces it whole and leaves nothing beside it. - #[test] - fn save_replaces_the_existing_file_without_leftovers() { - let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("doc.designcraft"); - std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap(); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap(); - let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); - assert!(saved.stories.values().any(|st| st.text == "Saved whole")); - let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); - assert_eq!(names, ["doc.designcraft"], "no temporary file is left"); - // A folder that doesn't exist: an error naming the path. - let missing = dir.join("missing").join("doc.designcraft"); - let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err(); - assert!(e.to_string().contains("missing"), "{e}"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A document that can't be written is an error: the file on disk, the unsaved state and the - /// recovery checkpoint all stay as they were. - #[test] - fn a_save_that_cannot_write_the_document_changes_nothing() { - let dir = std::env::temp_dir().join(format!("dc-save-fails-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("doc.designcraft"); - let recovery = dir.join("recovery"); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - let before = std::fs::read(&path).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Unsaved", "caret": false})).unwrap(); - s.execute("file.recovery.save", &json!({"dir": recovery.to_string_lossy()})).unwrap(); - let checkpoint = recovery.join(format!("{}.designcraft", s.doc().unwrap().uid)); - assert!(checkpoint.exists()); - - super::FAIL_TO_BYTES.with(|f| f.set(true)); - let save = s.execute("file.save", &json!({})); - let copy = s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})); - let save_as = s.execute("file.saveAs", &json!({"path": dir.join("other.designcraft").to_string_lossy()})); - let serialized = s.execute("file.serialize", &json!({})); - super::FAIL_TO_BYTES.with(|f| f.set(false)); - - let e = save.unwrap_err(); - assert!(e.to_string().contains("could not write the document"), "{e}"); - assert!(copy.is_err() && serialized.is_err(), "no empty copy and no empty download"); - assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); - assert!(!dir.join("copy.designcraft").exists()); - assert!(save_as.is_err() && !dir.join("other.designcraft").exists()); - assert!(!s.prefs.recent_files.iter().any(|p| p.ends_with("other.designcraft")), "a failed Save As isn't a recent file"); - assert!(s.doc().unwrap().is_dirty(), "the document still has unsaved changes"); - assert!(checkpoint.exists(), "the recovery checkpoint stays"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1050: a placement whose coordinates overflow to infinity is refused, and Save refuses a - /// document holding a non-finite rect anyway, leaving the file on disk as it was. - #[test] - fn non_finite_coordinates_are_refused_and_never_saved() { - let image = super::image_place_tests::image_file().unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let e = s.execute("file.place", &json!({"path": image.path(), "x": 1e308, "width": 1e308})).unwrap_err(); - assert!(e.to_string().contains("finite"), "{e}"); - assert!(s.doc().unwrap().doc.all_items().is_empty(), "nothing is placed"); - - let dir = std::env::temp_dir().join(format!("dc-save-non-finite-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("doc.designcraft"); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - let before = std::fs::read(&path).unwrap(); - let id = designcraft_doc::ItemId(s.execute("frame.create", &json!({"rect": [36, 36, 300, 200]})).unwrap()["id"].as_u64().unwrap()); - let st = s.doc_mut().unwrap(); - let infinite = designcraft_geom::Rect::new(36.0, 36.0, f64::INFINITY, 200.0); - std::sync::Arc::make_mut(&mut st.doc).item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(infinite); - let e = s.execute("file.save", &json!({})).unwrap_err(); - assert!(e.to_string().contains("not a finite number"), "{e}"); - assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); - assert!(s.doc().unwrap().is_dirty()); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(test)] -mod document_fonts_tests { - use std::path::{Path, PathBuf}; - - use designcraft_fonts::testing::font_with; - use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FaceRef, FontDb, FontSource}; - use serde_json::{Value, json}; - - use crate::Session; - - fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("dc-open-docfonts-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join(DOCUMENT_FONTS_FOLDER)).unwrap(); - dir - } - - /// A new document in `s` with a text frame holding `text` set in `family`. - fn new_document(s: &mut Session, family: &str, text: &str) { - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); - s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); - } - - /// A document whose text ("Hello") is set in `family`, written to `path` (`.idml`: as IDML). - fn write_document(path: &Path, family: &str) { - write_document_with(path, family, "Hello"); - } - - fn write_document_with(path: &Path, family: &str, text: &str) { - let mut s = Session::new(); - new_document(&mut s, family, text); - if path.extension().is_some_and(|e| e == "idml") { - std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - } else { - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - } - } - - fn font_entry(s: &mut Session, family: &str) -> Value { - let l = s.execute("font.list", &json!({})).unwrap(); - l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}")) - } - - /// Each character of the active document's text with the face it is drawn in. - fn glyph_faces(s: &Session) -> Vec<(char, FaceRef)> { - let st = s.doc().unwrap(); - let mut out = Vec::new(); - for (sid, story) in &st.doc.stories { - let cs = s.cache.get(&st.doc, *sid, None); - for g in cs.frames.iter().flat_map(|ft| &ft.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0) { - out.push((story.text[g.byte..].chars().next().unwrap(), g.face)); - } - } - out - } - - fn open(s: &mut Session, path: &Path) -> Value { - s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap() - } - - #[test] - fn opening_a_document_loads_its_document_fonts() { - const FAMILY: &str = "DocFont Open Test"; - let dir = temp_dir("designcraft"); - let fonts = dir.join(DOCUMENT_FONTS_FOLDER); - std::fs::write(fonts.join("open.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - std::fs::write(fonts.join("broken.otf"), b"not a font").unwrap(); - let path = dir.join("Brochure.designcraft"); - write_document(&path, FAMILY); - assert!(!FontDb::global().has_family(FAMILY)); - - let mut s = Session::new(); - let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!(r["documentFonts"], 1, "{r}"); - assert!(r["warnings"].to_string().contains("broken.otf"), "a damaged font is reported, not fatal: {r}"); - let f = font_entry(&mut s, FAMILY); - assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{f}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn opening_an_idml_file_loads_its_document_fonts() { - const FAMILY: &str = "DocFont IDML Test"; - let dir = temp_dir("idml"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("idml.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); - let path = dir.join("Brochure.idml"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!(r["documentFonts"], 1, "{r}"); - assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn an_indesign_file_says_how_to_bring_it_over() { - // The extension decides (any case), not the bytes: no InDesign-made file is needed. - let dir = temp_dir("indd"); - let path = dir.join("Brochure.INDD"); - std::fs::write(&path, b"not a DesignCraft document").unwrap(); - let mut s = Session::new(); - let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); - assert!(e.contains("InDesign Markup (IDML)") && !e.contains("expected value"), "{e}"); - let e = s.execute("file.openBytes", &json!({"name": "Brochure.indt", "base64": ""})).unwrap_err().to_string(); - assert!(e.contains("InDesign Markup (IDML)"), "{e}"); - assert!(s.doc().is_err(), "nothing was opened"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_document_fonts_folder_is_found_whatever_its_case() { - const FAMILY: &str = "DocFont Case Test"; - let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let fonts = dir.join("Document fonts"); - std::fs::create_dir_all(&fonts).unwrap(); - std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); - let path = dir.join("Brochure.idml"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - let r = open(&mut s, &path); - assert_eq!(r["documentFonts"], 1, "{r}"); - assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_document_without_the_folder_opens_as_before() { - let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("Plain.designcraft"); - write_document(&path, designcraft_fonts::DEFAULT_FAMILY); - let r = Session::new().execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}"); - let _ = std::fs::remove_dir_all(&dir); - } - #[test] - fn document_fonts_belong_to_their_document() { - const FAMILY: &str = "DocFont Scoped"; - let (a, b) = (temp_dir("scoped-a"), temp_dir("scoped-b")); - std::fs::write(a.join(DOCUMENT_FONTS_FOLDER).join("scoped.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - write_document(&a.join("A.designcraft"), FAMILY); - write_document(&b.join("B.designcraft"), FAMILY); - - let mut s = Session::new(); - assert_eq!(open(&mut s, &a.join("A.designcraft"))["documentFonts"], 1); - assert_eq!(open(&mut s, &b.join("B.designcraft"))["documentFonts"], 0); - // B, open at the same time, doesn't have A's font. - assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY), "{:?}", glyph_faces(&s)); - s.execute("file.activate", &json!({"index": 0})).unwrap(); - assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY), "{:?}", glyph_faces(&s)); - let _ = std::fs::remove_dir_all(&a); - let _ = std::fs::remove_dir_all(&b); - } - - #[test] - fn each_document_sets_its_own_font_of_a_name() { - const FAMILY: &str = "DocFont Same"; - let (a, b) = (temp_dir("same-a"), temp_dir("same-b")); - let (a_font, b_font) = (a.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"), b.join(DOCUMENT_FONTS_FOLDER).join("same.ttf")); - // A's font has both letters, B's only `b`. - std::fs::write(&a_font, font_with(FAMILY, &['a', 'b']).unwrap()).unwrap(); - std::fs::write(&b_font, font_with(FAMILY, &['b']).unwrap()).unwrap(); - // The same words in both: the word cache must not hand one document's shaping to the other. - write_document_with(&a.join("A.designcraft"), FAMILY, "ab ab ab"); - write_document_with(&b.join("B.designcraft"), FAMILY, "ab ab ab"); - - let mut s = Session::new(); - open(&mut s, &a.join("A.designcraft")); - open(&mut s, &b.join("B.designcraft")); - // Fallback fonts for the `a` B's font lacks: never A's font. - s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); - let in_b = glyph_faces(&s); - s.execute("file.activate", &json!({"index": 0})).unwrap(); - let in_a = glyph_faces(&s); - assert_eq!((in_a.len(), in_b.len()), (8, 8), "{in_a:?} {in_b:?}"); - for (c, f) in &in_a { - assert_eq!(f.source, FontSource::Document(a_font.clone()), "`{c}` in A"); - } - for (c, f) in &in_b { - match c { - 'a' => assert!(f.family != FAMILY, "B's font lacks `a`, and A's isn't B's: {f:?}"), - _ => assert_eq!(f.source, FontSource::Document(b_font.clone()), "`{c}` in B"), - } - } - let _ = std::fs::remove_dir_all(&a); - let _ = std::fs::remove_dir_all(&b); - } - - #[test] - fn closing_a_document_forgets_its_fonts_and_reopening_reuses_them() { - const FAMILY: &str = "DocFont Closed"; - let dir = temp_dir("closed"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("closed.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - let path = dir.join("Closed.designcraft"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - open(&mut s, &path); - let first = glyph_faces(&s)[0].1; - assert_eq!(first.family, FAMILY); - s.execute("file.close", &json!({})).unwrap(); - // A new document can't use the closed document's font. - new_document(&mut s, FAMILY, "Hello"); - assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY)); - // Reopened, the document has its font again: the face loaded the first time. - assert_eq!(open(&mut s, &path)["documentFonts"], 1); - assert!(glyph_faces(&s).iter().all(|(_, f)| *f == first), "{:?}", glyph_faces(&s)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_reverted_document_keeps_its_fonts() { - const FAMILY: &str = "DocFont Reverted"; - let dir = temp_dir("revert"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("revert.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - let path = dir.join("Revert.designcraft"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - open(&mut s, &path); - s.execute("frame.create", &json!({"rect": [72, 400, 200, 500], "content": "text", "text": "x"})).unwrap(); - s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn bold_and_italic_link_the_document_fonts_styles() { - const FAMILY: &str = "DocFont Linked"; - let dir = temp_dir("linked"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - let path = dir.join("Linked.designcraft"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - open(&mut s, &path); - let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - // The family is the document's alone: its Regular is the style without bold or italic. - let r = s.execute("type.bold", &json!({"on": false})).unwrap(); - assert_eq!(r["fontStyle"], "Regular", "{r}"); - let r = s.execute("type.italic", &json!({"on": false})).unwrap(); - assert_eq!(r["fontStyle"], "Regular", "{r}"); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod extension_tests { - use serde_json::json; - - use super::{is_document, with_document_extension}; - use crate::Session; - - #[test] - fn the_document_extension_is_added_when_missing_or_different() { - assert_eq!(with_document_extension("/a/Brochure"), "/a/Brochure.designcraft"); - assert_eq!(with_document_extension("/a/Brochure.idml"), "/a/Brochure.idml.designcraft"); - assert_eq!(with_document_extension("/a/v1.2 Brochure"), "/a/v1.2 Brochure.designcraft"); - assert_eq!(with_document_extension("/a/Brochure.DesignCraft"), "/a/Brochure.DesignCraft"); - assert_eq!(with_document_extension("/a/.."), "/a/.."); - } - - #[test] - fn save_adds_the_extension_and_open_recognises_files_by_content() { - let dir = std::env::temp_dir().join(format!("dc-file-extension-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - - let bare = dir.join("Brochure"); - let written = dir.join("Brochure.designcraft"); - let r = s.execute("file.saveAs", &json!({"path": bare.to_string_lossy()})).unwrap(); - assert_eq!(r["path"], json!(written.to_string_lossy()), "{r}"); - assert!(written.is_file() && !bare.exists()); - assert_eq!(s.doc().unwrap().path.as_deref(), written.to_str()); - let r = s.execute("file.saveACopy", &json!({"path": dir.join("Copy").to_string_lossy()})).unwrap(); - assert_eq!(r["path"], json!(dir.join("Copy.designcraft").to_string_lossy()), "{r}"); - - // A native file without the extension opens, and Save writes back to that same file. - std::fs::rename(&written, &bare).unwrap(); - let mut t = Session::new(); - t.execute("file.open", &json!({"path": bare.to_string_lossy()})).unwrap(); - let r = t.execute("file.save", &json!({})).unwrap(); - assert_eq!(r["path"], json!(bare.to_string_lossy()), "{r}"); - assert!(!written.exists()); - - // An IDML package without the extension opens as IDML, unsaved. - let idml = dir.join("Package"); - std::fs::write(&idml, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - assert!(is_document(&std::fs::read(&idml).unwrap()) && is_document(&std::fs::read(&bare).unwrap())); - // The start of the file is enough. - for path in [&idml, &bare] { - let bytes = std::fs::read(path).unwrap(); - assert!(is_document(&bytes[..bytes.len().min(128)]), "{path:?}"); - } - t.execute("file.open", &json!({"path": idml.to_string_lossy()})).unwrap(); - assert_eq!(t.doc().unwrap().path, None); - - let notes = dir.join("Notes"); - std::fs::write(¬es, b"plain text").unwrap(); - assert!(!is_document(b"plain text")); - let e = t.execute("file.open", &json!({"path": notes.to_string_lossy()})).unwrap_err(); - assert!(e.to_string().contains("not a DesignCraft or IDML file"), "{e}"); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(test)] -mod recent_tests { - use serde_json::json; - - use crate::Session; - - fn temp_dir(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("dc-recent-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn saved(s: &mut Session, path: &std::path::Path) -> String { - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); - path.to_string_lossy().to_string() - } - - /// Opening and saving put a file at the front once; Save a Copy doesn't count. - #[test] - fn open_and_save_order_the_list_without_duplicates() { - let dir = temp_dir("order"); - let mut s = Session::new(); - let a = saved(&mut s, &dir.join("a.designcraft")); - let b = saved(&mut s, &dir.join("b.designcraft")); - assert_eq!(s.prefs.recent_files, [b.clone(), a.clone()]); - s.execute("file.open", &json!({"path": a})).unwrap(); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "reopening moves it to the front, once"); - s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})).unwrap(); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a copy isn't a recent file"); - let rows = s.execute("file.recentFiles", &json!({})).unwrap(); - assert_eq!(rows[0], json!({"path": a, "name": "a.designcraft", "exists": true})); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn open_recent_reopens_and_a_missing_file_is_an_error_that_keeps_it_listed() { - let dir = temp_dir("open"); - let mut s = Session::new(); - let a = saved(&mut s, &dir.join("a.designcraft")); - let b = saved(&mut s, &dir.join("b.designcraft")); - let r = s.execute("file.openRecent", &json!({"index": 1})).unwrap(); - assert_eq!(s.documents()[r["index"].as_u64().unwrap() as usize].path.as_deref(), Some(a.as_str())); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()]); - std::fs::remove_file(&b).unwrap(); - let e = s.execute("file.openRecent", &json!({"index": 1})).unwrap_err(); - assert!(e.to_string().contains("can't be found"), "{e}"); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a disk that isn't connected comes back"); - assert_eq!(s.execute("file.recentFiles", &json!({})).unwrap()[1]["exists"], false); - // Hostile indices are errors, never panics. - for index in [json!(2), json!(u64::MAX), json!(-1), json!("0"), json!(null)] { - assert!(s.execute("file.openRecent", &json!({"index": index})).is_err(), "{index}"); - } - s.execute("file.clearRecent", &json!({})).unwrap(); - assert!(s.prefs.recent_files.is_empty()); - assert!(s.execute("file.openRecent", &json!({"index": 0})).is_err(), "disabled when empty"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Saving puts a small picture of the first page in the file, which the start screen reads - /// without opening the document. - #[test] - fn saved_files_carry_a_first_page_preview() { - let dir = temp_dir("preview"); - let mut s = Session::new(); - let a = saved(&mut s, &dir.join("a.designcraft")); - let p = super::recent_preview(&a).expect("a preview"); - assert_eq!(p.width.max(p.height), super::PREVIEW_PX as u32); - assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize); - assert!(p.rgba.as_chunks::<4>().0.iter().any(|px| px[3] == 255), "the page is drawn, not left transparent"); - let copy = dir.join("copy.designcraft"); - s.execute("file.saveACopy", &json!({"path": copy.to_string_lossy()})).unwrap(); - assert!(super::recent_preview(©.to_string_lossy()).is_some(), "Save a Copy has one too"); - // Files without one (saved before previews), missing files and other files have none. - let old = dir.join("old.designcraft"); - std::fs::write(&old, super::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap(); - for path in [old, dir.join("gone.designcraft"), dir.clone()] { - assert!(super::recent_preview(&path.to_string_lossy()).is_none(), "{path:?}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_list_keeps_at_most_the_preferred_count() { - let mut s = Session::new(); - for i in 0..30 { - s.prefs.note_recent_file(&format!("/work/{i}.designcraft")); - } - assert_eq!(s.prefs.recent_files.len(), 20, "default: 20"); - assert_eq!(s.prefs.recent_files[0], "/work/29.designcraft"); - s.execute("prefs.set", &json!({"recentFilesMax": 3})).unwrap(); - assert_eq!(s.prefs.recent_files, ["/work/29.designcraft", "/work/28.designcraft", "/work/27.designcraft"]); - assert_eq!(s.execute("prefs.set", &json!({"recentFilesMax": 4_000_000_000u64})).unwrap()["recentFilesMax"], crate::MAX_RECENT_FILES); - assert!(s.execute("prefs.set", &json!({"recentFilesMax": -1})).is_err()); - s.execute("prefs.set", &json!({"recentFilesMax": 0})).unwrap(); - s.prefs.note_recent_file("/work/new.designcraft"); - assert!(s.prefs.recent_files.is_empty(), "0 keeps no list"); - } - - /// The list is a preference: it survives the round trip through prefs.json. - #[test] - fn the_list_survives_a_restart() { - let mut s = Session::new(); - s.prefs.note_recent_file("/work/a.designcraft"); - let saved = serde_json::to_vec(&s.prefs).unwrap(); - let back: crate::Prefs = serde_json::from_slice(&saved).unwrap(); - assert_eq!(back.recent_files, ["/work/a.designcraft"]); - assert_eq!(back.recent_files_max, 20); - let old: crate::Prefs = serde_json::from_slice(br#"{"recoveryMinutes": 2.0}"#).unwrap(); - assert!(old.recent_files.is_empty(), "older prefs.json without the list still load"); - } -} - -#[cfg(test)] -mod hyphenation_default_tests { - use std::sync::Arc; - - use designcraft_doc::BASIC_PARAGRAPH; - use designcraft_doc::build::NewDocument; - use serde_json::json; - - use crate::Session; - - fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) { - let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default()); - (pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column) - } - - #[test] - fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false)); - - let mut d = designcraft_doc::Document::new(&NewDocument::default()); - let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap(); - st.para.hyph_capitalized = Some(true); - st.para.hyph_last_word = Some(true); - st.para.hyph_across_column = Some(true); - let b64 = super::base64_encode(&designcraft_idml::export_idml(&d)); - s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap(); - assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true)); - } -} diff --git a/crates/engine/src/cmd/file/bytes.rs b/crates/engine/src/cmd/file/bytes.rs new file mode 100644 index 000000000..d21105206 --- /dev/null +++ b/crates/engine/src/cmd/file/bytes.rs @@ -0,0 +1,163 @@ +use super::*; + +/// The document as `.designcraft` bytes, or an error when it can't be written; never an empty file. +pub fn to_bytes(d: &Document) -> Result> { + save_bytes(d, None) +} + +/// [`to_bytes`] with `preview` (PNG bytes) stored as the file's first-page picture. +fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result> { + #[cfg(test)] + if FAIL_TO_BYTES.with(std::cell::Cell::get) { + return Err(EngineError::Other("could not write the document: injected failure".into())); + } + designcraft_format::save_with_preview(d, preview).map_err(|e| EngineError::Other(format!("could not write the document: {e}"))) +} + +/// `path` ending in `.designcraft`: the extension is added when it is missing or another one, so +/// the file opens from the file manager. A path without a file name is left for the write to fail. +pub fn with_document_extension(path: &str) -> String { + let p = std::path::Path::new(path); + let ext = p.extension().and_then(std::ffi::OsStr::to_str); + if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) { + return path.to_string(); + } + format!("{path}.{}", designcraft_format::EXTENSION) +} + +/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package. +/// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their +/// `mimetype` entry, as both formats write them. +pub fn is_document(bytes: &[u8]) -> bool { + designcraft_idml::is_idml(bytes) || designcraft_format::starts_designcraft(bytes) || designcraft_format::is_designcraft(bytes) +} + +/// How much of a file [`is_document`] needs to recognise a package by its `mimetype` entry. +pub const DOCUMENT_SNIFF_BYTES: u64 = 64 << 10; + +/// The longer side of the first-page picture saved in the file, in pixels. +pub(super) const PREVIEW_PX: f64 = 320.0; + +/// [`to_bytes`] with a picture of the first page, which the start screen shows next to the file +/// in its recent files. A page that can't be drawn just leaves the picture out. +pub(super) fn to_bytes_with_preview(d: &Document, cache: &crate::Cache) -> Result> { + let preview = d.page_loc(0).and_then(|(si, pi)| { + let b = d.spreads.get(si)?.pages.get(pi)?.bounds(); + let side = b.width().max(b.height()); + (side.is_finite() && side > 0.0).then_some(PREVIEW_PX / side) + }); + let png = + preview.and_then(|scale| designcraft_render::Renderer::new().render_page(d, cache, 0, scale, false, &Default::default())).map(|r| r.to_png()); + save_bytes(d, png.as_deref()) +} + +pub fn from_bytes(b: &[u8]) -> Result { + load_bytes(b).map(|(d, _)| d) +} + +/// Read `.designcraft` bytes and repair what a hand-edited file can get wrong without failing the +/// open, with a warning for each repair. +pub(super) fn load_bytes(b: &[u8]) -> Result<(Document, Vec)> { + let mut d = designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))?; + let warnings = d.fix_parent_bases(); + Ok((d, warnings)) +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + out.push(B64[(n >> 18) as usize & 63] as char); + out.push(B64[(n >> 12) as usize & 63] as char); + out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' }); + } + out +} + +pub fn base64_decode(s: &str) -> Vec { + let mut out = Vec::with_capacity(s.len() * 3 / 4); + let mut buf = 0u32; + let mut bits = 0; + for b in s.bytes() { + let v = match b { + b'A'..=b'Z' => b - b'A', + b'a'..=b'z' => b - b'a' + 26, + b'0'..=b'9' => b - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + _ => continue, + } as u32; + buf = buf << 6 | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + } + } + out +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod extension_tests { + use serde_json::json; + + use super::{is_document, with_document_extension}; + use crate::Session; + + #[test] + fn the_document_extension_is_added_when_missing_or_different() { + assert_eq!(with_document_extension("/a/Brochure"), "/a/Brochure.designcraft"); + assert_eq!(with_document_extension("/a/Brochure.idml"), "/a/Brochure.idml.designcraft"); + assert_eq!(with_document_extension("/a/v1.2 Brochure"), "/a/v1.2 Brochure.designcraft"); + assert_eq!(with_document_extension("/a/Brochure.DesignCraft"), "/a/Brochure.DesignCraft"); + assert_eq!(with_document_extension("/a/.."), "/a/.."); + } + + #[test] + fn save_adds_the_extension_and_open_recognises_files_by_content() { + let dir = std::env::temp_dir().join(format!("dc-file-extension-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + + let bare = dir.join("Brochure"); + let written = dir.join("Brochure.designcraft"); + let r = s.execute("file.saveAs", &json!({"path": bare.to_string_lossy()})).unwrap(); + assert_eq!(r["path"], json!(written.to_string_lossy()), "{r}"); + assert!(written.is_file() && !bare.exists()); + assert_eq!(s.doc().unwrap().path.as_deref(), written.to_str()); + let r = s.execute("file.saveACopy", &json!({"path": dir.join("Copy").to_string_lossy()})).unwrap(); + assert_eq!(r["path"], json!(dir.join("Copy.designcraft").to_string_lossy()), "{r}"); + + // A native file without the extension opens, and Save writes back to that same file. + std::fs::rename(&written, &bare).unwrap(); + let mut t = Session::new(); + t.execute("file.open", &json!({"path": bare.to_string_lossy()})).unwrap(); + let r = t.execute("file.save", &json!({})).unwrap(); + assert_eq!(r["path"], json!(bare.to_string_lossy()), "{r}"); + assert!(!written.exists()); + + // An IDML package without the extension opens as IDML, unsaved. + let idml = dir.join("Package"); + std::fs::write(&idml, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + assert!(is_document(&std::fs::read(&idml).unwrap()) && is_document(&std::fs::read(&bare).unwrap())); + // The start of the file is enough. + for path in [&idml, &bare] { + let bytes = std::fs::read(path).unwrap(); + assert!(is_document(&bytes[..bytes.len().min(128)]), "{path:?}"); + } + t.execute("file.open", &json!({"path": idml.to_string_lossy()})).unwrap(); + assert_eq!(t.doc().unwrap().path, None); + + let notes = dir.join("Notes"); + std::fs::write(¬es, b"plain text").unwrap(); + assert!(!is_document(b"plain text")); + let e = t.execute("file.open", &json!({"path": notes.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("not a DesignCraft or IDML file"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/file/new.rs b/crates/engine/src/cmd/file/new.rs new file mode 100644 index 000000000..5847442d3 --- /dev/null +++ b/crates/engine/src/cmd/file/new.rs @@ -0,0 +1,338 @@ +#[cfg(test)] +use super::bytes::base64_encode; +use super::*; + +/// The longest saved preset name, and how many presets are kept. +const MAX_PRESET_NAME: usize = 100; + +const MAX_PRESETS: usize = 500; + +/// A built-in document preset or a saved one, by name (case ignored). +fn find_preset(s: &Session, name: &str) -> Option { + NewDocument::from_preset(name).or_else(|| s.prefs.document_presets.iter().find(|p| p.title.eq_ignore_ascii_case(name.trim())).cloned()) +} + +/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. +fn new_document_settings(s: &Session, p: &Value) -> Result { + let mut nd = match str_param(p, "preset") { + Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, + None => NewDocument::default(), + }; + apply_settings(&mut nd, p); + check_size("file.new", &nd)?; + Ok(nd) +} + +/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a +/// paragraph's last word or across a column. Opened and imported files keep their own values, +/// which is why `ParaProps` keeps the IDML defaults (on). +fn new_document(nd: &NewDocument) -> Document { + let mut d = Document::new(nd); + if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) { + st.para.hyph_capitalized = Some(false); + st.para.hyph_last_word = Some(false); + st.para.hyph_across_column = Some(false); + } + d +} + +pub(super) fn file_new(s: &mut Session, p: &Value) -> Result { + let mut nd = new_document_settings(s, p)?; + s.untitled += 1; + nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); + let d = new_document(&nd); + let i = s.add_document(DocState::new(d, None)); + Ok(json!({"index": i})) +} + +impl Session { + /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while + /// the New Document window is open. It replaces the previous preview document in its tab and + /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses + /// leave the previous preview as it was. Returns the preview document's uid. + pub fn preview_new_document(&mut self, p: &Value) -> Result { + let mut nd = new_document_settings(self, p)?; + let previous = match self.new_document_preview { + Some((_, previous)) => previous, + None => self.active().map(|d| d.uid), + }; + let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old)); + // A preview edited or saved by a menu or script while the window is open has become a + // document of its own: it stays, and the preview gets a tab of its own. + let replace = shown.filter(|&i| self.docs.get(i).is_some_and(|d| !d.is_dirty() && d.path.is_none())); + if replace.is_none() && shown.is_some() { + self.untitled += 1; + } + nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); + let st = DocState::new(new_document(&nd), None); + let uid = st.uid; + match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { + Some((i, d)) => { + *d = st; + self.active = Some(i); + } + None => { + // Pushed without `add_document`: the document that was active stays as it is. + self.docs.push(st); + self.active = Some(self.docs.len() - 1); + } + } + self.new_document_preview = Some((uid, previous)); + Ok(uid) + } + + /// The uid of New Document's preview document, while one is shown. + pub fn new_document_preview(&self) -> Option { + self.new_document_preview.map(|(uid, _)| uid) + } + + /// End New Document's preview. Its document goes, and if it was still the active document, + /// the one that was active before it is active again. A preview that a menu or script has + /// edited or saved stays open as a document, with an Untitled number. Returns the uid of the + /// document removed. Without a preview it does nothing. + pub fn end_new_document_preview(&mut self) -> Option { + let (uid, previous) = self.new_document_preview.take()?; + let i = self.docs.iter().position(|d| d.uid == uid)?; + let d = self.docs.get(i)?; + if d.is_dirty() || d.path.is_some() { + self.untitled += 1; + return None; + } + self.docs.remove(i); + self.active = match self.active { + Some(a) if a == i => previous.and_then(|p| self.docs.iter().position(|d| d.uid == p)).or_else(|| self.docs.len().checked_sub(1)), + Some(a) if a > i => Some(a - 1), + a => a, + }; + Some(uid) + } + + /// Create in the New Document window, with the settings the preview shows: the preview + /// document becomes the new document, as `file.new` would make it (the next Untitled number, + /// no undo history). Returns its index, or `None` when there is no unedited preview to keep. + pub fn keep_new_document_preview(&mut self) -> Option { + let (uid, _) = self.new_document_preview?; + let i = self.docs.iter().position(|d| d.uid == uid && !d.is_dirty() && d.path.is_none())?; + self.new_document_preview = None; + self.untitled += 1; + if let Some(d) = self.docs.get_mut(i) { + d.history.undo.clear(); + d.history.redo.clear(); + } + self.active = Some(i); + Some(i) + } +} + +pub(super) fn save_preset(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("file.savePreset", "missing `name`"))?.to_string(); + if name.chars().count() > MAX_PRESET_NAME { + return Err(bad("file.savePreset", format!("a preset name has at most {MAX_PRESET_NAME} characters"))); + } + if NewDocument::from_preset(&name).is_some() { + return Err(bad("file.savePreset", format!("`{name}` is a built-in preset"))); + } + let mut nd = NewDocument::default(); + apply_settings(&mut nd, p); + check_size("file.savePreset", &nd)?; + nd.title = name.clone(); + let presets = &mut s.prefs.document_presets; + presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); + if presets.len() >= MAX_PRESETS { + return Err(bad("file.savePreset", format!("at most {MAX_PRESETS} saved presets"))); + } + presets.push(nd); + Ok(json!({"name": name})) +} + +fn check_size(cmd: &str, nd: &NewDocument) -> Result<()> { + if !(nd.width > 0.0 && nd.height > 0.0 && nd.width <= 15552.0 && nd.height <= 15552.0) { + return Err(bad(cmd, "page size out of range (0 < size ≤ 216 in)")); + } + Ok(()) +} + +/// The document settings in `p` (as file.new takes them) over `nd`. +fn apply_settings(nd: &mut NewDocument, p: &Value) { + if let Some(intent) = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()) { + nd.intent = intent; + } + if let Some(units) = p.get("units").and_then(|v| serde_json::from_value(v.clone()).ok()) { + nd.units = units; + } + nd.width = f64_or(p, "width", nd.width); + nd.height = f64_or(p, "height", nd.height); + nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999); + nd.start_page = p.get("startPage").and_then(Value::as_u64).map(|v| v.clamp(1, 99_999) as u32).unwrap_or(nd.start_page); + nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages); + nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns); + nd.gutter = f64_or(p, "gutter", nd.gutter); + nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(nd.primary_text_frame); + match p.get("margins") { + Some(Value::Number(n)) => nd.margins = designcraft_doc::Margins::uniform(n.as_f64().unwrap_or(36.0)), + Some(v @ Value::Object(_)) => { + if let Ok(m) = serde_json::from_value(v.clone()) { + nd.margins = m; + } + } + _ => {} + } + if let Some(b) = edges(p, "bleed") { + nd.bleed = b; + } + if let Some(b) = edges(p, "slug") { + nd.slug = b; + } +} + +/// A bleed or slug parameter: one width for every edge, or [top, bottom, inside, outside]. +fn edges(p: &Value, key: &str) -> Option<[f64; 4]> { + let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1296.0) } else { 0.0 }; + match p.get(key)? { + Value::Array(a) => match a.iter().map(Value::as_f64).collect::>>()?.as_slice() { + [t, b, i, o] => Some([clean(*t), clean(*b), clean(*i), clean(*o)]), + _ => None, + }, + v => v.as_f64().map(|b| [clean(b); 4]), + } +} + +pub(super) fn file_sample(s: &mut Session, _p: &Value) -> Result { + let d = crate::sample::magazine(); + let i = s.add_document(DocState::new(d, None)); + Ok(json!({"index": i})) +} + +#[cfg(test)] +mod new_document_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn saved_presets_make_documents_by_name() { + let mut s = Session::new(); + let newsletter = json!({"name": "Newsletter", "units": "millimeters", "width": 595.2755905511812, "height": 841.8897637795277, + "pages": 8, "facingPages": false, "columns": 3, "margins": {"top": 20, "bottom": 30, "inside": 40, "outside": 50}, + "bleed": [9, 9, 0, 9], "primaryTextFrame": true}); + assert_eq!(s.execute("file.savePreset", &newsletter).unwrap()["name"], "Newsletter"); + let listed = s.execute("file.presets", &json!({})).unwrap(); + let saved: Vec<_> = listed.as_array().unwrap().iter().filter(|p| p["saved"] == true).collect(); + assert_eq!(saved.len(), 1); + assert_eq!((&saved[0]["name"], &saved[0]["units"]), (&json!("Newsletter"), &json!("millimeters"))); + // By name, case ignored; parameters still override the preset. + s.execute("file.new", &json!({"preset": "newsletter", "pages": 4})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), 4); + assert!(!d.settings.facing_pages && d.settings.primary_text_frame); + assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Millimeters); + assert_eq!(d.settings.bleed, [9.0, 9.0, 0.0, 9.0]); + assert_eq!(d.page(0).unwrap().margins.outside, 50.0); + assert!(d.title.starts_with("Untitled-"), "the preset's name isn't the document's"); + // The same name replaces it. + s.execute("file.savePreset", &json!({"name": "NEWSLETTER", "pages": 2})).unwrap(); + assert_eq!(s.prefs.document_presets.len(), 1); + s.execute("file.new", &json!({"preset": "Newsletter"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_count(), 2); + // Built-in names, empty and overlong names and impossible sizes are refused. + for bad in [json!({"name": "a4"}), json!({"name": " "}), json!({}), json!({"name": "x".repeat(101)}), json!({"name": "Huge", "width": 1e9})] + { + assert!(s.execute("file.savePreset", &bad).is_err(), "{bad}"); + } + s.execute("file.deletePreset", &json!({"name": "newsletter"})).unwrap(); + assert!(s.execute("file.deletePreset", &json!({"name": "newsletter"})).is_err()); + assert!(s.execute("file.new", &json!({"preset": "Newsletter"})).is_err()); + } + + #[test] + fn new_document_takes_intent_units_start_page_bleed_and_slug() { + let mut s = Session::new(); + let p = json!({"preset": "A4", "intent": "web", "units": "pixels", "startPage": 4, "bleed": [9, 0, 3, 6], "slug": 18}); + s.execute("file.new", &p).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.intent, designcraft_doc::Intent::Web); + assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Pixels); + assert_eq!(d.settings.bleed, [9.0, 0.0, 3.0, 6.0]); + assert_eq!(d.settings.slug, [18.0; 4]); + assert_eq!(d.page_name(0), "4"); + // Malformed edges are ignored, hostile ones bounded. + s.execute("file.new", &json!({"bleed": [1, 2], "slug": [-5, 1e12, 0, 0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.bleed, [0.0; 4]); + assert_eq!(d.settings.slug, [0.0, 1296.0, 0.0, 0.0]); + } + + #[test] + fn a_new_document_preview_replaces_itself_and_leaves_the_session_as_it_was() { + let dir = std::env::temp_dir().join(format!("dc-new-doc-preview-{}", std::process::id())); + let mut s = Session::new(); + s.recovery_dir = Some(dir.clone()); + s.execute("file.new", &json!({})).unwrap(); + let first = s.doc().unwrap().uid; + let a = s.preview_new_document(&json!({"width": 300})).unwrap(); + assert_eq!((s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (a, "Untitled-2")); + let b = s.preview_new_document(&json!({"width": 400, "columns": 3})).unwrap(); + assert_eq!(s.documents().len(), 2, "the preview replaces itself"); + assert!(s.preview_new_document(&json!({"width": 0})).is_err()); + let d = s.doc().unwrap(); + assert_eq!((d.uid, d.doc.settings.page_width, d.doc.page(0).unwrap().columns.count), (b, 400.0, 3), "a refused size keeps it"); + let listed = s.execute("document.list", &json!({})).unwrap(); + assert_eq!((listed[0].get("preview"), &listed[1]["preview"]), (None, &json!(true))); + assert_eq!(s.end_new_document_preview(), Some(b)); + let d = s.doc().unwrap(); + assert_eq!((s.documents().len(), d.uid, d.is_dirty(), d.history.undo.len()), (1, first, false, 0)); + assert_eq!(s.end_new_document_preview(), None, "without a preview it does nothing"); + // Edited by a script while shown, it writes no recovery data while it is a preview, and + // stays as a document when the window closes, with its Untitled number. + let c = s.preview_new_document(&json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(crate::recovery::save(&s, &dir).unwrap(), 0); + // The app's timer saves through the command. + assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"], 0); + assert!(crate::recovery::list(&dir).is_empty()); + assert_eq!(s.end_new_document_preview(), None); + assert_eq!((s.documents().len(), s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (2, c, "Untitled-2")); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.title, "Untitled-3"); + // Create with the previewed settings keeps the preview as the document. + let d = s.preview_new_document(&json!({"pages": 2})).unwrap(); + assert_eq!(s.keep_new_document_preview(), Some(3)); + let st = s.doc().unwrap(); + assert_eq!((st.uid, st.doc.title.as_str(), s.new_document_preview()), (d, "Untitled-4", None)); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.title, "Untitled-5"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(test)] +mod hyphenation_default_tests { + use std::sync::Arc; + + use designcraft_doc::BASIC_PARAGRAPH; + use designcraft_doc::build::NewDocument; + use serde_json::json; + + use crate::Session; + + fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) { + let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default()); + (pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column) + } + + #[test] + fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false)); + + let mut d = designcraft_doc::Document::new(&NewDocument::default()); + let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap(); + st.para.hyph_capitalized = Some(true); + st.para.hyph_last_word = Some(true); + st.para.hyph_across_column = Some(true); + let b64 = super::base64_encode(&designcraft_idml::export_idml(&d)); + s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap(); + assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true)); + } +} diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs new file mode 100644 index 000000000..d593267a5 --- /dev/null +++ b/crates/engine/src/cmd/file/open.rs @@ -0,0 +1,528 @@ +#[cfg(test)] +use super::bytes::{PREVIEW_PX, to_bytes}; +use super::bytes::{base64_decode, load_bytes}; +use super::*; + +pub(super) fn has_recent(s: &Session) -> std::result::Result<(), String> { + if s.prefs.recent_files.is_empty() { Err("no recent files".into()) } else { Ok(()) } +} + +/// A recent file's first-page picture as straight RGBA, for the start screen. +pub struct RecentPreview { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// The picture a `.designcraft` file saved of its first page; None when the file is gone, isn't +/// ours, or was saved before files had one (it gets one when saved again). +pub fn recent_preview(path: &str) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + // Only the zip directory and the preview entry are read, not the whole document. + let png = designcraft_format::read_preview(std::io::BufReader::new(crate::io::open(path).ok()?))?; + let img = designcraft_images::decode_rgba(&png)?; + let (width, height) = img.dimensions(); + Some(RecentPreview { width, height, rgba: img.into_raw() }) + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + None + } +} + +/// One File › Open Recent row: the path, its file name and whether it is still there. +pub(super) fn recent_entry(path: &str) -> Value { + let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()); + #[cfg(not(target_arch = "wasm32"))] + let exists = crate::io::is_file(path); + #[cfg(target_arch = "wasm32")] + let exists = false; + json!({"path": path, "name": name, "exists": exists}) +} + +pub(super) fn file_open_recent(s: &mut Session, p: &Value) -> Result { + let index = + p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("file.openRecent", "missing `index`"))?; + let path = s + .prefs + .recent_files + .get(index) + .cloned() + .ok_or_else(|| bad("file.openRecent", format!("no recent file {index} (the list has {})", s.prefs.recent_files.len())))?; + #[cfg(not(target_arch = "wasm32"))] + if !crate::io::is_file(&path) { + return Err(EngineError::Other(format!("{path} can't be found: it was moved, renamed or deleted, or its disk isn't connected"))); + } + file_open(s, &json!({"path": path})) +} + +/// `file.open`: the document opens and moves to the front of File › Open Recent. +pub(super) fn file_open(s: &mut Session, p: &Value) -> Result { + let r = open_path(s, p)?; + if let Some(path) = str_param(p, "path") { + s.prefs.note_recent_file(path); + } + Ok(r) +} + +fn open_path(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; + #[cfg(not(target_arch = "wasm32"))] + { + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; + // The content decides first: a file's extension may be missing or wrong. The extension + // then routes files whose content isn't recognised to the reader that explains why. + let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); + let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{"); + if designcraft_idml::is_idml(&bytes) || (!native && (crate::cmd::interchange::is_indd(path, &bytes) || ext == "idml")) { + return crate::cmd::interchange::open_idml(s, p); + } + if !native && ext != designcraft_format::EXTENSION { + return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file"))); + } + let (mut d, mut warnings) = load_bytes(&bytes)?; + crate::cmd::interchange::resolve_packaged_links(&mut d, Some(std::path::Path::new(path))); + crate::cmd::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); + warnings.extend(crate::cmd::style::grep_style_warnings(&d)); + let mut st = DocState::new(d, Some(path.to_string())); + st.fonts = fonts; + let i = s.add_document(st); + Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (s, path); + Err(EngineError::Other("use file.openBytes on the web".into())) + } +} + +/// Load the fonts in the `Document Fonts` folder beside the document file at `path` for `d` alone +/// (`d.font_scope`) → (the scope, for the document's state to hold; faces; a warning for each +/// font file skipped). Nothing on the web (no folders). +pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option>, usize, Vec) { + #[cfg(not(target_arch = "wasm32"))] + { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) }; + // A document's folder is the agent's to name (a recovery entry or a book lists paths too): + // the fonts crate asks the automation roots about the folder and each font file, so fonts + // outside the read root stay unloaded. + let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|p| crate::file_access::check_read(p)); + d.font_scope = r.scope; + let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope))); + (scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect()) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (d, path); + (None, 0, Vec::new()) + } +} + +/// The `Document Fonts` folder in `dir`, matched without regard to case: packages name it +/// `Document fonts`, and file systems on Linux are case-sensitive. The exact name wins. +#[cfg(not(target_arch = "wasm32"))] +fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + let exact = dir.join(DOCUMENT_FONTS_FOLDER); + if crate::io::is_dir(&exact) { + return exact; + } + crate::io::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path())) + .map_or(exact, |e| e.path()) +} + +pub(super) fn file_open_bytes(s: &mut Session, p: &Value) -> Result { + let b = base64_decode(str_param(p, "base64").unwrap_or("")); + // IDML packages are recognised by their stored `mimetype` first entry, INDD files by their + // signature (or their name). + if designcraft_idml::is_idml(&b) || crate::cmd::interchange::is_indd(str_param(p, "name").unwrap_or_default(), &b) { + return crate::cmd::interchange::open_idml(s, p); + } + let (mut d, warnings) = load_bytes(&b)?; + crate::cmd::datamerge::resolve_sources_on_open(&mut d, None); + if let Some(n) = str_param(p, "name") { + d.title = n.to_string(); + } + let i = s.add_document(DocState::new(d, None)); + Ok(json!({"index": i, "warnings": warnings})) +} + +#[cfg(test)] +mod document_fonts_tests { + use std::path::{Path, PathBuf}; + + use designcraft_fonts::testing::font_with; + use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FaceRef, FontDb, FontSource}; + use serde_json::{Value, json}; + + use crate::Session; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(DOCUMENT_FONTS_FOLDER)).unwrap(); + dir + } + + /// A new document in `s` with a text frame holding `text` set in `family`. + fn new_document(s: &mut Session, family: &str, text: &str) { + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); + s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); + } + + /// A document whose text ("Hello") is set in `family`, written to `path` (`.idml`: as IDML). + fn write_document(path: &Path, family: &str) { + write_document_with(path, family, "Hello"); + } + + fn write_document_with(path: &Path, family: &str, text: &str) { + let mut s = Session::new(); + new_document(&mut s, family, text); + if path.extension().is_some_and(|e| e == "idml") { + std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + } else { + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + } + } + + fn font_entry(s: &mut Session, family: &str) -> Value { + let l = s.execute("font.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}")) + } + + /// Each character of the active document's text with the face it is drawn in. + fn glyph_faces(s: &Session) -> Vec<(char, FaceRef)> { + let st = s.doc().unwrap(); + let mut out = Vec::new(); + for (sid, story) in &st.doc.stories { + let cs = s.cache.get(&st.doc, *sid, None); + for g in cs.frames.iter().flat_map(|ft| &ft.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0) { + out.push((story.text[g.byte..].chars().next().unwrap(), g.face)); + } + } + out + } + + fn open(s: &mut Session, path: &Path) -> Value { + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap() + } + + #[test] + fn opening_a_document_loads_its_document_fonts() { + const FAMILY: &str = "DocFont Open Test"; + let dir = temp_dir("designcraft"); + let fonts = dir.join(DOCUMENT_FONTS_FOLDER); + std::fs::write(fonts.join("open.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + std::fs::write(fonts.join("broken.otf"), b"not a font").unwrap(); + let path = dir.join("Brochure.designcraft"); + write_document(&path, FAMILY); + assert!(!FontDb::global().has_family(FAMILY)); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert!(r["warnings"].to_string().contains("broken.otf"), "a damaged font is reported, not fatal: {r}"); + let f = font_entry(&mut s, FAMILY); + assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{f}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn opening_an_idml_file_loads_its_document_fonts() { + const FAMILY: &str = "DocFont IDML Test"; + let dir = temp_dir("idml"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("idml.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); + let path = dir.join("Brochure.idml"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_indesign_file_says_how_to_bring_it_over() { + // The extension decides (any case), not the bytes: no InDesign-made file is needed. + let dir = temp_dir("indd"); + let path = dir.join("Brochure.INDD"); + std::fs::write(&path, b"not a DesignCraft document").unwrap(); + let mut s = Session::new(); + let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("InDesign Markup (IDML)") && !e.contains("expected value"), "{e}"); + let e = s.execute("file.openBytes", &json!({"name": "Brochure.indt", "base64": ""})).unwrap_err().to_string(); + assert!(e.contains("InDesign Markup (IDML)"), "{e}"); + assert!(s.doc().is_err(), "nothing was opened"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_document_fonts_folder_is_found_whatever_its_case() { + const FAMILY: &str = "DocFont Case Test"; + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts = dir.join("Document fonts"); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); + let path = dir.join("Brochure.idml"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + let r = open(&mut s, &path); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_document_without_the_folder_opens_as_before() { + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Plain.designcraft"); + write_document(&path, designcraft_fonts::DEFAULT_FAMILY); + let r = Session::new().execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] + fn document_fonts_belong_to_their_document() { + const FAMILY: &str = "DocFont Scoped"; + let (a, b) = (temp_dir("scoped-a"), temp_dir("scoped-b")); + std::fs::write(a.join(DOCUMENT_FONTS_FOLDER).join("scoped.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + write_document(&a.join("A.designcraft"), FAMILY); + write_document(&b.join("B.designcraft"), FAMILY); + + let mut s = Session::new(); + assert_eq!(open(&mut s, &a.join("A.designcraft"))["documentFonts"], 1); + assert_eq!(open(&mut s, &b.join("B.designcraft"))["documentFonts"], 0); + // B, open at the same time, doesn't have A's font. + assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY), "{:?}", glyph_faces(&s)); + s.execute("file.activate", &json!({"index": 0})).unwrap(); + assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY), "{:?}", glyph_faces(&s)); + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn each_document_sets_its_own_font_of_a_name() { + const FAMILY: &str = "DocFont Same"; + let (a, b) = (temp_dir("same-a"), temp_dir("same-b")); + let (a_font, b_font) = (a.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"), b.join(DOCUMENT_FONTS_FOLDER).join("same.ttf")); + // A's font has both letters, B's only `b`. + std::fs::write(&a_font, font_with(FAMILY, &['a', 'b']).unwrap()).unwrap(); + std::fs::write(&b_font, font_with(FAMILY, &['b']).unwrap()).unwrap(); + // The same words in both: the word cache must not hand one document's shaping to the other. + write_document_with(&a.join("A.designcraft"), FAMILY, "ab ab ab"); + write_document_with(&b.join("B.designcraft"), FAMILY, "ab ab ab"); + + let mut s = Session::new(); + open(&mut s, &a.join("A.designcraft")); + open(&mut s, &b.join("B.designcraft")); + // Fallback fonts for the `a` B's font lacks: never A's font. + s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); + let in_b = glyph_faces(&s); + s.execute("file.activate", &json!({"index": 0})).unwrap(); + let in_a = glyph_faces(&s); + assert_eq!((in_a.len(), in_b.len()), (8, 8), "{in_a:?} {in_b:?}"); + for (c, f) in &in_a { + assert_eq!(f.source, FontSource::Document(a_font.clone()), "`{c}` in A"); + } + for (c, f) in &in_b { + match c { + 'a' => assert!(f.family != FAMILY, "B's font lacks `a`, and A's isn't B's: {f:?}"), + _ => assert_eq!(f.source, FontSource::Document(b_font.clone()), "`{c}` in B"), + } + } + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn closing_a_document_forgets_its_fonts_and_reopening_reuses_them() { + const FAMILY: &str = "DocFont Closed"; + let dir = temp_dir("closed"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("closed.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Closed.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + let first = glyph_faces(&s)[0].1; + assert_eq!(first.family, FAMILY); + s.execute("file.close", &json!({})).unwrap(); + // A new document can't use the closed document's font. + new_document(&mut s, FAMILY, "Hello"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY)); + // Reopened, the document has its font again: the face loaded the first time. + assert_eq!(open(&mut s, &path)["documentFonts"], 1); + assert!(glyph_faces(&s).iter().all(|(_, f)| *f == first), "{:?}", glyph_faces(&s)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_reverted_document_keeps_its_fonts() { + const FAMILY: &str = "DocFont Reverted"; + let dir = temp_dir("revert"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("revert.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Revert.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + s.execute("frame.create", &json!({"rect": [72, 400, 200, 500], "content": "text", "text": "x"})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn bold_and_italic_link_the_document_fonts_styles() { + const FAMILY: &str = "DocFont Linked"; + let dir = temp_dir("linked"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Linked.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + // The family is the document's alone: its Regular is the style without bold or italic. + let r = s.execute("type.bold", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let r = s.execute("type.italic", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(test)] +mod recent_tests { + use serde_json::json; + + use crate::Session; + + fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dc-recent-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn saved(s: &mut Session, path: &std::path::Path) -> String { + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + path.to_string_lossy().to_string() + } + + /// Opening and saving put a file at the front once; Save a Copy doesn't count. + #[test] + fn open_and_save_order_the_list_without_duplicates() { + let dir = temp_dir("order"); + let mut s = Session::new(); + let a = saved(&mut s, &dir.join("a.designcraft")); + let b = saved(&mut s, &dir.join("b.designcraft")); + assert_eq!(s.prefs.recent_files, [b.clone(), a.clone()]); + s.execute("file.open", &json!({"path": a})).unwrap(); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "reopening moves it to the front, once"); + s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a copy isn't a recent file"); + let rows = s.execute("file.recentFiles", &json!({})).unwrap(); + assert_eq!(rows[0], json!({"path": a, "name": "a.designcraft", "exists": true})); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn open_recent_reopens_and_a_missing_file_is_an_error_that_keeps_it_listed() { + let dir = temp_dir("open"); + let mut s = Session::new(); + let a = saved(&mut s, &dir.join("a.designcraft")); + let b = saved(&mut s, &dir.join("b.designcraft")); + let r = s.execute("file.openRecent", &json!({"index": 1})).unwrap(); + assert_eq!(s.documents()[r["index"].as_u64().unwrap() as usize].path.as_deref(), Some(a.as_str())); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()]); + std::fs::remove_file(&b).unwrap(); + let e = s.execute("file.openRecent", &json!({"index": 1})).unwrap_err(); + assert!(e.to_string().contains("can't be found"), "{e}"); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a disk that isn't connected comes back"); + assert_eq!(s.execute("file.recentFiles", &json!({})).unwrap()[1]["exists"], false); + // Hostile indices are errors, never panics. + for index in [json!(2), json!(u64::MAX), json!(-1), json!("0"), json!(null)] { + assert!(s.execute("file.openRecent", &json!({"index": index})).is_err(), "{index}"); + } + s.execute("file.clearRecent", &json!({})).unwrap(); + assert!(s.prefs.recent_files.is_empty()); + assert!(s.execute("file.openRecent", &json!({"index": 0})).is_err(), "disabled when empty"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Saving puts a small picture of the first page in the file, which the start screen reads + /// without opening the document. + #[test] + fn saved_files_carry_a_first_page_preview() { + let dir = temp_dir("preview"); + let mut s = Session::new(); + let a = saved(&mut s, &dir.join("a.designcraft")); + let p = super::recent_preview(&a).expect("a preview"); + assert_eq!(p.width.max(p.height), super::PREVIEW_PX as u32); + assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize); + assert!(p.rgba.as_chunks::<4>().0.iter().any(|px| px[3] == 255), "the page is drawn, not left transparent"); + let copy = dir.join("copy.designcraft"); + s.execute("file.saveACopy", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(super::recent_preview(©.to_string_lossy()).is_some(), "Save a Copy has one too"); + // Files without one (saved before previews), missing files and other files have none. + let old = dir.join("old.designcraft"); + std::fs::write(&old, super::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap(); + for path in [old, dir.join("gone.designcraft"), dir.clone()] { + assert!(super::recent_preview(&path.to_string_lossy()).is_none(), "{path:?}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_list_keeps_at_most_the_preferred_count() { + let mut s = Session::new(); + for i in 0..30 { + s.prefs.note_recent_file(&format!("/work/{i}.designcraft")); + } + assert_eq!(s.prefs.recent_files.len(), 20, "default: 20"); + assert_eq!(s.prefs.recent_files[0], "/work/29.designcraft"); + s.execute("prefs.set", &json!({"recentFilesMax": 3})).unwrap(); + assert_eq!(s.prefs.recent_files, ["/work/29.designcraft", "/work/28.designcraft", "/work/27.designcraft"]); + assert_eq!(s.execute("prefs.set", &json!({"recentFilesMax": 4_000_000_000u64})).unwrap()["recentFilesMax"], crate::MAX_RECENT_FILES); + assert!(s.execute("prefs.set", &json!({"recentFilesMax": -1})).is_err()); + s.execute("prefs.set", &json!({"recentFilesMax": 0})).unwrap(); + s.prefs.note_recent_file("/work/new.designcraft"); + assert!(s.prefs.recent_files.is_empty(), "0 keeps no list"); + } + + /// The list is a preference: it survives the round trip through prefs.json. + #[test] + fn the_list_survives_a_restart() { + let mut s = Session::new(); + s.prefs.note_recent_file("/work/a.designcraft"); + let saved = serde_json::to_vec(&s.prefs).unwrap(); + let back: crate::Prefs = serde_json::from_slice(&saved).unwrap(); + assert_eq!(back.recent_files, ["/work/a.designcraft"]); + assert_eq!(back.recent_files_max, 20); + let old: crate::Prefs = serde_json::from_slice(br#"{"recoveryMinutes": 2.0}"#).unwrap(); + assert!(old.recent_files.is_empty(), "older prefs.json without the list still load"); + } +} diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs new file mode 100644 index 000000000..d9ecb3ad0 --- /dev/null +++ b/crates/engine/src/cmd/file/place.rs @@ -0,0 +1,753 @@ +#[cfg(test)] +use super::bytes::base64_encode; +use super::bytes::{base64_decode, from_bytes}; +use super::*; + +pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { + // The file's size and time before it is read (a linked text file remembers them). + let mut before = None; + let (bytes, name, link) = if let Some(b) = str_param(p, "base64") { + (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None) + } else if let Some(path) = str_param(p, "path") { + before = crate::cmd::datamerge::fingerprint_from_path(path); + #[cfg(not(target_arch = "wasm32"))] + let b = crate::io::read_file(path).map_err(EngineError::Other)?; + #[cfg(target_arch = "wasm32")] + let b: Vec = vec![]; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); + (b, name, Some(path.to_string())) + } else { + s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) }); + return ok(); + }; + // A page of another layout (IDML or DesignCraft): its objects, as one group. + let lname = name.to_lowercase(); + if lname.ends_with(".idml") || lname.ends_with(".designcraft") { + return place_layout_page(s, p, &name, &bytes); + } + // 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 crate::cmd::media::place_media(s, p, name, mime, bytes, link); + } + // Text files (plain, Word, RTF) flow into frames. + if designcraft_textimport::is_text_file(&name) { + return crate::cmd::place_text::place_text(s, p, &name, &bytes, link, before); + } + let want_w = p + .get("width") + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_f64() + .filter(|width| width.is_finite() && *width > 0.0) + .ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero")) + }) + .transpose()?; + // Image Import Options: which page of a PDF (1-based). + if designcraft_render::is_pdf(&bytes) { + designcraft_render::check_pdf_input_size(bytes.len()).map_err(|e| bad("file.place", e.to_string()))?; + } + let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { + Some(n) if designcraft_render::is_pdf(&bytes) => { + let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1); + let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)")); + let one_based = usize::try_from(n).map_err(|_| invalid_page())?; + if one_based == 0 || one_based > count { + return Err(invalid_page()); + } + let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?; + u32::try_from(zero_based).map_err(|_| invalid_page())? + } + _ => 0, + }; + let (pw, ph) = match pdf_page { + 0 => designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?, + n => designcraft_render::pdf_page_size(&bytes, n as usize) + .map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)) + .ok_or_else(|| bad("file.place", "can't read that PDF page"))?, + }; + // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages + // and SVGs keep their exact size: rounding it to whole points would stretch them unevenly. + let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64)); + // Image Import Options › Crop to: the graphic spans that box of the PDF page. + let is_pdf = designcraft_render::is_pdf(&bytes); + let pdf_crop = match str_param(p, "pdfCrop") { + Some(k) if is_pdf => PdfCrop::from_name(k) + .ok_or_else(|| bad("file.place", format!("unknown pdfCrop `{k}` (crop, art, trim, bleed, media, contentVisible, contentAll)")))?, + _ => PdfCrop::Crop, + }; + let mime = designcraft_render::image_mime(&bytes).to_string(); + let mut asset = Asset { page: pdf_page, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), pdf_crop, ..Default::default() }; + // The graphic's natural size: the box in points (the whole page when the box can't be found, + // and then the document says so). + let (bw, bh) = match crop_pdf(&mut asset) { + Some(size) => size, + None => { + asset.pdf_crop = PdfCrop::Crop; + (nw, nh) + } + }; + let target_frame = crate::cmd::id_param(p, "frame").or_else(|| { + if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { + return None; + } + let st = s.active()?; + st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) + }); + // `"spread": null` counts as omitted, like the other placement parameters. + let spread = match p.get("spread") { + Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0), + _ => crate::cmd::spread_param(p, "file.place")?, + }; + let lid = s.doc()?.active_layer; + let px = p.get("x").and_then(Value::as_f64); + let py = p.get("y").and_then(Value::as_f64); + if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { + return load_cursor(s, asset, (bw, bh)); + } + s.edit(|d, sel| { + let fresh = AssetId(d.alloc()); + let aid = d.add_asset(Asset { id: fresh, ..asset }); + // 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 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 the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. + let k = (r.width() / bw).max(r.height() / bh); + let (gw, gh) = (bw * k, bh * k); + let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); + validate_placement((bw, bh), r, xf, "file.place")?; + it.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (bw, bh), + xf, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + 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 r = crate::cmd::finite_rect("file.place", Rect::new(x, y, x + bw * k, y + bh * k))?; + let xf = Affine::translate((x, y)) * Affine::scale(k); + validate_placement((bw, bh), r, xf, "file.place")?; + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); + it.object_style = d.styles.default_graphic_frame.clone(); + if let Some(e) = d.styles.object_effects(&it.object_style) { + it.effects = e.clone(); + } + it.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (bw, bh), + xf, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + d.insert_item(spread, it, None)?; + id + }; + *sel = Selection::items(vec![id]); + Ok(json!({"id": id.0, "asset": aid.0})) + }) +} + +/// Sets `a.pdf_box` to where its `pdf_crop` box sits on its page and returns the box's size in +/// points. The crop box is the whole page and has none; nor has a page that can't be read, which +/// keeps its crop choice (a missing link relinked later gets its box then). +pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> { + a.pdf_box = None; + if a.pdf_crop == PdfCrop::Crop || !designcraft_render::is_pdf(&a.data) { + return None; + } + let (frac, size) = designcraft_render::pdf_crop_box(&a.data, a.page as usize, a.pdf_crop)?; + a.pdf_box = Some(frac); + Some(size) +} + +pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option)> { + if let Some(b) = str_param(p, "base64") { + return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)); + } + let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; + #[cfg(not(target_arch = "wasm32"))] + let b = crate::io::read_file(path).map_err(EngineError::Other)?; + #[cfg(target_arch = "wasm32")] + let b: Vec = vec![]; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); + 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(()) +} + +pub(super) 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 natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); + let mime = designcraft_render::image_mime(&bytes).to_string(); + let asset = Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }; + load_cursor(s, asset, natural) +} + +/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor. +fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64)) -> Result { + let name = asset.name.clone(); + let (pw, ph) = asset.pixels.unwrap_or_default(); + let aid = s.edit(|d, _| { + asset.id = AssetId(d.alloc()); + Ok(d.add_asset(asset)) + })?; + s.loaded = Some(crate::LoadedGraphic { asset: aid, size }); + s.set_tool("placeGun"); + Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true})) +} + +pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result { + let crate::LoadedGraphic { asset: aid, size: (nw, nh) } = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; + // Undo can remove the loaded graphic's asset. + if !s.doc()?.doc.assets.contains_key(&aid) { + s.loaded = None; + return Err(bad("place.drop", "the loaded graphic is no longer in the document")); + } + let sr = crate::cmd::spread_param(p, "place.drop")?; + let lid = s.doc()?.active_layer; + let frame = crate::cmd::id_param(p, "frame"); + let rect = crate::cmd::rect_param(p, "rect"); + let (x, y) = (crate::cmd::f64_or(p, "x", 0.0), crate::cmd::f64_or(p, "y", 0.0)); + let d = &s.doc()?.doc; + let (frame_rect, xf, auto_fit) = if let Some(fid) = frame { + let r = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.inner_bounds(); + let k = (r.width() / nw).max(r.height() / nh); + let xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k); + // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. + (r, xf, Default::default()) + } else { + if d.spread(sr).is_none() { + return Err(bad("place.drop", "no such spread")); + } + // Drag: fit proportionally; click: actual size at the point. + let (r, k) = match rect { + Some(r) if r.width() > 2.0 && r.height() > 2.0 => { + let k = (r.width() / nw).min(r.height() / nh); + (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) + } + _ => (Rect::new(x, y, x + nw, y + nh), 1.0), + }; + (r, Affine::translate((r.x0, r.y0)) * Affine::scale(k), Default::default()) + }; + validate_placement((nw, nh), frame_rect, xf, "place.drop")?; + let r = s.edit(|d, sel| { + let graphic = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (nw, nh), + xf, + clip: None, + auto_fit, + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + let id = if let Some(fid) = frame { + d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.content = graphic; + fid + } else { + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_rect)); + it.object_style = d.styles.default_graphic_frame.clone(); + if let Some(e) = d.styles.object_effects(&it.object_style) { + it.effects = e.clone(); + } + it.content = graphic; + d.insert_item(sr, it, None)?; + id + }; + *sel = Selection::items(vec![id]); + Ok(json!({"id": id.0})) + })?; + s.loaded = None; + s.set_tool("selection"); + Ok(r) +} + +/// File › Place of an IDML or DesignCraft document: the objects of one page (`layoutPage`, +/// 1-based) grouped, top-left at x/y (default: the source position on the first page). +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") { + crate::cmd::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)? + }; + let n = src.page_count(); + let page = p.get("layoutPage").and_then(Value::as_u64).unwrap_or(1) as usize; + if page == 0 || page > n { + return Err(bad(ID, format!("the document has {n} page(s)"))); + } + let (si, pi) = src.page_loc(page - 1).ok_or_else(|| bad(ID, "no such page"))?; + let sp = &src.spreads[si]; + let pr = sp.pages[pi].bounds(); + let ids: Vec = sp.items.iter().filter(|it| !it.hidden && sp.page_at_x(it.bounds().center().x) == Some(pi)).map(|it| it.id).collect(); + if ids.is_empty() { + return Err(bad(ID, format!("page {page} has no objects"))); + } + let to = crate::cmd::spread_param(p, ID)?; + let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), + _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), + }; + let placed = s.edit(|d, sel| { + for sw in &src.swatches { + if d.swatch(&sw.name).is_none() { + d.swatches.push(sw.clone()); + } + } + let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); + let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); + if !missing_p.is_empty() || !missing_c.is_empty() { + let st = d.styles_mut(); + st.paragraph.extend(missing_p); + st.character.extend(missing_c); + } + let new = crate::cmd::object::duplicate_from(d, &src, &ids, to, off)?; + *sel = Selection::items(new.clone()); + Ok(new) + })?; + if placed.len() > 1 { + let g = s.execute("object.group", &json!({"ids": placed.iter().map(|i| i.0).collect::>()}))?; + return Ok(json!({"id": g["id"], "items": placed.len()})); + } + Ok(json!({"id": placed[0].0, "items": 1})) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(super) mod image_place_tests { + use designcraft_doc::{Content, ItemId}; + use designcraft_geom::Rect; + use serde_json::{Value, json}; + + use crate::Session; + + pub(in crate::cmd::file) fn image_file() -> std::io::Result { + image_file_with_color(30) + } + + fn image_file_with_color(red: u8) -> std::io::Result { + let file = tempfile::Builder::new().suffix(".png").tempfile()?; + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png(); + std::fs::write(file.path(), png)?; + Ok(file) + } + + #[test] + fn consecutive_path_placements_create_distinct_graphics() { + let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()]; + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let mut placements = Vec::new(); + let mut originals = Vec::new(); + + for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) { + let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + assert!( + placements.iter().all(|previous: &Value| previous["id"] != placed["id"]), + "each explicit placement needs a distinct frame: {placed}" + ); + let document = &session.doc().unwrap().doc; + let item = document.item(id).unwrap(); + assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0)); + assert!(document.spreads[spread].items.iter().any(|item| item.id == id)); + originals.push(serde_json::to_value(item).unwrap()); + placements.push(placed); + } + + let document = &session.doc().unwrap().doc; + assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3); + assert_eq!(document.assets.len(), 3); + for (placed, original) in placements.iter().zip(originals) { + assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original); + } + let links = session.execute("links.list", &json!({})).unwrap(); + assert_eq!(links.as_array().unwrap().len(), 3); + for (placed, image) in placements.into_iter().zip(&images) { + let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap(); + assert_eq!(row["path"], json!(image.path())); + assert_eq!(row["uses"].as_array().unwrap().len(), 1); + assert_eq!(row["uses"][0]["id"], placed["id"]); + } + } + + #[test] + fn individual_placement_parameters_bypass_selected_frames() { + let image = image_file().unwrap(); + for graphic in [false, true] { + for (key, value, expected) in [ + ("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)), + ("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)), + ("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)), + ("width", 0, Rect::ZERO), + ("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)), + ] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + if graphic { + session.execute("file.place", &json!({"path": image.path()})).unwrap(); + } + let id = ItemId(selected["id"].as_u64().unwrap()); + let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap(); + let mut params = json!({"path": image.path()}); + params[key] = json!(value); + + let result = session.execute("file.place", ¶ms); + if key == "width" && value == 0 { + assert!(result.is_err(), "zero width must be rejected, graphic={graphic}"); + continue; + } + let placed = result.unwrap(); + + assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}"); + let document = &session.doc().unwrap().doc; + assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original); + let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), expected); + let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; + let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs(); + assert!(scale_x > 0.0 && scale_y > 0.0); + } + } + } + + #[test] + fn explicit_frame_wins_over_placement_and_selection() { + let image = image_file().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap(); + + let placed = + session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap(); + + assert_eq!(placed["id"], target["id"]); + let document = &session.doc().unwrap().doc; + let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + assert!(matches!(item.content, Content::Graphic(_))); + assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned)); + assert_eq!(document.spreads[0].items.len(), 2); + } + + fn assert_width_rejected(width: Value) -> std::result::Result<(), Box> { + let image = image_file()?; + for graphic in [false, true] { + for explicit_frame in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; + if graphic { + session.execute("file.place", &json!({"path": image.path()}))?; + } + let mut params = json!({"path": image.path(), "width": width}); + if explicit_frame { + params["frame"] = target["id"].clone(); + session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?; + } + let before = session.doc()?; + let document = before.doc.clone(); + let selection = before.selection.clone(); + let revision = before.revision; + let assets = document.assets.len(); + + let result = session.execute("file.place", ¶ms); + + assert!( + matches!(&result, Err(crate::EngineError::BadParams { .. })), + "width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}" + ); + let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?; + let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) }; + assert_eq!(cmd, "file.place"); + assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}"); + let after = session.doc()?; + assert!(std::sync::Arc::ptr_eq(&document, &after.doc)); + assert_eq!(after.selection, selection); + assert_eq!(after.revision, revision); + assert_eq!(after.doc.assets.len(), assets); + } + } + Ok(()) + } + + #[test] + fn width_rejected_when_zero() { + assert_width_rejected(json!(0)).unwrap(); + } + + #[test] + fn width_rejected_when_negative() { + assert_width_rejected(json!(-80)).unwrap(); + } + + #[test] + fn width_rejected_when_non_numeric() { + for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] { + assert_width_rejected(width).unwrap(); + } + } + + fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box> { + let image = image_file()?; + let previous_image = image_file_with_color(31)?; + for graphic in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; + let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?; + let mut params = json!({"path": image.path()}); + for key in keys { + params[*key] = Value::Null; + } + + let placed = session.execute("file.place", ¶ms)?; + + assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}"); + if let Some(previous) = previous { + assert_ne!(placed["asset"], previous["asset"]); + } + let document = &session.doc()?.doc; + let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?); + let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic"); + let Content::Graphic(content) = &item.content else { + return Err(crate::EngineError::Other("placed frame must be graphic".into()).into()); + }; + assert_eq!(json!(content.asset.0), placed["asset"]); + assert_eq!(document.spreads[0].items.len(), 1); + } + Ok(()) + } + + #[test] + fn selection_used_when_x_null() { + assert_null_placement_uses_selection(&["x"]).unwrap(); + } + + #[test] + fn selection_used_when_y_null() { + assert_null_placement_uses_selection(&["y"]).unwrap(); + } + + #[test] + fn selection_used_when_spread_null() { + assert_null_placement_uses_selection(&["spread"]).unwrap(); + } + + #[test] + fn selection_used_when_width_null() { + assert_null_placement_uses_selection(&["width"]).unwrap(); + } + + #[test] + fn selection_used_when_all_placement_parameters_null() { + assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap(); + } + + #[test] + fn implicit_placement_fills_empty_and_replaces_graphic_selection() { + let image = image_file().unwrap(); + let previous_image = image_file_with_color(31).unwrap(); + for graphic in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap()); + + let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap(); + + assert_eq!(placed["id"], selected["id"]); + if let Some(previous) = previous { + assert_ne!(placed["asset"], previous["asset"]); + } + let document = &session.doc().unwrap().doc; + let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; + assert_eq!(json!(content.asset.0), placed["asset"]); + assert_eq!(document.spreads[0].items.len(), 1); + } + } +} + +#[cfg(test)] +mod pdf_crop_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn place_pdf_cropped_to_its_trim_box() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + let size = |t: &mut Session, crop: &str| { + t.execute("edit.deselectAll", &json!({})).unwrap(); + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": crop, "width": 630, "x": 0, "y": 0})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + t.doc().unwrap().doc.item(id).unwrap().bounds() + }; + let full = size(&mut t, "crop"); + let trim = size(&mut t, "trim"); + assert!((full.width() - 630.0).abs() < 1e-6, "{full:?}"); + assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}"); + assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); + } + + /// Placing with `trim` stores the choice; the graphic spans the trim box and draws only it, + /// even in a frame larger than the graphic. + #[test] + fn a_pdf_placed_with_its_trim_box_stores_and_draws_that_box() { + // Letter with a 9 pt bleed: black in the bleed's top-left corner and a black square inside. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); + for rect in [[-9, -9, 0, 0], [10, 10, 30, 30]] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + } + let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); + + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + // `width` is the whole page's (630 pt with its bleed): half size. + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "trim", "width": 315, "x": 100, "y": 100})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = t.doc().unwrap().doc.clone(); + let it = d.item(id).unwrap(); + let g = it.graphic().unwrap().clone(); + let a = &d.assets[&g.asset]; + assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Trim); + let b = a.shown_box().unwrap(); + let want = [9.0 / 630.0, 9.0 / 810.0, 621.0 / 630.0, 801.0 / 810.0]; + assert!(b.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-4), "{b:?}"); + assert!((g.size.0 - 612.0).abs() < 1e-3 && (g.size.1 - 792.0).abs() < 1e-3, "{:?}", g.size); + let shown = (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + let fb = it.bounds(); + for (x, y) in [(shown.x0, 100.0), (shown.y0, 100.0), (shown.x1, 406.0), (shown.y1, 496.0), (fb.x1, 406.0), (fb.y1, 496.0)] { + assert!((x - y).abs() < 1e-3, "{shown:?} {fb:?}"); + } + + // A frame larger than the graphic still shows only the trim box. + t.edit(|d, _| { + d.item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(90.0, 90.0, 416.0, 506.0)); + Ok(()) + }) + .unwrap(); + let d = t.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &t.cache, 0, 1.0, false, &Default::default()).unwrap(); + let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 60; + // The page sits at 95.5: its bleed corner (95.5–100) is cut off, the square (105–115) shows + // where it is on the page. + assert!(!dark(97, 97), "the bleed is outside the trim box"); + assert!(!dark(103, 103), "the page is offset by its bleed"); + assert!(dark(110, 110), "the square inside the trim box"); + } + + #[test] + fn placed_pdf_keeps_its_exact_page_size() { + // A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = &t.doc().unwrap().doc; + let it = d.item(id).unwrap(); + let b = it.bounds(); + assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}"); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") }; + assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size); + } +} + +#[cfg(test)] +mod layout_place_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn place_a_page_of_an_idml_file_as_a_group() { + let mut a = Session::new(); + a.execute("file.new", &json!({"pages": 2})).unwrap(); + a.execute("frame.create", &json!({"rect": [100, 100, 200, 150], "content": "text", "text": "From page one", "caret": false})).unwrap(); + a.execute("frame.create", &json!({"rect": [300, 400, 350, 450]})).unwrap(); + let idml = designcraft_idml::export_idml(&a.doc().unwrap().doc); + let b64 = super::base64_encode(&idml); + let mut b = Session::new(); + b.execute("file.new", &json!({})).unwrap(); + let r = b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap(); + assert_eq!(r["items"], 2); + let d = &b.doc().unwrap().doc; + let g = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap(); + assert_eq!(g.children().len(), 2, "one group"); + let bb = g.bounds(); + assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.y0 - 100.0).abs() < 1e-6, "page top-left at 0,0: {bb:?}"); + assert!(d.stories.values().any(|st| st.text == "From page one"), "stories come along"); + assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty"); + } +} diff --git a/crates/engine/src/cmd/file/registry.rs b/crates/engine/src/cmd/file/registry.rs new file mode 100644 index 000000000..3d2e58093 --- /dev/null +++ b/crates/engine/src/cmd/file/registry.rs @@ -0,0 +1,139 @@ +use super::bytes::{base64_encode, to_bytes, to_bytes_with_preview, with_document_extension}; +use super::new::{file_new, file_sample, save_preset}; +use super::open::{file_open, file_open_bytes, file_open_recent, has_recent, recent_entry}; +use super::place::{file_place, place_drop, place_load, read_source}; +use super::save::{FolderAccess, can_revert, file_revert, file_save, recovery_dir}; +use super::snippets::{snippet_export, snippet_place}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"), + "{preset?: \"Letter\"|\"A4\"|… or a saved preset (file.savePreset), intent?: print|web|mobile, units?: points|picas|millimeters|…, width?, height?, pages?, startPage?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, slug?: n | [top, bottom, inside, outside], title?, primaryTextFrame?: bool}", + always, file_new), + cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), + cmd!(query "file.presets", "Document Presets", [], None, "{} → the built-in presets [{name, intent, width, height, units}], then the saved ones (with `saved: true`)", always, |s, _| { + let mut presets: Vec = PRESETS.iter().map(|p| serde_json::to_value(p).unwrap_or_default()).collect(); + presets.extend( + s.prefs + .document_presets + .iter() + .map(|p| json!({"name": p.title, "intent": p.intent, "width": p.width, "height": p.height, "units": p.units, "saved": true})), + ); + Ok(Value::Array(presets)) + }), + cmd!(noundo "file.savePreset", "Save Document Preset", [], None, + "{name, …the settings as file.new} — kept with the preferences; `file.new {preset: name}` uses it. A saved preset of that name is replaced; built-in names are refused", + always, save_preset), + cmd!(noundo "file.deletePreset", "Delete Document Preset", [], None, "{name} — a saved document preset", always, |s, p| { + let name = str_param(p, "name").unwrap_or_default().trim().to_string(); + let before = s.prefs.document_presets.len(); + s.prefs.document_presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); + if s.prefs.document_presets.len() == before { + return Err(bad("file.deletePreset", format!("no saved preset `{name}`"))); + } + ok() + }), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), + "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}", + always, file_open), + cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes), + cmd!(noundo "file.openRecent", "Open Recent File", [], None, + "{index} — reopen entry `index` of File › Open Recent (as file.open); a file that is gone is an error and stays listed", + has_recent, file_open_recent), + cmd!(query "file.recentFiles", "Recent Files", [], None, "{} → [{path, name, exists}] File › Open Recent, most recent first", always, |s, _| { + Ok(Value::Array(s.prefs.recent_files.iter().map(|p| recent_entry(p)).collect())) + }), + cmd!(noundo "file.clearRecent", "Clear Recent Files", ["File", "Open Recent"], None, "{} — empty File › Open Recent", has_recent, |s, _| { + s.prefs.recent_files.clear(); + ok() + }), + cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} — a `path` without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), + cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} — a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), + cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state; a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, |s, p| { + let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?; + let path = with_document_extension(path); + let mut copy = (*s.doc()?.doc).clone(); + crate::cmd::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); + let bytes = to_bytes_with_preview(©, &s.cache)?; + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": bytes.len()})) + }), + cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), + cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| { + let dir = recovery_dir(s, p, FolderAccess::Read)?; + Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect())) + }), + cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { + let dir = recovery_dir(s, p, FolderAccess::Write)?; + Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) + }), + cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { + // Reopening removes the entries it read. + let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?; + Ok(json!({"opened": crate::recovery::open(s, &dir)?})) + }), + cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { + let st = s.doc()?; + // Preview fill is session state. Serialize the unfilled template. + let doc = st.preview_stash.as_deref().unwrap_or(st.doc.as_ref()); + let bytes = to_bytes(doc)?; + Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})) + }), + cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, |s, p| { + let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).unwrap_or(0); + s.close_document(i); + ok() + }), + cmd!(noundo "file.activate", "Activate Document", [], None, "{index}", has_doc, |s, p| { + s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize); + ok() + }), + cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None, + "{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", crate::cmd::has_selection, snippet_export), + cmd!(query "place.styles", "Import Options: Styles", [], None, + "{path | base64, name} → {paragraph: [name], character: [name], conflicts: [name]} — the styles a Word/RTF file brings (for styleMap / styleConflicts)", + has_doc, |s, p| { + let (bytes, name, _) = read_source(p)?; + let imp = designcraft_textimport::import(&name, &bytes).map_err(|e| bad("place.styles", e.to_string()))?; + let st = &s.doc()?.doc.styles; + let conflicts: Vec<&str> = imp + .para_styles + .iter() + .filter(|x| st.para(&x.name).is_some()) + .chain(imp.char_styles.iter().filter(|x| st.char_style(&x.name).is_some())) + .map(|x| x.name.as_str()) + .collect(); + Ok(json!({"paragraph": imp.para_styles.iter().map(|x| &x.name).collect::>(), "character": imp.char_styles.iter().map(|x| &x.name).collect::>(), "conflicts": conflicts})) + }), + cmd!( + "snippet.place", + "Place Snippet", + [], + None, + "{path | base64, spread?, x?, y?} — items keep their positions unless x/y given (top-left)", + has_doc, + snippet_place + ), + cmd!( + "place.load", + "Load Place Cursor", + [], + None, + "{path | base64, name?} — load a graphic into the place cursor (then click/drag with the placeGun tool)", + has_doc, + place_load + ), + cmd!("place.drop", "Place Loaded Graphic", [], None, "{spread?, x, y, rect?: [x0,y0,x1,y1], frame?: id}", has_doc, place_drop), + cmd!( + "file.place", + "Place…", + ["File"], + Some("Cmd+D"), + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`", + has_doc, + file_place + ), + ] +} diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs new file mode 100644 index 000000000..38b3f8d46 --- /dev/null +++ b/crates/engine/src/cmd/file/save.rs @@ -0,0 +1,210 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::bytes::from_bytes; +use super::bytes::{to_bytes_with_preview, with_document_extension}; +#[cfg(not(target_arch = "wasm32"))] +use super::open::load_document_fonts; +#[cfg(test)] +use super::place::image_place_tests; +use super::*; + +pub(super) fn file_save(s: &mut Session, p: &Value) -> Result { + let st = s.doc_mut()?; + // Saving to the document's own file keeps its name, even one opened without the extension. + let path = str_param(p, "path") + .map(with_document_extension) + .or_else(|| st.path.clone()) + .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; + // Before anything about the document changes: a refused save leaves it as it was. + crate::file_access::check_write(&path).map_err(EngineError::Other)?; + // Prepare paths for the destination without changing the live document until persistence succeeds. + let mut saved = (*st.doc).clone(); + crate::cmd::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); + let paths_changed = saved.data_merge != st.doc.data_merge; + let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; + let bytes = to_bytes_with_preview(&saved, &s.cache)?; + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; + let st = s.doc_mut()?; + if paths_changed { + st.doc = saved; + st.bump_revision(); + } + st.path = Some(path.clone()); + st.saved_revision = st.revision; + st.saved_doc = st.doc.clone(); + #[cfg(not(target_arch = "wasm32"))] + { + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); + s.prefs.note_recent_file(&path); + } + Ok(json!({"path": path, "bytes": bytes.len()})) +} + +/// Which access a recovery command needs to the folder it is given. +#[derive(Clone, Copy)] +pub(super) enum FolderAccess { + Read, + Write, + ReadWrite, +} + +/// The recovery folder: `dir` when the command names one, else the session's own (set by the +/// host application, so not the automation roots' business). A named folder is checked against +/// the roots for `access`. +pub(super) fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result { + #[cfg(target_arch = "wasm32")] + { + let _ = (s, p, access); + Err(EngineError::Other("no recovery folder on the web".into())) + } + #[cfg(not(target_arch = "wasm32"))] + { + let Some(named) = str_param(p, "dir") else { + return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")); + }; + if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) { + crate::file_access::check_read(named).map_err(EngineError::Other)?; + } + if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) { + crate::file_access::check_write(named).map_err(EngineError::Other)?; + } + Ok(std::path::PathBuf::from(named)) + } +} + +pub(super) fn can_revert(s: &Session) -> std::result::Result<(), String> { + match s.active() { + Some(d) if d.path.is_some() && d.is_dirty() => Ok(()), + Some(d) if d.path.is_none() => Err("the document has never been saved".into()), + Some(_) => Err("no changes since the last save".into()), + None => Err("no document open".into()), + } +} + +pub(super) fn file_revert(s: &mut Session, _: &Value) -> Result { + let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; + #[cfg(not(target_arch = "wasm32"))] + { + let mut d = if path.to_ascii_lowercase().ends_with(".idml") { + let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?; + crate::cmd::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)?)? + }; + // As opened again: the fonts beside it now (unchanged files are shared, not read again). + let (fonts, _, _) = load_document_fonts(&mut d, &path); + let uid = s.doc()?.uid; + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; + let mut fresh = DocState::new(d, Some(path.clone())); + fresh.fonts = fonts; + fresh.uid = uid; + // A new state has a revision no state had before, so caches don't show the old document. + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); + *s.doc_mut()? = fresh; + Ok(json!({"path": path})) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (s, path); + Err(EngineError::Other("revert isn't available on the web".into())) + } +} + +#[cfg(test)] +mod save_tests { + use serde_json::json; + + use crate::Session; + + /// Saving over an existing document replaces it whole and leaves nothing beside it. + #[test] + fn save_replaces_the_existing_file_without_leftovers() { + let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap(); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap(); + let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); + assert!(saved.stories.values().any(|st| st.text == "Saved whole")); + let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, ["doc.designcraft"], "no temporary file is left"); + // A folder that doesn't exist: an error naming the path. + let missing = dir.join("missing").join("doc.designcraft"); + let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A document that can't be written is an error: the file on disk, the unsaved state and the + /// recovery checkpoint all stay as they were. + #[test] + fn a_save_that_cannot_write_the_document_changes_nothing() { + let dir = std::env::temp_dir().join(format!("dc-save-fails-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + let recovery = dir.join("recovery"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + let before = std::fs::read(&path).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Unsaved", "caret": false})).unwrap(); + s.execute("file.recovery.save", &json!({"dir": recovery.to_string_lossy()})).unwrap(); + let checkpoint = recovery.join(format!("{}.designcraft", s.doc().unwrap().uid)); + assert!(checkpoint.exists()); + + super::FAIL_TO_BYTES.with(|f| f.set(true)); + let save = s.execute("file.save", &json!({})); + let copy = s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})); + let save_as = s.execute("file.saveAs", &json!({"path": dir.join("other.designcraft").to_string_lossy()})); + let serialized = s.execute("file.serialize", &json!({})); + super::FAIL_TO_BYTES.with(|f| f.set(false)); + + let e = save.unwrap_err(); + assert!(e.to_string().contains("could not write the document"), "{e}"); + assert!(copy.is_err() && serialized.is_err(), "no empty copy and no empty download"); + assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); + assert!(!dir.join("copy.designcraft").exists()); + assert!(save_as.is_err() && !dir.join("other.designcraft").exists()); + assert!(!s.prefs.recent_files.iter().any(|p| p.ends_with("other.designcraft")), "a failed Save As isn't a recent file"); + assert!(s.doc().unwrap().is_dirty(), "the document still has unsaved changes"); + assert!(checkpoint.exists(), "the recovery checkpoint stays"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #1050: a placement whose coordinates overflow to infinity is refused, and Save refuses a + /// document holding a non-finite rect anyway, leaving the file on disk as it was. + #[test] + fn non_finite_coordinates_are_refused_and_never_saved() { + let image = super::image_place_tests::image_file().unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let e = s.execute("file.place", &json!({"path": image.path(), "x": 1e308, "width": 1e308})).unwrap_err(); + assert!(e.to_string().contains("finite"), "{e}"); + assert!(s.doc().unwrap().doc.all_items().is_empty(), "nothing is placed"); + + let dir = std::env::temp_dir().join(format!("dc-save-non-finite-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + let before = std::fs::read(&path).unwrap(); + let id = designcraft_doc::ItemId(s.execute("frame.create", &json!({"rect": [36, 36, 300, 200]})).unwrap()["id"].as_u64().unwrap()); + let st = s.doc_mut().unwrap(); + let infinite = designcraft_geom::Rect::new(36.0, 36.0, f64::INFINITY, 200.0); + std::sync::Arc::make_mut(&mut st.doc).item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(infinite); + let e = s.execute("file.save", &json!({})).unwrap_err(); + assert!(e.to_string().contains("not a finite number"), "{e}"); + assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); + assert!(s.doc().unwrap().is_dirty()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/file/snippets.rs b/crates/engine/src/cmd/file/snippets.rs new file mode 100644 index 000000000..231e2196b --- /dev/null +++ b/crates/engine/src/cmd/file/snippets.rs @@ -0,0 +1,54 @@ +use super::bytes::{base64_encode, from_bytes, to_bytes}; +use super::place::read_source; +use super::*; + +/// The selection as `.designcraft` snippet bytes. +pub(crate) fn snippet_bytes(s: &mut Session) -> Result> { + to_bytes(&crate::cmd::edit::clip_doc(s)?) +} + +pub(super) fn snippet_export(s: &mut Session, p: &Value) -> Result { + let d = crate::cmd::edit::clip_doc(s)?; + let bytes = to_bytes(&d)?; + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) + } + None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), + } +} + +pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { + let (bytes, _, _) = read_source(p)?; + let src = from_bytes(&bytes)?; + let ids: Vec = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); + if ids.is_empty() { + return Err(bad("snippet.place", "the snippet is empty")); + } + let sr = crate::cmd::spread_param(p, "snippet.place")?; + let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), + _ => designcraft_geom::Vec2::ZERO, + }; + s.edit(|d, sel| { + // Bring over styles and swatches the snippet uses that this document lacks. + for sw in &src.swatches { + if d.swatch(&sw.name).is_none() { + d.swatches.push(sw.clone()); + } + } + let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); + let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); + if !missing_p.is_empty() || !missing_c.is_empty() { + let st = d.styles_mut(); + st.paragraph.extend(missing_p); + st.character.extend(missing_c); + } + let new = crate::cmd::object::duplicate_from(d, &src, &ids, sr, off)?; + *sel = Selection::items(new.clone()); + Ok(json!({"ids": new.iter().map(|i| i.0).collect::>()})) + }) +}