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