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:
parent
a83572a3b0
commit
b41eba1cba
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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("")
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
|
|
@ -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}")))?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())))?;
|
||||
|
|
|
|||
Loading…
Reference in New Issue