use std::collections::BTreeMap;
use std::io::{Cursor, Write};
use serde::Deserialize;
use sha2::{Digest, Sha256};
pub const MAX_DOCUMENT_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_BLOCKS: usize = 100_000;
#[derive(Debug, Clone, Deserialize)]
pub struct DocumentSpec {
pub name: String,
pub target: String,
#[serde(default)]
pub provenance: String,
#[serde(default)]
pub effect_row: Vec<String>,
#[serde(default)]
pub epistemic_mode: String,
#[serde(default)]
pub blocks: Vec<BlockSpec>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BlockSpec {
pub kind: String,
#[serde(default)]
pub fields: Vec<FieldSpec>,
#[serde(default)]
pub children: Vec<BlockSpec>,
}
impl BlockSpec {
fn field(&self, name: &str) -> Option<&FieldSpec> {
self.fields.iter().find(|f| f.name == name)
}
fn text_of(&self, name: &str, values: &BTreeMap<String, String>) -> Option<String> {
self.field(name).map(|f| f.resolve(values))
}
fn attr_label(&self) -> Option<String> {
self.field("attribute").map(|f| f.value.clone())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct FieldSpec {
pub name: String,
pub kind: String,
#[serde(default)]
pub value: String,
#[serde(default)]
pub items: Vec<String>,
}
impl FieldSpec {
fn resolve(&self, values: &BTreeMap<String, String>) -> String {
match self.kind.as_str() {
"ref" => values
.get(&self.value)
.cloned()
.unwrap_or_else(|| format!("[unbound: {}]", self.value)),
"list" => self.items.join(", "),
_ => self.value.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct RenderedDocument {
pub content_type: String,
pub bytes: Vec<u8>,
pub sha256_hex: String,
pub extension: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OoxmlError {
UnknownTarget(String),
TooLarge(usize),
Malformed(String),
}
impl std::fmt::Display for OoxmlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OoxmlError::UnknownTarget(t) => write!(f, "unknown document target '{t}'"),
OoxmlError::TooLarge(n) => write!(f, "document exceeds the {MAX_DOCUMENT_BYTES}-byte cap ({n} bytes)"),
OoxmlError::Malformed(m) => write!(f, "malformed document: {m}"),
}
}
}
impl std::error::Error for OoxmlError {}
pub fn render(spec: &DocumentSpec, values: &BTreeMap<String, String>) -> Result<RenderedDocument, OoxmlError> {
let mut parts: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let (content_type, extension) = match spec.target.as_str() {
"docx" => {
build_docx(spec, values, &mut parts);
(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"docx",
)
}
"xlsx" => {
build_xlsx(spec, values, &mut parts);
(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"xlsx",
)
}
"pptx" => {
build_pptx(spec, values, &mut parts);
(
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"pptx",
)
}
other => return Err(OoxmlError::UnknownTarget(other.to_string())),
};
if spec.provenance != "none" && !spec.provenance.is_empty() {
parts.insert("customXml/provenance.xml".to_string(), provenance_xml(spec).into_bytes());
}
let bytes = zip_parts(&parts);
if bytes.len() > MAX_DOCUMENT_BYTES {
return Err(OoxmlError::TooLarge(bytes.len()));
}
let sha256_hex = hex(&Sha256::digest(&bytes));
Ok(RenderedDocument {
content_type: content_type.to_string(),
bytes,
sha256_hex,
extension: extension.to_string(),
})
}
fn zip_parts(parts: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::new();
{
let cursor = Cursor::new(&mut buf);
let mut zip = zip::ZipWriter::new(cursor);
let fixed = zip::DateTime::from_date_and_time(2026, 1, 1, 0, 0, 0)
.expect("valid DateTime constants");
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.last_modified_time(fixed);
for (name, data) in parts {
zip.start_file(name.as_str(), options).expect("zip start_file");
zip.write_all(data).expect("zip write_all");
}
zip.finish().expect("zip finish");
}
buf
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
const XML_DECL: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n";
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn walk<'a>(blocks: &'a [BlockSpec], f: &mut impl FnMut(&'a BlockSpec)) {
let mut count = 0usize;
fn inner<'a>(blocks: &'a [BlockSpec], f: &mut impl FnMut(&'a BlockSpec), count: &mut usize) {
for b in blocks {
if *count >= MAX_BLOCKS {
return;
}
*count += 1;
f(b);
inner(&b.children, f, count);
}
}
inner(blocks, f, &mut count);
}
fn build_docx(spec: &DocumentSpec, values: &BTreeMap<String, String>, parts: &mut BTreeMap<String, Vec<u8>>) {
parts.insert("[Content_Types].xml".into(), docx_content_types(spec).into_bytes());
parts.insert("_rels/.rels".into(), root_rels().into_bytes());
parts.insert("word/document.xml".into(), docx_document(spec, values).into_bytes());
parts.insert("docProps/core.xml".into(), core_props(spec).into_bytes());
}
fn docx_content_types(spec: &DocumentSpec) -> String {
let mut s = String::from(XML_DECL);
s.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">");
s.push_str("<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>");
s.push_str("<Default Extension=\"xml\" ContentType=\"application/xml\"/>");
s.push_str("<Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/>");
s.push_str("<Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>");
if spec.provenance != "none" && !spec.provenance.is_empty() {
s.push_str("<Override PartName=\"/customXml/provenance.xml\" ContentType=\"application/xml\"/>");
}
s.push_str("</Types>");
s
}
fn docx_document(spec: &DocumentSpec, values: &BTreeMap<String, String>) -> String {
let mut s = String::from(XML_DECL);
s.push_str("<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>");
walk(&spec.blocks, &mut |b| match b.kind.as_str() {
"section" => {
if let Some(h) = b.text_of("heading", values) {
s.push_str(&docx_heading(&h, 1));
}
}
"heading" => {
let level = b.field("level").map(|f| f.value.parse::<u8>().unwrap_or(2)).unwrap_or(2);
if let Some(t) = b.text_of("text", values) {
s.push_str(&docx_heading(&t, level));
}
}
"para" | "footnote" => {
if let Some(t) = b.text_of("text", values) {
let attr = b.attr_label();
s.push_str(&docx_para(&t, attr.as_deref()));
}
}
"table" => s.push_str(&docx_table(b, values)),
"page_break" => s.push_str("<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>"),
"chart" => {
let kind = b.field("kind").map(|f| f.value.clone()).unwrap_or_default();
s.push_str(&docx_para(&format!("[chart: {kind}]"), b.attr_label().as_deref()));
}
_ => {}
});
s.push_str("<w:sectPr/></w:body></w:document>");
s
}
fn docx_heading(text: &str, level: u8) -> String {
format!(
"<w:p><w:pPr><w:pStyle w:val=\"Heading{}\"/></w:pPr><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p>",
level.clamp(1, 6),
xml_escape(text)
)
}
fn docx_para(text: &str, attribute: Option<&str>) -> String {
let mut s = format!(
"<w:p><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r>",
xml_escape(text)
);
if let Some(a) = attribute {
s.push_str(&format!(
"<w:r><w:rPr><w:vertAlign w:val=\"superscript\"/></w:rPr><w:t xml:space=\"preserve\"> [source: {}]</w:t></w:r>",
xml_escape(a)
));
}
s.push_str("</w:p>");
s
}
fn docx_table(b: &BlockSpec, values: &BTreeMap<String, String>) -> String {
let cols: Vec<String> = b
.field("columns")
.map(|f| f.items.clone())
.unwrap_or_default();
let mut s = String::from("<w:tbl><w:tblPr><w:tblStyle w:val=\"TableGrid\"/></w:tblPr>");
if !cols.is_empty() {
s.push_str("<w:tr>");
for c in &cols {
s.push_str(&format!(
"<w:tc><w:p><w:r><w:rPr><w:b/></w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p></w:tc>",
xml_escape(c)
));
}
s.push_str("</w:tr>");
}
if let Some(rows) = b.text_of("rows", values) {
s.push_str("<w:tr>");
s.push_str(&format!(
"<w:tc><w:p><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p></w:tc>",
xml_escape(&rows)
));
for _ in 1..cols.len().max(1) {
s.push_str("<w:tc><w:p/></w:tc>");
}
s.push_str("</w:tr>");
}
s.push_str("</w:tbl>");
s
}
fn build_xlsx(spec: &DocumentSpec, values: &BTreeMap<String, String>, parts: &mut BTreeMap<String, Vec<u8>>) {
parts.insert("[Content_Types].xml".into(), xlsx_content_types(spec).into_bytes());
parts.insert("_rels/.rels".into(), root_rels().into_bytes());
parts.insert("xl/workbook.xml".into(), xlsx_workbook(spec).into_bytes());
parts.insert("xl/_rels/workbook.xml.rels".into(), xlsx_workbook_rels(spec).into_bytes());
parts.insert("docProps/core.xml".into(), core_props(spec).into_bytes());
let sheets: Vec<&BlockSpec> = spec.blocks.iter().filter(|b| b.kind == "sheet").collect();
let sheets = if sheets.is_empty() {
vec![]
} else {
sheets
};
if sheets.is_empty() {
parts.insert("xl/worksheets/sheet1.xml".into(), xlsx_empty_sheet().into_bytes());
} else {
for (i, sheet) in sheets.iter().enumerate() {
parts.insert(
format!("xl/worksheets/sheet{}.xml", i + 1),
xlsx_sheet(sheet, values).into_bytes(),
);
}
}
}
fn xlsx_content_types(spec: &DocumentSpec) -> String {
let mut s = String::from(XML_DECL);
s.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">");
s.push_str("<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>");
s.push_str("<Default Extension=\"xml\" ContentType=\"application/xml\"/>");
s.push_str("<Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>");
let n = spec.blocks.iter().filter(|b| b.kind == "sheet").count().max(1);
for i in 1..=n {
s.push_str(&format!("<Override PartName=\"/xl/worksheets/sheet{i}.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>"));
}
s.push_str("<Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>");
if spec.provenance != "none" && !spec.provenance.is_empty() {
s.push_str("<Override PartName=\"/customXml/provenance.xml\" ContentType=\"application/xml\"/>");
}
s.push_str("</Types>");
s
}
fn xlsx_workbook(spec: &DocumentSpec) -> String {
let mut s = String::from(XML_DECL);
s.push_str("<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><sheets>");
let sheets: Vec<&BlockSpec> = spec.blocks.iter().filter(|b| b.kind == "sheet").collect();
if sheets.is_empty() {
s.push_str("<sheet name=\"Sheet1\" sheetId=\"1\" r:id=\"rId1\"/>");
} else {
for (i, sh) in sheets.iter().enumerate() {
let name = sh.field("name").map(|f| f.value.clone()).unwrap_or_else(|| format!("Sheet{}", i + 1));
s.push_str(&format!(
"<sheet name=\"{}\" sheetId=\"{}\" r:id=\"rId{}\"/>",
xml_escape(&name),
i + 1,
i + 1
));
}
}
s.push_str("</sheets></workbook>");
s
}
fn xlsx_workbook_rels(spec: &DocumentSpec) -> String {
let mut s = String::from(XML_DECL);
s.push_str("<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">");
let n = spec.blocks.iter().filter(|b| b.kind == "sheet").count().max(1);
for i in 1..=n {
s.push_str(&format!(
"<Relationship Id=\"rId{i}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet{i}.xml\"/>"
));
}
s.push_str("</Relationships>");
s
}
fn xlsx_empty_sheet() -> String {
format!(
"{XML_DECL}<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData/></worksheet>"
)
}
fn xlsx_sheet(sheet: &BlockSpec, values: &BTreeMap<String, String>) -> String {
let mut s = String::from(XML_DECL);
s.push_str("<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\"><sheetData>");
let mut row_idx = 1u32;
for child in &sheet.children {
match child.kind.as_str() {
"row" => {
let cells: Vec<String> = match child.field("cells") {
Some(f) if f.kind == "list" => f.items.clone(),
Some(f) => vec![f.resolve(values)],
None => vec![],
};
s.push_str(&format!("<row r=\"{row_idx}\">"));
for (ci, cell) in cells.iter().enumerate() {
let col = col_letter(ci as u32);
if let Ok(n) = cell.trim().parse::<f64>() {
s.push_str(&format!("<c r=\"{col}{row_idx}\"><v>{n}</v></c>"));
} else {
s.push_str(&format!(
"<c r=\"{col}{row_idx}\" t=\"inlineStr\"><is><t xml:space=\"preserve\">{}</t></is></c>",
xml_escape(cell)
));
}
}
s.push_str("</row>");
row_idx += 1;
}
"formula" => {
let cell = child.field("cell").map(|f| f.value.clone()).unwrap_or_else(|| format!("A{row_idx}"));
let expr = child.field("expr").map(|f| f.value.clone()).unwrap_or_default();
s.push_str(&format!(
"<row r=\"{row_idx}\"><c r=\"{}\"><f>{}</f></c></row>",
xml_escape(&cell),
xml_escape(expr.trim_start_matches('='))
));
row_idx += 1;
}
_ => {}
}
}
s.push_str("</sheetData></worksheet>");
s
}
fn col_letter(mut n: u32) -> String {
let mut s = String::new();
loop {
s.insert(0, (b'A' + (n % 26) as u8) as char);
if n < 26 {
break;
}
n = n / 26 - 1;
}
s
}
fn build_pptx(spec: &DocumentSpec, values: &BTreeMap<String, String>, parts: &mut BTreeMap<String, Vec<u8>>) {
parts.insert("[Content_Types].xml".into(), pptx_content_types(spec).into_bytes());
parts.insert("_rels/.rels".into(), root_rels().into_bytes());
parts.insert("docProps/core.xml".into(), core_props(spec).into_bytes());
let slides: Vec<&BlockSpec> = spec.blocks.iter().filter(|b| b.kind == "slide").collect();
let count = slides.len().max(1);
parts.insert("ppt/presentation.xml".into(), pptx_presentation(count).into_bytes());
parts.insert("ppt/_rels/presentation.xml.rels".into(), pptx_presentation_rels(count).into_bytes());
for (i, slide) in slides.iter().enumerate() {
parts.insert(
format!("ppt/slides/slide{}.xml", i + 1),
pptx_slide(slide, values).into_bytes(),
);
}
if slides.is_empty() {
parts.insert("ppt/slides/slide1.xml".into(), pptx_slide_empty().into_bytes());
}
}
fn pptx_content_types(spec: &DocumentSpec) -> String {
let mut s = String::from(XML_DECL);
s.push_str("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">");
s.push_str("<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>");
s.push_str("<Default Extension=\"xml\" ContentType=\"application/xml\"/>");
s.push_str("<Override PartName=\"/ppt/presentation.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml\"/>");
let n = spec.blocks.iter().filter(|b| b.kind == "slide").count().max(1);
for i in 1..=n {
s.push_str(&format!("<Override PartName=\"/ppt/slides/slide{i}.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slide+xml\"/>"));
}
s.push_str("<Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>");
if spec.provenance != "none" && !spec.provenance.is_empty() {
s.push_str("<Override PartName=\"/customXml/provenance.xml\" ContentType=\"application/xml\"/>");
}
s.push_str("</Types>");
s
}
fn pptx_presentation(count: usize) -> String {
let mut s = String::from(XML_DECL);
s.push_str("<p:presentation xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\"><p:sldIdLst>");
for i in 1..=count {
s.push_str(&format!("<p:sldId id=\"{}\" r:id=\"rId{}\"/>", 255 + i, i));
}
s.push_str("</p:sldIdLst></p:presentation>");
s
}
fn pptx_presentation_rels(count: usize) -> String {
let mut s = String::from(XML_DECL);
s.push_str("<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">");
for i in 1..=count {
s.push_str(&format!(
"<Relationship Id=\"rId{i}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide\" Target=\"slides/slide{i}.xml\"/>"
));
}
s.push_str("</Relationships>");
s
}
fn pptx_slide(slide: &BlockSpec, values: &BTreeMap<String, String>) -> String {
let mut body = String::new();
for child in &slide.children {
match child.kind.as_str() {
"placeholder" | "notes" => {
if let Some(t) = child.text_of("text", values) {
body.push_str(&pptx_text_para(&t));
}
}
"bullets" => {
if let Some(f) = child.field("items") {
for item in if f.kind == "list" { f.items.clone() } else { vec![f.resolve(values)] } {
body.push_str(&pptx_text_para(&format!("• {item}")));
}
}
}
_ => {}
}
}
pptx_slide_shell(&body)
}
fn pptx_slide_empty() -> String {
pptx_slide_shell("")
}
fn pptx_slide_shell(body: &str) -> String {
format!(
"{XML_DECL}<p:sld xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id=\"1\" name=\"\"/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/><p:sp><p:nvSpPr><p:cNvPr id=\"2\" name=\"Body\"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/>{body}</p:txBody></p:sp></p:spTree></p:cSld></p:sld>"
)
}
fn pptx_text_para(text: &str) -> String {
format!(
"<a:p><a:r><a:t>{}</a:t></a:r></a:p>",
xml_escape(text)
)
}
fn root_rels() -> String {
let mut s = String::from(XML_DECL);
s.push_str("<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">");
s.push_str("<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/>");
s.push_str("<Relationship Id=\"rIdWb\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/>");
s.push_str("<Relationship Id=\"rIdPr\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"ppt/presentation.xml\"/>");
s.push_str("<Relationship Id=\"rIdCore\" Type=\"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties\" Target=\"docProps/core.xml\"/>");
s.push_str("</Relationships>");
s
}
fn core_props(spec: &DocumentSpec) -> String {
format!(
"{XML_DECL}<cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>{}</dc:title><dc:creator>axon-document-synthesis</dc:creator></cp:coreProperties>",
xml_escape(&spec.name)
)
}
fn provenance_xml(spec: &DocumentSpec) -> String {
let mut s = String::from(XML_DECL);
s.push_str("<axonProvenance xmlns=\"urn:axon:provenance:1\">");
s.push_str(&format!("<document>{}</document>", xml_escape(&spec.name)));
s.push_str(&format!("<target>{}</target>", xml_escape(&spec.target)));
s.push_str(&format!("<provenance>{}</provenance>", xml_escape(&spec.provenance)));
if !spec.epistemic_mode.is_empty() {
s.push_str(&format!("<epistemicMode>{}</epistemicMode>", xml_escape(&spec.epistemic_mode)));
}
s.push_str("<effects>");
for e in &spec.effect_row {
s.push_str(&format!("<effect>{}</effect>", xml_escape(e)));
}
s.push_str("</effects>");
s.push_str("<fields>");
walk(&spec.blocks, &mut |b| {
for f in &b.fields {
if f.name == "attribute" {
s.push_str(&format!(
"<field block=\"{}\" attribute=\"{}\"/>",
xml_escape(&b.kind),
xml_escape(&f.value)
));
} else if f.kind == "ref" {
s.push_str(&format!(
"<field block=\"{}\" slot=\"{}\" ref=\"{}\"/>",
xml_escape(&b.kind),
xml_escape(&f.name),
xml_escape(&f.value)
));
}
}
});
s.push_str("</fields>");
s.push_str("</axonProvenance>");
s
}
#[cfg(test)]
mod tests {
use super::*;
fn field(name: &str, kind: &str, value: &str) -> FieldSpec {
FieldSpec { name: name.into(), kind: kind.into(), value: value.into(), items: vec![] }
}
fn docx_spec() -> DocumentSpec {
DocumentSpec {
name: "report".into(),
target: "docx".into(),
provenance: "embedded".into(),
effect_row: vec!["io".into(), "storage".into()],
epistemic_mode: String::new(),
blocks: vec![BlockSpec {
kind: "section".into(),
fields: vec![field("heading", "text", "Q3 Results")],
children: vec![
BlockSpec { kind: "para".into(), fields: vec![field("text", "text", "Audited & final.")], children: vec![] },
BlockSpec { kind: "para".into(), fields: vec![field("text", "ref", "revenue"), field("attribute", "ref", "analyst")], children: vec![] },
],
}],
}
}
fn values() -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
m.insert("revenue".to_string(), "Revenue up 12%".to_string());
m
}
#[test]
fn renders_a_valid_zip_with_ooxml_parts() {
let out = render(&docx_spec(), &values()).unwrap();
assert_eq!(out.extension, "docx");
assert_eq!(&out.bytes[0..2], b"PK");
let reader = zip::ZipArchive::new(Cursor::new(out.bytes.clone())).unwrap();
let names: Vec<String> = reader.file_names().map(|s| s.to_string()).collect();
assert!(names.contains(&"[Content_Types].xml".to_string()));
assert!(names.contains(&"word/document.xml".to_string()));
assert!(names.contains(&"customXml/provenance.xml".to_string()));
}
#[test]
fn is_byte_deterministic() {
let a = render(&docx_spec(), &values()).unwrap();
let b = render(&docx_spec(), &values()).unwrap();
assert_eq!(a.bytes, b.bytes, "same input must produce byte-identical output");
assert_eq!(a.sha256_hex, b.sha256_hex);
}
#[test]
fn ref_is_resolved_through_values_and_xml_escaped() {
let out = render(&docx_spec(), &values()).unwrap();
let mut zip = zip::ZipArchive::new(Cursor::new(out.bytes)).unwrap();
let mut doc = String::new();
use std::io::Read;
zip.by_name("word/document.xml").unwrap().read_to_string(&mut doc).unwrap();
assert!(doc.contains("Revenue up 12%"), "ref resolved");
assert!(doc.contains("Audited & final."), "literal xml-escaped");
assert!(doc.contains("[source: analyst]"), "attribution rendered visibly");
}
#[test]
fn unbound_ref_is_loud_not_silent() {
let out = render(&docx_spec(), &BTreeMap::new()).unwrap();
let mut zip = zip::ZipArchive::new(Cursor::new(out.bytes)).unwrap();
use std::io::Read;
let mut doc = String::new();
zip.by_name("word/document.xml").unwrap().read_to_string(&mut doc).unwrap();
assert!(doc.contains("[unbound: revenue]"));
}
#[test]
fn provenance_part_records_target_and_refs() {
let out = render(&docx_spec(), &values()).unwrap();
let mut zip = zip::ZipArchive::new(Cursor::new(out.bytes)).unwrap();
use std::io::Read;
let mut prov = String::new();
zip.by_name("customXml/provenance.xml").unwrap().read_to_string(&mut prov).unwrap();
assert!(prov.contains("<target>docx</target>"));
assert!(prov.contains("ref=\"revenue\""));
assert!(prov.contains("attribute=\"analyst\""));
}
#[test]
fn provenance_none_omits_the_part() {
let mut spec = docx_spec();
spec.provenance = "none".into();
let out = render(&spec, &values()).unwrap();
let reader = zip::ZipArchive::new(Cursor::new(out.bytes)).unwrap();
assert!(!reader.file_names().any(|n| n == "customXml/provenance.xml"));
}
#[test]
fn xlsx_renders_rows_and_formula() {
let spec = DocumentSpec {
name: "sheet".into(),
target: "xlsx".into(),
provenance: "none".into(),
effect_row: vec![],
epistemic_mode: String::new(),
blocks: vec![BlockSpec {
kind: "sheet".into(),
fields: vec![field("name", "text", "Data")],
children: vec![
BlockSpec { kind: "row".into(), fields: vec![FieldSpec { name: "cells".into(), kind: "list".into(), value: String::new(), items: vec!["Region".into(), "Revenue".into()] }], children: vec![] },
BlockSpec { kind: "formula".into(), fields: vec![field("cell", "text", "B10"), field("expr", "text", "SUM(B2:B9)")], children: vec![] },
],
}],
};
let out = render(&spec, &BTreeMap::new()).unwrap();
assert_eq!(out.extension, "xlsx");
let mut zip = zip::ZipArchive::new(Cursor::new(out.bytes)).unwrap();
use std::io::Read;
let mut sheet = String::new();
zip.by_name("xl/worksheets/sheet1.xml").unwrap().read_to_string(&mut sheet).unwrap();
assert!(sheet.contains("Region"));
assert!(sheet.contains("<f>SUM(B2:B9)</f>"));
}
#[test]
fn pptx_renders_slides_and_bullets() {
let spec = DocumentSpec {
name: "deck".into(),
target: "pptx".into(),
provenance: "none".into(),
effect_row: vec![],
epistemic_mode: String::new(),
blocks: vec![BlockSpec {
kind: "slide".into(),
fields: vec![field("layout", "text", "Title and Content")],
children: vec![BlockSpec {
kind: "bullets".into(),
fields: vec![FieldSpec { name: "items".into(), kind: "list".into(), value: String::new(), items: vec!["First".into(), "Second".into()] }],
children: vec![],
}],
}],
};
let out = render(&spec, &BTreeMap::new()).unwrap();
assert_eq!(out.extension, "pptx");
let mut zip = zip::ZipArchive::new(Cursor::new(out.bytes)).unwrap();
use std::io::Read;
let mut slide = String::new();
zip.by_name("ppt/slides/slide1.xml").unwrap().read_to_string(&mut slide).unwrap();
assert!(slide.contains("First"));
}
#[test]
fn col_letter_maps_correctly() {
assert_eq!(col_letter(0), "A");
assert_eq!(col_letter(25), "Z");
assert_eq!(col_letter(26), "AA");
}
#[test]
fn unknown_target_is_typed_error() {
let mut spec = docx_spec();
spec.target = "pdf".into();
assert!(matches!(render(&spec, &values()), Err(OoxmlError::UnknownTarget(_))));
}
}