use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::spec_expand::expand_yaml_document_spec;
use crate::DocumentMetadata;
use crate::{DocxError, HeaderFooter, PageNumbering, PageSetup, Result, Stylesheet};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DocumentSpec {
pub output_name: Option<String>,
pub metadata: DocumentMetadata,
pub page_setup: Option<PageSetup>,
pub header: Option<HeaderFooter>,
pub footer: Option<HeaderFooter>,
pub page_numbering: Option<PageNumbering>,
pub styles: Stylesheet,
pub blocks: Vec<BlockSpec>,
#[serde(skip)]
asset_base_dir: Option<PathBuf>,
}
impl DocumentSpec {
pub fn new() -> Self {
Self::default()
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let content = fs::read_to_string(path)?;
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let mut spec = match extension.as_str() {
"yaml" | "yml" | "" => Self::from_yaml_path(&content, Some(path)),
"json" => Self::from_json_str(&content),
"toml" => Self::from_toml_str(&content),
other => Err(DocxError::parse(format!(
"unsupported document spec extension '{other}', expected .yaml, .yml, .json, or .toml"
))),
}?;
spec.asset_base_dir = path.parent().map(Path::to_path_buf);
Ok(spec)
}
pub fn from_yaml_str(content: &str) -> Result<Self> {
Self::from_yaml_path(content, None)
}
pub fn from_json_str(content: &str) -> Result<Self> {
serde_json::from_str(content)
.map_err(|error| DocxError::parse(format!("invalid JSON document spec: {error}")))
}
pub fn from_toml_str(content: &str) -> Result<Self> {
toml::from_str(content)
.map_err(|error| DocxError::parse(format!("invalid TOML document spec: {error}")))
}
pub fn to_yaml_string(&self) -> Result<String> {
serde_yaml::to_string(self)
.map_err(|error| DocxError::parse(format!("failed to serialize YAML spec: {error}")))
}
pub fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self)
.map_err(|error| DocxError::parse(format!("failed to serialize JSON spec: {error}")))
}
pub fn to_toml_pretty(&self) -> Result<String> {
toml::to_string_pretty(self)
.map_err(|error| DocxError::parse(format!("failed to serialize TOML spec: {error}")))
}
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("yaml")
.to_ascii_lowercase();
let content = match extension.as_str() {
"yaml" | "yml" | "" => self.to_yaml_string()?,
"json" => self.to_json_pretty()?,
"toml" => self.to_toml_pretty()?,
other => {
return Err(DocxError::parse(format!(
"unsupported document spec extension '{other}', expected .yaml, .yml, .json, or .toml"
)))
}
};
fs::write(path, content)?;
Ok(())
}
pub fn write_yaml_template(path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, Self::default_yaml_template())?;
Ok(())
}
pub fn default_yaml_template() -> &'static str {
DEFAULT_YAML_TEMPLATE
}
pub fn asset_base_dir(&self) -> Option<&Path> {
self.asset_base_dir.as_deref()
}
pub fn set_asset_base_dir(&mut self, base_dir: Option<PathBuf>) -> &mut Self {
self.asset_base_dir = base_dir;
self
}
pub fn with_asset_base_dir(mut self, base_dir: impl Into<PathBuf>) -> Self {
self.asset_base_dir = Some(base_dir.into());
self
}
fn from_yaml_path(content: &str, source_path: Option<&Path>) -> Result<Self> {
let expanded = expand_yaml_document_spec(content, source_path)?;
serde_yaml::from_value(expanded)
.map_err(|error| DocxError::parse(format!("invalid YAML document spec: {error}")))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BlockSpec {
CoverTitle {
text: String,
},
Title {
text: String,
},
Subtitle {
text: String,
},
Hero {
text: String,
},
CenteredNote {
text: String,
},
PageHeading {
text: String,
},
Section {
text: String,
},
Body {
text: String,
},
Tagline {
text: String,
},
Paragraph {
spec: ParagraphSpec,
},
Bullets {
items: Vec<String>,
},
Numbered {
items: Vec<String>,
},
LabelValues {
items: Vec<LabelValueSpec>,
},
Metrics {
items: Vec<MetricSpec>,
},
Table {
spec: TableSpec,
},
Image {
#[serde(flatten)]
spec: VisualSpec,
},
Logo {
#[serde(flatten)]
spec: VisualSpec,
},
Signature {
#[serde(flatten)]
spec: VisualSpec,
},
Chart {
#[serde(flatten)]
spec: VisualSpec,
},
Spacer,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ParagraphSpec {
pub runs: Vec<RunSpec>,
pub style_id: Option<String>,
pub alignment: Option<ParagraphAlignmentSpec>,
pub spacing_before_twips: Option<u32>,
pub spacing_after_twips: Option<u32>,
pub page_break_before: bool,
}
impl ParagraphSpec {
pub fn new<I>(runs: I) -> Self
where
I: IntoIterator<Item = RunSpec>,
{
Self {
runs: runs.into_iter().collect(),
..Self::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ParagraphAlignmentSpec {
Left,
Center,
Right,
Justified,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RunSpec {
pub text: String,
pub style_id: Option<String>,
pub bold: bool,
pub italic: bool,
pub underline: Option<UnderlineStyleSpec>,
pub strikethrough: bool,
pub small_caps: bool,
pub shadow: bool,
pub color: Option<String>,
pub font_family: Option<String>,
pub size_pt: Option<f32>,
pub vertical_align: Option<VerticalAlignSpec>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct VisualSpec {
pub path: String,
pub alt_text: Option<String>,
pub alignment: Option<ParagraphAlignmentSpec>,
pub width_twips: Option<u32>,
pub height_twips: Option<u32>,
pub max_width_twips: Option<u32>,
pub max_height_twips: Option<u32>,
}
impl VisualSpec {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
..Self::default()
}
}
}
impl RunSpec {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
..Self::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnderlineStyleSpec {
Single,
Double,
Dotted,
Dash,
Wavy,
Words,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerticalAlignSpec {
Superscript,
Subscript,
Baseline,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LabelValueSpec {
pub label: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetricSpec {
pub label: String,
pub value: String,
pub tone: Tone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Tone {
Positive,
Neutral,
Warning,
Risk,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TableSpec {
pub style_id: Option<String>,
pub columns: Vec<ColumnSpec>,
pub rows: Vec<RowSpec>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ColumnSpec {
pub label: String,
pub width: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RowSpec {
pub cells: Vec<CellSpec>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CellSpec {
Text { text: String },
Status(StatusSpec),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatusSpec {
pub text: String,
pub tone: Tone,
}
pub fn document<I>(blocks: I) -> DocumentSpec
where
I: IntoIterator<Item = BlockSpec>,
{
DocumentSpec {
output_name: None,
metadata: DocumentMetadata::default(),
page_setup: None,
header: None,
footer: None,
page_numbering: None,
styles: Stylesheet::default(),
blocks: blocks.into_iter().collect(),
asset_base_dir: None,
}
}
pub fn cover_title(text: impl Into<String>) -> BlockSpec {
BlockSpec::CoverTitle { text: text.into() }
}
pub fn title(text: impl Into<String>) -> BlockSpec {
BlockSpec::Title { text: text.into() }
}
pub fn subtitle(text: impl Into<String>) -> BlockSpec {
BlockSpec::Subtitle { text: text.into() }
}
pub fn hero(text: impl Into<String>) -> BlockSpec {
BlockSpec::Hero { text: text.into() }
}
pub fn centered_note(text: impl Into<String>) -> BlockSpec {
BlockSpec::CenteredNote { text: text.into() }
}
pub fn page_heading(text: impl Into<String>) -> BlockSpec {
BlockSpec::PageHeading { text: text.into() }
}
pub fn section(text: impl Into<String>) -> BlockSpec {
BlockSpec::Section { text: text.into() }
}
pub fn body(text: impl Into<String>) -> BlockSpec {
BlockSpec::Body { text: text.into() }
}
pub fn tagline(text: impl Into<String>) -> BlockSpec {
BlockSpec::Tagline { text: text.into() }
}
pub fn paragraph(spec: ParagraphSpec) -> BlockSpec {
BlockSpec::Paragraph { spec }
}
pub fn bullets<I, S>(items: I) -> BlockSpec
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
BlockSpec::Bullets {
items: items.into_iter().map(Into::into).collect(),
}
}
pub fn numbered<I, S>(items: I) -> BlockSpec
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
BlockSpec::Numbered {
items: items.into_iter().map(Into::into).collect(),
}
}
pub fn label_values<I, L, V>(items: I) -> BlockSpec
where
I: IntoIterator<Item = (L, V)>,
L: Into<String>,
V: Into<String>,
{
BlockSpec::LabelValues {
items: items
.into_iter()
.map(|(label, value)| LabelValueSpec {
label: label.into(),
value: value.into(),
})
.collect(),
}
}
pub fn metric(label: impl Into<String>, value: impl Into<String>, tone: Tone) -> MetricSpec {
MetricSpec {
label: label.into(),
value: value.into(),
tone,
}
}
pub fn metrics<I>(items: I) -> BlockSpec
where
I: IntoIterator<Item = MetricSpec>,
{
BlockSpec::Metrics {
items: items.into_iter().collect(),
}
}
pub fn col(label: impl Into<String>, width: u32) -> ColumnSpec {
ColumnSpec {
label: label.into(),
width,
}
}
pub fn text(text: impl Into<String>) -> CellSpec {
CellSpec::Text { text: text.into() }
}
pub fn status(text: impl Into<String>, tone: Tone) -> StatusSpec {
StatusSpec {
text: text.into(),
tone,
}
}
pub fn row<T>(value: T) -> RowSpec
where
T: IntoRowSpec,
{
value.into_row_spec()
}
pub fn table<C, R>(columns: C, rows: R) -> BlockSpec
where
C: IntoIterator<Item = ColumnSpec>,
R: IntoIterator<Item = RowSpec>,
{
BlockSpec::Table {
spec: TableSpec {
style_id: None,
columns: columns.into_iter().collect(),
rows: rows.into_iter().collect(),
},
}
}
pub fn image(path: impl Into<String>) -> BlockSpec {
BlockSpec::Image {
spec: VisualSpec::new(path),
}
}
pub fn logo(path: impl Into<String>) -> BlockSpec {
BlockSpec::Logo {
spec: VisualSpec::new(path),
}
}
pub fn signature(path: impl Into<String>) -> BlockSpec {
BlockSpec::Signature {
spec: VisualSpec::new(path),
}
}
pub fn chart(path: impl Into<String>) -> BlockSpec {
BlockSpec::Chart {
spec: VisualSpec::new(path),
}
}
pub fn spacer() -> BlockSpec {
BlockSpec::Spacer
}
pub trait IntoRowSpec {
fn into_row_spec(self) -> RowSpec;
}
impl From<&str> for CellSpec {
fn from(value: &str) -> Self {
text(value)
}
}
impl From<String> for CellSpec {
fn from(value: String) -> Self {
text(value)
}
}
impl From<StatusSpec> for CellSpec {
fn from(value: StatusSpec) -> Self {
CellSpec::Status(value)
}
}
macro_rules! impl_into_row_spec {
($( $name:ident ),+ $(,)?) => {
impl<$( $name ),+> IntoRowSpec for ($( $name, )+)
where
$( $name: Into<CellSpec>, )+
{
#[allow(non_snake_case)]
fn into_row_spec(self) -> RowSpec {
let ($( $name, )+) = self;
RowSpec {
cells: vec![$( $name.into(), )+],
}
}
}
};
}
impl_into_row_spec!(A);
impl_into_row_spec!(A, B);
impl_into_row_spec!(A, B, C);
impl_into_row_spec!(A, B, C, D);
impl_into_row_spec!(A, B, C, D, E);
const DEFAULT_YAML_TEMPLATE: &str = r#"# RusDox document spec template
# Save this file as `mydoc.yaml` and run:
# rusdox mydoc.yaml
output_name: my-document
# Optional core and custom metadata:
# metadata:
# title: My Document
# author: RusDox
# subject: Quarterly review
# keywords:
# - planning
# - board
# custom_properties:
# Client: Acme Corp
# Optional layout controls:
# page_setup:
# width_twips: 12240
# height_twips: 15840
# margin_top_twips: 1440
# margin_right_twips: 1440
# margin_bottom_twips: 1440
# margin_left_twips: 1440
# header:
# text: "Quarterly review"
# alignment: center
# footer:
# text: "Page {page} of {pages}"
# alignment: right
# page_numbering:
# start_at: 1
# format: decimal
# Optional reusable named styles:
# styles:
# paragraph:
# - id: lead
# based_on: Normal
# paragraph:
# alignment: center
# spacing_after: 180
# run:
# bold: true
# color: "0F172A"
# run:
# - id: accent
# based_on: DefaultParagraphFont
# properties:
# italic: true
# color: "AA5500"
# Optional YAML composition helpers:
# variables:
# company: Acme Corp
# regions:
# - name: North America
# owner: Maya
# - name: EMEA
# owner: Leon
blocks:
- type: title
text: My Document
- type: subtitle
text: Written as data, rendered by Rust
- type: section
text: Summary
- type: body
text: Replace this with your real content.
- type: bullets
items:
- Keep content in order.
- Let config handle styling.
- Render to DOCX and PDF with one command.
"#;
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::{
body, bullets, chart, col, document, image, label_values, logo, metric, metrics, numbered,
paragraph, row, section, signature, status, table, title, BlockSpec,
ParagraphAlignmentSpec, ParagraphSpec, RunSpec, Tone, UnderlineStyleSpec, VisualSpec,
};
use crate::{
Border, BorderStyle, DocumentMetadata, HeaderFooter, PageNumberFormat, PageNumbering,
PageSetup, ParagraphAlignment, ParagraphList, ParagraphStyle, ParagraphStyleProperties,
RunStyle, RunStyleProperties, Stylesheet, TableBorders, TableStyle, TableStyleProperties,
};
#[test]
fn spec_round_trips_through_json() {
let spec = document([
title("Board Report"),
section("Summary"),
body("Everything is readable."),
bullets(["Fast", "Configurable"]),
numbered(["One", "Two"]),
label_values([("Owner", "Finance")]),
metrics([metric("ARR", "$18.7M", Tone::Positive)]),
table(
[col("Item", 4_000), col("Status", 2_000)],
[row(("Pipeline", status("Watch", Tone::Warning)))],
),
BlockSpec::Image {
spec: VisualSpec {
path: "assets/template-gallery.png".to_string(),
alt_text: Some("Gallery".to_string()),
max_width_twips: Some(7_200),
..VisualSpec::default()
},
},
logo("assets/rusdox-mark.svg"),
chart("assets/benchmark-stress-1000-pages.svg"),
signature("assets/signature-demo.svg"),
]);
let json = serde_json::to_string_pretty(&spec).expect("serialize spec");
let round_trip: super::DocumentSpec =
serde_json::from_str(&json).expect("deserialize spec");
assert_eq!(round_trip, spec);
}
#[test]
fn spec_round_trips_through_yaml() {
let spec = super::DocumentSpec {
output_name: Some("hello-world".to_string()),
metadata: DocumentMetadata::new()
.title("Hello World")
.author("RusDox")
.subject("Round-trip"),
page_setup: Some(PageSetup::new(11_880, 16_380).margins(900, 1_000, 1_100, 1_200)),
header: Some(
HeaderFooter::new("Board Report").with_alignment(ParagraphAlignment::Center),
),
footer: Some(
HeaderFooter::new("Page {page} of {pages}")
.with_alignment(ParagraphAlignment::Right),
),
page_numbering: Some(PageNumbering::new(PageNumberFormat::UpperRoman).start_at(3)),
styles: crate::Stylesheet::default(),
blocks: vec![
title("Hello"),
paragraph(ParagraphSpec {
runs: vec![
RunSpec {
text: "Bold".to_string(),
bold: true,
..RunSpec::default()
},
RunSpec {
text: " | ".to_string(),
..RunSpec::default()
},
RunSpec {
text: "Underline".to_string(),
underline: Some(UnderlineStyleSpec::Single),
..RunSpec::default()
},
],
alignment: Some(ParagraphAlignmentSpec::Center),
..ParagraphSpec::default()
}),
image("assets/template-gallery.png"),
],
asset_base_dir: None,
};
let yaml = spec.to_yaml_string().expect("serialize yaml");
let round_trip = super::DocumentSpec::from_yaml_str(&yaml).expect("deserialize yaml");
assert_eq!(round_trip, spec);
}
#[test]
fn spec_round_trips_named_styles_and_style_references() {
let border = Border::new(BorderStyle::Single).size(8).color("CBD5E1");
let spec = super::DocumentSpec {
output_name: Some("styled-spec".to_string()),
metadata: DocumentMetadata::default(),
page_setup: None,
header: None,
footer: None,
page_numbering: None,
styles: Stylesheet::new()
.add_paragraph_style(
ParagraphStyle::new("lead")
.based_on("Normal")
.next("body")
.paragraph(ParagraphStyleProperties {
list: Some(ParagraphList::bullet_with_id(7)),
alignment: Some(ParagraphAlignment::Center),
spacing_before: Some(120),
spacing_after: Some(240),
keep_next: Some(true),
page_break_before: Some(false),
})
.run(RunStyleProperties::new().bold().color("0F172A")),
)
.add_run_style(
RunStyle::new("accent")
.based_on("DefaultParagraphFont")
.properties(RunStyleProperties::new().italic().color("AA5500")),
)
.add_table_style(
TableStyle::new("grid").based_on("TableNormal").properties(
TableStyleProperties::new()
.width(9_360)
.borders(TableBorders::new().top(border)),
),
),
blocks: vec![
paragraph(ParagraphSpec {
style_id: Some("lead".to_string()),
runs: vec![RunSpec {
text: "Styled".to_string(),
style_id: Some("accent".to_string()),
..RunSpec::default()
}],
..ParagraphSpec::default()
}),
BlockSpec::Table {
spec: super::TableSpec {
style_id: Some("grid".to_string()),
columns: vec![
super::ColumnSpec {
label: "Metric".to_string(),
width: 4_680,
},
super::ColumnSpec {
label: "Value".to_string(),
width: 4_680,
},
],
rows: vec![super::RowSpec {
cells: vec![
super::CellSpec::Text {
text: "ARR".to_string(),
},
super::CellSpec::Text {
text: "$18.7M".to_string(),
},
],
}],
},
},
],
asset_base_dir: None,
};
let yaml = spec.to_yaml_string().expect("serialize yaml");
assert!(yaml.contains("styles:"));
assert!(yaml.contains("style_id: lead"));
assert!(yaml.contains("style_id: accent"));
assert!(yaml.contains("style_id: grid"));
assert!(yaml.contains("based_on: Normal"));
assert!(yaml.contains("based_on: DefaultParagraphFont"));
assert!(yaml.contains("based_on: TableNormal"));
let round_trip = super::DocumentSpec::from_yaml_str(&yaml).expect("deserialize yaml");
assert_eq!(round_trip, spec);
}
#[test]
fn load_from_path_uses_extension_based_parser() {
let temp = tempdir().expect("temp dir");
let yaml_path = temp.path().join("spec.yaml");
let json_path = temp.path().join("spec.json");
fs::write(
&yaml_path,
r#"
output_name: hello-world
blocks:
- type: title
text: Hello
"#,
)
.expect("write yaml");
fs::write(
&json_path,
r#"{"blocks":[{"type":"title","text":"Hello"}]}"#,
)
.expect("write json");
let yaml_spec = super::DocumentSpec::load_from_path(&yaml_path).expect("load yaml");
let json_spec = super::DocumentSpec::load_from_path(&json_path).expect("load json");
assert_eq!(yaml_spec.output_name.as_deref(), Some("hello-world"));
assert_eq!(yaml_spec.blocks.len(), 1);
assert_eq!(json_spec.blocks.len(), 1);
}
#[test]
fn load_from_path_expands_yaml_variables_includes_repeaters_and_metadata() {
let temp = tempdir().expect("temp dir");
let fragment_path = temp.path().join("summary.yaml");
let spec_path = temp.path().join("spec.yaml");
fs::write(
&fragment_path,
r#"variables:
intro: Summary for {{client}}
blocks:
- type: body
text: "{{intro}}"
"#,
)
.expect("write fragment");
fs::write(
&spec_path,
r#"output_name: regional-plan
metadata:
title: "{{client}} Regional Plan"
author: Strategy Team
subject: "{{quarter}} rollout"
keywords:
- "{{quarter}}"
- planning
custom_properties:
Client: "{{client}}"
variables:
client: Acme
quarter: Q2
regions:
- name: North America
owner: Maya
- name: EMEA
owner: Leon
blocks:
- type: title
text: "{{client}} Regional Plan"
- type: include
path: summary.yaml
- type: repeat
variable: regions
as: region
blocks:
- type: section
text: "{{region.name}}"
- type: body
text: "Owner: {{region.owner}}"
"#,
)
.expect("write spec");
let spec = super::DocumentSpec::load_from_path(&spec_path).expect("load expanded yaml");
assert_eq!(spec.metadata.title.as_deref(), Some("Acme Regional Plan"));
assert_eq!(spec.metadata.subject.as_deref(), Some("Q2 rollout"));
assert_eq!(spec.metadata.keywords, vec!["Q2", "planning"]);
assert_eq!(
spec.metadata
.custom_properties
.get("Client")
.map(String::as_str),
Some("Acme")
);
assert_eq!(spec.blocks.len(), 6);
}
#[test]
fn tuple_rows_accept_plain_text_and_status_cells() {
let row = row(("ARR", "$18.7M", status("Strong", Tone::Positive), "On plan"));
assert_eq!(row.cells.len(), 4);
}
}