Skip to main content

zenith_scene/compile/
mod.rs

1//! Scene compilation: `Document` → `CompileResult`.
2//!
3//! Entry point: [`compile`].
4//!
5//! Rect, ellipse, line, text, code, and group nodes are compiled; the page
6//! background is emitted first; unknown nodes produce an advisory diagnostic
7//! and are skipped.
8//!
9//! [`compile`] renders page 0; [`compile_page`] renders a chosen page by index.
10//!
11//! The compiler is split across submodules: `leaf` (rect/ellipse/line/
12//! polygon/polyline), `text` (text + code shaping), `container` (group +
13//! frame), `image`, `paint` (color/gradient/shadow resolvers), and
14//! `util` (small geometry/diagnostic helpers). This module keeps the public
15//! entry points, the per-subtree `RenderCtx`, and the `compile_node`
16//! dispatcher that routes each node kind to its submodule.
17
18mod anchor;
19mod chain;
20mod chart;
21mod container;
22mod crop;
23mod ctx;
24mod data_resolve;
25mod effect;
26mod field;
27mod footnote;
28mod image;
29mod leaf;
30mod line_jumps;
31mod markdown_resolve;
32mod paint;
33mod pattern;
34mod table;
35mod table_flow;
36mod text;
37mod toc;
38mod util;
39
40use std::collections::BTreeMap;
41
42use zenith_core::{
43    ComponentDef, DataContext, Diagnostic, Document, FontProvider, MasterDef, Node, PropertyValue,
44    Style, dim_to_px, resolve_tokens,
45};
46use zenith_layout::RustybuzzEngine;
47
48use crate::ir::{Paint, Rect, Scene, SceneCommand};
49
50use anchor::build_anchor_map;
51use chain::resolve_chains_document;
52use chart::compile_chart;
53use container::{compile_frame, compile_group, compile_instance};
54pub(in crate::compile) use ctx::NodeCtx;
55use data_resolve::{scan_for_data_refs, substitute_data_refs};
56use effect::{compile_light, compile_mesh};
57use field::{
58    FieldCtx, build_node_boxes, build_page_index_map, build_section_assignments, compute_live_area,
59    resolve_field_to_text,
60};
61use image::compile_image;
62use leaf::{
63    ConnectorEnv, RectEllipseEnv, ShapeCompileEnv, compile_connector, compile_ellipse,
64    compile_line, compile_polygon, compile_polyline, compile_rect, compile_shape,
65};
66use markdown_resolve::{resolve_markdown, scan_for_markdown_text};
67use paint::{resolve_property_color, resolve_property_gradient};
68use pattern::compile_pattern;
69use table::{TableEmitCtx, compile_table};
70use table_flow::resolve_table_flows;
71use text::{TextCompileEnv, compile_code, compile_text, empty_md_blocks};
72use toc::resolve_toc_to_text;
73
74/// Compile-time lookup of component definitions by id. Threaded through the
75/// node-compilation dispatch so [`Node::Instance`] can expand its referenced
76/// component subtree. Ordered (`BTreeMap`) for deterministic iteration.
77pub(super) type ComponentMap<'a> = BTreeMap<&'a str, &'a ComponentDef>;
78
79/// Compile-time lookup of master-page definitions by id. A page with a `master`
80/// attribute projects the named master's nodes (with fields resolved against
81/// that page) UNDER its own children. Ordered (`BTreeMap`) for determinism.
82pub(super) type MasterMap<'a> = BTreeMap<&'a str, &'a MasterDef>;
83
84// ── Render context ────────────────────────────────────────────────────────────
85
86/// Per-subtree rendering context that cascades through the node tree.
87///
88/// Each field accumulates transformations as we descend:
89/// - `opacity` — multiplied together at each group boundary; leaf nodes
90///   apply it on top of their own node-level opacity.
91/// - `dx`/`dy` — translation offset accumulated from all ancestor groups
92///   with an `x`/`y` property; added to every leaf geometry position.
93#[derive(Clone, Copy)]
94pub(super) struct RenderCtx {
95    /// Accumulated opacity multiplier (1.0 = fully opaque).
96    pub(super) opacity: f64,
97    /// Accumulated x-translation in pixels.
98    pub(super) dx: f64,
99    /// Accumulated y-translation in pixels.
100    pub(super) dy: f64,
101    /// Resolved page baseline-grid pitch in pixels, when active on this page.
102    /// `Some(g)` with `g > 0.0` snaps text line baselines onto `{0, g, 2g, …}`
103    /// measured in the post-`dy` coordinate space; `None` → no grid (the snap is
104    /// skipped, byte-identical to before). Cascades unchanged to every child
105    /// context so all text on the page shares one grid.
106    pub(super) baseline_grid: Option<f64>,
107}
108
109impl RenderCtx {
110    fn root() -> Self {
111        RenderCtx {
112            opacity: 1.0,
113            dx: 0.0,
114            dy: 0.0,
115            baseline_grid: None,
116        }
117    }
118
119    /// Identity context used by the footnote zone's scratch MEASURE pass: the
120    /// synthesized footnote text is compiled into a throwaway buffer at the
121    /// origin to read its laid-out height before the real (offset) emit. Same
122    /// fields as [`RenderCtx::root`].
123    pub(super) fn measure() -> Self {
124        RenderCtx {
125            opacity: 1.0,
126            dx: 0.0,
127            dy: 0.0,
128            baseline_grid: None,
129        }
130    }
131
132    /// Root context translated by a fixed pixel offset on both axes. Used to
133    /// shift all page content into the trim box when a print bleed is active:
134    /// authored coordinate `(0, 0)` then lands at the trim corner `(b, b)`.
135    fn root_offset(dx: f64, dy: f64) -> Self {
136        RenderCtx {
137            opacity: 1.0,
138            dx,
139            dy,
140            baseline_grid: None,
141        }
142    }
143}
144
145// ── Public result type ────────────────────────────────────────────────────────
146
147/// The result of compiling a [`Document`] into a [`Scene`].
148#[derive(Debug, Clone)]
149pub struct CompileResult {
150    /// The compiled display list.
151    pub scene: Scene,
152    /// All diagnostics collected during compilation (may include token-resolution
153    /// diagnostics, unit advisories, and unsupported-node advisories).
154    pub diagnostics: Vec<Diagnostic>,
155}
156
157// ── Style cascade helper ──────────────────────────────────────────────────────
158
159/// Look up a style property value by (style_ref, style_map, key).
160///
161/// Returns `None` when there is no style reference, the style id is not in the
162/// map, or the style does not carry the requested key.
163pub(super) fn style_prop<'a>(
164    style_ref: &Option<String>,
165    style_map: &'a BTreeMap<&str, &Style>,
166    key: &str,
167) -> Option<&'a PropertyValue> {
168    let sid = style_ref.as_deref()?;
169    style_map.get(sid)?.properties.get(key)
170}
171
172// ── Entry point ───────────────────────────────────────────────────────────────
173
174/// Compile `doc` into a [`CompileResult`], using `fonts` to shape text nodes.
175///
176/// [`compile_page`] renders a chosen page; this wrapper renders page 0.  If the
177/// document has no pages an empty scene is returned with an advisory diagnostic.
178///
179/// Pass `&zenith_core::default_provider()` to use the bundled Noto Sans
180/// font, which is sufficient for basic text rendering.
181///
182/// # No-panic guarantee
183///
184/// This function never calls `unwrap`, `expect`, `panic!`, `todo!`,
185/// `unimplemented!`, or performs unchecked indexing.  All failure paths push a
186/// diagnostic and continue.
187pub fn compile(doc: &Document, fonts: &dyn FontProvider) -> CompileResult {
188    compile_page(doc, fonts, 0, None)
189}
190
191/// Compile the page at `page_index` (0-based) of `doc` into a [`CompileResult`],
192/// using `fonts` to shape text nodes.
193///
194/// If the document has no pages an empty scene is returned with a
195/// `scene.no_pages` advisory; if `page_index` is out of range (but pages exist)
196/// an empty scene is returned with a `scene.page_out_of_range` advisory.
197///
198/// Pass `&zenith_core::default_provider()` to use the bundled Noto Sans
199/// font, which is sufficient for basic text rendering.
200///
201/// Pass `Some(&data_ctx)` to resolve `(data)"field.path"` property references at
202/// compile time. Pass `None` to skip data binding — a document with no `(data)`
203/// refs and `data: None` is byte-identical to previous behavior.
204///
205/// # No-panic guarantee
206///
207/// This function never calls `unwrap`, `expect`, `panic!`, `todo!`,
208/// `unimplemented!`, or performs unchecked indexing (page lookup uses `.get()`).
209/// All failure paths push a diagnostic and continue.
210pub fn compile_page(
211    doc: &Document,
212    fonts: &dyn FontProvider,
213    page_index: usize,
214    data: Option<&DataContext>,
215) -> CompileResult {
216    let mut diagnostics: Vec<Diagnostic> = Vec::new();
217
218    // ── Step 0: data-binding pre-pass ─────────────────────────────────────
219    // Resolve every `(data)"field"` property reference and every span
220    // `data-ref` BEFORE compilation so all downstream resolvers only ever see
221    // `Literal` / `TokenRef` / `Dimension` values.
222    //
223    // - `data = Some`: clone the doc once, substitute in place, then compile the
224    //   clone. The clone is unavoidable because compilation borrows `doc`
225    //   immutably elsewhere; it only happens on the data-binding path.
226    // - `data = None`: NEVER clone. A read-only scan emits a single
227    //   `data.no_context` advisory iff any ref exists, then the original `doc`
228    //   compiles by reference — byte-identical to the no-data-binding path.
229    let mut md_blocks: markdown_resolve::MdBlockMap = markdown_resolve::MdBlockMap::new();
230    let owned_doc: Option<Document> = match data {
231        Some(ctx) => {
232            let mut cloned = doc.clone();
233            substitute_data_refs(&mut cloned, ctx, &mut diagnostics);
234            // ── Step 0b: markdown-resolution pass ────────────────────────
235            // For each `text` node with `format="markdown"`, concatenate the
236            // (now data-substituted) span texts, replace spans with the parsed
237            // inline styled spans, and record the parsed BLOCK list in
238            // `md_blocks` (consumed by the block-layout path). Nodes without
239            // `format="markdown"` are skipped (byte-identical).
240            md_blocks = resolve_markdown(&mut cloned);
241            Some(cloned)
242        }
243        None => {
244            // Read-only scan: emit ONE `data.no_context` advisory iff a ref
245            // exists. No clone, no mutation — byte-identical when refs are absent.
246            if scan_for_data_refs(doc) {
247                diagnostics.push(Diagnostic::advisory(
248                    "data.no_context",
249                    "document contains `(data)` references but no data context was \
250                     provided at compile time; the references are left unresolved",
251                    None,
252                    None,
253                ));
254            }
255            // ── Step 0b: markdown-resolution pass (no-data path) ─────────
256            // Even without a data context, `format="markdown"` nodes must be
257            // resolved. Clone only when at least one markdown-format text node
258            // exists; otherwise skip entirely (byte-identical to before).
259            if scan_for_markdown_text(doc) {
260                let mut cloned = doc.clone();
261                md_blocks = resolve_markdown(&mut cloned);
262                Some(cloned)
263            } else {
264                None
265            }
266        }
267    };
268    // From here on, compile against the (possibly substituted) document.
269    let doc: &Document = owned_doc.as_ref().unwrap_or(doc);
270
271    // ── Step 1: resolve tokens ────────────────────────────────────────────
272    let token_resolution = resolve_tokens(&doc.tokens);
273    diagnostics.extend(token_resolution.diagnostics);
274    let resolved = &token_resolution.resolved;
275
276    // ── Step 1b: build style lookup map ──────────────────────────────────
277    let style_map: BTreeMap<&str, &Style> = doc
278        .styles
279        .styles
280        .iter()
281        .map(|s| (s.id.as_str(), s))
282        .collect();
283
284    // ── Step 1c: build component lookup map ──────────────────────────────
285    // Instances expand their referenced component at compile time. First
286    // declaration wins on a duplicate id (the validator flags id.duplicate).
287    let mut component_map: ComponentMap = BTreeMap::new();
288    for comp in &doc.components {
289        component_map.entry(comp.id.as_str()).or_insert(comp);
290    }
291
292    // ── Step 1d: build master lookup map + page-ref index ────────────────
293    // A page's `master` attribute projects the named master's nodes (fields
294    // resolved against that page) under the page's own children. The page-ref
295    // index maps every node id to the 1-based page that contains it, for
296    // `page-ref` field resolution. Both are document-wide and order-stable.
297    let mut master_map: MasterMap = BTreeMap::new();
298    for master in &doc.masters {
299        master_map.entry(master.id.as_str()).or_insert(master);
300    }
301    let page_index_by_node_id = build_page_index_map(doc);
302
303    // ── Step 2: select the requested page ────────────────────────────────
304    let Some(page) = doc.body.pages.get(page_index) else {
305        if doc.body.pages.is_empty() {
306            diagnostics.push(Diagnostic::advisory(
307                "scene.no_pages",
308                "document has no pages; an empty scene is returned",
309                None,
310                Some(doc.body.id.clone()),
311            ));
312        } else {
313            diagnostics.push(Diagnostic::advisory(
314                "scene.page_out_of_range",
315                format!(
316                    "page index {} is out of range; document has {} page(s)",
317                    page_index,
318                    doc.body.pages.len()
319                ),
320                None,
321                Some(doc.body.id.clone()),
322            ));
323        }
324        return CompileResult {
325            scene: Scene::new(0.0, 0.0),
326            diagnostics,
327        };
328    };
329
330    // ── Step 3: page dimensions → pixels ─────────────────────────────────
331    let page_w = match dim_to_px(page.width.value, &page.width.unit) {
332        Some(v) => v,
333        None => {
334            diagnostics.push(Diagnostic::advisory(
335                "scene.unsupported_unit",
336                format!(
337                    "page '{}' width uses an unsupported unit; cannot compile scene",
338                    page.id
339                ),
340                page.source_span,
341                Some(page.id.clone()),
342            ));
343            return CompileResult {
344                scene: Scene::new(0.0, 0.0),
345                diagnostics,
346            };
347        }
348    };
349    let page_h = match dim_to_px(page.height.value, &page.height.unit) {
350        Some(v) => v,
351        None => {
352            diagnostics.push(Diagnostic::advisory(
353                "scene.unsupported_unit",
354                format!(
355                    "page '{}' height uses an unsupported unit; cannot compile scene",
356                    page.id
357                ),
358                page.source_span,
359                Some(page.id.clone()),
360            ));
361            return CompileResult {
362                scene: Scene::new(0.0, 0.0),
363                diagnostics,
364            };
365        }
366    };
367
368    // ── Step 3b: resolve print bleed ─────────────────────────────────────
369    // A page may declare a uniform `bleed` margin. When it resolves to a
370    // positive pixel value `b`, the media (canvas) box expands to
371    // `(page_w + 2b) × (page_h + 2b)`, the trim box is the inner
372    // `[b, b, page_w, page_h]`, all content shifts by `(b, b)`, the background
373    // fills the whole media box, and crop marks are drawn at the trim corners.
374    // An absent / unresolvable / non-positive bleed yields `b = 0`, which is
375    // byte-identical to the no-bleed path. The validator surfaces a warning for
376    // an unresolvable unit or a negative value; the compiler just ignores it.
377    let bleed = page
378        .bleed
379        .as_ref()
380        .and_then(|d| dim_to_px(d.value, &d.unit))
381        .filter(|&px| px > 0.0)
382        .unwrap_or(0.0);
383
384    // Media box (full canvas including bleed on all four sides).
385    let media_w = page_w + 2.0 * bleed;
386    let media_h = page_h + 2.0 * bleed;
387
388    let mut scene = Scene::new(media_w, media_h);
389
390    // ── Step 4: outermost media-edge clip (normative rule) ────────
391    // The clip covers the entire media box so content and background may bleed
392    // into the margin. With bleed = 0 this is exactly the page rectangle.
393    scene.commands.push(SceneCommand::PushClip {
394        x: 0.0,
395        y: 0.0,
396        w: media_w,
397        h: media_h,
398    });
399
400    // ── Step 5: optional page background (fills the entire media box) ────
401    if let Some(bg_prop) = &page.background {
402        if let Some(gradient) = resolve_property_gradient(bg_prop, resolved, &page.id) {
403            // Page background applies no opacity cascade (mirrors the solid path).
404            scene.commands.push(SceneCommand::FillRect {
405                x: 0.0,
406                y: 0.0,
407                w: media_w,
408                h: media_h,
409                paint: Paint::Gradient(gradient),
410            });
411        } else if let Some(color) =
412            resolve_property_color(bg_prop, resolved, &mut diagnostics, &page.id)
413        {
414            scene.commands.push(SceneCommand::FillRect {
415                x: 0.0,
416                y: 0.0,
417                w: media_w,
418                h: media_h,
419                paint: Paint::solid(color),
420            });
421        }
422    }
423
424    // ── Step 5b: anchor pre-pass (PAGE-LOCAL) ────────────────────────────
425    // Walk page top-level children once, building a map from node id to the
426    // derived (x, y) for nodes that carry a recognized `anchor` attribute and
427    // have px-resolvable w/h. Built once; threaded read-only into compile_node.
428    let anchors = build_anchor_map(page, page_w, page_h, resolved);
429
430    // ── Step 6: threaded-text chain pre-pass (DOCUMENT-WIDE) ─────────────
431    // Resolve every text chain ONCE across ALL pages (deterministic
432    // page-then-source-order walk into frames + groups), distributing each
433    // chain's source article across every member's box — flowing across page
434    // boundaries. The map is keyed by global node id; this page's nodes look up
435    // the slice assigned to them. Chains' diagnostics (e.g. a source font
436    // fallback) are document-wide and would otherwise be emitted once per page;
437    // they are collected into a throwaway buffer here and only the diagnostics
438    // attributable to THIS page's chain members would be surfaced — but since
439    // distribution is global, we keep the page-local behaviour deterministic by
440    // discarding the pre-pass's own advisories on non-zero pages (they were
441    // already surfaced on page 0). Page 0 keeps them.
442    let engine = RustybuzzEngine::new();
443    let mut chain_diags: Vec<Diagnostic> = Vec::new();
444    let chains = resolve_chains_document(
445        doc,
446        resolved,
447        &style_map,
448        fonts,
449        &engine,
450        &md_blocks,
451        &mut chain_diags,
452    );
453    // Multi-page table flow pre-pass (DOCUMENT-WIDE), built ONCE like the chain
454    // map and threaded identically into every `compile_node`. Its advisories are
455    // document-wide; like the chain diags they surface only on page 0.
456    let flows = resolve_table_flows(doc, resolved, &style_map, fonts, &engine, &mut chain_diags);
457    if page_index == 0 {
458        diagnostics.extend(chain_diags);
459    }
460
461    // ── Step 7: build the per-page field context ─────────────────────────
462    // The 1-based page index drives the folio + parity (recto = odd, verso =
463    // even). The live area mirrors the validator's margin formula so an omitted
464    // field x/w auto-mirrors recto/verso via the page margins.
465    let page_index_1based = page_index + 1;
466    // Single source of truth for parity (explicit page.parity > document
467    // page-parity-start > default index%2==1). Mirrors the validator.
468    let is_recto = doc.page_is_recto(page, page_index_1based);
469    let mirror_margins = doc.mirror_margins.unwrap_or(false);
470    // RTL book: the binding margin is mirrored to the opposite side (recto →
471    // inner-on-right). Matches the validator's `margin.rs` parity.
472    let rtl_book = doc.page_progression.as_deref() == Some("rtl");
473    let live_area = compute_live_area(
474        doc,
475        page,
476        page_w,
477        page_h,
478        is_recto,
479        mirror_margins,
480        rtl_book,
481    );
482
483    // ── Step 7b: collect this page's footnote markers ────────────────────
484    // Every `footnote` DIRECT child of the page is auto-numbered 1..N in source
485    // order (an explicit `marker` overrides the number but keeps its slot). The
486    // ordered map drives both the inline superscript markers (a text span's
487    // `footnote_ref` keys in) and the bottom-zone rendering below.
488    let footnote_markers = footnote::collect_footnote_markers(page);
489
490    // ── Step 7c: build this page's node bounding-box map ─────────────────
491    // Maps every id-bearing page node with a resolvable x/y/w/h rect to its
492    // ABSOLUTE page-coordinate box, accumulating group/instance translation
493    // (frames are clip-only). Drives text-runaround exclusion lookup. Empty when
494    // no node carries a complete rect (byte-identical to before for any text node
495    // without `text-exclusion`).
496    let node_boxes = build_node_boxes(page, resolved);
497
498    // ── Step 7d: compute section assignments (document-wide, one-shot) ───
499    // Precompute once (outside any inner loop — this is the single page compile
500    // entry point): maps each 0-based page index to its section assignment.
501    // The lifetime of the returned assignments is tied to `doc`, which outlives
502    // the compile function.
503    let section_assignments = build_section_assignments(doc);
504    let section_assign = section_assignments.get(page_index).and_then(|opt| *opt);
505
506    let field_ctx = FieldCtx {
507        page_index_1based,
508        is_recto,
509        live_area,
510        page_index_by_node_id: &page_index_by_node_id,
511        footnote_markers: &footnote_markers,
512        node_boxes: &node_boxes,
513        total_pages: doc.body.pages.len(),
514        pages: &doc.body.pages,
515        section_page_index: section_assign.map(|a| a.page_index_in_section),
516        section_page_count: section_assign.map(|a| a.page_count),
517        section_folio_start: section_assign.map(|a| a.folio_start),
518        section_folio_style: section_assign.and_then(|a| a.folio_style),
519        section_name: section_assign.map(|a| a.name),
520    };
521
522    // Bundle the page-wide immutable lookups once; threaded read-only into every
523    // top-level `compile_node` (master projection + page children) and cascaded
524    // unchanged down the container/table recursion.
525    let node_cx = NodeCtx {
526        resolved,
527        style_map: &style_map,
528        components: &component_map,
529        fonts,
530        engine: &engine,
531        chains: &chains,
532        flows: &flows,
533        anchors: &anchors,
534        field_ctx: &field_ctx,
535        md_blocks: &md_blocks,
536        page_block_styles: &page.block_styles,
537        doc_block_styles: &doc.body.block_styles,
538    };
539
540    // ── Resolve the page baseline grid ───────────────────────────────────
541    // A page may declare `baseline-grid=(px)14`. When it resolves to a positive
542    // pixel value `g`, every text node on this page snaps its line baselines
543    // onto the grid `{0, g, 2g, …}` (see [`RenderCtx::baseline_grid`]). An
544    // absent / unresolvable / non-positive value yields `None`, byte-identical
545    // to a page with no grid.
546    let baseline_grid: Option<f64> = page
547        .baseline_grid
548        .as_ref()
549        .and_then(|d| dim_to_px(d.value, &d.unit))
550        .filter(|g| g.is_finite() && *g > 0.0);
551
552    let mut root_ctx = if bleed > 0.0 {
553        // Shift authored coordinates into the trim box. With bleed = 0 this is
554        // the identity root context (byte-identical to before).
555        RenderCtx::root_offset(bleed, bleed)
556    } else {
557        RenderCtx::root()
558    };
559    // Thread the grid into BOTH the bleed and no-bleed root contexts. The grid
560    // is measured in the post-`dy` (shifted) coordinate space, the same space
561    // the emitted baselines live in, so a bleed-shifted page snaps consistently.
562    root_ctx.baseline_grid = baseline_grid;
563
564    // Absolute indices, in document order, of the `StrokePolyline` emitted by
565    // each top-level connector (master-projected then page-own). Used only by
566    // the opt-in line-jump post-pass; empty/unused when the page declares no
567    // `line-jumps`, so the rest of compile is byte-identical.
568    let mut connector_strokes: Vec<usize> = Vec::new();
569
570    // ── Step 7a: project the page's master (UNDER its own children) ──────
571    // When `page.master` names a declared master, clone the master's children,
572    // prefix every projected id with the page id (avoid cross-page collisions),
573    // and compile them BEFORE the page's own children so running heads / folios
574    // sit behind body text. Fields inside the master resolve against THIS page.
575    // An unknown master reference is a hard validation error; here it is simply
576    // skipped (the compiler never panics on bad references).
577    if let Some(master_id) = &page.master
578        && let Some(master) = master_map.get(master_id.as_str())
579    {
580        let mut projected = master.children.clone();
581        let prefix = format!("{}/", page.id);
582        container::prefix_ids_in_children(&mut projected, &prefix);
583        for node in &projected {
584            compile_node(
585                node,
586                node_cx,
587                &mut scene.commands,
588                &mut diagnostics,
589                &mut connector_strokes,
590                root_ctx,
591            );
592        }
593    }
594
595    // ── Step 7b: page children in source order (z-order: first = bottom) ─
596    for node in &page.children {
597        compile_node(
598            node,
599            node_cx,
600            &mut scene.commands,
601            &mut diagnostics,
602            &mut connector_strokes,
603            root_ctx,
604        );
605    }
606
607    // ── Step 7b′: opt-in connector line-jumps (hops at crossings) ────────
608    // Only "arc"/"gap" run; "none"/an unrecognized value/absent leaves the
609    // commands untouched, so a page without `line-jumps` is byte-identical.
610    if let Some(mode) = page.line_jumps.as_deref()
611        && (mode == "arc" || mode == "gap")
612    {
613        line_jumps::apply_line_jumps(&mut scene.commands, &connector_strokes, mode);
614    }
615
616    // ── Step 7c: footnote zone (page furniture, above the bottom margin) ─
617    // Rendered AFTER the page's own children (so it paints on top of body
618    // content) but inside the media clip. Draws the separator rule plus the
619    // stacked, auto-numbered footnotes; warns on body/zone overlap. A page with
620    // no footnotes emits nothing here (byte-identical to before).
621    footnote::compile_footnote_zone(
622        page,
623        live_area,
624        footnote::FootnoteZoneEnv {
625            markers: &footnote_markers,
626            resolved,
627            style_map: &style_map,
628            fonts,
629            engine: &engine,
630            chains: &chains,
631            anchors: &anchors,
632            field_ctx: &field_ctx,
633        },
634        &mut scene.commands,
635        &mut diagnostics,
636        root_ctx,
637    );
638
639    // ── Step 8: close the outermost clip ─────────────────────────────────
640    scene.commands.push(SceneCommand::PopClip);
641
642    // ── Step 9: crop / trim marks (only when a bleed is active) ──────────
643    // Emitted AFTER content and OUTSIDE the clip so the marks sit on top and
644    // live entirely in the bleed margin at the four trim corners.
645    if bleed > 0.0 {
646        crop::emit_crop_marks(&mut scene.commands, bleed, page_w, page_h);
647    }
648
649    // ── Step 10: print trim box ──────────────────────────────────────────
650    // When a bleed is active the media box (`scene.width`/`height`) includes
651    // the bleed on all four sides; the trim box is the inner page rectangle
652    // `[b, b, page_w, page_h]` that the finished piece is cut to. Backends that
653    // care about print boxes (PDF) read this; raster backends ignore it. With
654    // bleed = 0 the trim box equals the media box, so we leave `trim` as `None`.
655    if bleed > 0.0 {
656        scene.trim = Some(Rect {
657            x: bleed,
658            y: bleed,
659            w: page_w,
660            h: page_h,
661        });
662    }
663
664    CompileResult { scene, diagnostics }
665}
666
667// ── Node dispatch ─────────────────────────────────────────────────────────────
668
669/// The `role` of any node, if set. Used to exclude non-printing nodes
670/// (`role="guide"`) from render output.
671pub(super) fn node_role(node: &Node) -> Option<&str> {
672    match node {
673        Node::Rect(n) => n.role.as_deref(),
674        Node::Ellipse(n) => n.role.as_deref(),
675        Node::Line(n) => n.role.as_deref(),
676        Node::Text(n) => n.role.as_deref(),
677        Node::Code(n) => n.role.as_deref(),
678        Node::Frame(n) => n.role.as_deref(),
679        Node::Group(n) => n.role.as_deref(),
680        Node::Image(n) => n.role.as_deref(),
681        Node::Polygon(n) => n.role.as_deref(),
682        Node::Polyline(n) => n.role.as_deref(),
683        Node::Instance(n) => n.role.as_deref(),
684        Node::Field(n) => n.role.as_deref(),
685        Node::Toc(n) => n.role.as_deref(),
686        Node::Footnote(n) => n.role.as_deref(),
687        Node::Table(n) => n.role.as_deref(),
688        Node::Shape(n) => n.role.as_deref(),
689        Node::Connector(n) => n.role.as_deref(),
690        Node::Pattern(n) => n.role.as_deref(),
691        Node::Chart(n) => n.role.as_deref(),
692        Node::Light(n) => n.role.as_deref(),
693        Node::Mesh(n) => n.role.as_deref(),
694        Node::Unknown(_) => None,
695    }
696}
697
698/// Route a single node to the submodule that compiles its kind.
699///
700/// Each arm forwards the full cascade context to a `compile_*` function; the
701/// emitted `SceneCommand` stream is identical to the previous inline match.
702///
703/// Returns the child's laid-out content height in pixels for the kinds whose
704/// intrinsic height is meaningful to flow layout (`text`/`code`); every other
705/// kind returns `0.0`. The absolute-positioning callers ignore this value, so
706/// command output is unchanged; only the flow-layout path in [`container`]
707/// consumes it to advance its vertical cursor.
708pub(in crate::compile) fn compile_node(
709    node: &Node,
710    cx: NodeCtx,
711    commands: &mut Vec<SceneCommand>,
712    diagnostics: &mut Vec<Diagnostic>,
713    connector_strokes: &mut Vec<usize>,
714    ctx: RenderCtx,
715) -> f64 {
716    // Non-printing guide nodes (`role="guide"`) are excluded from render output
717    // entirely — including their subtree when the guide is a group/frame.
718    if node_role(node) == Some("guide") {
719        return 0.0;
720    }
721
722    let NodeCtx {
723        resolved,
724        style_map,
725        components,
726        fonts,
727        engine,
728        chains,
729        flows,
730        anchors,
731        field_ctx,
732        md_blocks,
733        page_block_styles,
734        doc_block_styles,
735    } = cx;
736
737    match node {
738        Node::Rect(rect) => {
739            compile_rect(
740                rect,
741                RectEllipseEnv {
742                    resolved,
743                    style_map,
744                    anchors,
745                },
746                commands,
747                diagnostics,
748                ctx,
749            );
750            0.0
751        }
752        Node::Ellipse(ellipse) => {
753            compile_ellipse(
754                ellipse,
755                RectEllipseEnv {
756                    resolved,
757                    style_map,
758                    anchors,
759                },
760                commands,
761                diagnostics,
762                ctx,
763            );
764            0.0
765        }
766        Node::Light(light) => {
767            compile_light(light, resolved, commands, diagnostics, ctx);
768            0.0
769        }
770        Node::Mesh(mesh) => {
771            compile_mesh(mesh, resolved, commands, diagnostics, ctx);
772            0.0
773        }
774        Node::Text(text) => compile_text(
775            text,
776            TextCompileEnv {
777                resolved,
778                style_map,
779                fonts,
780                engine,
781                chains,
782                footnote_markers: field_ctx.footnote_markers,
783                node_boxes: field_ctx.node_boxes,
784                anchors,
785                md_blocks,
786                page_block_styles,
787                doc_block_styles,
788            },
789            commands,
790            diagnostics,
791            ctx,
792        ),
793        Node::Line(line) => {
794            compile_line(line, resolved, style_map, commands, diagnostics, ctx);
795            0.0
796        }
797        Node::Frame(frame) => {
798            compile_frame(frame, cx, commands, diagnostics, connector_strokes, ctx);
799            0.0
800        }
801        Node::Group(group) => {
802            compile_group(group, cx, commands, diagnostics, connector_strokes, ctx);
803            0.0
804        }
805        Node::Instance(instance) => {
806            compile_instance(instance, cx, commands, diagnostics, connector_strokes, ctx);
807            0.0
808        }
809        Node::Field(field) => {
810            // Resolve the field against this page into a concrete single-line
811            // text node and compile it via the normal text path. An unresolved
812            // field (absent running-head side, unknown type, unresolved
813            // page-ref) yields nothing.
814            if let Some(text_node) = resolve_field_to_text(field, field_ctx) {
815                compile_text(
816                    &text_node,
817                    TextCompileEnv {
818                        resolved,
819                        style_map,
820                        fonts,
821                        engine,
822                        chains,
823                        footnote_markers: field_ctx.footnote_markers,
824                        node_boxes: field_ctx.node_boxes,
825                        anchors,
826                        md_blocks: empty_md_blocks(),
827                        page_block_styles: &[],
828                        doc_block_styles: &[],
829                    },
830                    commands,
831                    diagnostics,
832                    ctx,
833                );
834            }
835            0.0
836        }
837        Node::Toc(toc) => {
838            // Resolve the toc against the full document into a multi-line
839            // tab-leader text block and compile it via the normal text path.
840            // A toc with no matching headings, no selector, or visible=false
841            // yields nothing.
842            if let Some(text_node) =
843                resolve_toc_to_text(toc, field_ctx.pages, field_ctx.page_index_by_node_id)
844            {
845                compile_text(
846                    &text_node,
847                    TextCompileEnv {
848                        resolved,
849                        style_map,
850                        fonts,
851                        engine,
852                        chains,
853                        footnote_markers: field_ctx.footnote_markers,
854                        node_boxes: field_ctx.node_boxes,
855                        anchors,
856                        md_blocks: empty_md_blocks(),
857                        page_block_styles: &[],
858                        doc_block_styles: &[],
859                    },
860                    commands,
861                    diagnostics,
862                    ctx,
863                );
864            }
865            0.0
866        }
867        Node::Image(image) => {
868            compile_image(image, resolved, commands, diagnostics, anchors, ctx);
869            0.0
870        }
871        Node::Polygon(poly) => {
872            compile_polygon(poly, resolved, style_map, commands, diagnostics, ctx);
873            0.0
874        }
875        Node::Polyline(poly) => {
876            compile_polyline(poly, resolved, style_map, commands, diagnostics, ctx);
877            0.0
878        }
879        Node::Code(code) => compile_code(
880            code,
881            TextCompileEnv {
882                resolved,
883                style_map,
884                fonts,
885                engine,
886                chains,
887                footnote_markers: field_ctx.footnote_markers,
888                node_boxes: field_ctx.node_boxes,
889                anchors,
890                md_blocks: empty_md_blocks(),
891                page_block_styles: &[],
892                doc_block_styles: &[],
893            },
894            commands,
895            diagnostics,
896            ctx,
897        ),
898        Node::Table(table) => {
899            compile_table(
900                TableEmitCtx {
901                    table,
902                    resolved,
903                    style_map,
904                    components,
905                    fonts,
906                    engine,
907                    chains,
908                    flows,
909                    anchors,
910                    field_ctx,
911                },
912                commands,
913                diagnostics,
914                ctx,
915            );
916            0.0
917        }
918        Node::Shape(shape) => {
919            compile_shape(
920                shape,
921                commands,
922                diagnostics,
923                ShapeCompileEnv {
924                    resolved,
925                    style_map,
926                    fonts,
927                    engine,
928                    chains,
929                    footnote_markers: field_ctx.footnote_markers,
930                    node_boxes: field_ctx.node_boxes,
931                    anchors,
932                    ctx,
933                },
934            );
935            0.0
936        }
937        Node::Connector(connector) => {
938            // Record the connector's stroke (top-level OR nested) at its dispatch
939            // point so the opt-in line-jump post-pass can hop it. The post-pass
940            // filters by transform depth, so rotated/bracketed connectors are
941            // excluded there, not here.
942            let start = commands.len();
943            compile_connector(
944                connector,
945                commands,
946                diagnostics,
947                ConnectorEnv {
948                    resolved,
949                    style_map,
950                    fonts,
951                    engine,
952                    chains,
953                    footnote_markers: field_ctx.footnote_markers,
954                    node_boxes: field_ctx.node_boxes,
955                    anchors,
956                    ctx,
957                },
958            );
959            line_jumps::record_connector_stroke(commands, start, connector_strokes);
960            0.0
961        }
962        Node::Pattern(p) => compile_pattern(p, cx, commands, diagnostics, ctx),
963        Node::Chart(c) => compile_chart(c, cx, commands, diagnostics, ctx),
964        Node::Footnote(_) => {
965            // Footnotes are NON-flowing page furniture: they carry no x/y/w/h
966            // and are NOT rendered in the normal z-order dispatch. The page-level
967            // footnote pass (`footnote::compile_footnote_zone`, run by
968            // `compile_page`) collects every page-level footnote in source order,
969            // auto-numbers them, and renders the bottom zone + separator. A
970            // footnote reached here (e.g. nested in a container) renders nothing.
971            0.0
972        }
973        Node::Unknown(unknown) => {
974            diagnostics.push(Diagnostic::advisory(
975                "scene.unsupported_node",
976                format!(
977                    "unknown node kind '{}' cannot be compiled; the node is skipped \
978                     (forward-compatibility: this kind may be supported in a later version)",
979                    unknown.kind
980                ),
981                unknown.source_span,
982                None,
983            ));
984            0.0
985        }
986    }
987}