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![],