use std::collections::BTreeMap;
use zenith_core::{Document, KdlAdapter, KdlSource, PropertyValue, dim_to_px};
use zenith_tx::{Op, OpSpan, Permissions, Transaction, TxStatus, run_transaction};
#[derive(Debug)]
pub struct VariantExpansion {
pub results: Vec<VariantResult>,
}
impl VariantExpansion {
pub fn generated(&self) -> usize {
self.results
.iter()
.filter(|r| matches!(r.outcome, VariantOutcome::Generated(_)))
.count()
}
pub fn failed(&self) -> usize {
self.results
.iter()
.filter(|r| matches!(r.outcome, VariantOutcome::Failed(_)))
.count()
}
}
#[derive(Debug)]
pub struct VariantResult {
pub id: String,
pub source: String,
pub outcome: VariantOutcome,
}
#[derive(Debug)]
pub enum VariantOutcome {
Generated(Box<Document>),
Failed(String),
}
pub fn expand_variants(doc: &Document) -> VariantExpansion {
if doc.variants.is_empty() {
return VariantExpansion {
results: Vec::new(),
};
}
let sorted: BTreeMap<&str, _> = doc.variants.iter().map(|v| (v.id.as_str(), v)).collect();
let mut base = doc.clone();
base.variants.clear();
let mut results: Vec<VariantResult> = Vec::with_capacity(sorted.len());
for variant in sorted.values() {
let mut ops: Vec<Op> = Vec::new();
ops.push(Op::SetPageSize {
page: variant.source.clone(),
w: variant.w.to_kdl_string(),
h: variant.h.to_kdl_string(),
});
for ov in &variant.overrides {
if let Some(visible) = ov.visible {
ops.push(Op::SetVisible {
node: ov.node.clone(),
visible,
});
}
if ov.x.is_some() || ov.y.is_some() || ov.w.is_some() || ov.h.is_some() {
ops.push(Op::SetGeometry {
node: ov.node.clone(),
x: ov.x.as_ref().and_then(|d| dim_to_px(d.value, &d.unit)),
y: ov.y.as_ref().and_then(|d| dim_to_px(d.value, &d.unit)),
w: ov.w.as_ref().and_then(|d| dim_to_px(d.value, &d.unit)),
h: ov.h.as_ref().and_then(|d| dim_to_px(d.value, &d.unit)),
rotate: None,
});
}
if let Some(fill) = &ov.fill {
ops.push(Op::SetFill {
node: ov.node.clone(),
fill: property_value_to_fill_str(fill),
});
}
if let Some(text) = &ov.text {
ops.push(Op::ReplaceText {
node: ov.node.clone(),
spans: vec![OpSpan {
text: text.clone(),
fill: None,
font_weight: None,
italic: None,
underline: None,
strikethrough: None,
vertical_align: None,
footnote_ref: None,
}],
});
}
}
let tx = Transaction {
ops,
permissions: Permissions::default(),
};
let outcome = match run_transaction(&base, &tx) {
Err(e) => VariantOutcome::Failed(format!("transaction engine error: {}", e.message)),
Ok(tx_result) if tx_result.status == TxStatus::Rejected => {
let msgs: Vec<String> = tx_result
.diagnostics
.iter()
.map(|d| {
format!(
"{}[{}]: {}",
crate::json_types::severity_str(&d.severity),
d.code,
d.message
)
})
.collect();
VariantOutcome::Failed(format!("transaction rejected: {}", msgs.join("; ")))
}
Ok(tx_result) => {
match KdlAdapter.parse(tx_result.source_after.as_bytes()) {
Err(e) => VariantOutcome::Failed(format!(
"post-transaction parse error: {}",
e.message
)),
Ok(materialized) => VariantOutcome::Generated(Box::new(materialized)),
}
}
};
results.push(VariantResult {
id: variant.id.clone(),
source: variant.source.clone(),
outcome,
});
}
VariantExpansion { results }
}
fn property_value_to_fill_str(pv: &PropertyValue) -> String {
match pv {
PropertyValue::TokenRef(id) => id.clone(),
PropertyValue::Literal(s) => s.clone(),
PropertyValue::Dimension(d) => d.to_kdl_string(),
PropertyValue::DataRef(path) => path.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use zenith_core::KdlAdapter;
const DOC_TWO_VARIANTS: &str = r##"zenith version=1 {
project id="proj.v" name="Variant Test"
tokens format="zenith-token-v1" {
token id="color.bg" type="color" value="#ffffff"
token id="color.ink" type="color" value="#111111"
token id="color.accent" type="color" value="#e11d48"
}
styles {}
document id="doc.v" title="Variant Test" {
page id="page.a" w=(px)800 h=(px)600 {
rect id="rect.bg" x=(px)0 y=(px)0 w=(px)800 h=(px)600 fill=(token)"color.bg"
text id="text.label" x=(px)10 y=(px)10 w=(px)780 h=(px)80 fill=(token)"color.ink" {
span "original text"
}
}
}
variants {
variant id="var.large" source="page.a" w=(px)1920 h=(px)1080 {
override node="text.label" text="large variant"
}
variant id="var.small" source="page.a" w=(px)320 h=(px)180 {
override node="rect.bg" visible=#false
}
}
}
"##;
const DOC_MISSING_NODE_VARIANT: &str = r##"zenith version=1 {
project id="proj.mv" name="Missing Node Test"
tokens format="zenith-token-v1" {
token id="color.bg" type="color" value="#ffffff"
}
styles {}
document id="doc.mv" title="Missing Node Test" {
page id="page.m" w=(px)400 h=(px)300 {
rect id="rect.only" x=(px)0 y=(px)0 w=(px)400 h=(px)300 fill=(token)"color.bg"
}
}
variants {
variant id="var.bad" source="page.m" w=(px)800 h=(px)600 {
override node="node.does.not.exist" visible=#false
}
variant id="var.good" source="page.m" w=(px)200 h=(px)150 {
}
}
}
"##;
const DOC_FILL_VARIANT: &str = r##"zenith version=1 {
project id="proj.fv" name="Fill Variant Test"
tokens format="zenith-token-v1" {
token id="color.bg" type="color" value="#ffffff"
token id="color.alt" type="color" value="#3b82f6"
}
styles {}
document id="doc.fv" title="Fill Variant Test" {
page id="page.f" w=(px)400 h=(px)300 {
rect id="rect.hero" x=(px)0 y=(px)0 w=(px)400 h=(px)300 fill=(token)"color.bg"
}
}
variants {
variant id="var.filled" source="page.f" w=(px)400 h=(px)300 {
override node="rect.hero" fill=(token)"color.alt"
}
}
}
"##;
const DOC_NO_VARIANTS: &str = r##"zenith version=1 {
project id="proj.nv" name="No Variants"
tokens format="zenith-token-v1" {
token id="color.bg" type="color" value="#ffffff"
}
styles {}
document id="doc.nv" title="No Variants" {
page id="page.nv" w=(px)400 h=(px)300 {
rect id="rect.bg" x=(px)0 y=(px)0 w=(px)400 h=(px)300 fill=(token)"color.bg"
}
}
}
"##;
fn parse(src: &str) -> Document {
KdlAdapter
.parse(src.as_bytes())
.expect("fixture must parse")
}
#[test]
fn empty_variants_returns_empty_expansion() {
let doc = parse(DOC_NO_VARIANTS);
let expansion = expand_variants(&doc);
assert_eq!(expansion.results.len(), 0);
assert_eq!(expansion.generated(), 0);
assert_eq!(expansion.failed(), 0);
}
#[test]
fn two_variants_both_generated_in_id_order() {
let doc = parse(DOC_TWO_VARIANTS);
let expansion = expand_variants(&doc);
assert_eq!(expansion.generated(), 2);
assert_eq!(expansion.failed(), 0);
assert_eq!(expansion.results.len(), 2);
assert_eq!(expansion.results[0].id, "var.large");
assert_eq!(expansion.results[1].id, "var.small");
assert_eq!(expansion.results[0].source, "page.a");
assert_eq!(expansion.results[1].source, "page.a");
}
#[test]
fn var_large_page_resized_and_text_replaced() {
let doc = parse(DOC_TWO_VARIANTS);
let expansion = expand_variants(&doc);
let result = expansion
.results
.iter()
.find(|r| r.id == "var.large")
.expect("var.large must be present");
let VariantOutcome::Generated(ref materialized) = result.outcome else {
panic!("var.large must be Generated, got failure");
};
let page = materialized
.body
.pages
.iter()
.find(|p| p.id == "page.a")
.expect("page.a must exist");
assert_eq!(page.width.value, 1920.0);
assert_eq!(page.height.value, 1080.0);
let text_node =
find_text_node_by_id(materialized, "text.label").expect("text.label must exist");
let first_span_text: String = text_node.spans.iter().map(|s| s.text.as_str()).collect();
assert_eq!(first_span_text, "large variant");
}
#[test]
fn var_small_page_resized_and_node_hidden() {
let doc = parse(DOC_TWO_VARIANTS);
let expansion = expand_variants(&doc);
let result = expansion
.results
.iter()
.find(|r| r.id == "var.small")
.expect("var.small must be present");
let VariantOutcome::Generated(ref materialized) = result.outcome else {
panic!("var.small must be Generated, got failure");
};
let page = materialized
.body
.pages
.iter()
.find(|p| p.id == "page.a")
.expect("page.a must exist");
assert_eq!(page.width.value, 320.0);
assert_eq!(page.height.value, 180.0);
let rect = find_rect_node_by_id(materialized, "rect.bg").expect("rect.bg must exist");
assert_eq!(rect.visible, Some(false));
}
#[test]
fn fill_override_applied() {
let doc = parse(DOC_FILL_VARIANT);
let expansion = expand_variants(&doc);
assert_eq!(expansion.generated(), 1);
assert_eq!(expansion.failed(), 0);
let result = &expansion.results[0];
assert_eq!(result.id, "var.filled");
let VariantOutcome::Generated(ref materialized) = result.outcome else {
panic!("var.filled must be Generated");
};
let rect = find_rect_node_by_id(materialized, "rect.hero").expect("rect.hero must exist");
assert_eq!(
rect.fill,
Some(PropertyValue::TokenRef("color.alt".to_owned()))
);
}
const DOC_GEOMETRY_VARIANT: &str = r##"zenith version=1 {
project id="proj.gv" name="Geometry Variant Test"
tokens format="zenith-token-v1" {
token id="color.bg" type="color" value="#ffffff"
}
styles {}
document id="doc.gv" title="Geometry Variant Test" {
page id="page.g" w=(px)1920 h=(px)1080 {
rect id="rect.hero" x=(px)0 y=(px)0 w=(px)400 h=(px)200 fill=(token)"color.bg"
}
}
variants {
variant id="var.geo" source="page.g" w=(px)1920 h=(px)1080 {
override node="rect.hero" x=(px)100 y=(px)266 w=(px)880 h=(px)340
}
}
}
"##;
const DOC_PARTIAL_GEOMETRY_VARIANT: &str = r##"zenith version=1 {
project id="proj.pgv" name="Partial Geometry Test"
tokens format="zenith-token-v1" {
token id="color.bg" type="color" value="#ffffff"
}
styles {}
document id="doc.pgv" title="Partial Geometry Test" {
page id="page.pg" w=(px)800 h=(px)600 {
rect id="rect.box" x=(px)10 y=(px)20 w=(px)300 h=(px)150 fill=(token)"color.bg"
}
}
variants {
variant id="var.pgeo" source="page.pg" w=(px)800 h=(px)600 {
override node="rect.box" y=(px)50
}
}
}
"##;
#[test]
fn geometry_override_repositions_node() {
let doc = parse(DOC_GEOMETRY_VARIANT);
let expansion = expand_variants(&doc);
assert_eq!(expansion.generated(), 1, "var.geo must be generated");
assert_eq!(expansion.failed(), 0);
let result = &expansion.results[0];
assert_eq!(result.id, "var.geo");
let VariantOutcome::Generated(ref materialized) = result.outcome else {
panic!("var.geo must be Generated");
};
let rect = find_rect_node_by_id(materialized, "rect.hero").expect("rect.hero must exist");
assert_eq!(
rect.x.as_ref().and_then(pv_value),
Some(100.0),
"x must be overridden to 100"
);
assert_eq!(
rect.y.as_ref().and_then(pv_value),
Some(266.0),
"y must be overridden to 266"
);
assert_eq!(
rect.w.as_ref().and_then(pv_value),
Some(880.0),
"w must be overridden to 880"
);
assert_eq!(
rect.h.as_ref().and_then(pv_value),
Some(340.0),
"h must be overridden to 340"
);
}
#[test]
fn partial_geometry_override_only_changes_specified_axes() {
let doc = parse(DOC_PARTIAL_GEOMETRY_VARIANT);
let expansion = expand_variants(&doc);
assert_eq!(expansion.generated(), 1, "var.pgeo must be generated");
assert_eq!(expansion.failed(), 0);
let result = &expansion.results[0];
assert_eq!(result.id, "var.pgeo");
let VariantOutcome::Generated(ref materialized) = result.outcome else {
panic!("var.pgeo must be Generated");
};
let rect = find_rect_node_by_id(materialized, "rect.box").expect("rect.box must exist");
assert_eq!(
rect.x.as_ref().and_then(pv_value),
Some(10.0),
"x must remain 10 (unset in override)"
);
assert_eq!(
rect.y.as_ref().and_then(pv_value),
Some(50.0),
"y must be overridden to 50"
);
assert_eq!(
rect.w.as_ref().and_then(pv_value),
Some(300.0),
"w must remain 300 (unset in override)"
);
assert_eq!(
rect.h.as_ref().and_then(pv_value),
Some(150.0),
"h must remain 150 (unset in override)"
);
}
#[test]
fn missing_node_override_fails_sibling_still_generated() {
let doc = parse(DOC_MISSING_NODE_VARIANT);
let expansion = expand_variants(&doc);
assert_eq!(expansion.results.len(), 2);
let bad = &expansion.results[0];
let good = &expansion.results[1];
assert_eq!(bad.id, "var.bad");
assert_eq!(good.id, "var.good");
assert!(
matches!(good.outcome, VariantOutcome::Generated(_)),
"var.good must be Generated"
);
assert!(
matches!(bad.outcome, VariantOutcome::Failed(_)),
"var.bad must be Failed because its override target does not exist"
);
if let VariantOutcome::Failed(ref reason) = bad.outcome {
assert!(
reason.contains("node.does.not.exist"),
"failure reason should mention the missing node id; got: {reason}"
);
}
}
#[test]
fn source_document_not_mutated() {
let doc = parse(DOC_TWO_VARIANTS);
let original_page_w = doc.body.pages[0].width.value;
let _ = expand_variants(&doc);
assert_eq!(
doc.body.pages[0].width.value, original_page_w,
"source document must not be mutated"
);
}
fn find_text_node_by_id<'a>(doc: &'a Document, id: &str) -> Option<&'a zenith_core::TextNode> {
for page in &doc.body.pages {
if let Some(n) = find_text_in_nodes(&page.children, id) {
return Some(n);
}
}
None
}
fn find_text_in_nodes<'a>(
nodes: &'a [zenith_core::Node],
id: &str,
) -> Option<&'a zenith_core::TextNode> {
for node in nodes {
match node {
zenith_core::Node::Text(n) if n.id == id => return Some(n),
zenith_core::Node::Frame(n) => {
if let Some(found) = find_text_in_nodes(&n.children, id) {
return Some(found);
}
}
zenith_core::Node::Group(n) => {
if let Some(found) = find_text_in_nodes(&n.children, id) {
return Some(found);
}
}
_ => {}
}
}
None
}
fn pv_value(pv: &zenith_core::PropertyValue) -> Option<f64> {
match pv {
zenith_core::PropertyValue::Dimension(d) => Some(d.value),
_ => None,
}
}
fn find_rect_node_by_id<'a>(doc: &'a Document, id: &str) -> Option<&'a zenith_core::RectNode> {
for page in &doc.body.pages {
if let Some(n) = find_rect_in_nodes(&page.children, id) {
return Some(n);
}
}
None
}
fn find_rect_in_nodes<'a>(
nodes: &'a [zenith_core::Node],
id: &str,
) -> Option<&'a zenith_core::RectNode> {
for node in nodes {
match node {
zenith_core::Node::Rect(n) if n.id == id => return Some(n),
zenith_core::Node::Frame(n) => {
if let Some(found) = find_rect_in_nodes(&n.children, id) {
return Some(found);
}
}
zenith_core::Node::Group(n) => {
if let Some(found) = find_rect_in_nodes(&n.children, id) {
return Some(found);
}
}
_ => {}
}
}
None
}
}