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.
This commit is contained in:
Cameron Gilroy 2026-10-07 21:22:06 +11:00
parent a83572a3b0
commit b41eba1cba
9 changed files with 2691 additions and 166 deletions

View File

@ -1,162 +0,0 @@
//! Data Merge (Window → Utilities → Data Merge): a template spread with `<<Field>>` 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<CommandSpec> {
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<Vec<String>> {
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<Value> {
let records: Vec<Vec<(String, String)>> = 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<ItemId> = 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<ItemId>> = 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 <<Name>>, welcome aboard as <<Title>>."}))
.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
}
}

View File

@ -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("")
}

View File

@ -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;

View File

@ -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)
}

View File

@ -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)]);
}

View File

@ -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}")))?;

View File

@ -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;

View File

@ -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;

View File

@ -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())))?;