zenith-scene 0.0.7

Zenith backend-neutral scene IR and compilation (geometry, text wrap, anchors, opacity, clip).
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
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
//! Scene compilation: `Document` → `CompileResult`.
//!
//! Entry point: [`compile`].
//!
//! Rect, ellipse, line, text, code, and group nodes are compiled; the page
//! background is emitted first; unknown nodes produce an advisory diagnostic
//! and are skipped.
//!
//! [`compile`] renders page 0; [`compile_page`] renders a chosen page by index.
//!
//! The compiler is split across submodules: `leaf` (rect/ellipse/line/
//! polygon/polyline), `text` (text + code shaping), `container` (group +
//! frame), `image`, `paint` (color/gradient/shadow resolvers), and
//! `util` (small geometry/diagnostic helpers). This module keeps the public
//! entry points, the per-subtree `RenderCtx`, and the `compile_node`
//! dispatcher that routes each node kind to its submodule.

mod anchor;
mod chain;
mod chart;
mod container;
mod crop;
mod ctx;
mod data_resolve;
mod effect;
mod field;
mod footnote;
mod image;
mod leaf;
mod line_jumps;
mod markdown_resolve;
mod paint;
mod pattern;
mod table;
mod table_flow;
mod text;
mod toc;
mod util;

use std::collections::BTreeMap;

use zenith_core::{
    ComponentDef, DataContext, Diagnostic, Document, FontProvider, MasterDef, Node, PropertyValue,
    Style, dim_to_px, resolve_tokens,
};
use zenith_layout::RustybuzzEngine;

use crate::ir::{Paint, Rect, Scene, SceneCommand};

use anchor::build_anchor_map;
use chain::resolve_chains_document;
use chart::compile_chart;
use container::{compile_frame, compile_group, compile_instance};
pub(in crate::compile) use ctx::NodeCtx;
use data_resolve::{scan_for_data_refs, substitute_data_refs};
use effect::{compile_light, compile_mesh};
use field::{
    FieldCtx, build_node_boxes, build_page_index_map, build_section_assignments, compute_live_area,
    resolve_field_to_text,
};
use image::compile_image;
use leaf::{
    ConnectorEnv, RectEllipseEnv, ShapeCompileEnv, compile_connector, compile_ellipse,
    compile_line, compile_polygon, compile_polyline, compile_rect, compile_shape,
};
use markdown_resolve::{resolve_markdown, scan_for_markdown_text};
use paint::{resolve_property_color, resolve_property_gradient};
use pattern::compile_pattern;
use table::{TableEmitCtx, compile_table};
use table_flow::resolve_table_flows;
use text::{TextCompileEnv, compile_code, compile_text, empty_md_blocks};
use toc::resolve_toc_to_text;

/// Compile-time lookup of component definitions by id. Threaded through the
/// node-compilation dispatch so [`Node::Instance`] can expand its referenced
/// component subtree. Ordered (`BTreeMap`) for deterministic iteration.
pub(super) type ComponentMap<'a> = BTreeMap<&'a str, &'a ComponentDef>;

/// Compile-time lookup of master-page definitions by id. A page with a `master`
/// attribute projects the named master's nodes (with fields resolved against
/// that page) UNDER its own children. Ordered (`BTreeMap`) for determinism.
pub(super) type MasterMap<'a> = BTreeMap<&'a str, &'a MasterDef>;

// ── Render context ────────────────────────────────────────────────────────────

/// Per-subtree rendering context that cascades through the node tree.
///
/// Each field accumulates transformations as we descend:
/// - `opacity` — multiplied together at each group boundary; leaf nodes
///   apply it on top of their own node-level opacity.
/// - `dx`/`dy` — translation offset accumulated from all ancestor groups
///   with an `x`/`y` property; added to every leaf geometry position.
#[derive(Clone, Copy)]
pub(super) struct RenderCtx {
    /// Accumulated opacity multiplier (1.0 = fully opaque).
    pub(super) opacity: f64,
    /// Accumulated x-translation in pixels.
    pub(super) dx: f64,
    /// Accumulated y-translation in pixels.
    pub(super) dy: f64,
    /// Resolved page baseline-grid pitch in pixels, when active on this page.
    /// `Some(g)` with `g > 0.0` snaps text line baselines onto `{0, g, 2g, …}`
    /// measured in the post-`dy` coordinate space; `None` → no grid (the snap is
    /// skipped, byte-identical to before). Cascades unchanged to every child
    /// context so all text on the page shares one grid.
    pub(super) baseline_grid: Option<f64>,
}

impl RenderCtx {
    fn root() -> Self {
        RenderCtx {
            opacity: 1.0,
            dx: 0.0,
            dy: 0.0,
            baseline_grid: None,
        }
    }

    /// Identity context used by the footnote zone's scratch MEASURE pass: the
    /// synthesized footnote text is compiled into a throwaway buffer at the
    /// origin to read its laid-out height before the real (offset) emit. Same
    /// fields as [`RenderCtx::root`].
    pub(super) fn measure() -> Self {
        RenderCtx {
            opacity: 1.0,
            dx: 0.0,
            dy: 0.0,
            baseline_grid: None,
        }
    }

    /// Root context translated by a fixed pixel offset on both axes. Used to
    /// shift all page content into the trim box when a print bleed is active:
    /// authored coordinate `(0, 0)` then lands at the trim corner `(b, b)`.
    fn root_offset(dx: f64, dy: f64) -> Self {
        RenderCtx {
            opacity: 1.0,
            dx,
            dy,
            baseline_grid: None,
        }
    }
}

// ── Public result type ────────────────────────────────────────────────────────

/// The result of compiling a [`Document`] into a [`Scene`].
#[derive(Debug, Clone)]
pub struct CompileResult {
    /// The compiled display list.
    pub scene: Scene,
    /// All diagnostics collected during compilation (may include token-resolution
    /// diagnostics, unit advisories, and unsupported-node advisories).
    pub diagnostics: Vec<Diagnostic>,
}

// ── Style cascade helper ──────────────────────────────────────────────────────

/// Look up a style property value by (style_ref, style_map, key).
///
/// Returns `None` when there is no style reference, the style id is not in the
/// map, or the style does not carry the requested key.
pub(super) fn style_prop<'a>(
    style_ref: &Option<String>,
    style_map: &'a BTreeMap<&str, &Style>,
    key: &str,
) -> Option<&'a PropertyValue> {
    let sid = style_ref.as_deref()?;
    style_map.get(sid)?.properties.get(key)
}

// ── Entry point ───────────────────────────────────────────────────────────────

/// Compile `doc` into a [`CompileResult`], using `fonts` to shape text nodes.
///
/// [`compile_page`] renders a chosen page; this wrapper renders page 0.  If the
/// document has no pages an empty scene is returned with an advisory diagnostic.
///
/// Pass `&zenith_core::default_provider()` to use the bundled Noto Sans
/// font, which is sufficient for basic text rendering.
///
/// # No-panic guarantee
///
/// This function never calls `unwrap`, `expect`, `panic!`, `todo!`,
/// `unimplemented!`, or performs unchecked indexing.  All failure paths push a
/// diagnostic and continue.
pub fn compile(doc: &Document, fonts: &dyn FontProvider) -> CompileResult {
    compile_page(doc, fonts, 0, None)
}

/// Compile the page at `page_index` (0-based) of `doc` into a [`CompileResult`],
/// using `fonts` to shape text nodes.
///
/// If the document has no pages an empty scene is returned with a
/// `scene.no_pages` advisory; if `page_index` is out of range (but pages exist)
/// an empty scene is returned with a `scene.page_out_of_range` advisory.
///
/// Pass `&zenith_core::default_provider()` to use the bundled Noto Sans
/// font, which is sufficient for basic text rendering.
///
/// Pass `Some(&data_ctx)` to resolve `(data)"field.path"` property references at
/// compile time. Pass `None` to skip data binding — a document with no `(data)`
/// refs and `data: None` is byte-identical to previous behavior.
///
/// # No-panic guarantee
///
/// This function never calls `unwrap`, `expect`, `panic!`, `todo!`,
/// `unimplemented!`, or performs unchecked indexing (page lookup uses `.get()`).
/// All failure paths push a diagnostic and continue.
pub fn compile_page(
    doc: &Document,
    fonts: &dyn FontProvider,
    page_index: usize,
    data: Option<&DataContext>,
) -> CompileResult {
    let mut diagnostics: Vec<Diagnostic> = Vec::new();

    // ── Step 0: data-binding pre-pass ─────────────────────────────────────
    // Resolve every `(data)"field"` property reference and every span
    // `data-ref` BEFORE compilation so all downstream resolvers only ever see
    // `Literal` / `TokenRef` / `Dimension` values.
    //
    // - `data = Some`: clone the doc once, substitute in place, then compile the
    //   clone. The clone is unavoidable because compilation borrows `doc`
    //   immutably elsewhere; it only happens on the data-binding path.
    // - `data = None`: NEVER clone. A read-only scan emits a single
    //   `data.no_context` advisory iff any ref exists, then the original `doc`
    //   compiles by reference — byte-identical to the no-data-binding path.
    let mut md_blocks: markdown_resolve::MdBlockMap = markdown_resolve::MdBlockMap::new();
    let owned_doc: Option<Document> = match data {
        Some(ctx) => {
            let mut cloned = doc.clone();
            substitute_data_refs(&mut cloned, ctx, &mut diagnostics);
            // ── Step 0b: markdown-resolution pass ────────────────────────
            // For each `text` node with `format="markdown"`, concatenate the
            // (now data-substituted) span texts, replace spans with the parsed
            // inline styled spans, and record the parsed BLOCK list in
            // `md_blocks` (consumed by the block-layout path). Nodes without
            // `format="markdown"` are skipped (byte-identical).
            md_blocks = resolve_markdown(&mut cloned);
            Some(cloned)
        }
        None => {
            // Read-only scan: emit ONE `data.no_context` advisory iff a ref
            // exists. No clone, no mutation — byte-identical when refs are absent.
            if scan_for_data_refs(doc) {
                diagnostics.push(Diagnostic::advisory(
                    "data.no_context",
                    "document contains `(data)` references but no data context was \
                     provided at compile time; the references are left unresolved",
                    None,
                    None,
                ));
            }
            // ── Step 0b: markdown-resolution pass (no-data path) ─────────
            // Even without a data context, `format="markdown"` nodes must be
            // resolved. Clone only when at least one markdown-format text node
            // exists; otherwise skip entirely (byte-identical to before).
            if scan_for_markdown_text(doc) {
                let mut cloned = doc.clone();
                md_blocks = resolve_markdown(&mut cloned);
                Some(cloned)
            } else {
                None
            }
        }
    };
    // From here on, compile against the (possibly substituted) document.
    let doc: &Document = owned_doc.as_ref().unwrap_or(doc);

    // ── Step 1: resolve tokens ────────────────────────────────────────────
    let token_resolution = resolve_tokens(&doc.tokens);
    diagnostics.extend(token_resolution.diagnostics);
    let resolved = &token_resolution.resolved;

    // ── Step 1b: build style lookup map ──────────────────────────────────
    let style_map: BTreeMap<&str, &Style> = doc
        .styles
        .styles
        .iter()
        .map(|s| (s.id.as_str(), s))
        .collect();

    // ── Step 1c: build component lookup map ──────────────────────────────
    // Instances expand their referenced component at compile time. First
    // declaration wins on a duplicate id (the validator flags id.duplicate).
    let mut component_map: ComponentMap = BTreeMap::new();
    for comp in &doc.components {
        component_map.entry(comp.id.as_str()).or_insert(comp);
    }

    // ── Step 1d: build master lookup map + page-ref index ────────────────
    // A page's `master` attribute projects the named master's nodes (fields
    // resolved against that page) under the page's own children. The page-ref
    // index maps every node id to the 1-based page that contains it, for
    // `page-ref` field resolution. Both are document-wide and order-stable.
    let mut master_map: MasterMap = BTreeMap::new();
    for master in &doc.masters {
        master_map.entry(master.id.as_str()).or_insert(master);
    }
    let page_index_by_node_id = build_page_index_map(doc);

    // ── Step 2: select the requested page ────────────────────────────────
    let Some(page) = doc.body.pages.get(page_index) else {
        if doc.body.pages.is_empty() {
            diagnostics.push(Diagnostic::advisory(
                "scene.no_pages",
                "document has no pages; an empty scene is returned",
                None,
                Some(doc.body.id.clone()),
            ));
        } else {
            diagnostics.push(Diagnostic::advisory(
                "scene.page_out_of_range",
                format!(
                    "page index {} is out of range; document has {} page(s)",
                    page_index,
                    doc.body.pages.len()
                ),
                None,
                Some(doc.body.id.clone()),
            ));
        }
        return CompileResult {
            scene: Scene::new(0.0, 0.0),
            diagnostics,
        };
    };

    // ── Step 3: page dimensions → pixels ─────────────────────────────────
    let page_w = match dim_to_px(page.width.value, &page.width.unit) {
        Some(v) => v,
        None => {
            diagnostics.push(Diagnostic::advisory(
                "scene.unsupported_unit",
                format!(
                    "page '{}' width uses an unsupported unit; cannot compile scene",
                    page.id
                ),
                page.source_span,
                Some(page.id.clone()),
            ));
            return CompileResult {
                scene: Scene::new(0.0, 0.0),
                diagnostics,
            };
        }
    };
    let page_h = match dim_to_px(page.height.value, &page.height.unit) {
        Some(v) => v,
        None => {
            diagnostics.push(Diagnostic::advisory(
                "scene.unsupported_unit",
                format!(
                    "page '{}' height uses an unsupported unit; cannot compile scene",
                    page.id
                ),
                page.source_span,
                Some(page.id.clone()),
            ));
            return CompileResult {
                scene: Scene::new(0.0, 0.0),
                diagnostics,
            };
        }
    };

    // ── Step 3b: resolve print bleed ─────────────────────────────────────
    // A page may declare a uniform `bleed` margin. When it resolves to a
    // positive pixel value `b`, the media (canvas) box expands to
    // `(page_w + 2b) × (page_h + 2b)`, the trim box is the inner
    // `[b, b, page_w, page_h]`, all content shifts by `(b, b)`, the background
    // fills the whole media box, and crop marks are drawn at the trim corners.
    // An absent / unresolvable / non-positive bleed yields `b = 0`, which is
    // byte-identical to the no-bleed path. The validator surfaces a warning for
    // an unresolvable unit or a negative value; the compiler just ignores it.
    let bleed = page
        .bleed
        .as_ref()
        .and_then(|d| dim_to_px(d.value, &d.unit))
        .filter(|&px| px > 0.0)
        .unwrap_or(0.0);

    // Media box (full canvas including bleed on all four sides).
    let media_w = page_w + 2.0 * bleed;
    let media_h = page_h + 2.0 * bleed;

    let mut scene = Scene::new(media_w, media_h);

    // ── Step 4: outermost media-edge clip (normative rule) ────────
    // The clip covers the entire media box so content and background may bleed
    // into the margin. With bleed = 0 this is exactly the page rectangle.
    scene.commands.push(SceneCommand::PushClip {
        x: 0.0,
        y: 0.0,
        w: media_w,
        h: media_h,
    });

    // ── Step 5: optional page background (fills the entire media box) ────
    if let Some(bg_prop) = &page.background {
        if let Some(gradient) = resolve_property_gradient(bg_prop, resolved, &page.id) {
            // Page background applies no opacity cascade (mirrors the solid path).
            scene.commands.push(SceneCommand::FillRect {
                x: 0.0,
                y: 0.0,
                w: media_w,
                h: media_h,
                paint: Paint::Gradient(gradient),
            });
        } else if let Some(color) =
            resolve_property_color(bg_prop, resolved, &mut diagnostics, &page.id)
        {
            scene.commands.push(SceneCommand::FillRect {
                x: 0.0,
                y: 0.0,
                w: media_w,
                h: media_h,
                paint: Paint::solid(color),
            });
        }
    }

    // ── Step 5b: anchor pre-pass (PAGE-LOCAL) ────────────────────────────
    // Walk page top-level children once, building a map from node id to the
    // derived (x, y) for nodes that carry a recognized `anchor` attribute and
    // have px-resolvable w/h. Built once; threaded read-only into compile_node.
    let anchors = build_anchor_map(page, page_w, page_h, resolved);

    // ── Step 6: threaded-text chain pre-pass (DOCUMENT-WIDE) ─────────────
    // Resolve every text chain ONCE across ALL pages (deterministic
    // page-then-source-order walk into frames + groups), distributing each
    // chain's source article across every member's box — flowing across page
    // boundaries. The map is keyed by global node id; this page's nodes look up
    // the slice assigned to them. Chains' diagnostics (e.g. a source font
    // fallback) are document-wide and would otherwise be emitted once per page;
    // they are collected into a throwaway buffer here and only the diagnostics
    // attributable to THIS page's chain members would be surfaced — but since
    // distribution is global, we keep the page-local behaviour deterministic by
    // discarding the pre-pass's own advisories on non-zero pages (they were
    // already surfaced on page 0). Page 0 keeps them.
    let engine = RustybuzzEngine::new();
    let mut chain_diags: Vec<Diagnostic> = Vec::new();
    let chains = resolve_chains_document(
        doc,
        resolved,
        &style_map,
        fonts,
        &engine,
        &md_blocks,
        &mut chain_diags,
    );
    // Multi-page table flow pre-pass (DOCUMENT-WIDE), built ONCE like the chain
    // map and threaded identically into every `compile_node`. Its advisories are
    // document-wide; like the chain diags they surface only on page 0.
    let flows = resolve_table_flows(doc, resolved, &style_map, fonts, &engine, &mut chain_diags);
    if page_index == 0 {
        diagnostics.extend(chain_diags);
    }

    // ── Step 7: build the per-page field context ─────────────────────────
    // The 1-based page index drives the folio + parity (recto = odd, verso =
    // even). The live area mirrors the validator's margin formula so an omitted
    // field x/w auto-mirrors recto/verso via the page margins.
    let page_index_1based = page_index + 1;
    // Single source of truth for parity (explicit page.parity > document
    // page-parity-start > default index%2==1). Mirrors the validator.
    let is_recto = doc.page_is_recto(page, page_index_1based);
    let mirror_margins = doc.mirror_margins.unwrap_or(false);
    // RTL book: the binding margin is mirrored to the opposite side (recto →
    // inner-on-right). Matches the validator's `margin.rs` parity.
    let rtl_book = doc.page_progression.as_deref() == Some("rtl");
    let live_area = compute_live_area(
        doc,
        page,
        page_w,
        page_h,
        is_recto,
        mirror_margins,
        rtl_book,
    );

    // ── Step 7b: collect this page's footnote markers ────────────────────
    // Every `footnote` DIRECT child of the page is auto-numbered 1..N in source
    // order (an explicit `marker` overrides the number but keeps its slot). The
    // ordered map drives both the inline superscript markers (a text span's
    // `footnote_ref` keys in) and the bottom-zone rendering below.
    let footnote_markers = footnote::collect_footnote_markers(page);

    // ── Step 7c: build this page's node bounding-box map ─────────────────
    // Maps every id-bearing page node with a resolvable x/y/w/h rect to its
    // ABSOLUTE page-coordinate box, accumulating group/instance translation
    // (frames are clip-only). Drives text-runaround exclusion lookup. Empty when
    // no node carries a complete rect (byte-identical to before for any text node
    // without `text-exclusion`).
    let node_boxes = build_node_boxes(page, resolved);

    // ── Step 7d: compute section assignments (document-wide, one-shot) ───
    // Precompute once (outside any inner loop — this is the single page compile
    // entry point): maps each 0-based page index to its section assignment.
    // The lifetime of the returned assignments is tied to `doc`, which outlives
    // the compile function.
    let section_assignments = build_section_assignments(doc);
    let section_assign = section_assignments.get(page_index).and_then(|opt| *opt);

    let field_ctx = FieldCtx {
        page_index_1based,
        is_recto,
        live_area,
        page_index_by_node_id: &page_index_by_node_id,
        footnote_markers: &footnote_markers,
        node_boxes: &node_boxes,
        total_pages: doc.body.pages.len(),
        pages: &doc.body.pages,
        section_page_index: section_assign.map(|a| a.page_index_in_section),
        section_page_count: section_assign.map(|a| a.page_count),
        section_folio_start: section_assign.map(|a| a.folio_start),
        section_folio_style: section_assign.and_then(|a| a.folio_style),
        section_name: section_assign.map(|a| a.name),
    };

    // Bundle the page-wide immutable lookups once; threaded read-only into every
    // top-level `compile_node` (master projection + page children) and cascaded
    // unchanged down the container/table recursion.
    let node_cx = NodeCtx {
        resolved,
        style_map: &style_map,
        components: &component_map,
        fonts,
        engine: &engine,
        chains: &chains,
        flows: &flows,
        anchors: &anchors,
        field_ctx: &field_ctx,
        md_blocks: &md_blocks,
        page_block_styles: &page.block_styles,
        doc_block_styles: &doc.body.block_styles,
    };

    // ── Resolve the page baseline grid ───────────────────────────────────
    // A page may declare `baseline-grid=(px)14`. When it resolves to a positive
    // pixel value `g`, every text node on this page snaps its line baselines
    // onto the grid `{0, g, 2g, …}` (see [`RenderCtx::baseline_grid`]). An
    // absent / unresolvable / non-positive value yields `None`, byte-identical
    // to a page with no grid.
    let baseline_grid: Option<f64> = page
        .baseline_grid
        .as_ref()
        .and_then(|d| dim_to_px(d.value, &d.unit))
        .filter(|g| g.is_finite() && *g > 0.0);

    let mut root_ctx = if bleed > 0.0 {
        // Shift authored coordinates into the trim box. With bleed = 0 this is
        // the identity root context (byte-identical to before).
        RenderCtx::root_offset(bleed, bleed)
    } else {
        RenderCtx::root()
    };
    // Thread the grid into BOTH the bleed and no-bleed root contexts. The grid
    // is measured in the post-`dy` (shifted) coordinate space, the same space
    // the emitted baselines live in, so a bleed-shifted page snaps consistently.
    root_ctx.baseline_grid = baseline_grid;

    // Absolute indices, in document order, of the `StrokePolyline` emitted by
    // each top-level connector (master-projected then page-own). Used only by
    // the opt-in line-jump post-pass; empty/unused when the page declares no
    // `line-jumps`, so the rest of compile is byte-identical.
    let mut connector_strokes: Vec<usize> = Vec::new();

    // ── Step 7a: project the page's master (UNDER its own children) ──────
    // When `page.master` names a declared master, clone the master's children,
    // prefix every projected id with the page id (avoid cross-page collisions),
    // and compile them BEFORE the page's own children so running heads / folios
    // sit behind body text. Fields inside the master resolve against THIS page.
    // An unknown master reference is a hard validation error; here it is simply
    // skipped (the compiler never panics on bad references).
    if let Some(master_id) = &page.master
        && let Some(master) = master_map.get(master_id.as_str())
    {
        let mut projected = master.children.clone();
        let prefix = format!("{}/", page.id);
        container::prefix_ids_in_children(&mut projected, &prefix);
        for node in &projected {
            compile_node(
                node,
                node_cx,
                &mut scene.commands,
                &mut diagnostics,
                &mut connector_strokes,
                root_ctx,
            );
        }
    }

    // ── Step 7b: page children in source order (z-order: first = bottom) ─
    for node in &page.children {
        compile_node(
            node,
            node_cx,
            &mut scene.commands,
            &mut diagnostics,
            &mut connector_strokes,
            root_ctx,
        );
    }

    // ── Step 7b′: opt-in connector line-jumps (hops at crossings) ────────
    // Only "arc"/"gap" run; "none"/an unrecognized value/absent leaves the
    // commands untouched, so a page without `line-jumps` is byte-identical.
    if let Some(mode) = page.line_jumps.as_deref()
        && (mode == "arc" || mode == "gap")
    {
        line_jumps::apply_line_jumps(&mut scene.commands, &connector_strokes, mode);
    }

    // ── Step 7c: footnote zone (page furniture, above the bottom margin) ─
    // Rendered AFTER the page's own children (so it paints on top of body
    // content) but inside the media clip. Draws the separator rule plus the
    // stacked, auto-numbered footnotes; warns on body/zone overlap. A page with
    // no footnotes emits nothing here (byte-identical to before).
    footnote::compile_footnote_zone(
        page,
        live_area,
        footnote::FootnoteZoneEnv {
            markers: &footnote_markers,
            resolved,
            style_map: &style_map,
            fonts,
            engine: &engine,
            chains: &chains,
            anchors: &anchors,
            field_ctx: &field_ctx,
        },
        &mut scene.commands,
        &mut diagnostics,
        root_ctx,
    );

    // ── Step 8: close the outermost clip ─────────────────────────────────
    scene.commands.push(SceneCommand::PopClip);

    // ── Step 9: crop / trim marks (only when a bleed is active) ──────────
    // Emitted AFTER content and OUTSIDE the clip so the marks sit on top and
    // live entirely in the bleed margin at the four trim corners.
    if bleed > 0.0 {
        crop::emit_crop_marks(&mut scene.commands, bleed, page_w, page_h);
    }

    // ── Step 10: print trim box ──────────────────────────────────────────
    // When a bleed is active the media box (`scene.width`/`height`) includes
    // the bleed on all four sides; the trim box is the inner page rectangle
    // `[b, b, page_w, page_h]` that the finished piece is cut to. Backends that
    // care about print boxes (PDF) read this; raster backends ignore it. With
    // bleed = 0 the trim box equals the media box, so we leave `trim` as `None`.
    if bleed > 0.0 {
        scene.trim = Some(Rect {
            x: bleed,
            y: bleed,
            w: page_w,
            h: page_h,
        });
    }

    CompileResult { scene, diagnostics }
}

// ── Node dispatch ─────────────────────────────────────────────────────────────

/// The `role` of any node, if set. Used to exclude non-printing nodes
/// (`role="guide"`) from render output.
pub(super) fn node_role(node: &Node) -> Option<&str> {
    match node {
        Node::Rect(n) => n.role.as_deref(),
        Node::Ellipse(n) => n.role.as_deref(),
        Node::Line(n) => n.role.as_deref(),
        Node::Text(n) => n.role.as_deref(),
        Node::Code(n) => n.role.as_deref(),
        Node::Frame(n) => n.role.as_deref(),
        Node::Group(n) => n.role.as_deref(),
        Node::Image(n) => n.role.as_deref(),
        Node::Polygon(n) => n.role.as_deref(),
        Node::Polyline(n) => n.role.as_deref(),
        Node::Instance(n) => n.role.as_deref(),
        Node::Field(n) => n.role.as_deref(),
        Node::Toc(n) => n.role.as_deref(),
        Node::Footnote(n) => n.role.as_deref(),
        Node::Table(n) => n.role.as_deref(),
        Node::Shape(n) => n.role.as_deref(),
        Node::Connector(n) => n.role.as_deref(),
        Node::Pattern(n) => n.role.as_deref(),
        Node::Chart(n) => n.role.as_deref(),
        Node::Light(n) => n.role.as_deref(),
        Node::Mesh(n) => n.role.as_deref(),
        Node::Unknown(_) => None,
    }
}

/// Route a single node to the submodule that compiles its kind.
///
/// Each arm forwards the full cascade context to a `compile_*` function; the
/// emitted `SceneCommand` stream is identical to the previous inline match.
///
/// Returns the child's laid-out content height in pixels for the kinds whose
/// intrinsic height is meaningful to flow layout (`text`/`code`); every other
/// kind returns `0.0`. The absolute-positioning callers ignore this value, so
/// command output is unchanged; only the flow-layout path in [`container`]
/// consumes it to advance its vertical cursor.
pub(in crate::compile) fn compile_node(
    node: &Node,
    cx: NodeCtx,
    commands: &mut Vec<SceneCommand>,
    diagnostics: &mut Vec<Diagnostic>,
    connector_strokes: &mut Vec<usize>,
    ctx: RenderCtx,
) -> f64 {
    // Non-printing guide nodes (`role="guide"`) are excluded from render output
    // entirely — including their subtree when the guide is a group/frame.
    if node_role(node) == Some("guide") {
        return 0.0;
    }

    let NodeCtx {
        resolved,
        style_map,
        components,
        fonts,
        engine,
        chains,
        flows,
        anchors,
        field_ctx,
        md_blocks,
        page_block_styles,
        doc_block_styles,
    } = cx;

    match node {
        Node::Rect(rect) => {
            compile_rect(
                rect,
                RectEllipseEnv {
                    resolved,
                    style_map,
                    anchors,
                },
                commands,
                diagnostics,
                ctx,
            );
            0.0
        }
        Node::Ellipse(ellipse) => {
            compile_ellipse(
                ellipse,
                RectEllipseEnv {
                    resolved,
                    style_map,
                    anchors,
                },
                commands,
                diagnostics,
                ctx,
            );
            0.0
        }
        Node::Light(light) => {
            compile_light(light, resolved, commands, diagnostics, ctx);
            0.0
        }
        Node::Mesh(mesh) => {
            compile_mesh(mesh, resolved, commands, diagnostics, ctx);
            0.0
        }
        Node::Text(text) => compile_text(
            text,
            TextCompileEnv {
                resolved,
                style_map,
                fonts,
                engine,
                chains,
                footnote_markers: field_ctx.footnote_markers,
                node_boxes: field_ctx.node_boxes,
                anchors,
                md_blocks,
                page_block_styles,
                doc_block_styles,
            },
            commands,
            diagnostics,
            ctx,
        ),
        Node::Line(line) => {
            compile_line(line, resolved, style_map, commands, diagnostics, ctx);
            0.0
        }
        Node::Frame(frame) => {
            compile_frame(frame, cx, commands, diagnostics, connector_strokes, ctx);
            0.0
        }
        Node::Group(group) => {
            compile_group(group, cx, commands, diagnostics, connector_strokes, ctx);
            0.0
        }
        Node::Instance(instance) => {
            compile_instance(instance, cx, commands, diagnostics, connector_strokes, ctx);
            0.0
        }
        Node::Field(field) => {
            // Resolve the field against this page into a concrete single-line
            // text node and compile it via the normal text path. An unresolved
            // field (absent running-head side, unknown type, unresolved
            // page-ref) yields nothing.
            if let Some(text_node) = resolve_field_to_text(field, field_ctx) {
                compile_text(
                    &text_node,
                    TextCompileEnv {
                        resolved,
                        style_map,
                        fonts,
                        engine,
                        chains,
                        footnote_markers: field_ctx.footnote_markers,
                        node_boxes: field_ctx.node_boxes,
                        anchors,
                        md_blocks: empty_md_blocks(),
                        page_block_styles: &[],
                        doc_block_styles: &[],
                    },
                    commands,
                    diagnostics,
                    ctx,
                );
            }
            0.0
        }
        Node::Toc(toc) => {
            // Resolve the toc against the full document into a multi-line
            // tab-leader text block and compile it via the normal text path.
            // A toc with no matching headings, no selector, or visible=false
            // yields nothing.
            if let Some(text_node) =
                resolve_toc_to_text(toc, field_ctx.pages, field_ctx.page_index_by_node_id)
            {
                compile_text(
                    &text_node,
                    TextCompileEnv {
                        resolved,
                        style_map,
                        fonts,
                        engine,
                        chains,
                        footnote_markers: field_ctx.footnote_markers,
                        node_boxes: field_ctx.node_boxes,
                        anchors,
                        md_blocks: empty_md_blocks(),
                        page_block_styles: &[],
                        doc_block_styles: &[],
                    },
                    commands,
                    diagnostics,
                    ctx,
                );
            }
            0.0
        }
        Node::Image(image) => {
            compile_image(image, resolved, commands, diagnostics, anchors, ctx);
            0.0
        }
        Node::Polygon(poly) => {
            compile_polygon(poly, resolved, style_map, commands, diagnostics, ctx);
            0.0
        }
        Node::Polyline(poly) => {
            compile_polyline(poly, resolved, style_map, commands, diagnostics, ctx);
            0.0
        }
        Node::Code(code) => compile_code(
            code,
            TextCompileEnv {
                resolved,
                style_map,
                fonts,
                engine,
                chains,
                footnote_markers: field_ctx.footnote_markers,
                node_boxes: field_ctx.node_boxes,
                anchors,
                md_blocks: empty_md_blocks(),
                page_block_styles: &[],
                doc_block_styles: &[],
            },
            commands,
            diagnostics,
            ctx,
        ),
        Node::Table(table) => {
            compile_table(
                TableEmitCtx {
                    table,
                    resolved,
                    style_map,
                    components,
                    fonts,
                    engine,
                    chains,
                    flows,
                    anchors,
                    field_ctx,
                },
                commands,
                diagnostics,
                ctx,
            );
            0.0
        }
        Node::Shape(shape) => {
            compile_shape(
                shape,
                commands,
                diagnostics,
                ShapeCompileEnv {
                    resolved,
                    style_map,
                    fonts,
                    engine,
                    chains,
                    footnote_markers: field_ctx.footnote_markers,
                    node_boxes: field_ctx.node_boxes,
                    anchors,
                    ctx,
                },
            );
            0.0
        }
        Node::Connector(connector) => {
            // Record the connector's stroke (top-level OR nested) at its dispatch
            // point so the opt-in line-jump post-pass can hop it. The post-pass
            // filters by transform depth, so rotated/bracketed connectors are
            // excluded there, not here.
            let start = commands.len();
            compile_connector(
                connector,
                commands,
                diagnostics,
                ConnectorEnv {
                    resolved,
                    style_map,
                    fonts,
                    engine,
                    chains,
                    footnote_markers: field_ctx.footnote_markers,
                    node_boxes: field_ctx.node_boxes,
                    anchors,
                    ctx,
                },
            );
            line_jumps::record_connector_stroke(commands, start, connector_strokes);
            0.0
        }
        Node::Pattern(p) => compile_pattern(p, cx, commands, diagnostics, ctx),
        Node::Chart(c) => compile_chart(c, cx, commands, diagnostics, ctx),
        Node::Footnote(_) => {
            // Footnotes are NON-flowing page furniture: they carry no x/y/w/h
            // and are NOT rendered in the normal z-order dispatch. The page-level
            // footnote pass (`footnote::compile_footnote_zone`, run by
            // `compile_page`) collects every page-level footnote in source order,
            // auto-numbers them, and renders the bottom zone + separator. A
            // footnote reached here (e.g. nested in a container) renders nothing.
            0.0
        }
        Node::Unknown(unknown) => {
            diagnostics.push(Diagnostic::advisory(
                "scene.unsupported_node",
                format!(
                    "unknown node kind '{}' cannot be compiled; the node is skipped \
                     (forward-compatibility: this kind may be supported in a later version)",
                    unknown.kind
                ),
                unknown.source_span,
                None,
            ));
            0.0
        }
    }
}