zenith-tool 0.0.7

The Zenith command-line interface (the `zenith` binary) for the design-document toolchain.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
//! Pure in-memory variant generation engine.
//!
//! [`expand_variants`] is the single public entry point.  It consumes a parsed
//! [`Document`], iterates `doc.variants` in stable id order, and for each
//! definition clones the source document, builds a transaction op batch, and
//! runs it through the same [`run_transaction`] path that `zenith merge` uses.
//!
//! No file I/O, no CLI parsing, no rendering.  Those live in the CLI entry point.

use std::collections::BTreeMap;

use zenith_core::{Document, KdlAdapter, KdlSource, PropertyValue, dim_to_px};
use zenith_tx::{Op, OpSpan, Permissions, Transaction, TxStatus, run_transaction};

// ── Result / outcome types ────────────────────────────────────────────────────

/// The complete result of one [`expand_variants`] call.
///
/// `results` is sorted by variant id (ascending), matching the deterministic
/// processing order.
#[derive(Debug)]
pub struct VariantExpansion {
    pub results: Vec<VariantResult>,
}

impl VariantExpansion {
    /// Number of successfully-generated variants.
    pub fn generated(&self) -> usize {
        self.results
            .iter()
            .filter(|r| matches!(r.outcome, VariantOutcome::Generated(_)))
            .count()
    }

    /// Number of failed variants.
    pub fn failed(&self) -> usize {
        self.results
            .iter()
            .filter(|r| matches!(r.outcome, VariantOutcome::Failed(_)))
            .count()
    }
}

/// Result for a single variant entry.
#[derive(Debug)]
pub struct VariantResult {
    /// The variant's stable id.
    pub id: String,
    /// The source page id this variant derives from.
    pub source: String,
    /// Either the materialized document or a failure reason.
    pub outcome: VariantOutcome,
}

/// Outcome of applying one variant's op batch.
#[derive(Debug)]
pub enum VariantOutcome {
    /// The transaction was accepted; contains the materialized document.
    /// Boxed: a `Document` is much larger than the `Failed` string payload.
    Generated(Box<Document>),
    /// The transaction was rejected or the engine returned a hard error.
    /// Contains a human-readable reason string.
    Failed(String),
}

// ── expand_variants ───────────────────────────────────────────────────────────

/// Expand all variant definitions in `doc` into materialized documents.
///
/// Processes variants in ascending `id` order (deterministic).  A failure on
/// one variant does NOT abort the rest — every variant is attempted independently.
///
/// Returns an empty [`VariantExpansion`] when `doc.variants` is empty.
pub fn expand_variants(doc: &Document) -> VariantExpansion {
    if doc.variants.is_empty() {
        return VariantExpansion {
            results: Vec::new(),
        };
    }

    // Collect into a BTreeMap keyed by id to enforce deterministic ordering
    // without mutating the caller's slice.  Duplicate ids are caught by
    // validation and are not expected here; if they slip through the last
    // writer wins (both would produce the same key anyway since validation blocks).
    let sorted: BTreeMap<&str, _> = doc.variants.iter().map(|v| (v.id.as_str(), v)).collect();

    // Generation consumes the variants block: each materialized variant is a
    // concrete page, not a template. Strip the block from the base document the
    // transactions run against so (a) the output carries no `variants` block and
    // (b) one variant's override problems don't fail a sibling variant when the
    // post-transaction validation re-checks the (shared) variants block.
    let mut base = doc.clone();
    base.variants.clear();

    let mut results: Vec<VariantResult> = Vec::with_capacity(sorted.len());

    for variant in sorted.values() {
        // Build the op batch for this variant.
        let mut ops: Vec<Op> = Vec::new();

        // 1. Resize the source page to the variant's target dimensions.
        ops.push(Op::SetPageSize {
            page: variant.source.clone(),
            w: variant.w.to_kdl_string(),
            h: variant.h.to_kdl_string(),
        });

        // 2. Per-override ops, in stored order, sub-ordered:
        //    visible → geometry → fill → text.
        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(),
        };

        // 3. Run the transaction against the variants-stripped base document.
        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) => {
                // Re-parse source_after into the materialized document.
                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 }
}

// ── Private helpers ───────────────────────────────────────────────────────────

/// Extract a string to pass to [`Op::SetFill`] from a [`PropertyValue`].
///
/// [`Op::SetFill`] accepts a token id and stores it as
/// `PropertyValue::TokenRef`.  For `TokenRef` fills this is straightforward.
/// For `Literal` and `Dimension` fills the raw string is passed through; the
/// engine will still wrap it as `TokenRef`, which post-validation will then
/// reject as `token.unknown_reference` — surfacing a `Failed` outcome for that
/// variant rather than silently producing a corrupt document.
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(),
    }
}

// ── Unit tests ────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use zenith_core::KdlAdapter;

    // ── Fixtures ──────────────────────────────────────────────────────────────

    /// A minimal document with two variants so tests can exercise independent
    /// generation in a single parse.
    ///
    /// Page `page.a` contains:
    ///   - `rect.bg`     — a background rect (has `fill`, no text)
    ///   - `text.label`  — a text node with a single span
    ///
    /// Variant `var.small` → resizes page.a to 320×180, hides `rect.bg`.
    /// Variant `var.large` → resizes page.a to 1920×1080, overrides `text.label` text.
    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
    }
  }
}
"##;

    /// A document whose single variant overrides a node that does NOT exist —
    /// used to assert the tx engine's behavior on an unknown override target.
    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 {
    }
  }
}
"##;

    /// A document with a fill-override variant.
    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"
    }
  }
}
"##;

    /// A document with no variants block at all.
    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"
    }
  }
}
"##;

    // ── Helper ────────────────────────────────────────────────────────────────

    fn parse(src: &str) -> Document {
        KdlAdapter
            .parse(src.as_bytes())
            .expect("fixture must parse")
    }

    // ── Tests ─────────────────────────────────────────────────────────────────

    #[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);

        // Both variants should succeed.
        assert_eq!(expansion.generated(), 2);
        assert_eq!(expansion.failed(), 0);
        assert_eq!(expansion.results.len(), 2);

        // Results are sorted by id (ascending).  "var.large" < "var.small".
        assert_eq!(expansion.results[0].id, "var.large");
        assert_eq!(expansion.results[1].id, "var.small");

        // Both carry the correct source page.
        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");
        };

        // Page should be resized to 1920×1080.
        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);

        // text.label should now contain "large variant".
        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");
        };

        // Page should be resized to 320×180.
        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);

        // rect.bg should be hidden (visible = Some(false)).
        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");
        };

        // rect.hero fill should be TokenRef("color.alt").
        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()))
        );
    }

    /// A document whose single variant repositions a rect node via x/y/w/h
    /// geometry overrides — all four axes specified.
    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
    }
  }
}
"##;

    /// A document whose single variant overrides only `y` on a rect (partial
    /// geometry — x/w/h left to the tx engine's partial-apply semantics).
    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");

        // All four geometry overrides must be applied.
        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");

        // Only y was overridden; x/w/h must keep their original values.
        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);

        // var.bad targets a missing node → should fail.
        // var.good has no overrides → should succeed.
        assert_eq!(expansion.results.len(), 2);

        // Results sorted by id: "var.bad" < "var.good".
        let bad = &expansion.results[0];
        let good = &expansion.results[1];
        assert_eq!(bad.id, "var.bad");
        assert_eq!(good.id, "var.good");

        // var.good must be Generated regardless of var.bad's outcome.
        assert!(
            matches!(good.outcome, VariantOutcome::Generated(_)),
            "var.good must be Generated"
        );

        // var.bad: the tx engine emits tx.unknown_node for a missing override target,
        // which causes a Rejected status → Failed outcome.
        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() {
        // expand_variants takes &Document; the source doc must be identical
        // after the call (no shared mutation).
        let doc = parse(DOC_TWO_VARIANTS);
        let original_page_w = doc.body.pages[0].width.value;

        let _ = expand_variants(&doc);

        // Source page width must still be 800.
        assert_eq!(
            doc.body.pages[0].width.value, original_page_w,
            "source document must not be mutated"
        );
    }

    // ── Node-finding helpers (test-only) ─────────────────────────────────────

    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
    }

    /// Extract the px value of a geometry property that is a raw dimension
    /// (geometry is now `Option<PropertyValue>`; token refs read back as `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
    }
}