From b3dafb8244c5a6414c15284a79d0db4e14864b68 Mon Sep 17 00:00:00 2001 From: Cameron Gilroy Date: Wed, 7 Oct 2026 21:22:06 +1100 Subject: [PATCH] Data merge: store a linked source, placeholders, and options A document keeps one data source, the placeholders that point at its fields, and the last merge layout. Empty merge state is omitted from the file. New documents and IDML imports start with that state empty. --- crates/doc/src/build.rs | 3 +- crates/doc/src/datamerge.rs | 268 ++++++++++++++++++++++++++++++++++++ crates/doc/src/lib.rs | 8 ++ crates/idml/src/import.rs | 1 + 4 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 crates/doc/src/datamerge.rs diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index ea58043c0..62d39f280 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -11,7 +11,7 @@ use crate::ids::{ItemId, LayerId, PageId, SpreadId}; use crate::item::Item; use crate::page::{Columns, Margins, Page, PageSide, ParentInfo, Section, Spread}; use crate::styles::Styles; -use crate::{DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, ParaFormat, Result, SpreadRef}; +use crate::{DataMerge, DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, ParaFormat, Result, SpreadRef}; /// A New Document preset. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -145,6 +145,7 @@ impl Document { }], assets: BTreeMap::new(), hyperlinks: vec![], + data_merge: DataMerge::default(), bookmarks: vec![], user_words: vec![], hyphenation_exceptions: vec![], diff --git a/crates/doc/src/datamerge.rs b/crates/doc/src/datamerge.rs new file mode 100644 index 000000000..2bf06c846 --- /dev/null +++ b/crates/doc/src/datamerge.rs @@ -0,0 +1,268 @@ +//! Data merge: one linked table, placeholders, and the last merge options. +//! +//! Phase 1 stores at most one source. The list is the seam for a later multi-source manager. +//! Preview is session state and is not stored here. + +use serde::{Deserialize, Serialize}; + +use crate::{ItemId, StoryId}; + +/// How a column is read when a placeholder does not say otherwise. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DataFieldKind { + #[default] + Text, + Image, + Qr, +} + +impl DataFieldKind { + pub fn as_str(self) -> &'static str { + match self { + DataFieldKind::Text => "text", + DataFieldKind::Image => "image", + DataFieldKind::Qr => "qr", + } + } +} + +/// A column in a data source. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataField { + pub name: String, + pub kind: DataFieldKind, +} + +/// Text delimiter. Excel sources leave this unused. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Delimiter { + #[default] + Comma, + Tab, + Semicolon, +} + +impl Delimiter { + pub fn as_str(self) -> &'static str { + match self { + Delimiter::Comma => "comma", + Delimiter::Tab => "tab", + Delimiter::Semicolon => "semicolon", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "comma" => Some(Delimiter::Comma), + "tab" => Some(Delimiter::Tab), + "semicolon" => Some(Delimiter::Semicolon), + _ => None, + } + } + + pub fn char(self) -> char { + match self { + Delimiter::Comma => ',', + Delimiter::Tab => '\t', + Delimiter::Semicolon => ';', + } + } +} + +/// File size and modification time of the last successful read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Fingerprint { + pub size: u64, + pub mtime: i64, +} + +/// Whether the file on disk still matches the cache. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SourceStatus { + #[default] + Ok, + Missing, + Modified, +} + +/// One linked table. Rows are the cache. Each row is the same length as `fields`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataSource { + pub id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relative_path: Option, + pub name: String, + #[serde(default)] + pub delimiter: Delimiter, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sheet: Option, + pub fields: Vec, + pub rows: Vec>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + #[serde(default)] + pub status: SourceStatus, + /// Extra-cell notes from the last read, repeated on a later merge. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +/// What a placeholder does at fill time. This wins over the column kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PlaceholderRole { + Text, + Image, + Qr, + Hyperlink, +} + +impl PlaceholderRole { + pub fn as_str(self) -> &'static str { + match self { + PlaceholderRole::Text => "text", + PlaceholderRole::Image => "image", + PlaceholderRole::Qr => "qr", + PlaceholderRole::Hyperlink => "hyperlink", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "text" => Some(PlaceholderRole::Text), + "image" => Some(PlaceholderRole::Image), + "qr" => Some(PlaceholderRole::Qr), + "hyperlink" => Some(PlaceholderRole::Hyperlink), + _ => None, + } + } +} + +/// Where the placeholder sits. Offsets match `HyperlinkSource::Text` (story byte offsets). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum PlaceholderAnchor { + Text { story: StoryId, start: usize, end: usize }, + Item { id: ItemId }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Placeholder { + pub id: u64, + pub source_id: u64, + pub field: String, + pub role: PlaceholderRole, + pub anchor: PlaceholderAnchor, +} + +/// Last merge options, saved with the document. The merge command reads these and does not +/// write them back, so a one-off run leaves the template's undo stack alone. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MergeOptions { + /// `all`, `one`, or `range`. + #[serde(default = "all_records")] + pub records: String, + /// 1-based record used when `records` is `one`. + #[serde(default = "one_u32")] + pub one: u32, + /// Range string used when `records` is `range`. + #[serde(default)] + pub range: String, + /// `single` or `multiple`. + #[serde(default = "single_page")] + pub per_page: String, + /// `rows` or `columns`. Used when tiling. + #[serde(default = "arrange_rows")] + pub arrange: String, + /// Top, right, bottom, left, in points. + #[serde(default = "default_insets")] + pub insets: [f64; 4], + #[serde(default)] + pub column_spacing: f64, + #[serde(default)] + pub row_spacing: f64, + /// `fitProportionally`, `fillProportionally`, `fitContentToFrame`, or `none`. + #[serde(default = "fit_prop")] + pub fitting: String, + #[serde(default)] + pub center: bool, + #[serde(default = "yes")] + pub link_images: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +fn all_records() -> String { + "all".into() +} +fn one_u32() -> u32 { + 1 +} +fn single_page() -> String { + "single".into() +} +fn arrange_rows() -> String { + "rows".into() +} +fn default_insets() -> [f64; 4] { + [36.0, 36.0, 36.0, 36.0] +} +fn fit_prop() -> String { + "fitProportionally".into() +} +fn yes() -> bool { + true +} + +impl Default for MergeOptions { + fn default() -> Self { + MergeOptions { + records: all_records(), + one: 1, + range: String::new(), + per_page: single_page(), + arrange: arrange_rows(), + insets: default_insets(), + column_spacing: 0.0, + row_spacing: 0.0, + fitting: fit_prop(), + center: false, + link_images: true, + limit: None, + } + } +} + +impl MergeOptions { + pub fn is_default(&self) -> bool { + self == &MergeOptions::default() + } +} + +/// Document data-merge state. Empty state is omitted from the save. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DataMerge { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sources: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub placeholders: Vec, + #[serde(default, skip_serializing_if = "MergeOptions::is_default")] + pub options: MergeOptions, +} + +impl DataMerge { + pub fn is_empty(&self) -> bool { + self.sources.is_empty() && self.placeholders.is_empty() && self.options.is_default() + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 271126d53..23d08930e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -13,6 +13,7 @@ pub mod anchored; pub mod arrow; pub mod attrs; pub mod build; +pub mod datamerge; mod edit; pub mod endnotes; pub mod ids; @@ -34,6 +35,10 @@ use std::sync::Arc; pub use anchored::{AnchorPosition, AnchoredObject, OBJECT_MARK}; pub use attrs::*; +pub use datamerge::{ + DataField, DataFieldKind, DataMerge, DataSource, Delimiter, Fingerprint, MergeOptions, Placeholder, PlaceholderAnchor, PlaceholderRole, + SourceStatus, +}; pub use designcraft_color as color; pub use designcraft_geom as geom; pub use edit::{ItemLoc, ItemPath, SpreadRef, item_hit as edit_hit}; @@ -482,6 +487,9 @@ pub struct Document { /// Hyperlinks (Window → Interactive → Hyperlinks). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub hyperlinks: Vec, + /// Data merge: the linked table, placeholders, and the last merge options. + #[serde(default, skip_serializing_if = "DataMerge::is_empty")] + pub data_merge: DataMerge, /// PDF bookmarks (Window → Interactive → Bookmarks). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub bookmarks: Vec, diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 8cf17fe04..ac6b0d8a6 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -2123,6 +2123,7 @@ impl<'r> Importer<'r> { sections: std::mem::take(&mut self.sections), assets: std::mem::take(&mut self.assets), hyperlinks: vec![], + data_merge: Default::default(), bookmarks: vec![], user_words: vec![], hyphenation_exceptions: vec![],