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}