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}