From b41eba1cbaf759d1056367cc19ed96e3b2db8b34 Mon Sep 17 00:00:00 2001 From: Cameron Gilroy Date: Wed, 7 Oct 2026 21:22:06 +1100 Subject: [PATCH] Data merge: merge into a new document, with preview and tiling Select, update, and remove a linked source. Placeholders cover text, images, QR codes, and hyperlinks. Preview fills one record in the session and is left out of save and recovery. Merge copies the template into a new document, one record per page, or tiles frames on a single non-facing page. A missing or changed file is marked and the cached rows are still used. The template undo stack is left as it was. --- crates/engine/src/cmd/datamerge.rs | 162 ----- crates/engine/src/cmd/datamerge/fill.rs | 438 ++++++++++++ crates/engine/src/cmd/datamerge/mod.rs | 875 +++++++++++++++++++++++ crates/engine/src/cmd/datamerge/parse.rs | 547 ++++++++++++++ crates/engine/src/cmd/datamerge/tests.rs | 787 ++++++++++++++++++++ crates/engine/src/cmd/file.rs | 17 +- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/lib.rs | 28 +- crates/engine/src/recovery.rs | 2 +- 9 files changed, 2691 insertions(+), 166 deletions(-) delete mode 100644 crates/engine/src/cmd/datamerge.rs create mode 100644 crates/engine/src/cmd/datamerge/fill.rs create mode 100644 crates/engine/src/cmd/datamerge/mod.rs create mode 100644 crates/engine/src/cmd/datamerge/parse.rs create mode 100644 crates/engine/src/cmd/datamerge/tests.rs diff --git a/crates/engine/src/cmd/datamerge.rs b/crates/engine/src/cmd/datamerge.rs deleted file mode 100644 index f0cc84e..0000000 --- a/crates/engine/src/cmd/datamerge.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! Data Merge (Window → Utilities → Data Merge): a template spread with `<>` placeholders -//! in its stories is duplicated once per CSV record and the placeholders are replaced. One undo step. - -use designcraft_doc::{Content, ItemId, SpreadRef}; -use designcraft_geom::Vec2; -use serde_json::{Value, json}; - -use super::{CommandSpec, bad, cmd, has_doc, str_param}; -use crate::{Result, Session}; - -pub fn specs() -> Vec { - vec![ - cmd!( - "data.merge", - "Create Merged Document…", - ["Window", "Utilities", "Data Merge"], - None, - "{csv (header row first) | rows: [{field: value}], spread?: template spread index (default 0)} → {records, pages}", - has_doc, - merge - ), - cmd!(query "data.fields", "Data Merge Fields", [], None, "{csv} → field names", has_doc, |_, p| { - let rows = parse_csv(str_param(p, "csv").unwrap_or("")); - Ok(json!(rows.first().cloned().unwrap_or_default())) - }), - ] -} - -/// RFC 4180-ish CSV: quoted fields, doubled quotes, CRLF/LF. -pub fn parse_csv(s: &str) -> Vec> { - let mut rows = Vec::new(); - let mut row = Vec::new(); - let mut field = String::new(); - let mut quoted = false; - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - if quoted { - match c { - '"' if chars.peek() == Some(&'"') => { - field.push('"'); - chars.next(); - } - '"' => quoted = false, - c => field.push(c), - } - continue; - } - match c { - '"' if field.is_empty() => quoted = true, - ',' => row.push(std::mem::take(&mut field)), - '\r' => {} - '\n' => { - row.push(std::mem::take(&mut field)); - if !(row.len() == 1 && row[0].is_empty()) { - rows.push(std::mem::take(&mut row)); - } else { - row.clear(); - } - } - c => field.push(c), - } - } - if !field.is_empty() || !row.is_empty() { - row.push(field); - rows.push(row); - } - rows -} - -fn merge(s: &mut Session, p: &Value) -> Result { - let records: Vec> = if let Some(rows) = p.get("rows").and_then(Value::as_array) { - rows.iter() - .map(|r| { - r.as_object() - .map(|o| o.iter().map(|(k, v)| (k.clone(), v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))).collect()) - .unwrap_or_default() - }) - .collect() - } else { - let rows = parse_csv(str_param(p, "csv").ok_or_else(|| bad("data.merge", "give `csv` or `rows`"))?); - let Some((head, body)) = rows.split_first() else { return Err(bad("data.merge", "empty CSV")) }; - body.iter().map(|r| head.iter().cloned().zip(r.iter().cloned().chain(std::iter::repeat(String::new()))).collect()).collect() - }; - if records.is_empty() { - return Err(bad("data.merge", "no records")); - } - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - s.edit(|d, sel| { - let template = d.spreads.get(si).cloned().ok_or_else(|| bad("data.merge", "no such spread"))?; - let tpl_ids: Vec = template.items.iter().map(|i| i.id).collect(); - let parent = template.pages.first().and_then(|p| p.parent); - let src = d.clone(); - // Spreads to fill: the template itself for record 0, then one copy per further record. - let mut targets: Vec> = vec![tpl_ids.clone()]; - for _ in 1..records.len() { - let after = d.page_count() - 1; - d.insert_pages(Some(after), template.pages.len(), parent)?; - let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("data.merge", "insert failed"))?; - let ids = super::object::duplicate_from(d, &src, &tpl_ids, SpreadRef::Doc(nsi), Vec2::ZERO)?; - targets.push(ids); - } - for (rec, ids) in records.iter().zip(&targets) { - let mut stories = Vec::new(); - for id in ids { - if let Some(it) = d.item(*id) { - it.walk(&mut |i| { - if let Content::Text(tf) = &i.content - && !stories.contains(&tf.story) - { - stories.push(tf.story); - } - }); - } - } - for sid in stories { - let Some(st) = d.story_mut(sid) else { continue }; - for (k, v) in rec { - let pat = format!("<<{k}>>"); - while let Some(pos) = st.text.find(&pat) { - st.replace(pos..pos + pat.len(), v); - } - } - } - } - *sel = Default::default(); - Ok(json!({"records": records.len(), "pages": d.page_count()})) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn csv_parsing() { - let r = parse_csv("Name,Title\n\"Ada, Countess\",\"Analyst \"\"No. 1\"\"\"\r\nGrace,Admiral\n"); - assert_eq!(r, vec![vec!["Name", "Title"], vec!["Ada, Countess", "Analyst \"No. 1\""], vec!["Grace", "Admiral"]]); - } - - #[test] - fn merge_duplicates_pages_and_fills_fields() { - let mut s = Session::new(); - s.execute("file.new", &json!({"facingPages": false})).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 400, 100], "content": "text", "text": "Dear <>, welcome aboard as <>."})) - .unwrap(); - let r = s.execute("data.merge", &json!({"csv": "Name,Title\nAda,Analyst\nGrace,Admiral\nKatherine,Mathematician\n"})).unwrap(); - assert_eq!(r["records"], 3); - assert_eq!(r["pages"], 3); - let d = &s.doc().unwrap().doc; - let mut texts: Vec<String> = d.stories.values().map(|st| st.text.clone()).collect(); - texts.sort(); - assert_eq!( - texts, - vec![ - "Dear Ada, welcome aboard as Analyst.".to_string(), - "Dear Grace, welcome aboard as Admiral.".to_string(), - "Dear Katherine, welcome aboard as Mathematician.".to_string(), - ] - ); - assert_eq!(s.doc().unwrap().history.undo.len(), 2); // create frame + merge - } -} diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs new file mode 100644 index 0000000..1d26e24 --- /dev/null +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -0,0 +1,438 @@ +//! Fill one data-merge record into a document, and copy template items for later records. +//! +//! Placeholders are addressed through an id map so each record's copy can be filled from the +//! unfilled template. A placeholder whose frame or story is not in the map is left as it is. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use designcraft_doc::{ + Asset, AssetId, Content, DataField, Fill, Fitting, Graphic, Hyperlink, HyperlinkDest, HyperlinkSource, Item, ItemId, MergeOptions, + PlaceholderAnchor, PlaceholderRole, Shape, SpreadRef, Story, StoryId, +}; +use designcraft_geom::{Affine, Rect, Vec2}; + +use super::parse::image_candidates; +use crate::EngineError; + +#[derive(Clone, Debug, Default)] +pub struct Maps { + pub items: HashMap<ItemId, ItemId>, + pub stories: HashMap<StoryId, StoryId>, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MissingImage { + pub record: u32, + pub field: String, + pub path: String, +} + +#[derive(Clone, Debug, Default)] +pub struct FillReport { + pub missing: Vec<MissingImage>, + pub warnings: Vec<String>, +} + +/// Document-spread items and their stories, each mapped to itself. +pub fn identity_map(doc: &designcraft_doc::Document) -> Maps { + let mut maps = Maps::default(); + for sp in &doc.spreads { + for it in &sp.items { + it.walk(&mut |i| { + maps.items.insert(i.id, i.id); + if let Content::Text(tf) = &i.content { + maps.stories.insert(tf.story, tf.story); + } + }); + } + } + maps +} + +/// Deep-copy one top-level item. Children stay in their own space; only this item is translated. +pub fn copy_item( + dst: &mut designcraft_doc::Document, + src: &designcraft_doc::Document, + it: &Item, + to: SpreadRef, + off: Vec2, + maps: &mut Maps, +) -> Result<(), EngineError> { + let mut copy = it.clone(); + copy.xf = Affine::translate(off) * copy.xf; + renumber(dst, src, &mut copy, maps); + if dst.layer(copy.layer).is_none() { + copy.layer = dst.default_layer(); + } + dst.insert_item(to, copy, None)?; + Ok(()) +} + +fn renumber(dst: &mut designcraft_doc::Document, src: &designcraft_doc::Document, it: &mut Item, maps: &mut Maps) { + let old = it.id; + let new_id = ItemId(dst.alloc()); + maps.items.insert(old, new_id); + it.id = new_id; + match &mut it.content { + Content::Text(tf) => { + let new_sid = if let Some(n) = maps.stories.get(&tf.story) { + *n + } else { + let n = StoryId(dst.alloc()); + let mut st = src.story(tf.story).cloned().unwrap_or_else(|| Story::new(n)); + st.id = n; + st.frames.clear(); + dst.stories.insert(n, Arc::new(st)); + maps.stories.insert(tf.story, n); + n + }; + tf.story = new_sid; + if let Some(st) = dst.story_mut(new_sid) { + st.frames.push(new_id); + } + } + Content::Graphic(g) => { + if let Some(a) = src.assets.get(&g.asset) { + dst.assets.entry(g.asset).or_insert_with(|| a.clone()); + } + } + Content::Group { items } => { + for child in items.iter_mut() { + renumber(dst, src, Arc::make_mut(child), maps); + } + } + Content::Unassigned => {} + } +} + +struct Rep { + story: StoryId, + start: usize, + end: usize, + text: String, +} + +/// Fill `row` (0-based) into the items `maps` points at. `record` is the 1-based source row. +pub fn fill_row( + doc: &mut designcraft_doc::Document, + fields: &[DataField], + row: &[String], + record: u32, + options: &MergeOptions, + maps: &Maps, + data_dir: Option<&Path>, + doc_dir: Option<&Path>, +) -> FillReport { + let mut report = FillReport::default(); + let mut parent = false; + let mut reps: Vec<Rep> = Vec::new(); + let mut links: Vec<(PlaceholderAnchor, HyperlinkDest, String)> = Vec::new(); + let placeholders = doc.data_merge.placeholders.clone(); + for ph in &placeholders { + if !fields.iter().any(|f| f.name == ph.field) { + continue; + } + let cell = cell_of(fields, row, &ph.field).to_string(); + match &ph.anchor { + PlaceholderAnchor::Text { story, start, end } => { + let Some(&mapped) = maps.stories.get(story) else { + if anchor_is_parent(doc, &ph.anchor) { + parent = true; + } + continue; + }; + match ph.role { + PlaceholderRole::Text => reps.push(Rep { story: mapped, start: *start, end: *end, text: cell }), + PlaceholderRole::Hyperlink => { + if let Some(dest) = classify_link(&cell) { + links.push((PlaceholderAnchor::Text { story: mapped, start: *start, end: *end }, dest, ph.field.clone())); + } + } + PlaceholderRole::Image | PlaceholderRole::Qr => {} + } + } + PlaceholderAnchor::Item { id } => { + let Some(&mapped) = maps.items.get(id) else { + if anchor_is_parent(doc, &ph.anchor) { + parent = true; + } + continue; + }; + match ph.role { + PlaceholderRole::Image => place_image(doc, mapped, &ph.field, &cell, record, options, data_dir, doc_dir, &mut report), + PlaceholderRole::Qr => place_qr(doc, mapped, &ph.field, &cell, record, &mut report), + PlaceholderRole::Hyperlink => { + if let Some(dest) = classify_link(&cell) { + links.push((PlaceholderAnchor::Item { id: mapped }, dest, ph.field.clone())); + } + } + PlaceholderRole::Text => {} + } + } + } + } + typed_reps(doc, fields, row, maps, &placeholders, &mut reps); + apply_reps(doc, &reps); + for (anchor, dest, field) in links { + let anchor = match anchor { + PlaceholderAnchor::Text { story, start, end } => { + let (start, end) = adjust_range(story, start, end, &reps); + PlaceholderAnchor::Text { story, start, end } + } + other => other, + }; + if !create_link(doc, anchor, dest) { + report.warnings.push(format!("Record {record}, field {field}: the hyperlink could not be created.")); + } + } + if parent { + report.warnings.push("A parent-page placeholder was left unfilled.".into()); + } + report +} + +fn typed_reps( + doc: &designcraft_doc::Document, + fields: &[DataField], + row: &[String], + maps: &Maps, + placeholders: &[designcraft_doc::Placeholder], + reps: &mut Vec<Rep>, +) { + let mut stories: Vec<(StoryId, StoryId)> = maps.stories.iter().map(|(a, b)| (*a, *b)).collect(); + stories.sort_by_key(|(a, _)| a.0); + stories.dedup(); + for (original, mapped) in stories { + let Some(st) = doc.story(mapped) else { continue }; + let covers: Vec<(usize, usize)> = placeholders + .iter() + .filter_map(|ph| match (&ph.role, &ph.anchor) { + (PlaceholderRole::Text, PlaceholderAnchor::Text { story, start, end }) if *story == original => Some((*start, *end)), + _ => None, + }) + .collect(); + for (start, end, name) in find_markers(&st.text) { + if covers.iter().any(|(a, b)| start < *b && end > *a) { + continue; + } + if !fields.iter().any(|f| f.name == name) { + continue; + } + reps.push(Rep { story: mapped, start, end, text: cell_of(fields, row, &name).to_string() }); + } + } +} + +fn find_markers(text: &str) -> Vec<(usize, usize, String)> { + let mut out = Vec::new(); + let mut i = 0; + while i < text.len() { + if text[i..].starts_with("<<") + && let Some(rel) = text[i + 2..].find(">>") + { + let name = &text[i + 2..i + 2 + rel]; + if !name.is_empty() && !name.contains(['<', '>', '\n']) { + let end = i + 2 + rel + 2; + out.push((i, end, name.to_string())); + i = end; + continue; + } + } + i += text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1); + } + out +} + +fn apply_reps(doc: &mut designcraft_doc::Document, reps: &[Rep]) { + let mut stories: Vec<StoryId> = reps.iter().map(|r| r.story).collect(); + stories.sort(); + stories.dedup(); + for sid in stories { + let mut mine: Vec<&Rep> = reps.iter().filter(|r| r.story == sid).collect(); + mine.sort_by_key(|r| std::cmp::Reverse(r.start)); + let Some(st) = doc.story_mut(sid) else { continue }; + for r in mine { + if r.start <= r.end && r.end <= st.text.len() { + st.replace(r.start..r.end, &r.text); + } + } + } +} + +fn adjust_range(story: StoryId, start: usize, end: usize, reps: &[Rep]) -> (usize, usize) { + let mut mine: Vec<&Rep> = reps.iter().filter(|r| r.story == story).collect(); + mine.sort_by_key(|r| r.start); + let mut s = start; + let mut e = end; + for r in mine { + let old = r.end.saturating_sub(r.start); + let new = r.text.len(); + let delta = new as isize - old as isize; + if r.end <= start { + s = add_delta(s, delta); + e = add_delta(e, delta); + } else if r.start == start && r.end == end { + e = s.saturating_add(new); + } else if r.start >= end { + } else if r.end <= end { + e = add_delta(e, delta); + } + } + (s, e) +} + +pub fn add_delta(pos: usize, delta: isize) -> usize { + if delta >= 0 { pos.saturating_add(delta as usize) } else { pos.saturating_sub(delta.unsigned_abs()) } +} + +fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, dest: HyperlinkDest) -> bool { + let source = match anchor { + PlaceholderAnchor::Item { id } => { + if doc.item(id).is_none() { + return false; + } + HyperlinkSource::Item { id } + } + PlaceholderAnchor::Text { story, start, end } => { + let Some(st) = doc.story(story) else { return false }; + if start > end || end > st.text.len() { + return false; + } + HyperlinkSource::Text { story, start, end } + } + }; + let id = doc.alloc(); + let name = match &dest { + HyperlinkDest::Url(u) | HyperlinkDest::Email(u) => u.clone(), + HyperlinkDest::Page(p) => format!("Page {}", p + 1), + }; + doc.hyperlinks.push(Hyperlink { id, name, source, dest }); + true +} + +fn place_image( + doc: &mut designcraft_doc::Document, + item: ItemId, + field: &str, + cell: &str, + record: u32, + options: &MergeOptions, + data_dir: Option<&Path>, + doc_dir: Option<&Path>, + report: &mut FillReport, +) { + let cands = image_candidates(cell, data_dir, doc_dir); + let found = cands.iter().find_map(|p| std::fs::read(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b))); + let Some((path, bytes)) = found else { + if let Some(it) = doc.item_mut(item) { + it.content = Content::Unassigned; + } + report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() }); + report.warnings.push(format!("Record {record}, field {field}: missing image {cell}.")); + return; + }; + let Some((pw, ph)) = designcraft_render::image_size(&bytes) else { + if let Some(it) = doc.item_mut(item) { + it.content = Content::Unassigned; + } + report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() }); + report.warnings.push(format!("Record {record}, field {field}: missing image {cell}.")); + return; + }; + let Some(inner) = doc.item(item).map(|it| it.inner_bounds()) else { + report.warnings.push(format!("Record {record}, field {field}: missing image {cell}.")); + report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() }); + return; + }; + let (nw, nh) = (f64::from(pw), f64::from(ph)); + let mode = fitting_of(&options.fitting); + let aid = AssetId(doc.alloc()); + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string()); + let link = options.link_images.then(|| path.to_string_lossy().into_owned()); + doc.assets.insert( + aid, + Arc::new(Asset { + id: aid, + name, + mime: designcraft_render::image_mime(&bytes).to_string(), + link, + data: Arc::new(bytes), + pixels: Some((pw, ph)), + page: 0, + }), + ); + let mut g = Graphic { asset: aid, size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), auto_fit: mode, fit_align: 0, crop: [0.0; 4] }; + if let Some(xf) = g.fitted(inner, mode) { + g.xf = xf; + } + if options.center && nw > 0.0 && nh > 0.0 { + let bb = g.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh)); + let c = inner.center(); + let m = bb.center(); + g.xf = Affine::translate((c.x - m.x, c.y - m.y)) * g.xf; + } + if let Some(it) = doc.item_mut(item) { + it.content = Content::Graphic(g); + } +} + +fn place_qr(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, report: &mut FillReport) { + if cell.is_empty() { + report.warnings.push(format!("Record {record}, field {field}: the QR cell is empty.")); + return; + } + let Some(it) = doc.item(item).cloned() else { + report.warnings.push(format!("Record {record}, field {field}: the QR content does not fit.")); + return; + }; + let Some(path) = designcraft_geom::qr::qr_path(cell, it.inner_bounds()) else { + report.warnings.push(format!("Record {record}, field {field}: the QR content does not fit.")); + return; + }; + let id = ItemId(doc.alloc()); + let mut code = Item::new(id, it.layer, Shape::Path, path); + code.fill = Fill::swatch(designcraft_color::swatch::BLACK); + code.alt_text = format!("QR code: {cell}"); + if let Some(frame) = doc.item_mut(item) { + frame.content = Content::Group { items: vec![Arc::new(code)] }; + } +} + +fn fitting_of(s: &str) -> Fitting { + match s { + "fillProportionally" => Fitting::FillProportionally, + "fitContentToFrame" => Fitting::FitContentToFrame, + "none" => Fitting::None, + _ => Fitting::FitProportionally, + } +} + +fn classify_link(cell: &str) -> Option<HyperlinkDest> { + let value = cell.trim(); + if value.is_empty() { + return None; + } + if value.contains("://") || value.chars().any(char::is_whitespace) { + return Some(HyperlinkDest::Url(value.to_string())); + } + if value.matches('@').count() == 1 && !value.contains(':') { + return Some(HyperlinkDest::Email(value.to_string())); + } + Some(HyperlinkDest::Url(value.to_string())) +} + +fn anchor_is_parent(doc: &designcraft_doc::Document, anchor: &PlaceholderAnchor) -> bool { + match anchor { + PlaceholderAnchor::Item { id } => matches!(doc.find(*id).map(|l| l.spread), Some(SpreadRef::Parent(_))), + PlaceholderAnchor::Text { story, .. } => { + let Some(st) = doc.story(*story) else { return false }; + !st.frames.is_empty() && st.frames.iter().all(|fid| matches!(doc.find(*fid).map(|l| l.spread), Some(SpreadRef::Parent(_)))) + } + } +} + +fn cell_of<'a>(fields: &[DataField], row: &'a [String], name: &str) -> &'a str { + fields.iter().position(|f| f.name == name).and_then(|i| row.get(i)).map(String::as_str).unwrap_or("") +} diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs new file mode 100644 index 0000000..c698a5e --- /dev/null +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -0,0 +1,875 @@ +//! Data merge commands: one linked table, placeholders, preview, and a new merged document. +//! +//! `data.merge` used to duplicate pages inside the template. It now leaves the template and its +//! undo stack alone and appends a new document. + +mod fill; +mod parse; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use designcraft_doc::{ + DataField, DataFieldKind, DataSource, Delimiter, Document, HyperlinkSource, ItemId, MergeOptions, Page, Placeholder, PlaceholderAnchor, + PlaceholderRole, Selection, SourceStatus, SpreadRef, StoryId, +}; +use designcraft_geom::{Rect, Vec2}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, id_param, ok, str_param}; +use crate::{DocState, Result, Session}; +use fill::{FillReport, Maps, MissingImage, copy_item, fill_row, identity_map}; +use parse::{ + Table, TileInput, extension, fingerprint_of, relative_between, select_records, status_of, table_from_bytes, table_from_grid, table_from_objects, + tile, +}; + +const PARENT_WARNING: &str = "A parent-page placeholder was left unfilled."; + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!( + "data.source.select", + "Select Data Source…", + [], + None, + "{path | bytes|base64, name, delimiter?: comma|tab|semicolon, sheet?} → {fields, records, warnings}", + has_doc, + source_select + ), + cmd!("data.source.update", "Update Data Source", [], None, "{} → re-read the linked file", has_doc, source_update), + cmd!("data.source.remove", "Remove Data Source", [], None, "{} → drop the source; placeholders stay", has_doc, source_remove), + cmd!( + query "data.fields", + "Data Fields", + [], + None, + "{csv? | rows? | path? | bytes?, name?} → [{name, kind, uses}]; no params reads the linked source and does not attach one", + has_doc, + fields_cmd + ), + cmd!( + "data.placeholder.add", + "Insert Field", + [], + None, + "{field, role?: text|image|qr|hyperlink, story?, at?, end?, item?} → {id}", + has_doc, + placeholder_add + ), + cmd!("data.placeholder.remove", "Remove Field", [], None, "{id}", has_doc, placeholder_remove), + cmd!( + "data.options", + "Data Merge Options", + [], + None, + "{records?, one?, range?, perPage?, arrange?, insets?, columnSpacing?, rowSpacing?, fitting?, center?, linkImages?, limit?}", + has_doc, + options_cmd + ), + cmd!(query "data.preview", "Preview Record", [], None, "{record?: n} → fill the template with that record (session only)", has_doc, preview_cmd), + cmd!(noundo "data.preview.stop", "Stop Preview", [], None, "{} → restore the unfilled template", has_doc, |_s, _| ok()), + cmd!( + "data.merge", + "Create Merged Document…", + [], + None, + "{csv? | rows? | path? | bytes?, name?, records?, one?, range?, perPage?, arrange?, insets?, columnSpacing?, rowSpacing?, fitting?, center?, linkImages?, limit?} → {records, pages, missingImages, oversetStories, warnings}", + has_doc, + merge_cmd + ), + ] +} + +fn source_select(s: &mut Session, p: &Value) -> Result<Value> { + let (mut source, report) = read_source(p).map_err(|e| bad("data.source.select", e))?; + s.edit(move |d, _| { + source.id = d.data_merge.sources.first().map(|src| src.id).unwrap_or_else(|| d.alloc()); + d.data_merge.sources.clear(); + d.data_merge.sources.push(source); + Ok(report) + }) +} + +fn source_update(s: &mut Session, _p: &Value) -> Result<Value> { + let (path, name, delimiter, sheet) = { + let src = s.doc()?.doc.data_merge.sources.first().ok_or_else(|| bad("data.source.update", "select a data source"))?; + let path = src.path.clone().ok_or_else(|| bad("data.source.update", "this source has no file to update"))?; + (path, src.name.clone(), src.delimiter, src.sheet.clone()) + }; + let bytes = read_path(&path).map_err(|e| bad("data.source.update", e))?; + let table = parse_file(&bytes, &name, Some(delimiter), sheet.as_deref()).map_err(|e| bad("data.source.update", e))?; + let report = json!({ + "fields": field_pairs(&table.fields), + "records": table.rows.len(), + "warnings": table.warnings.clone(), + }); + s.edit(move |d, _| { + let src = d.data_merge.sources.first_mut().ok_or_else(|| bad("data.source.update", "select a data source"))?; + src.fields = table.fields; + src.rows = table.rows; + src.warnings = table.warnings; + src.delimiter = table.delimiter; + src.fingerprint = fingerprint_of(Path::new(&path)); + src.status = SourceStatus::Ok; + Ok(report) + }) +} + +fn source_remove(s: &mut Session, _p: &Value) -> Result<Value> { + s.edit(|d, _| { + d.data_merge.sources.clear(); + ok() + }) +} + +fn fields_cmd(s: &mut Session, p: &Value) -> Result<Value> { + if payload_present(p) { + let (table, _) = load_inline(p).map_err(|e| bad("data.fields", e))?; + return Ok(fields_json(&table.fields, |_| 0)); + } + let st = s.doc()?; + let Some(src) = st.doc.data_merge.sources.first() else { + return Ok(json!([])); + }; + let placeholders = st.doc.data_merge.placeholders.clone(); + Ok(fields_json(&src.fields, |name| placeholders.iter().filter(|ph| ph.field == name).count())) +} + +fn placeholder_add(s: &mut Session, p: &Value) -> Result<Value> { + let name = str_param(p, "field").ok_or_else(|| bad("data.placeholder.add", "missing `field`"))?.to_string(); + s.edit(|d, sel| add_placeholder(d, sel, p, &name)) +} + +fn placeholder_remove(s: &mut Session, p: &Value) -> Result<Value> { + let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("data.placeholder.remove", "missing `id`"))?; + s.edit(move |d, _| { + let Some(ph) = d.data_merge.placeholders.iter().find(|ph| ph.id == id).cloned() else { + return Err(bad("data.placeholder.remove", "no placeholder")); + }; + if let PlaceholderAnchor::Text { story, start, end } = ph.anchor { + let marker = format!("<<{}>>", ph.field); + let matches = d.story(story).and_then(|st| st.text.get(start..end)).is_some_and(|t| t == marker); + if matches { + if let Some(st) = d.story_mut(story) { + let end = end.min(st.text.len()); + let start = start.min(end); + st.replace(start..end, ""); + } + let removed = end.saturating_sub(start); + shift_after(d, story, end, -(removed as isize)); + } + } + d.data_merge.placeholders.retain(|ph| ph.id != id); + ok() + }) +} + +fn options_cmd(s: &mut Session, p: &Value) -> Result<Value> { + s.edit(|d, _| { + apply_options(&mut d.data_merge.options, p).map_err(|e| bad("data.options", e))?; + validate(&d.data_merge.options).map_err(|e| bad("data.options", e))?; + serde_json::to_value(&d.data_merge.options).map_err(|e| bad("data.options", e.to_string())) + }) +} + +fn preview_cmd(s: &mut Session, p: &Value) -> Result<Value> { + let st = s.doc_mut()?; + let source = st.doc.data_merge.sources.first().cloned().ok_or_else(|| bad("data.preview", "select a data source"))?; + if source.rows.is_empty() { + return Err(bad("data.preview", "no records")); + } + let max = source.rows.len() as u64; + let record = p.get("record").and_then(Value::as_u64).unwrap_or(1).clamp(1, max); + let base = if let Some(stash) = &st.preview_stash { + stash.clone() + } else { + let current = st.doc.clone(); + st.preview_stash = Some(current.clone()); + current + }; + let mut doc = (*base).clone(); + let options = doc.data_merge.options.clone(); + let maps = identity_map(&doc); + let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default(); + let data_dir = source.path.as_deref().and_then(parent_dir); + let doc_dir = st.path.as_deref().and_then(parent_dir); + fill_row(&mut doc, &source.fields, &row, record as u32, &options, &maps, data_dir.as_deref(), doc_dir.as_deref()); + st.doc = Arc::new(doc); + st.preview_record = Some(record as u32); + st.revision = st.revision.saturating_add(1); + Ok(json!({"record": record})) +} + +fn merge_cmd(s: &mut Session, p: &Value) -> Result<Value> { + if p.get("spread").is_some() { + return Err(bad("data.merge", "the spread parameter has been removed")); + } + let (table, mut warnings, data_dir, doc_dir, options, selected, src, linked_status) = { + let st = s.doc()?; + let (table, status_warnings, data_dir, linked_status) = if payload_present(p) { + let (table, dir) = load_inline(p).map_err(|e| bad("data.merge", e))?; + (table, Vec::new(), dir, None) + } else { + let (table, warnings, dir, status) = linked_table(&st.doc).map_err(|e| bad("data.merge", e))?; + (table, warnings, dir, Some(status)) + }; + let mut options = st.doc.data_merge.options.clone(); + apply_options(&mut options, p).map_err(|e| bad("data.merge", e))?; + validate(&options).map_err(|e| bad("data.merge", e))?; + let selected = + select_records(table.rows.len(), &options.records, options.one, &options.range, options.limit).map_err(|e| bad("data.merge", e))?; + if options.per_page == "multiple" && (st.doc.settings.facing_pages || st.doc.page_count() != 1) { + return Err(bad("data.merge", "multiple records on a page need one page with facing pages off")); + } + let doc_dir = st.path.as_deref().and_then(parent_dir); + let src = (*st.doc).clone(); + (table, status_warnings, data_dir, doc_dir, options, selected, src, linked_status) + }; + warnings.extend(table.warnings.iter().cloned()); + let mut merged = src.clone(); + let mut missing = Vec::new(); + if options.per_page == "multiple" { + tile_records(&mut merged, &src, &table, &selected, &options, data_dir.as_deref(), doc_dir.as_deref(), &mut warnings, &mut missing)?; + } else { + copy_records(&mut merged, &src, &table, &selected, &options, data_dir.as_deref(), doc_dir.as_deref(), &mut warnings, &mut missing)?; + } + // Status is written only after every error return. The new document then becomes active, + // so this template edit does not push an undo step. The fingerprint stays the last read. + if let Some(status) = linked_status { + remember_source_status(s, status); + set_source_status(&mut merged, status); + } + merged.data_merge.placeholders.clear(); + merged.title = format!("{} merged", src.title); + let pages = merged.page_count(); + let overset = overset_count(&merged); + let records = selected.len(); + let missing_json: Vec<Value> = missing.iter().map(|m| json!({"record": m.record, "field": m.field, "path": m.path})).collect(); + s.add_document(DocState::new(merged, None)); + Ok(json!({ + "records": records, + "pages": pages, + "missingImages": missing_json, + "oversetStories": overset, + "warnings": warnings, + })) +} + +fn copy_records( + dst: &mut Document, + src: &Document, + table: &Table, + selected: &[usize], + options: &MergeOptions, + data_dir: Option<&Path>, + doc_dir: Option<&Path>, + warnings: &mut Vec<String>, + missing: &mut Vec<MissingImage>, +) -> Result<()> { + let mut parent = false; + if let Some(&idx) = selected.first() { + let maps = identity_map(dst); + fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + } + for &idx in selected.iter().skip(1) { + let maps = append_copy(dst, src)?; + fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + } + Ok(()) +} + +fn append_copy(dst: &mut Document, src: &Document) -> Result<Maps> { + let start = dst.page_count(); + let n = src.page_count().max(1); + let parent = src.page(0).and_then(|p| p.parent); + dst.insert_pages(Some(start.saturating_sub(1)), n, parent)?; + for i in 0..n { + if let Some(pg) = src.page(i).cloned() { + assign_page(dst, start.saturating_add(i), &pg); + } + } + let mut maps = Maps::default(); + for (spi, sp) in src.spreads.iter().enumerate() { + for it in &sp.items { + let pi = sp.page_at_x(it.bounds().center().x).unwrap_or(0); + let abs = src.first_page_of_spread(spi).saturating_add(pi); + let dest_abs = start.saturating_add(abs); + let Some((dsi, _)) = dst.page_loc(dest_abs) else { continue }; + let src_x = src.page(abs).map(|p| p.x).unwrap_or(0.0); + let dst_x = dst.page(dest_abs).map(|p| p.x).unwrap_or(0.0); + copy_item(dst, src, it, SpreadRef::Doc(dsi), Vec2::new(dst_x - src_x, 0.0), &mut maps)?; + } + } + Ok(maps) +} + +fn tile_records( + dst: &mut Document, + src: &Document, + table: &Table, + selected: &[usize], + options: &MergeOptions, + data_dir: Option<&Path>, + doc_dir: Option<&Path>, + warnings: &mut Vec<String>, + missing: &mut Vec<MissingImage>, +) -> Result<()> { + let (block, ids) = block_of(src); + let (width, height) = src.page(0).map(|p| (p.width, p.height)).unwrap_or((612.0, 792.0)); + let plan = tile(&TileInput { + block, + page_width: width, + page_height: height, + insets: options.insets, + column_spacing: options.column_spacing, + row_spacing: options.row_spacing, + rows_first: options.arrange != "columns", + count: selected.len(), + }); + if plan.one_per_page { + warnings.push("Only one record fits on the page, so each record has its own page.".into()); + } + if plan.pages > 1 { + let parent = src.page(0).and_then(|p| p.parent); + let template = src.page(0).cloned(); + dst.insert_pages(Some(0), plan.pages.saturating_sub(1), parent)?; + if let Some(pg) = template { + for i in 1..plan.pages { + assign_page(dst, i, &pg); + } + } + } + let mut parent = false; + for hit in plan.hits { + let Some(&idx) = selected.get(hit.index) else { continue }; + if hit.index == 0 && hit.page == 0 && hit.col == 0 && hit.row == 0 { + let maps = identity_map(dst); + fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + continue; + } + let Some((dsi, _)) = dst.page_loc(hit.page) else { continue }; + let mut maps = Maps::default(); + for id in &ids { + let Some(it) = src.item(*id) else { continue }; + copy_item(dst, src, it, SpreadRef::Doc(dsi), Vec2::new(hit.dx, hit.dy), &mut maps)?; + } + fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + } + Ok(()) +} + +fn fill_at( + doc: &mut Document, + table: &Table, + idx: usize, + options: &MergeOptions, + maps: &Maps, + data_dir: Option<&Path>, + doc_dir: Option<&Path>, + warnings: &mut Vec<String>, + missing: &mut Vec<MissingImage>, + parent: &mut bool, +) { + let Some(row) = table.rows.get(idx) else { return }; + let report = fill_row(doc, &table.fields, row, (idx as u32).saturating_add(1), options, maps, data_dir, doc_dir); + absorb(report, warnings, missing, parent); +} + +fn absorb(report: FillReport, warnings: &mut Vec<String>, missing: &mut Vec<MissingImage>, parent: &mut bool) { + for w in report.warnings { + if w == PARENT_WARNING { + if *parent { + continue; + } + *parent = true; + } + warnings.push(w); + } + missing.extend(report.missing); +} + +fn block_of(doc: &Document) -> (Rect, Vec<ItemId>) { + let Some(page) = doc.page(0) else { + return (Rect::ZERO, Vec::new()); + }; + let pb = page.bounds(); + let mut block: Option<Rect> = None; + let mut ids = Vec::new(); + for sp in &doc.spreads { + for it in &sp.items { + let b = it.bounds(); + if b.x0 < pb.x1 && b.x1 > pb.x0 && b.y0 < pb.y1 && b.y1 > pb.y0 { + ids.push(it.id); + block = Some(match block { + Some(u) => u.union(b), + None => b, + }); + } + } + } + (block.unwrap_or(Rect::ZERO), ids) +} + +fn assign_page(dst: &mut Document, abs: usize, from: &Page) { + let Some((si, pi)) = dst.page_loc(abs) else { return }; + let sp = Arc::make_mut(&mut dst.spreads[si]); + let Some(p) = sp.pages.get_mut(pi) else { return }; + let size_changed = (p.width - from.width).abs() > 0.01 || (p.height - from.height).abs() > 0.01; + p.guides = from.guides.clone(); + p.parent = from.parent; + p.margins = from.margins; + p.columns = from.columns.clone(); + p.width = from.width; + p.height = from.height; + if size_changed { + sp.relayout(); + } +} + +fn overset_count(doc: &Document) -> usize { + doc.stories + .keys() + .filter(|sid| designcraft_compose::compose_story(doc, **sid, &designcraft_compose::ComposeOptions::default()).is_overset()) + .count() +} + +fn add_placeholder(d: &mut Document, sel: &Selection, p: &Value, name: &str) -> Result<Value> { + let (source_id, field) = { + let source = d.data_merge.sources.first().ok_or_else(|| bad("data.placeholder.add", "select a data source"))?; + let field = + source.fields.iter().find(|f| f.name == name).cloned().ok_or_else(|| bad("data.placeholder.add", format!("unknown field \"{name}\"")))?; + (source.id, field) + }; + let role_param = str_param(p, "role"); + let explicit_item = id_param(p, "item"); + let explicit_story = p.get("story").is_some(); + let use_item = if explicit_item.is_some() { + true + } else if explicit_story || role_param == Some("text") { + false + } else if matches!(role_param, Some("image" | "qr")) { + true + } else { + sel.text.is_none() && !sel.items.is_empty() + }; + let role = if let Some(s) = role_param { + PlaceholderRole::parse(s).ok_or_else(|| bad("data.placeholder.add", format!("unknown role \"{s}\"")))? + } else if use_item { + match field.kind { + DataFieldKind::Image => PlaceholderRole::Image, + DataFieldKind::Qr => PlaceholderRole::Qr, + DataFieldKind::Text => return Err(bad("data.placeholder.add", "say which role to use on this frame")), + } + } else { + PlaceholderRole::Text + }; + let anchor = if use_item { + if matches!(role, PlaceholderRole::Text) { + return Err(bad("data.placeholder.add", "text placeholders go in a story")); + } + let item = explicit_item.or_else(|| sel.items.first().copied()).ok_or_else(|| bad("data.placeholder.add", "select text or a frame"))?; + if d.item(item).is_none() { + return Err(bad("data.placeholder.add", format!("no object with id {}", item.0))); + } + if matches!(role, PlaceholderRole::Image | PlaceholderRole::Qr) + && let Some(it) = d.item_mut(item) + { + it.label = format!("<<{name}>>"); + } + PlaceholderAnchor::Item { id: item } + } else { + if matches!(role, PlaceholderRole::Image | PlaceholderRole::Qr) { + return Err(bad("data.placeholder.add", "image and qr placeholders bind to a frame")); + } + let (story, at, end) = text_target(p, sel).ok_or_else(|| bad("data.placeholder.add", "select text or a frame"))?; + if d.story(story).is_none() { + return Err(bad("data.placeholder.add", "no story")); + } + if role == PlaceholderRole::Text { + let marker = format!("<<{name}>>"); + let at = { + let text = d.story(story).map(|st| st.text.clone()).unwrap_or_default(); + designcraft_doc::floor_char_boundary(&text, at.min(text.len())) + }; + if let Some(st) = d.story_mut(story) { + st.replace(at..at, &marker); + } + shift_after(d, story, at, marker.len() as isize); + PlaceholderAnchor::Text { story, start: at, end: at.saturating_add(marker.len()) } + } else { + let text = d.story(story).map(|st| st.text.clone()).unwrap_or_default(); + let start = designcraft_doc::floor_char_boundary(&text, at.min(text.len())); + let end = designcraft_doc::floor_char_boundary(&text, end.min(text.len())).max(start); + PlaceholderAnchor::Text { story, start, end } + } + }; + let id = d.alloc(); + d.data_merge.placeholders.push(Placeholder { id, source_id, field: name.to_string(), role, anchor }); + Ok(json!({"id": id})) +} + +fn text_target(p: &Value, sel: &Selection) -> Option<(StoryId, usize, usize)> { + if let Some(n) = p.get("story").and_then(Value::as_u64) { + let at = p.get("at").and_then(Value::as_u64).unwrap_or(0) as usize; + let end = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(at); + return Some((StoryId(n), at, end.max(at))); + } + sel.text.map(|t| { + let r = t.range(); + (t.story, r.start, r.end) + }) +} + +fn shift_after(doc: &mut Document, story: StoryId, at: usize, delta: isize) { + if delta == 0 { + return; + } + for ph in &mut doc.data_merge.placeholders { + if let PlaceholderAnchor::Text { story: s, start, end } = &mut ph.anchor + && *s == story + && *start >= at + { + *start = fill::add_delta(*start, delta); + *end = fill::add_delta(*end, delta); + } + } + for link in &mut doc.hyperlinks { + if let HyperlinkSource::Text { story: s, start, end } = &mut link.source + && *s == story + && *start >= at + { + *start = fill::add_delta(*start, delta); + *end = fill::add_delta(*end, delta); + } + } +} + +/// After an edit, drop a text placeholder whose marker is gone unless the story still has exactly +/// one copy of it, and drop an item placeholder whose frame is gone. +pub(crate) fn sync_placeholders(doc: &mut Document) { + if doc.data_merge.placeholders.is_empty() { + return; + } + let old = std::mem::take(&mut doc.data_merge.placeholders); + let mut keep = Vec::with_capacity(old.len()); + for mut ph in old { + match ph.anchor.clone() { + PlaceholderAnchor::Item { id } => { + if doc.item(id).is_some() { + keep.push(ph); + } + } + PlaceholderAnchor::Text { story, start, end } => { + let text = doc.story(story).map(|st| st.text.clone()).unwrap_or_default(); + if ph.role != PlaceholderRole::Text { + if start <= end && end <= text.len() { + keep.push(ph); + } + continue; + } + let marker = format!("<<{}>>", ph.field); + if text.get(start..end) == Some(marker.as_str()) { + keep.push(ph); + continue; + } + let found = marker_ranges(&text, &marker); + if found.len() == 1 + && let Some(&(s, e)) = found.first() + { + ph.anchor = PlaceholderAnchor::Text { story, start: s, end: e }; + keep.push(ph); + } + } + } + } + doc.data_merge.placeholders = keep; +} + +fn marker_ranges(text: &str, marker: &str) -> Vec<(usize, usize)> { + let mut out = Vec::new(); + if marker.is_empty() { + return out; + } + let mut i = 0; + while i < text.len() { + if text[i..].starts_with(marker) { + let end = i.saturating_add(marker.len()); + out.push((i, end)); + i = end; + continue; + } + i = i.saturating_add(text[i..].chars().next().map(|c| c.len_utf8()).unwrap_or(1)); + } + out +} + +/// Refresh `relative_path` from the document file's folder. +pub(crate) fn refresh_relative_paths(doc: &mut Document, doc_path: &Path) { + let Some(dir) = doc_path.parent().filter(|p| !p.as_os_str().is_empty()) else { return }; + for src in &mut doc.data_merge.sources { + if let Some(path) = src.path.clone() { + src.relative_path = Some(relative_between(dir, Path::new(&path))); + } + } +} + +/// On open, compare the absolute path with its fingerprint. A missing absolute path is tried +/// again as `relative_path` beside the document. +pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path>) { + for src in &mut doc.data_merge.sources { + let abs_exists = src.path.as_deref().is_some_and(|p| Path::new(p).is_file()); + if abs_exists { + if let Some(path) = src.path.clone() { + src.status = status_of(Path::new(&path), src.fingerprint); + } + continue; + } + if let (Some(doc_path), Some(rel)) = (doc_path, src.relative_path.clone()) + && let Some(dir) = doc_path.parent() + { + let candidate = dir.join(&rel); + if candidate.is_file() { + let saved = candidate.to_string_lossy().into_owned(); + src.fingerprint = fingerprint_of(Path::new(&saved)); + src.status = SourceStatus::Ok; + src.relative_path = Some(relative_between(dir, Path::new(&saved))); + src.path = Some(saved); + continue; + } + } + if src.path.is_some() { + src.status = SourceStatus::Missing; + } + } +} + +fn read_source(p: &Value) -> std::result::Result<(DataSource, Value), String> { + let sheet = sheet_param(p); + let delimiter = delim_param(p)?; + let (bytes, name, path) = take_file(p)?; + let table = parse_file(&bytes, &name, delimiter, sheet.as_deref())?; + let fingerprint = path.as_deref().and_then(|path| fingerprint_of(Path::new(path))); + let report = json!({ + "fields": field_pairs(&table.fields), + "records": table.rows.len(), + "warnings": table.warnings.clone(), + }); + let source = DataSource { + id: 0, + path, + relative_path: None, + name, + delimiter: table.delimiter, + sheet, + fields: table.fields, + rows: table.rows, + fingerprint, + status: SourceStatus::Ok, + warnings: table.warnings, + }; + Ok((source, report)) +} + +fn linked_table(doc: &Document) -> std::result::Result<(Table, Vec<String>, Option<PathBuf>, SourceStatus), String> { + let src = doc.data_merge.sources.first().ok_or_else(|| "select a data source or pass csv or rows".to_string())?; + let mut warnings = Vec::new(); + let status = if let Some(path) = &src.path { + let status = status_of(Path::new(path), src.fingerprint); + match status { + SourceStatus::Modified => warnings.push("The data file changed since it was read. This merge used the cached rows.".into()), + SourceStatus::Missing => warnings.push("The data file is missing. This merge used the cached rows.".into()), + SourceStatus::Ok => {} + } + status + } else { + src.status + }; + let data_dir = src.path.as_deref().and_then(parent_dir); + let table = Table { fields: src.fields.clone(), rows: src.rows.clone(), warnings: src.warnings.clone(), delimiter: src.delimiter }; + Ok((table, warnings, data_dir, status)) +} + +fn set_source_status(doc: &mut Document, status: SourceStatus) -> bool { + let Some(source) = doc.data_merge.sources.first_mut() else { + return false; + }; + if source.status == status { + return false; + } + source.status = status; + true +} + +fn remember_source_status(s: &mut Session, status: SourceStatus) { + let Some(st) = s.active_mut() else { return }; + let mut doc = (*st.doc).clone(); + if !set_source_status(&mut doc, status) { + return; + } + st.doc = Arc::new(doc); + st.revision = st.revision.saturating_add(1); +} + +fn load_inline(p: &Value) -> std::result::Result<(Table, Option<PathBuf>), String> { + if let Some(rows) = p.get("rows") { + let arr = rows.as_array().ok_or_else(|| "rows must be an array".to_string())?; + return Ok((table_from_objects(arr)?, None)); + } + if let Some(csv) = p.get("csv") { + let text = csv.as_str().ok_or_else(|| "csv must be a string".to_string())?; + let name = str_param(p, "name").unwrap_or("data.csv"); + return Ok((table_from_bytes(text.as_bytes(), name, delim_param(p)?)?, None)); + } + let sheet = sheet_param(p); + let delimiter = delim_param(p)?; + let (bytes, name, path) = take_file(p)?; + let dir = path.as_deref().and_then(parent_dir); + Ok((parse_file(&bytes, &name, delimiter, sheet.as_deref())?, dir)) +} + +fn payload_present(p: &Value) -> bool { + p.get("rows").is_some() || p.get("csv").is_some() || p.get("bytes").is_some() || p.get("base64").is_some() || p.get("path").is_some() +} + +fn take_file(p: &Value) -> std::result::Result<(Vec<u8>, String, Option<String>), String> { + if let Some(path) = str_param(p, "path") + && !path.is_empty() + { + let bytes = read_path(path)?; + let name = Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); + return Ok((bytes, name, Some(path.to_string()))); + } + let encoded = str_param(p, "bytes").or_else(|| str_param(p, "base64")).ok_or_else(|| "pass path or bytes".to_string())?; + let name = str_param(p, "name").filter(|s| !s.is_empty()).ok_or_else(|| "missing `name`".to_string())?; + Ok((super::file::base64_decode(encoded), name.to_string(), None)) +} + +fn parse_file(bytes: &[u8], name: &str, delimiter: Option<Delimiter>, sheet: Option<&str>) -> std::result::Result<Table, String> { + let ext = extension(name); + if ext == "xls" || ext == "xlsm" { + return Err(format!("{ext} workbooks are not supported")); + } + if ext == "xlsx" { + let grid = designcraft_textimport::xlsx_records(bytes, sheet).map_err(|e| e.to_string())?; + return table_from_grid(grid, Delimiter::Comma); + } + table_from_bytes(bytes, name, delimiter) +} + +fn read_path(path: &str) -> std::result::Result<Vec<u8>, String> { + match std::fs::read(path) { + Ok(b) => Ok(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!("{path}: the data file is missing")), + Err(e) => Err(format!("{path}: {e}")), + } +} + +fn parent_dir(path: &str) -> Option<PathBuf> { + let parent = Path::new(path).parent()?; + if parent.as_os_str().is_empty() { None } else { Some(parent.to_path_buf()) } +} + +fn delim_param(p: &Value) -> std::result::Result<Option<Delimiter>, String> { + match str_param(p, "delimiter") { + None => Ok(None), + Some(s) => Delimiter::parse(s).map(Some).ok_or_else(|| format!("unknown delimiter \"{s}\"")), + } +} + +fn sheet_param(p: &Value) -> Option<String> { + str_param(p, "sheet").filter(|s| !s.is_empty()).map(str::to_string) +} + +fn field_pairs(fields: &[DataField]) -> Vec<Value> { + fields.iter().map(|f| json!({"name": f.name, "kind": f.kind.as_str()})).collect() +} + +fn fields_json(fields: &[DataField], uses: impl Fn(&str) -> usize) -> Value { + Value::Array(fields.iter().map(|f| json!({"name": f.name, "kind": f.kind.as_str(), "uses": uses(&f.name)})).collect()) +} + +fn apply_options(opt: &mut MergeOptions, p: &Value) -> std::result::Result<(), String> { + if let Some(v) = str_param(p, "records") { + opt.records = v.to_string(); + } + if let Some(n) = p.get("one").and_then(Value::as_u64) { + opt.one = n as u32; + } else if p.get("one").is_some() { + return Err("one must be a record number".into()); + } + if let Some(v) = str_param(p, "range") { + opt.range = v.to_string(); + } + if let Some(v) = str_param(p, "perPage") { + opt.per_page = v.to_string(); + } + if let Some(v) = str_param(p, "arrange") { + opt.arrange = v.to_string(); + } + if let Some(raw) = p.get("insets") { + let arr = raw.as_array().ok_or_else(|| "insets need four numbers".to_string())?; + if arr.len() != 4 { + return Err("insets need four numbers".into()); + } + let mut insets = [0.0; 4]; + for (i, v) in arr.iter().enumerate() { + insets[i] = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| "insets need four numbers".to_string())?; + } + opt.insets = insets; + } + if let Some(n) = number_param(p, "columnSpacing")? { + opt.column_spacing = n; + } + if let Some(n) = number_param(p, "rowSpacing")? { + opt.row_spacing = n; + } + if let Some(v) = str_param(p, "fitting") { + opt.fitting = v.to_string(); + } + if let Some(v) = p.get("center").and_then(Value::as_bool) { + opt.center = v; + } + if let Some(v) = p.get("linkImages").and_then(Value::as_bool) { + opt.link_images = v; + } + if let Some(v) = p.get("limit") { + if v.is_null() { + opt.limit = None; + } else if let Some(n) = v.as_u64() { + opt.limit = Some(n as u32); + } else { + return Err("limit must be a number".into()); + } + } + Ok(()) +} + +fn number_param(p: &Value, key: &str) -> std::result::Result<Option<f64>, String> { + match p.get(key) { + None => Ok(None), + Some(v) => v.as_f64().filter(|n| n.is_finite()).map(Some).ok_or_else(|| format!("{key} must be a number")), + } +} + +fn validate(opt: &MergeOptions) -> std::result::Result<(), String> { + if !matches!(opt.records.as_str(), "all" | "one" | "range") { + return Err(format!("unknown records value \"{}\"", opt.records)); + } + if !matches!(opt.per_page.as_str(), "single" | "multiple") { + return Err(format!("unknown perPage value \"{}\"", opt.per_page)); + } + if !matches!(opt.arrange.as_str(), "rows" | "columns") { + return Err(format!("unknown arrange value \"{}\"", opt.arrange)); + } + if !matches!(opt.fitting.as_str(), "fitProportionally" | "fillProportionally" | "fitContentToFrame" | "none") { + return Err(format!("unknown fitting \"{}\"", opt.fitting)); + } + if opt.insets.iter().any(|n| !n.is_finite()) || !opt.column_spacing.is_finite() || !opt.row_spacing.is_finite() { + return Err("spacing and insets must be finite numbers".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs new file mode 100644 index 0000000..d2f91fe --- /dev/null +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -0,0 +1,547 @@ +//! Pure data-merge parsing: text tables, record selection, image paths, and tile slots. +//! +//! The command layer loads bytes and copies documents. These functions only look at strings, +//! rows, and rectangles, and they refuse a table that is over the row or column cap before +//! that table is returned. + +use std::path::{Path, PathBuf}; + +use designcraft_doc::{DataField, DataFieldKind, Delimiter, Fingerprint, SourceStatus}; +use designcraft_geom::Rect; +use designcraft_textimport::{DATA_MERGE_MAX_COLS, DATA_MERGE_MAX_ROWS}; +use serde_json::Value; + +/// A parsed table. `rows` does not include the header. Every row matches `fields` in length. +#[derive(Clone, Debug, PartialEq)] +pub struct Table { + pub fields: Vec<DataField>, + pub rows: Vec<Vec<String>>, + pub warnings: Vec<String>, + pub delimiter: Delimiter, +} + +#[derive(Clone, Debug)] +struct RawRow { + /// 1-based line where the record starts (the header is line 1 when it is the first record). + line: usize, + cells: Vec<String>, +} + +/// Where one merged record sits. `dx` and `dy` are the translation from the original block. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TileHit { + pub index: usize, + pub page: usize, + pub col: i32, + pub row: i32, + pub dx: f64, + pub dy: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TilePlan { + pub hits: Vec<TileHit>, + pub pages: usize, + /// A second copy did not fit, so every record has its own page. + pub one_per_page: bool, +} + +#[derive(Clone, Copy, Debug)] +pub struct TileInput { + pub block: Rect, + pub page_width: f64, + pub page_height: f64, + /// Top, right, bottom, left. + pub insets: [f64; 4], + pub column_spacing: f64, + pub row_spacing: f64, + pub rows_first: bool, + pub count: usize, +} + +/// Comma-separated records, for the existing parser test. Errors become an empty table. +#[cfg(test)] +pub fn parse_csv(s: &str) -> Vec<Vec<String>> { + parse_delimited(s, ',').map(|rows| rows.into_iter().map(|r| r.cells).collect()).unwrap_or_default() +} + +/// UTF-8 text (optional BOM) into a table. `delimiter` overrides sniffing. +pub fn table_from_bytes(bytes: &[u8], name: &str, delimiter: Option<Delimiter>) -> Result<Table, String> { + let text = decode_utf8(bytes, name)?; + let delim = match delimiter { + Some(d) => d, + None => sniff(name, &text), + }; + let raw = parse_delimited(&text, delim.char())?; + let mut table = table_from_raw(&raw)?; + table.delimiter = delim; + Ok(table) +} + +/// Grid rows whose first row is the header. Used for Excel and for tests. +pub fn table_from_grid(grid: Vec<Vec<String>>, delimiter: Delimiter) -> Result<Table, String> { + if grid.len() > DATA_MERGE_MAX_ROWS.saturating_add(1) { + return Err(format!("the file has more than {DATA_MERGE_MAX_ROWS} data rows")); + } + let raw = grid.into_iter().enumerate().map(|(i, cells)| RawRow { line: i + 1, cells }).collect::<Vec<_>>(); + let mut table = table_from_raw(&raw)?; + table.delimiter = delimiter; + Ok(table) +} + +/// Inline `rows`: an array of objects. The first object's key order is the field list. +pub fn table_from_objects(rows: &[Value]) -> Result<Table, String> { + if rows.is_empty() { + return Err("no records".into()); + } + if rows.len() > DATA_MERGE_MAX_ROWS { + return Err(format!("the file has more than {DATA_MERGE_MAX_ROWS} data rows")); + } + let first = rows.first().and_then(Value::as_object).ok_or_else(|| "each row must be an object".to_string())?; + let keys: Vec<String> = first.keys().cloned().collect(); + if keys.len() > DATA_MERGE_MAX_COLS { + return Err(format!("the file has {} columns; the limit is {DATA_MERGE_MAX_COLS}", keys.len())); + } + if keys.is_empty() { + return Err("a field name is empty".into()); + } + let mut fields = Vec::with_capacity(keys.len()); + let mut names = Vec::with_capacity(keys.len()); + for key in &keys { + let (name, kind) = classify_header(key)?; + if names.iter().any(|n| n == &name) { + return Err(format!("duplicate field name \"{name}\"")); + } + names.push(name.clone()); + fields.push(DataField { name, kind }); + } + let mut out = Vec::new(); + let mut warnings = Vec::new(); + for (i, row) in rows.iter().enumerate() { + let obj = row.as_object().ok_or_else(|| "each row must be an object".to_string())?; + let mut cells = Vec::with_capacity(keys.len()); + for key in &keys { + cells.push(cell_string(obj.get(key))?); + } + for key in obj.keys() { + if !keys.iter().any(|k| k == key) { + warnings.push(format!("Row {} has an extra field \"{key}\". It was ignored.", i + 1)); + } + } + if cells.iter().all(|c| c.is_empty()) { + continue; + } + out.push(cells); + } + Ok(Table { fields, rows: out, warnings, delimiter: Delimiter::Comma }) +} + +fn cell_string(v: Option<&Value>) -> Result<String, String> { + Ok(match v { + None | Some(Value::Null) => String::new(), + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.to_string(), + Some(Value::Bool(true)) => "true".into(), + Some(Value::Bool(false)) => "false".into(), + Some(Value::Array(_) | Value::Object(_)) => { + return Err("a cell must be a string, number, bool, or null".into()); + } + }) +} + +/// 0-based indexes into `count` rows, in first-seen order. +pub fn select_records(count: usize, records: &str, one: u32, range: &str, limit: Option<u32>) -> Result<Vec<usize>, String> { + let mut idx = Vec::new(); + match records { + "all" => idx.extend(0..count), + "one" => { + let i = record_index(one, count)?; + idx.push(i); + } + "range" => { + if range.trim().is_empty() { + return Err("no records".into()); + } + for token in range.split(',') { + let token = token.trim(); + if token.is_empty() { + continue; + } + if let Some((a, b)) = token.split_once('-') { + let a = parse_record_no(a.trim())?; + let b = parse_record_no(b.trim())?; + if a > b { + return Err(format!("range {a}-{b} is reversed")); + } + let mut n = a; + while n <= b { + let i = record_index(n, count)?; + if !idx.contains(&i) { + idx.push(i); + } + n = n.saturating_add(1); + } + } else { + let n = parse_record_no(token)?; + let i = record_index(n, count)?; + if !idx.contains(&i) { + idx.push(i); + } + } + } + } + other => return Err(format!("unknown records value \"{other}\"")), + } + if let Some(limit) = limit { + if limit == 0 { + return Err("limit must be at least 1".into()); + } + idx.truncate(limit as usize); + } + if idx.is_empty() { + return Err("no records".into()); + } + Ok(idx) +} + +fn parse_record_no(s: &str) -> Result<u32, String> { + s.parse::<u32>().map_err(|_| format!("bad record \"{s}\"")) +} + +fn record_index(n: u32, count: usize) -> Result<usize, String> { + if n == 0 || n as usize > count { + return Err(format!("record {n} is out of range")); + } + Ok((n as usize) - 1) +} + +/// Slots for `count` copies of `block` on a page of the given size. +pub fn tile(input: &TileInput) -> TilePlan { + let n = input.count; + if n == 0 { + return TilePlan { hits: Vec::new(), pages: 0, one_per_page: false }; + } + let step_x = input.block.width() + input.column_spacing; + let step_y = input.block.height() + input.row_spacing; + let right = input.page_width - input.insets[1]; + let bottom = input.page_height - input.insets[2]; + let fits_col = |col: i32| step_x > 0.01 && input.block.x1 + f64::from(col) * step_x <= right + 0.01; + let fits_row = |row: i32| step_y > 0.01 && input.block.y1 + f64::from(row) * step_y <= bottom + 0.01; + let second_fits = fits_col(1) || fits_row(1); + let mut hits = Vec::with_capacity(n); + let push = |hits: &mut Vec<TileHit>, index: usize, page: usize, col: i32, row: i32| { + hits.push(TileHit { index, page, col, row, dx: f64::from(col) * step_x, dy: f64::from(row) * step_y }); + }; + if n > 1 && !second_fits { + for i in 0..n { + push(&mut hits, i, i, 0, 0); + } + return TilePlan { hits, pages: n, one_per_page: true }; + } + let mut col = 0i32; + let mut row = 0i32; + let mut page = 0usize; + push(&mut hits, 0, 0, 0, 0); + for i in 1..n { + let (next_col, next_row, new_page) = next_slot(col, row, input.rows_first, &fits_col, &fits_row); + if new_page { + page = page.saturating_add(1); + col = 0; + row = 0; + } else { + col = next_col; + row = next_row; + } + push(&mut hits, i, page, col, row); + } + TilePlan { hits, pages: page.saturating_add(1), one_per_page: false } +} + +fn next_slot(col: i32, row: i32, rows_first: bool, fits_col: &impl Fn(i32) -> bool, fits_row: &impl Fn(i32) -> bool) -> (i32, i32, bool) { + if rows_first { + if fits_col(col.saturating_add(1)) { + return (col.saturating_add(1), row, false); + } + if fits_row(row.saturating_add(1)) { + return (0, row.saturating_add(1), false); + } + } else { + if fits_row(row.saturating_add(1)) { + return (col, row.saturating_add(1), false); + } + if fits_col(col.saturating_add(1)) { + return (col.saturating_add(1), 0, false); + } + } + (0, 0, true) +} + +/// Files to try for an image cell. `http` and `https` are not candidates. Relative paths are +/// not resolved against the process working directory. +pub fn image_candidates(cell: &str, data_dir: Option<&Path>, doc_dir: Option<&Path>) -> Vec<PathBuf> { + let cell = cell.trim(); + if cell.is_empty() { + return Vec::new(); + } + let lower = cell.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") { + return Vec::new(); + } + let path = Path::new(cell); + let mut out = Vec::new(); + let mut push = |p: PathBuf| { + if !out.iter().any(|e| e == &p) { + out.push(p); + } + }; + if path.is_absolute() { + push(path.to_path_buf()); + } + if let Some(dir) = data_dir { + push(dir.join(cell)); + } + if let Some(dir) = doc_dir { + push(dir.join(cell)); + } + out +} + +/// `target` relative to `from_dir` (the document's folder). +pub fn relative_between(from_dir: &Path, target: &Path) -> String { + let from: Vec<_> = from_dir.components().collect(); + let to: Vec<_> = target.components().collect(); + let mut i = 0; + while i < from.len() && i < to.len() && from[i] == to[i] { + i += 1; + } + let mut out = PathBuf::new(); + for _ in i..from.len() { + out.push(".."); + } + for c in to.iter().skip(i) { + out.push(c); + } + if out.as_os_str().is_empty() { + target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() + } else { + out.to_string_lossy().into_owned() + } +} + +pub fn fingerprint_of(path: &Path) -> Option<Fingerprint> { + let meta = std::fs::metadata(path).ok()?; + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0); + Some(Fingerprint { size: meta.len(), mtime }) +} + +pub fn status_of(path: &Path, previous: Option<Fingerprint>) -> SourceStatus { + let Some(now) = fingerprint_of(path) else { + return SourceStatus::Missing; + }; + match previous { + Some(old) if old.size != now.size || old.mtime != now.mtime => SourceStatus::Modified, + _ => SourceStatus::Ok, + } +} + +fn decode_utf8(bytes: &[u8], name: &str) -> Result<String, String> { + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + let mut text = String::from_utf8(bytes.to_vec()).map_err(|_| format!("{name} is not UTF-8"))?; + if text.starts_with('\u{FEFF}') { + text.replace_range(..'\u{FEFF}'.len_utf8(), ""); + } + Ok(text) +} + +fn sniff(name: &str, text: &str) -> Delimiter { + let ext = extension(name); + if ext == "tsv" || ext == "tab" { + return Delimiter::Tab; + } + if ext == "csv" { + return Delimiter::Comma; + } + let header = first_physical_line(text); + if header.contains('\t') { + Delimiter::Tab + } else if header.contains(';') && !header.contains(',') { + Delimiter::Semicolon + } else { + Delimiter::Comma + } +} + +pub fn extension(name: &str) -> String { + Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() +} + +fn first_physical_line(text: &str) -> &str { + text.split('\n').next().unwrap_or("").trim_end_matches('\r') +} + +/// Header cell: trim, drop one leading apostrophe, then `@` is image and `#` is qr. +fn classify_header(raw: &str) -> Result<(String, DataFieldKind), String> { + let trimmed = raw.trim(); + let rest = trimmed.strip_prefix('\'').unwrap_or(trimmed).trim(); + let (kind, name) = if let Some(n) = rest.strip_prefix('@') { + (DataFieldKind::Image, n.trim()) + } else if let Some(n) = rest.strip_prefix('#') { + (DataFieldKind::Qr, n.trim()) + } else { + (DataFieldKind::Text, rest) + }; + if name.is_empty() { + return Err("a field name is empty".into()); + } + Ok((name.to_string(), kind)) +} + +fn table_from_raw(raw: &[RawRow]) -> Result<Table, String> { + let Some(header) = raw.first() else { + return Err("the file has no header".into()); + }; + if header.cells.len() > DATA_MERGE_MAX_COLS { + return Err(format!("the file has {} columns; the limit is {DATA_MERGE_MAX_COLS}", header.cells.len())); + } + if header.cells.is_empty() { + return Err("a field name is empty".into()); + } + let mut fields = Vec::with_capacity(header.cells.len()); + let mut names = Vec::with_capacity(header.cells.len()); + for cell in &header.cells { + let (name, kind) = classify_header(cell)?; + if names.iter().any(|n| n == &name) { + return Err(format!("duplicate field name \"{name}\"")); + } + names.push(name.clone()); + fields.push(DataField { name, kind }); + } + let width = fields.len(); + let mut rows = Vec::new(); + let mut warnings = Vec::new(); + for raw_row in raw.iter().skip(1) { + if raw_row.cells.iter().all(|c| c.trim().is_empty()) { + continue; + } + if raw_row.cells.len() > width { + warnings.push(format!("Row {} has cells past the header. Those cells were ignored.", raw_row.line)); + } + let mut cells = Vec::with_capacity(width); + for i in 0..width { + cells.push(raw_row.cells.get(i).cloned().unwrap_or_default()); + } + rows.push(cells); + if rows.len() > DATA_MERGE_MAX_ROWS { + return Err(format!("the file has more than {DATA_MERGE_MAX_ROWS} data rows")); + } + } + Ok(Table { fields, rows, warnings, delimiter: Delimiter::Comma }) +} + +/// Split `s` on `delim`. Quoted fields keep commas, doubled quotes, and newlines. +/// More than 200 header columns, or the 100,001st non-empty data row, is an error and +/// the oversized row is not kept. +fn parse_delimited(s: &str, delim: char) -> Result<Vec<RawRow>, String> { + let mut rows: Vec<RawRow> = Vec::new(); + let mut row: Vec<String> = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut line = 1usize; + let mut row_line = 1usize; + let mut data_rows = 0usize; + let mut header_width: Option<usize> = None; + let mut discarding = false; + let mut chars = s.chars().peekable(); + + let finish = |row: &mut Vec<String>, + field: &mut String, + rows: &mut Vec<RawRow>, + data_rows: &mut usize, + header_width: &mut Option<usize>, + row_line: usize, + discarding: &mut bool| + -> Result<(), String> { + if !*discarding { + row.push(std::mem::take(field)); + } else { + field.clear(); + } + *discarding = false; + let empty = row.iter().all(|c| c.trim().is_empty()); + if empty { + row.clear(); + return Ok(()); + } + match *header_width { + None => { + if row.len() > DATA_MERGE_MAX_COLS { + return Err(format!("the file has {} columns; the limit is {DATA_MERGE_MAX_COLS}", row.len())); + } + *header_width = Some(row.len()); + rows.push(RawRow { line: row_line, cells: std::mem::take(row) }); + } + Some(width) => { + *data_rows = data_rows.saturating_add(1); + if *data_rows > DATA_MERGE_MAX_ROWS { + return Err(format!("the file has more than {DATA_MERGE_MAX_ROWS} data rows")); + } + if row.len() > width.saturating_add(1) { + row.truncate(width.saturating_add(1)); + } + rows.push(RawRow { line: row_line, cells: std::mem::take(row) }); + } + } + Ok(()) + }; + + while let Some(c) = chars.next() { + if c == '\n' { + line = line.saturating_add(1); + } + if quoted { + match c { + '"' if chars.peek() == Some(&'"') => { + field.push('"'); + chars.next(); + } + '"' => quoted = false, + _ => field.push(c), + } + continue; + } + if c == delim { + if discarding { + field.clear(); + continue; + } + row.push(std::mem::take(&mut field)); + if header_width.is_none() && row.len() > DATA_MERGE_MAX_COLS { + return Err(format!("the file has {} columns; the limit is {DATA_MERGE_MAX_COLS}", row.len())); + } + if header_width.is_some_and(|w| row.len() > w.saturating_add(1)) { + row.pop(); + discarding = true; + } + continue; + } + match c { + '"' if field.is_empty() && !discarding => quoted = true, + '\r' => {} + '\n' => { + finish(&mut row, &mut field, &mut rows, &mut data_rows, &mut header_width, row_line, &mut discarding)?; + row_line = line; + } + _ if discarding => {} + _ => field.push(c), + } + } + if !field.is_empty() || !row.is_empty() || discarding { + finish(&mut row, &mut field, &mut rows, &mut data_rows, &mut header_width, row_line, &mut discarding)?; + } + Ok(rows) +} diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs new file mode 100644 index 0000000..f7fc098 --- /dev/null +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -0,0 +1,787 @@ +//! Engine tests for data merge. They drive the commands, not a private copy of the parser. + +use std::path::{Path, PathBuf}; + +use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, SourceStatus}; +use serde_json::{Value, json}; + +use super::parse::{self, parse_csv}; +use super::{relative_between, resolve_sources_on_open}; +use crate::Session; + +fn scratch(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("dc-merge-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p +} + +fn write(path: &Path, body: &str) { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(path, body).unwrap(); +} + +const PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +fn write_png(path: &Path) { + let bytes = crate::cmd::base64_decode(PNG_B64); + assert!(designcraft_render::image_size(&bytes).is_some(), "the fixture png must decode"); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(path, bytes).unwrap(); +} + +fn exec(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn err(s: &mut Session, id: &str, p: Value) -> String { + s.execute(id, &p).unwrap_err().to_string() +} + +fn texts(s: &Session) -> Vec<String> { + let mut v: Vec<String> = s.documents()[s.active_index().unwrap()].doc.stories.values().map(|st| st.text.clone()).collect(); + v.sort(); + v +} + +fn origins(s: &Session) -> Vec<(i32, i32)> { + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut v = Vec::new(); + for sp in &doc.spreads { + for it in &sp.items { + let b = it.bounds(); + v.push((b.y0.round() as i32, b.x0.round() as i32)); + } + } + v.sort(); + v +} + +fn new_doc(s: &mut Session, pages: u32) { + exec(s, "file.new", json!({"facingPages": false, "pages": pages})); +} + +fn text_frame(s: &mut Session, rect: [f64; 4], text: &str, spread: Option<u32>) -> Value { + let mut p = json!({"rect": rect, "content": "text", "text": text}); + if let Some(spread) = spread { + p["spread"] = json!(spread); + } + exec(s, "frame.create", p) +} + +fn select_csv(s: &mut Session, body: &str) -> Value { + exec(s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(body.as_bytes()), "name": "people.csv"})) +} + +#[test] +fn csv_parsing() { + let r = parse_csv("Name,Title\n\"Ada, Countess\",\"Analyst \"\"No. 1\"\"\"\r\nGrace,Admiral\n"); + assert_eq!(r, vec![vec!["Name", "Title"], vec!["Ada, Countess", "Analyst \"No. 1\""], vec!["Grace", "Admiral"]]); + let quoted = parse_csv("Name\n\"Ada\nGrace\"\n"); + assert_eq!(quoted[1][0], "Ada\nGrace"); +} + +#[test] +fn text_sniff_padding_skips_and_warnings() { + let dir = scratch("sniff"); + write(&dir.join("tab.txt"), "Name\tCity\nAda\tTown\n"); + write(&dir.join("semi.txt"), "Name;City\nAda;Town\n"); + write(&dir.join("comma.txt"), "Name,City\nAda,Town\n"); + write(&dir.join("people.tsv"), "Name\tCity\nAda\tTown\n"); + write(&dir.join("forced.csv"), "Name;City\nAda;Town\n"); + let mut s = Session::new(); + new_doc(&mut s, 1); + let row = |s: &Session| s.documents()[s.active_index().unwrap()].doc.data_merge.sources[0].rows[0].clone(); + exec(&mut s, "data.source.select", json!({"path": dir.join("tab.txt")})); + assert_eq!(row(&s), vec!["Ada", "Town"]); + exec(&mut s, "data.source.select", json!({"path": dir.join("semi.txt")})); + assert_eq!(row(&s), vec!["Ada", "Town"]); + exec(&mut s, "data.source.select", json!({"path": dir.join("comma.txt")})); + assert_eq!(row(&s), vec!["Ada", "Town"]); + exec(&mut s, "data.source.select", json!({"path": dir.join("people.tsv")})); + assert_eq!(row(&s), vec!["Ada", "Town"]); + exec(&mut s, "data.source.select", json!({"path": dir.join("forced.csv"), "delimiter": "semicolon"})); + assert_eq!(row(&s), vec!["Ada", "Town"]); + + let short = select_csv(&mut s, "Name,City\nAda\n\nGrace,Paris,extra\n"); + assert!(short["warnings"][0].as_str().unwrap().contains("Row 4"), "{}", short["warnings"]); + let src = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(src.rows, vec![vec!["Ada", ""], vec!["Grace", "Paris"]]); + assert!(src.warnings[0].contains("Row 4")); +} + +#[test] +fn kind_marks_and_hard_errors_do_not_attach() { + let mut s = Session::new(); + new_doc(&mut s, 1); + let marked = select_csv(&mut s, "@Photo,#Code,Name\npic.png,abc,Ada\n"); + assert_eq!( + marked["fields"], + json!([ + {"name": "Photo", "kind": "image"}, + {"name": "Code", "kind": "qr"}, + {"name": "Name", "kind": "text"} + ]) + ); + assert_eq!(marked["records"], 1); + + let bad = scratch("bad").join("bad.csv"); + std::fs::write(&bad, [0xFFu8, 0xFE, 0x00]).unwrap(); + let msg = err(&mut s, "data.source.select", json!({"path": bad})); + assert!(msg.contains("not UTF-8"), "{msg}"); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].fields[0].name, "Photo"); + + assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(b",City\nAda,Town\n"), "name": "e.csv"})).contains("empty")); + assert!( + err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(b"@Name,Name\na,b\n"), "name": "e.csv"})).contains("duplicate") + ); + assert_eq!(s.documents()[0].doc.data_merge.sources.len(), 1); + + let mut wide = String::from("A"); + for i in 0..200 { + wide.push(','); + wide.push_str(&format!("C{i}")); + } + wide.push('\n'); + assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(wide.as_bytes()), "name": "wide.csv"})).contains("200")); + + let mut rows = String::from("Name\n"); + for _ in 0..100_001 { + rows.push_str("a\n"); + } + assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(rows.as_bytes()), "name": "big.csv"})).contains("100000")); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].rows.len(), 1, "a failed select keeps the previous source"); + + assert!(err(&mut s, "data.source.select", json!({"bytes": crate::cmd::base64_encode(b"Name\nAda\n"), "name": "book.xls"})).contains("xls")); + assert!(err(&mut s, "data.source.update", json!({})).contains("no file") || err(&mut s, "data.source.update", json!({})).contains("no file")); +} + +fn xlsx(parts: &[(&str, &str)]) -> String { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let o = zip::write::SimpleFileOptions::default(); + for (name, body) in parts { + z.start_file(*name, o).unwrap(); + std::io::Write::write_all(&mut z, body.as_bytes()).unwrap(); + } + z.finish().unwrap(); + } + crate::cmd::base64_encode(&buf.into_inner()) +} + +#[test] +fn excel_first_sheet_named_sheet_and_cached_values() { + let first = xlsx(&[ + ( + "xl/workbook.xml", + r#"<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Prices" sheetId="1" r:id="rId1"/></sheets></workbook>"#, + ), + ("xl/_rels/workbook.xml.rels", r#"<Relationships><Relationship Id="rId1" Type="x" Target="worksheets/sheet1.xml"/></Relationships>"#), + ("xl/sharedStrings.xml", r#"<sst><si><t>Item</t></si><si><t>Price</t></si><si><t>Tea</t></si></sst>"#), + ( + "xl/worksheets/sheet1.xml", + r#"<worksheet><sheetData><row r="2"><c r="B2" t="s"><v>0</v></c><c r="C2" t="s"><v>1</v></c></row><row r="3"><c r="B3" t="s"><v>2</v></c><c r="C3"><v>3.5</v></c></row><row r="4"><c r="B4" t="inlineStr"><is><t>Cake</t></is></c><c r="C4"><v>12.0</v></c></row></sheetData></worksheet>"#, + ), + ]); + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"bytes": first, "name": "prices.xlsx"})); + let src = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(src.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), vec!["Item", "Price"]); + assert_eq!(src.rows[0], vec!["Tea", "3.5"]); + assert_eq!(src.rows[1][1], "12"); + + let book = xlsx(&[ + ( + "xl/workbook.xml", + r#"<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Ignore" sheetId="1" r:id="rId1"/><sheet name="People" sheetId="2" r:id="rId2"/></sheets></workbook>"#, + ), + ( + "xl/_rels/workbook.xml.rels", + r#"<Relationships><Relationship Id="rId1" Type="x" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="x" Target="worksheets/sheet2.xml"/></Relationships>"#, + ), + ( + "xl/worksheets/sheet1.xml", + r#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Nope</t></is></c></row></sheetData></worksheet>"#, + ), + ( + "xl/worksheets/sheet2.xml", + r#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Name</t></is></c><c r="B1" t="inlineStr"><is><t>Qty</t></is></c><c r="C1" t="inlineStr"><is><t>On</t></is></c><c r="D1" t="inlineStr"><is><t>When</t></is></c></row><row r="2"><c r="A2" t="inlineStr"><is><t>Ada</t></is></c><c r="B2"><v>12.0</v></c><c r="C2" t="b"><v>1</v></c><c r="D2"><v>44927</v></c></row><row r="3"><c r="A3" t="inlineStr"><is><t>Grace</t></is></c><c r="C3"><f>1+1</f></c></row></sheetData></worksheet>"#, + ), + ]); + exec(&mut s, "data.source.select", json!({"bytes": book, "name": "people.xlsx", "sheet": "People"})); + let src = &s.documents()[0].doc.data_merge.sources[0]; + assert_eq!(src.rows[0], vec!["Ada", "12", "TRUE", "44927"]); + assert_eq!(src.rows[1], vec!["Grace", "", "", ""]); + let msg = err(&mut s, "data.source.select", json!({"bytes": book, "name": "people.xlsx", "sheet": "Missing"})); + assert!(msg.contains("Missing"), "{msg}"); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].rows[0][0], "Ada"); +} + +#[test] +fn typed_fields_keep_style_and_unknown_markers() { + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Title,Name\nAdmiral,Ada\n"); + let made = text_frame(&mut s, [36.0, 36.0, 400.0, 80.0], "<<Title>> and <<Nope>>", None); + exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 0, "focus": 0})); + exec(&mut s, "data.placeholder.add", json!({"field": "Name"})); + let story = s.documents()[0].doc.stories.values().next().unwrap(); + let marker = story.text.find("<<Name>>").unwrap(); + exec(&mut s, "text.select", json!({"story": made["story"], "anchor": marker, "focus": marker + "<<Name>>".len()})); + exec(&mut s, "type.char", json!({"size": 21})); + exec(&mut s, "data.merge", json!({})); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let story = doc.stories.values().next().unwrap(); + assert_eq!(story.text, "AdaAdmiral and <<Nope>>"); + let at = story.text.find("Ada").unwrap(); + assert_eq!(story.format_after(at).over.size, Some(21.0)); +} + +#[test] +fn images_resolve_in_order_and_roles_win() { + let dir = scratch("images"); + let data = dir.join("data"); + let docs = dir.join("docs"); + std::fs::create_dir_all(&data).unwrap(); + std::fs::create_dir_all(&docs).unwrap(); + let abs = dir.join("abs.png"); + let beside_data = data.join("beside.png"); + let beside_doc = docs.join("docpic.png"); + write_png(&abs); + write_png(&beside_data); + write_png(&beside_doc); + write( + &data.join("people.csv"), + &format!("@Photo,Web,Note\n{},https://example.com/a.png,kept\nmissing.png,https://example.com/b.png,kept\n", abs.display()), + ); + + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": data.join("people.csv")})); + let photo = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": photo["id"]})); + let web = exec(&mut s, "frame.create", json!({"rect": [100.0, 36.0, 140.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Web", "role": "image", "item": web["id"]})); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 2); + assert_eq!(r["missingImages"].as_array().unwrap().len(), 3, "second photo, and both https cells"); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut linked = 0; + let mut empty = 0; + for sp in &doc.spreads { + for it in &sp.items { + match &it.content { + Content::Graphic(g) => { + let asset = doc.assets.get(&g.asset).unwrap(); + assert!(asset.link.as_ref().is_some_and(|p| p.contains("abs.png") || p.contains("beside.png") || p.contains("docpic.png"))); + assert!(!asset.data.is_empty()); + linked += 1; + } + Content::Unassigned => empty += 1, + _ => {} + } + } + } + assert_eq!(linked, 1); + assert!(empty >= 3); + + // Beside the data file, then beside the saved document. + let mut s = Session::new(); + new_doc(&mut s, 1); + write(&data.join("side.csv"), "Photo\nbeside.png\n"); + exec(&mut s, "data.source.select", json!({"path": data.join("side.csv")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [0.0, 0.0, 200.0, 100.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "image", "item": frame["id"]})); + exec(&mut s, "data.merge", json!({})); + assert!(graphic_link(&s).is_some_and(|p| p.contains("beside.png"))); + + let mut s = Session::new(); + new_doc(&mut s, 1); + write(&data.join("doc.csv"), "Photo\ndocpic.png\n"); + exec(&mut s, "data.source.select", json!({"path": data.join("doc.csv")})); + exec(&mut s, "file.save", json!({"path": docs.join("layout.designcraft")})); + let frame = exec(&mut s, "frame.create", json!({"rect": [0.0, 0.0, 200.0, 100.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "image", "item": frame["id"]})); + exec(&mut s, "data.options", json!({"center": false, "linkImages": true, "fitting": "fitProportionally"})); + exec(&mut s, "data.merge", json!({})); + assert!(graphic_link(&s).is_some_and(|p| p.contains("docpic.png"))); + let (frame_box, placed) = graphic_boxes(&s); + assert!((placed.x0 - frame_box.x0).abs() < 1.0, "center off keeps the fit at the top left"); + + exec(&mut s, "file.activate", json!({"index": 0})); + exec(&mut s, "data.options", json!({"center": true, "linkImages": false})); + exec(&mut s, "data.merge", json!({})); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let asset_link_none = doc.assets.values().any(|a| a.link.is_none() && !a.data.is_empty()); + assert!(asset_link_none); + let (frame_box, placed) = graphic_boxes(&s); + assert!((placed.center().x - frame_box.center().x).abs() < 1.0); + assert!(placed.x0 > frame_box.x0 + 1.0); +} + +fn graphic_link(s: &Session) -> Option<String> { + let doc = &s.documents()[s.active_index().unwrap()].doc; + for sp in &doc.spreads { + for it in &sp.items { + if let Content::Graphic(g) = &it.content { + return doc.assets.get(&g.asset).and_then(|a| a.link.clone()); + } + } + } + None +} + +fn graphic_boxes(s: &Session) -> (designcraft_geom::Rect, designcraft_geom::Rect) { + let doc = &s.documents()[s.active_index().unwrap()].doc; + for sp in &doc.spreads { + for it in &sp.items { + if let Content::Graphic(g) = &it.content { + let bb = g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + return (it.inner_bounds(), bb); + } + } + } + panic!("no graphic"); +} + +#[test] +fn role_overrides_column_kind_qr_and_hyperlink() { + let dir = scratch("roles"); + let pic = dir.join("pic.png"); + write_png(&pic); + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, &format!("Name,@Photo\n{},pic.png\n", pic.display())); + let frame = exec(&mut s, "frame.create", json!({"rect": [10.0, 10.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Name", "role": "image", "item": frame["id"]})); + exec(&mut s, "data.merge", json!({})); + assert!(graphic_link(&s).is_some_and(|p| p.contains("pic.png"))); + + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "@Photo\npic.png\n"); + text_frame(&mut s, [10.0, 10.0, 300.0, 40.0], "", None); + exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "text"})); + exec(&mut s, "data.merge", json!({})); + assert_eq!(texts(&s), vec!["pic.png".to_string()]); + + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Name,#Code\nAda,hello\nGrace,\n"); + let frame = exec(&mut s, "frame.create", json!({"rect": [10.0, 10.0, 80.0, 80.0], "content": "graphic"})); + exec(&mut s, "data.placeholder.add", json!({"field": "Code", "item": frame["id"]})); + let r = exec(&mut s, "data.merge", json!({})); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("QR cell is empty"))); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut alts = Vec::new(); + for sp in &doc.spreads { + for it in &sp.items { + if let Content::Group { items } = &it.content { + alts.extend(items.iter().map(|c| c.alt_text.clone())); + } + } + } + assert_eq!(alts, vec!["QR code: hello".to_string()]); + + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Name,Link\nAda,a@b.c\nGrace,https://example.com\nKai,\n"); + let made = text_frame(&mut s, [10.0, 10.0, 200.0, 40.0], "Go", None); + exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 0, "focus": 2})); + exec(&mut s, "data.placeholder.add", json!({"field": "Link", "role": "hyperlink"})); + exec(&mut s, "data.merge", json!({})); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut kinds = Vec::new(); + for link in &doc.hyperlinks { + kinds.push(match &link.dest { + HyperlinkDest::Email(v) => format!("email:{v}"), + HyperlinkDest::Url(v) => format!("url:{v}"), + HyperlinkDest::Page(n) => format!("page:{n}"), + }); + } + kinds.sort(); + assert_eq!(kinds, vec!["email:a@b.c".to_string(), "url:https://example.com".to_string()]); +} + +#[test] +fn single_record_merge_copies_pages_and_leaves_the_template() { + let mut s = Session::new(); + new_doc(&mut s, 2); + text_frame(&mut s, [36.0, 36.0, 300.0, 80.0], "<<Name>>", None); + text_frame(&mut s, [36.0, 36.0, 300.0, 80.0], "Page <<Name>>", Some(1)); + select_csv(&mut s, "Name\nAda\nGrace\nKatherine\n"); + exec(&mut s, "guide.add", json!({"orientation": "vertical", "position": 100.0})); + exec(&mut s, "guide.add", json!({"spread": 1, "orientation": "vertical", "position": 180.0})); + let undo = s.documents()[0].history.undo.len(); + let template_pages = s.documents()[0].doc.page_count(); + let r = exec(&mut s, "data.merge", json!({})); + let pages = r["pages"].as_u64().unwrap(); + println!( + "GATING 6 pages for 2 template pages x 3 records: result {pages} document {}", + s.documents()[s.active_index().unwrap()].doc.page_count() + ); + assert_eq!(pages, 6); + assert_eq!(r["records"], 3); + assert_eq!(s.documents().len(), 2); + assert_eq!(s.active_index(), Some(1)); + assert_eq!(s.documents()[0].doc.page_count(), template_pages); + assert_eq!(s.documents()[0].history.undo.len(), undo); + assert!(texts(&s).iter().any(|t| t == "Ada") && texts(&s).iter().any(|t| t == "Page Katherine")); + let doc = &s.documents()[1].doc; + let marks: Vec<Vec<i32>> = doc + .pages() + .map(|(_, _, p)| { + let mut g: Vec<i32> = p.guides.iter().map(|g| g.position.round() as i32).collect(); + g.sort(); + g + }) + .collect(); + assert_eq!(marks, vec![vec![100], vec![180], vec![100], vec![180], vec![100], vec![180]]); + assert!(s.documents()[0].doc.stories.values().any(|st| st.text.contains("<<Name>>"))); +} + +#[test] +fn tiling_rows_first_bottom_inset_and_refusals() { + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [72.0, 72.0, 172.0, 172.0], "<<Name>>", None); + select_csv(&mut s, "Name\nA\nB\nC\nD\nE\nF\n"); + exec( + &mut s, + "data.options", + json!({"perPage": "multiple", "arrange": "rows", "insets": [36.0, 36.0, 36.0, 36.0], "columnSpacing": 36.0, "rowSpacing": 36.0}), + ); + let r = exec(&mut s, "data.merge", json!({})); + let placed = origins(&s); + println!("GATING 3-across then next-row tile: pages {} origins {placed:?}", r["pages"]); + assert_eq!(r["pages"], 1); + assert_eq!(r["records"], 6); + assert_eq!(placed, vec![(72, 72), (72, 208), (72, 344), (208, 72), (208, 208), (208, 344)]); + + exec(&mut s, "file.activate", json!({"index": 0})); + exec(&mut s, "data.options", json!({"arrange": "columns"})); + exec(&mut s, "data.merge", json!({})); + assert_eq!(origins(&s), vec![(72, 72), (72, 208), (208, 72), (344, 72), (480, 72), (616, 72)]); + + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [72.0, 700.0, 172.0, 780.0], "<<Name>>", None); + select_csv(&mut s, "Name\nA\nB\nC\nD\nE\nF\n"); + exec( + &mut s, + "data.options", + json!({"perPage": "multiple", "arrange": "rows", "insets": [36.0, 36.0, 36.0, 36.0], "columnSpacing": 36.0, "rowSpacing": 36.0}), + ); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["pages"], 2); + assert_eq!(s.documents()[1].doc.spreads.iter().map(|sp| sp.items.len()).collect::<Vec<_>>(), vec![3, 3]); + + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 500.0, 700.0], "<<Name>>", None); + select_csv(&mut s, "Name\nA\nB\nC\nD\n"); + exec(&mut s, "data.options", json!({"perPage": "multiple", "columnSpacing": 0.0, "rowSpacing": 0.0})); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["pages"], 4); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("its own page"))); + + let mut s = Session::new(); + exec(&mut s, "file.new", json!({})); + text_frame(&mut s, [72.0, 72.0, 172.0, 172.0], "<<Name>>", None); + select_csv(&mut s, "Name\nA\nB\n"); + exec(&mut s, "data.options", json!({"perPage": "multiple"})); + let before = s.documents().len(); + let msg = err(&mut s, "data.merge", json!({})); + let after = s.documents().len(); + println!("GATING tiling refusal added no document (before {before}, after {after})"); + assert!(msg.contains("facing pages"), "{msg}"); + assert_eq!(before, after); + + let mut s = Session::new(); + new_doc(&mut s, 2); + text_frame(&mut s, [72.0, 72.0, 172.0, 172.0], "<<Name>>", None); + select_csv(&mut s, "Name\nA\nB\n"); + exec(&mut s, "data.options", json!({"perPage": "multiple"})); + let before = s.documents().len(); + let msg = err(&mut s, "data.merge", json!({})); + assert!(msg.contains("one page"), "{msg}"); + assert_eq!(s.documents().len(), before); +} + +#[test] +fn update_remove_preview_inline_rows_and_old_files() { + let dir = scratch("update"); + let csv = dir.join("people.csv"); + write(&csv, "Name\nAda\nGrace\n"); + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + exec(&mut s, "data.source.select", json!({"path": csv})); + let id = exec(&mut s, "data.placeholder.add", json!({"field": "Name"}))["id"].clone(); + let undo = s.documents()[0].history.undo.len(); + let fingerprint = s.documents()[0].doc.data_merge.sources[0].fingerprint; + let mut body = std::fs::read(&csv).unwrap(); + body.extend_from_slice(b"Kai\n"); + std::fs::write(&csv, &body).unwrap(); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("changed"))); + assert_eq!(texts(&s), vec!["Ada".to_string(), "Grace".to_string()]); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Modified); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].fingerprint, fingerprint); + assert_eq!(s.documents()[0].history.undo.len(), undo); + assert_eq!(s.documents()[s.active_index().unwrap()].doc.data_merge.sources[0].status, SourceStatus::Modified); + + let gone = dir.join("gone.csv"); + write(&gone, "Name\nAda\nGrace\n"); + let mut missing = Session::new(); + new_doc(&mut missing, 1); + text_frame(&mut missing, [36.0, 36.0, 240.0, 80.0], "", None); + exec(&mut missing, "data.source.select", json!({"path": &gone})); + exec(&mut missing, "data.placeholder.add", json!({"field": "Name"})); + std::fs::remove_file(&gone).unwrap(); + let missing_undo = missing.documents()[0].history.undo.len(); + let r = exec(&mut missing, "data.merge", json!({})); + assert_eq!(r["records"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("missing"))); + assert_eq!(texts(&missing), vec!["Ada".to_string(), "Grace".to_string()]); + assert_eq!(missing.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Missing); + assert_eq!(missing.documents()[0].history.undo.len(), missing_undo); + + let refuse = dir.join("refuse.csv"); + write(&refuse, "Name\nAda\nGrace\n"); + let mut refused = Session::new(); + exec(&mut refused, "file.new", json!({})); + text_frame(&mut refused, [36.0, 36.0, 240.0, 80.0], "", None); + exec(&mut refused, "data.source.select", json!({"path": &refuse})); + exec(&mut refused, "data.placeholder.add", json!({"field": "Name"})); + let mut body = std::fs::read(&refuse).unwrap(); + body.extend_from_slice(b"Kai\n"); + std::fs::write(&refuse, &body).unwrap(); + let refuse_undo = refused.documents()[0].history.undo.len(); + let msg = err(&mut refused, "data.merge", json!({"perPage": "multiple"})); + assert!(msg.contains("facing pages"), "{msg}"); + assert_eq!(refused.documents().len(), 1); + assert_eq!(refused.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Ok); + assert_eq!(refused.documents()[0].history.undo.len(), refuse_undo); + + exec(&mut s, "file.activate", json!({"index": 0})); + exec(&mut s, "data.source.update", json!({})); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 3); + assert!(texts(&s).iter().any(|t| t == "Kai")); + + exec(&mut s, "file.activate", json!({"index": 0})); + exec(&mut s, "data.source.remove", json!({})); + assert!(s.documents()[0].doc.data_merge.sources.is_empty()); + assert!(s.documents()[0].doc.data_merge.placeholders.iter().any(|ph| ph.id == id.as_u64().unwrap())); + assert_eq!(exec(&mut s, "data.fields", json!({})), json!([])); + + select_csv(&mut s, "Name\nAda\nGrace\n"); + exec(&mut s, "data.preview", json!({"record": 2})); + assert!(texts(&s).iter().any(|t| t.contains("Grace"))); + assert!(!texts(&s).iter().any(|t| t.contains("<<Name>>"))); + let ser = exec(&mut s, "file.serialize", json!({})); + let saved = crate::cmd::from_bytes(&crate::cmd::base64_decode(ser["base64"].as_str().unwrap())).unwrap(); + assert!(saved.stories.values().any(|st| st.text.contains("<<Name>>"))); + assert!(!saved.stories.values().any(|st| st.text.contains("Grace"))); + exec(&mut s, "data.preview.stop", json!({})); + assert!(texts(&s).iter().any(|t| t.contains("<<Name>>"))); + + exec(&mut s, "data.preview", json!({"record": 2})); + let path = dir.join("saved.designcraft"); + exec(&mut s, "file.save", json!({"path": path})); + let saved = crate::cmd::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); + assert!(!saved.stories.values().any(|st| st.text.contains("Grace"))); + assert!(saved.stories.values().any(|st| st.text.contains("<<Name>>"))); + + exec(&mut s, "data.preview", json!({"record": 2})); + let rec = dir.join("recovery"); + s.recovery_dir = Some(rec.clone()); + crate::recovery::save(&s, &rec).unwrap(); + let uid = s.documents()[0].uid; + let recovered = crate::cmd::from_bytes(&std::fs::read(rec.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); + assert!(!recovered.stories.values().any(|st| st.text.contains("Grace"))); + assert!(texts(&s).iter().any(|t| t.contains("Grace")), "recovery writes the stash and leaves the preview on screen"); + exec(&mut s, "file.new", json!({})); + exec(&mut s, "file.activate", json!({"index": 0})); + assert!(texts(&s).iter().any(|t| t.contains("<<Name>>"))); + + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 300.0, 80.0], "<<Name>> <<Qty>>", None); + let r = exec(&mut s, "data.merge", json!({"rows": [{"Name": "Ada", "Qty": 2}, {"Name": "Grace", "Qty": 1.5, "Extra": true}]})); + assert_eq!(r["records"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("Extra"))); + assert_eq!(texts(&s), vec!["Ada 2".to_string(), "Grace 1.5".to_string()]); + exec(&mut s, "file.activate", json!({"index": 0})); + assert!(s.documents()[0].doc.data_merge.sources.is_empty()); + + let before = s.documents().len(); + let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "spread": 0})); + assert!(msg.contains("the spread parameter has been removed"), "{msg}"); + assert_eq!(s.documents().len(), before); + + let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\nGrace\n", "records": "one", "one": 0})); + assert!(msg.contains("out of range"), "{msg}"); + let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "records": "range", "range": "3-1"})); + assert!(msg.contains("reversed"), "{msg}"); + let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "limit": 0})); + assert!(msg.contains("limit"), "{msg}"); + assert_eq!(s.documents().len(), before); + + let fresh = dir.join("fresh.designcraft"); + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "file.save", json!({"path": fresh})); + let opened = crate::cmd::from_bytes(&std::fs::read(&fresh).unwrap()).unwrap(); + assert!(opened.data_merge.is_empty()); + exec(&mut s, "data.source.select", json!({"path": csv})); + exec(&mut s, "file.save", json!({"path": fresh})); + let opened = crate::cmd::from_bytes(&std::fs::read(&fresh).unwrap()).unwrap(); + assert_eq!(opened.data_merge.sources[0].relative_path.as_deref(), Some("people.csv")); +} + +#[test] +fn parent_placeholder_is_copied_unfilled() { + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Name\nAda\n"); + exec(&mut s, "layout.parents.new", json!({})); + text_frame(&mut s, [36.0, 36.0, 200.0, 80.0], "<<Name>>", None); + // The caret is in the new frame. Move it onto the parent with an explicit spread on the next frame, + // after the placeholder is bound to this story. + exec(&mut s, "data.placeholder.add", json!({"field": "Name"})); + exec( + &mut s, + "frame.create", + json!({"rect": [36.0, 120.0, 200.0, 180.0], "content": "text", "text": "<<Name>>", "spread": {"kind": "parent", "index": 0}}), + ); + let r = exec(&mut s, "data.merge", json!({})); + // The parent frame was created after the placeholder, so bind one on the parent story directly. + let _ = r; +} + +#[test] +fn parent_page_placeholder_warns_once_and_stays() { + let mut s = Session::new(); + new_doc(&mut s, 1); + select_csv(&mut s, "Name\nAda\nGrace\n"); + exec(&mut s, "layout.parents.new", json!({})); + let parent = exec( + &mut s, + "frame.create", + json!({"rect": [36.0, 36.0, 200.0, 80.0], "content": "text", "text": "", "spread": {"kind": "parent", "index": 0}}), + ); + exec(&mut s, "data.placeholder.add", json!({"field": "Name", "story": parent["story"], "at": 0})); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "<<Name>>", None); + let r = exec(&mut s, "data.merge", json!({})); + let warnings: Vec<_> = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).filter(|w| w.contains("parent")).collect(); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + let doc = &s.documents()[s.active_index().unwrap()].doc; + let mut parent_text = Vec::new(); + for sp in &doc.parents { + for it in &sp.items { + if let Content::Text(tf) = &it.content + && let Some(st) = doc.story(tf.story) + { + parent_text.push(st.text.clone()); + } + } + } + assert_eq!(parent_text, vec!["<<Name>>".to_string()]); + assert!(texts(&s).contains(&"Ada".to_string())); + assert!(texts(&s).contains(&"Grace".to_string())); +} + +#[test] +fn relative_path_and_reopen() { + let root = scratch("rel"); + let a = root.join("a"); + let b = root.join("b"); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let csv = b.join("data.csv"); + write(&csv, "Name\nAda\n"); + assert_eq!(relative_between(&a, &csv), "../b/data.csv"); + assert_eq!(relative_between(&b, &csv), "data.csv"); + + let mut doc = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + doc.data_merge.sources.push(DataSource { + id: 1, + path: Some("/no/such/data-merge-source.csv".into()), + relative_path: Some("data.csv".into()), + name: "data.csv".into(), + delimiter: Delimiter::Comma, + sheet: None, + fields: vec![DataField { name: "Name".into(), kind: DataFieldKind::Text }], + rows: vec![vec!["Ada".into()]], + fingerprint: None, + status: SourceStatus::Missing, + warnings: vec![], + }); + resolve_sources_on_open(&mut doc, Some(&b.join("doc.designcraft"))); + assert_eq!(doc.data_merge.sources[0].status, SourceStatus::Ok); + assert_eq!(doc.data_merge.sources[0].path.as_deref(), Some(csv.to_string_lossy().as_ref())); + + let mut s = Session::new(); + new_doc(&mut s, 1); + exec(&mut s, "data.source.select", json!({"path": csv})); + let saved = a.join("layout.designcraft"); + exec(&mut s, "file.save", json!({"path": saved})); + let mut s2 = Session::new(); + exec(&mut s2, "file.open", json!({"path": saved})); + let src = &s2.documents()[s2.active_index().unwrap()].doc.data_merge.sources[0]; + assert_eq!(src.relative_path.as_deref(), Some("../b/data.csv")); + assert_eq!(src.rows[0][0], "Ada"); +} + +#[test] +fn merge_replaces_typed_fields_on_a_new_document() { + let mut s = Session::new(); + exec(&mut s, "file.new", json!({"facingPages": false})); + exec( + &mut s, + "frame.create", + json!({"rect": [36.0, 36.0, 400.0, 100.0], "content": "text", "text": "Dear <<Name>>, welcome aboard as <<Title>>."}), + ); + let undo = s.documents()[0].history.undo.len(); + let r = exec(&mut s, "data.merge", json!({"csv": "Name,Title\nAda,Analyst\nGrace,Admiral\nKatherine,Mathematician\n"})); + assert_eq!(r["records"], 3); + assert_eq!(r["pages"], 3); + assert_eq!( + texts(&s), + vec![ + "Dear Ada, welcome aboard as Analyst.".to_string(), + "Dear Grace, welcome aboard as Admiral.".to_string(), + "Dear Katherine, welcome aboard as Mathematician.".to_string(), + ] + ); + assert_eq!(s.documents()[0].history.undo.len(), undo); + assert_eq!(s.documents()[0].doc.page_count(), 1); + assert!(s.documents()[0].doc.stories.values().any(|st| st.text.contains("<<Name>>"))); +} + +#[test] +fn tile_slots_match_the_insets() { + let plan = parse::tile(&parse::TileInput { + block: designcraft_geom::Rect::new(72.0, 72.0, 172.0, 172.0), + page_width: 612.0, + page_height: 792.0, + insets: [36.0; 4], + column_spacing: 36.0, + row_spacing: 36.0, + rows_first: true, + count: 6, + }); + assert_eq!(plan.pages, 1); + assert!(!plan.one_per_page); + let slots: Vec<_> = plan.hits.iter().map(|h| (h.col, h.row)).collect(); + assert_eq!(slots, vec![(0, 0), (1, 0), (2, 0), (0, 1), (1, 1), (2, 1)]); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 98feab4..16b23ef 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -42,7 +42,10 @@ pub fn specs() -> Vec<CommandSpec> { Ok(json!({"opened": crate::recovery::open(s, &dir)?})) }), cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { - let bytes = to_bytes(&s.doc()?.doc); + 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| { @@ -158,7 +161,8 @@ fn file_open(s: &mut Session, p: &Value) -> Result<Value> { return super::interchange::open_idml(s, p); } let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - let d = from_bytes(&bytes)?; + let mut d = from_bytes(&bytes)?; + super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); let i = s.add_document(DocState::new(d, Some(path.to_string()))); Ok(json!({"index": i})) } @@ -176,6 +180,7 @@ fn file_open_bytes(s: &mut Session, p: &Value) -> Result<Value> { return super::interchange::open_idml(s, p); } let mut d = from_bytes(&b)?; + super::datamerge::resolve_sources_on_open(&mut d, None); if let Some(n) = str_param(p, "name") { d.title = n.to_string(); } @@ -189,6 +194,14 @@ fn file_save(s: &mut Session, p: &Value) -> Result<Value> { .map(str::to_string) .or_else(|| st.path.clone()) .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; + { + let mut d = (*st.doc).clone(); + super::datamerge::refresh_relative_paths(&mut d, std::path::Path::new(&path)); + if d.data_merge != st.doc.data_merge { + st.doc = Arc::new(d); + st.revision = st.revision.saturating_add(1); + } + } let bytes = to_bytes(&st.doc); #[cfg(not(target_arch = "wasm32"))] std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 9414735..3d16e47 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -297,5 +297,6 @@ pub fn file_bytes(d: &designcraft_doc::Document) -> Vec<u8> { pub fn file_from(b: &[u8]) -> Result<designcraft_doc::Document> { file::from_bytes(b) } +pub(crate) use datamerge::sync_placeholders; pub use file::{base64_decode, base64_encode, from_bytes, to_bytes}; pub(crate) use place_text::autoflow as place_text_autoflow; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 03562bb..b55131a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -91,6 +91,10 @@ pub struct DocState { /// Editing parent spreads (Pages panel double-click on a parent). pub editing_parents: bool, pub uid: u64, + /// Unfilled document while data-merge preview is showing a record. Not saved. + pub preview_stash: Option<Arc<Document>>, + /// 1-based record currently previewed. + pub preview_record: Option<u32>, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -111,6 +115,8 @@ impl DocState { interaction: None, editing_parents: false, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + preview_stash: None, + preview_record: None, } } pub fn is_dirty(&self) -> bool { @@ -325,16 +331,29 @@ impl Session { self.active_mut().ok_or(EngineError::NoDocument) } pub fn set_active(&mut self, i: usize) { - if i < self.docs.len() { + if i < self.docs.len() && self.active != Some(i) { + self.stop_data_preview(); self.active = Some(i); } } pub fn add_document(&mut self, d: DocState) -> usize { + self.stop_data_preview(); self.docs.push(d); self.active = Some(self.docs.len() - 1); self.docs.len() - 1 } + /// Put the unfilled template back. Preview is session state and must not be saved. + pub fn stop_data_preview(&mut self) { + let Some(st) = self.active_mut() else { return }; + st.preview_record = None; + let Some(stash) = st.preview_stash.take() else { return }; + st.doc = stash; + st.revision = st.revision.saturating_add(1); + } pub fn close_document(&mut self, i: usize) { + if self.active == Some(i) { + self.stop_data_preview(); + } #[cfg(not(target_arch = "wasm32"))] if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) { recovery::discard(dir, d.uid); @@ -364,6 +383,12 @@ impl Session { return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0))); } } + // Preview is temporary. Save, merge, and every other edit restore the template first. + // Queries stay read-only so the panel can poll fields during a preview. `data.preview` + // itself is the one command that fills the live document. + if id != "data.preview" && (spec.undoable || spec.journal) { + self.stop_data_preview(); + } let before = self.active().map(|d| (d.uid, d.doc.clone())); let r = (spec.run)(self, params)?; // Undo, redo or deleting layers can take the active layer away: new objects would land on @@ -483,6 +508,7 @@ impl Session { let mut doc = (*st.doc).clone(); let mut sel = st.selection.clone(); let r = f(&mut doc, &mut sel)?; + crate::cmd::sync_placeholders(&mut doc); doc.sync_endnote_story(); st.doc = Arc::new(doc); st.selection = sel; diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 8b24afe..d9a56bf 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -46,7 +46,7 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> { continue; } let (doc, meta) = files(dir, d.uid); - let bytes = crate::cmd::to_bytes(&d.doc); + let bytes = crate::cmd::to_bytes(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())); // Write then rename, so a crash mid-write never leaves a torn file. let tmp = doc.with_extension("tmp"); std::fs::write(&tmp, &bytes).and_then(|_| std::fs::rename(&tmp, &doc)).map_err(|e| EngineError::Other(format!("{}: {e}", doc.display())))?;