Skip to main content

zenith_core/validate/check/
driver.rs

1//! The document-level validation driver.
2//!
3//! Holds the [`validate`] entry point — the single document walk that runs
4//! token resolution and every document/page-level semantic check — together
5//! with its orchestration helpers (id collection, footnote-ref resolution,
6//! per-declaration checks for assets/libraries/provenance, and the styles
7//! block). The check module root re-exports [`validate`] (and `register_id`,
8//! which the node submodules call) as part of the public surface.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use crate::ast::brand::BrandContract;
13use crate::ast::document::Document;
14use crate::ast::policy::DiagnosticPolicy;
15use crate::ast::style::Style;
16use crate::ast::value::{PropertyValue, Unit, dim_to_px};
17use crate::color::parse_rgb;
18use crate::diagnostics::Diagnostic;
19use crate::tokens::{ResolvedToken, ResolvedValue};
20
21use super::brand::check_brand_contract;
22use super::contrast::check_text_contrast;
23use super::nodes::{WalkCtx, WalkPos, check_sibling_anchors, walk_node};
24use super::passes::{
25    check_footnote_refs, collect_local_ids, register_id, validate_asset_decl,
26    validate_library_decl, validate_provenance_def, validate_style_block,
27};
28use super::policy::{apply_policy, check_policy_entries};
29use super::recipes::check_recipes;
30use super::report::ValidationReport;
31use super::variants::check_variants;
32use super::visual::{VisualExpect, check_block_styles, check_visual_prop};
33use super::{fold, margin, safezone};
34
35/// Run the full document validation pass against the document's own in-file
36/// diagnostic policy and in-file brand contract.
37///
38/// This is a thin wrapper over [`validate_with_policy`] that passes
39/// `doc.diagnostic_policy` and `&doc.brand_contract`. It preserves the
40/// historical contract exactly: a document with no `diagnostics { … }` or
41/// `brand { … }` block carries empty defaults, which are identity passes, so
42/// the output is byte-identical to running validation with no config at all.
43pub fn validate(doc: &Document) -> ValidationReport {
44    validate_with_policy(doc, &doc.diagnostic_policy, &doc.brand_contract)
45}
46
47/// Run the full document validation pass, applying an externally supplied
48/// `policy` and `brand` contract at their respective choke points.
49///
50/// The caller is responsible for assembling `policy` (e.g. merging config-file
51/// and CLI-flag policy with the document's in-file policy) and `brand` (e.g.
52/// merging global/local config brand contracts with the document's in-file
53/// `brand { … }` block). Passing `&doc.diagnostic_policy` and
54/// `&doc.brand_contract` reproduces [`validate`] exactly.
55///
56/// Internally runs `resolve_tokens` on `doc.tokens`, merges those diagnostics,
57/// then walks the full document collecting all semantic diagnostics.
58/// Never hard-fails; all findings are returned in the [`ValidationReport`].
59pub fn validate_with_policy(
60    doc: &Document,
61    policy: &DiagnosticPolicy,
62    brand: &BrandContract,
63) -> ValidationReport {
64    // ── Step 1: token resolution ──────────────────────────────────────────
65    let token_resolution = crate::tokens::resolve_tokens(&doc.tokens);
66    let resolved_tokens: &BTreeMap<String, ResolvedToken> = &token_resolution.resolved;
67
68    let mut diagnostics: Vec<Diagnostic> = token_resolution.diagnostics;
69
70    // ── Brand-contract check ──────────────────────────────────────────────
71    // Runs right after token resolution so we have the resolved token map.
72    // Uses the EFFECTIVE brand contract supplied by the caller (which may be a
73    // merge of global/local config + in-file), not doc.brand_contract directly.
74    // An empty contract is an identity pass (no diagnostics, byte-identical).
75    check_brand_contract(brand, resolved_tokens, &mut diagnostics);
76
77    // ── Document color space ──────────────────────────────────────────────
78    // `colorspace` is informational export metadata; it does not affect PNG
79    // output. Only "srgb" and "cmyk" are recognized; any other value is a
80    // Warning (forward-compatible — never a hard error).
81    if let Some(cs) = &doc.colorspace
82        && cs != "srgb"
83        && cs != "cmyk"
84    {
85        diagnostics.push(Diagnostic::warning(
86            "document.invalid_colorspace",
87            format!(
88                "document colorspace '{}' is unrecognized; expected \"srgb\" or \
89                 \"cmyk\" (this attribute is export metadata and does not change \
90                 PNG output)",
91                cs
92            ),
93            None,
94            None,
95        ));
96    }
97
98    // ── Document page-progression ─────────────────────────────────────────
99    // `page_progression` is export metadata; it does not affect page render
100    // order or PNG output. Only "ltr" and "rtl" are recognized; any other value
101    // is a Warning (forward-compatible — never a hard error).
102    if let Some(pp) = &doc.page_progression
103        && pp != "ltr"
104        && pp != "rtl"
105    {
106        diagnostics.push(Diagnostic::warning(
107            "document.invalid_page_progression",
108            format!(
109                "document page-progression '{}' is unrecognized; expected \"ltr\" or \
110                 \"rtl\" (this attribute is export metadata and does not change \
111                 page order or PNG output)",
112                pp
113            ),
114            None,
115            None,
116        ));
117    }
118
119    // ── Document page-parity-start ────────────────────────────────────────
120    // `page_parity_start` selects whether page 1 is a recto (default) or a verso.
121    // Only "recto" and "verso" (case-insensitive) are recognized; any other value
122    // is a Warning (forward-compatible — never a hard error) and falls back to the
123    // default parity.
124    if let Some(pps) = &doc.page_parity_start
125        && !pps.eq_ignore_ascii_case("recto")
126        && !pps.eq_ignore_ascii_case("verso")
127    {
128        diagnostics.push(Diagnostic::warning(
129            "document.invalid_page_parity_start",
130            format!(
131                "document page-parity-start '{}' is unrecognized; expected \"recto\" \
132                 or \"verso\" (falling back to the default where page 1 is a recto)",
133                pps
134            ),
135            None,
136            None,
137        ));
138    }
139
140    // ── Document spread-gutter ────────────────────────────────────────────
141    // `spread_gutter` must resolve to a finite non-negative px value when
142    // present. An unresolvable unit (pct/deg/unknown) or a negative value is a
143    // Warning; the spread simply renders with no gutter. Never a hard error.
144    if let Some(gutter) = &doc.spread_gutter {
145        match dim_to_px(gutter.value, &gutter.unit) {
146            None => {
147                diagnostics.push(Diagnostic::warning(
148                    "document.invalid_spread_gutter",
149                    "document spread-gutter uses an unresolvable unit; \
150                     allowed units are px and pt (spread renders with no gutter)",
151                    None,
152                    None,
153                ));
154            }
155            Some(px) if px < 0.0 => {
156                diagnostics.push(Diagnostic::warning(
157                    "document.invalid_spread_gutter",
158                    "document spread-gutter must be non-negative \
159                     (spread renders with no gutter)",
160                    None,
161                    None,
162                ));
163            }
164            Some(_) => {}
165        }
166    }
167
168    // ── Step 2: collect all IDs and gather referenced token ids ──────────
169    // `seen_ids` accumulates every id encountered across the whole document.
170    // When we encounter a duplicate we push `id.duplicate`.
171    let mut seen_ids: BTreeSet<String> = BTreeSet::new();
172    let mut referenced_token_ids: BTreeSet<String> = BTreeSet::new();
173
174    // Declared asset ids, collected once so the node walk can validate that
175    // every `image.asset` reference points at a declared `AssetDecl.id`.
176    let declared_asset_ids: BTreeSet<String> =
177        doc.assets.assets.iter().map(|d| d.id.clone()).collect();
178
179    // Declared style ids, collected once so the node walk can validate that
180    // every `style="..."` node attribute references a declared style.
181    let declared_style_ids: BTreeSet<String> =
182        doc.styles.styles.iter().map(|s| s.id.clone()).collect();
183
184    // Declared component ids, collected once so the node walk can validate that
185    // every `instance component="..."` references a declared component.
186    let declared_component_ids: BTreeSet<String> =
187        doc.components.iter().map(|c| c.id.clone()).collect();
188
189    // Per-component LOCAL descendant id sets, used to validate that an override
190    // `ref` targets a real descendant. Built once before the page walk. Ordered
191    // for determinism. A component appears once; a duplicate component id is
192    // diagnosed separately (id.duplicate) and the first wins in this map.
193    let mut component_local_ids: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
194    for comp in &doc.components {
195        let mut local: BTreeSet<String> = BTreeSet::new();
196        collect_local_ids(&comp.children, &mut local);
197        component_local_ids.entry(comp.id.clone()).or_insert(local);
198    }
199
200    // Declared master ids, collected once so the page walk can validate that
201    // every `page master="..."` references a declared master.
202    let declared_master_ids: BTreeSet<String> = doc.masters.iter().map(|m| m.id.clone()).collect();
203
204    // Declared library ids, collected once so each provenance `origin` record can
205    // validate that its `library="..."` references a library declared in the
206    // `libraries` block.
207    let declared_library_ids: BTreeSet<String> =
208        doc.libraries.iter().map(|l| l.id.clone()).collect();
209
210    // Declared token ids, collected once so a provenance `node` target may also
211    // reference a local TOKEN (a token imported from a library), not just a node.
212    let declared_token_ids: BTreeSet<String> =
213        doc.tokens.tokens.iter().map(|t| t.id.clone()).collect();
214
215    // Token id → TokenType map, used by check_recipes to distinguish undeclared
216    // tokens from declared-but-non-color tokens in the palette check.
217    // BTreeMap for determinism; built once, shared with check_recipes.
218    let token_type_map: BTreeMap<&str, &crate::ast::TokenType> = doc
219        .tokens
220        .tokens
221        .iter()
222        .map(|t| (t.id.as_str(), &t.token_type))
223        .collect();
224
225    // Document-wide set of every node id (across pages, masters, and components),
226    // used to resolve a `page-ref` field's `target`. Ordered iteration is not
227    // required (membership only); collected once before the walk.
228    let mut all_node_ids: BTreeSet<String> = BTreeSet::new();
229    for page in &doc.body.pages {
230        collect_local_ids(&page.children, &mut all_node_ids);
231    }
232    for master in &doc.masters {
233        collect_local_ids(&master.children, &mut all_node_ids);
234    }
235    for comp in &doc.components {
236        collect_local_ids(&comp.children, &mut all_node_ids);
237    }
238
239    // Style lookup by id, so the contrast check can resolve a text node's
240    // style-inherited fill / font-size / font-weight. Ordered for determinism.
241    let style_map: BTreeMap<&str, &Style> = doc
242        .styles
243        .styles
244        .iter()
245        .map(|s| (s.id.as_str(), s))
246        .collect();
247
248    // ── Token IDs ─────────────────────────────────────────────────────────
249    for token in &doc.tokens.tokens {
250        register_id(&token.id, &mut seen_ids, &mut diagnostics);
251    }
252
253    // ── Style IDs ─────────────────────────────────────────────────────────
254    for style in &doc.styles.styles {
255        register_id(&style.id, &mut seen_ids, &mut diagnostics);
256    }
257
258    // ── Style property validation ─────────────────────────────────────────
259    validate_style_block(
260        &doc.styles,
261        resolved_tokens,
262        &mut referenced_token_ids,
263        &mut diagnostics,
264    );
265
266    // ── Asset IDs and per-declaration checks ──────────────────────────────
267    for decl in &doc.assets.assets {
268        register_id(&decl.id, &mut seen_ids, &mut diagnostics);
269        validate_asset_decl(decl, &mut diagnostics);
270    }
271
272    // ── Library IDs and per-declaration checks ────────────────────────────
273    // Library ids share the global id namespace (like asset/token/style ids),
274    // so duplicate library declarations and collisions are caught here.
275    for decl in &doc.libraries {
276        register_id(&decl.id, &mut seen_ids, &mut diagnostics);
277        validate_library_decl(decl, &mut diagnostics);
278    }
279
280    // ── Component definitions ─────────────────────────────────────────────
281    // The component id participates in the GLOBAL uniqueness set. Each
282    // component's CHILD ids are validated for uniqueness within a LOCAL scope
283    // (a fresh seen-id set per component) so the same local id may appear in
284    // two different components without colliding. Token/asset/style refs inside
285    // a component are validated ONCE here at the definition, by walking the
286    // component's children exactly like page children (no page bounds → no
287    // off_canvas/contrast checks, which are placement-relative).
288    for comp in &doc.components {
289        register_id(&comp.id, &mut seen_ids, &mut diagnostics);
290
291        let mut local_seen: BTreeSet<String> = BTreeSet::new();
292        // Components are not page-children: no safe-zones apply.
293        let no_zones: BTreeSet<&str> = BTreeSet::new();
294        let ctx = WalkCtx {
295            resolved_tokens,
296            declared_asset_ids: &declared_asset_ids,
297            declared_style_ids: &declared_style_ids,
298            declared_component_ids: &declared_component_ids,
299            component_local_ids: &component_local_ids,
300            all_node_ids: &all_node_ids,
301            zone_ids: &no_zones,
302        };
303        for child in &comp.children {
304            walk_node(
305                child,
306                ctx,
307                &mut local_seen,
308                &mut referenced_token_ids,
309                WalkPos {
310                    page_px_bounds: None,
311                    in_flow_parent: false,
312                    enclosing_frame: None,
313                    in_container: false,
314                    parent_box_known: false,
315                },
316                &mut diagnostics,
317            );
318        }
319    }
320
321    // ── Master definitions ────────────────────────────────────────────────
322    // Mirrors the component-definition validation: the master id participates
323    // in the GLOBAL uniqueness set, and each master's CHILD ids are validated
324    // for uniqueness within a LOCAL scope (a fresh seen-id set per master) so
325    // the same local id may appear in two masters without colliding. Token/
326    // asset/style refs and field types inside a master are validated ONCE here
327    // at the definition by walking its children exactly like page children.
328    for master in &doc.masters {
329        register_id(&master.id, &mut seen_ids, &mut diagnostics);
330
331        let mut local_seen: BTreeSet<String> = BTreeSet::new();
332        // Masters are not page-children: no safe-zones apply.
333        let no_zones: BTreeSet<&str> = BTreeSet::new();
334        let ctx = WalkCtx {
335            resolved_tokens,
336            declared_asset_ids: &declared_asset_ids,
337            declared_style_ids: &declared_style_ids,
338            declared_component_ids: &declared_component_ids,
339            component_local_ids: &component_local_ids,
340            all_node_ids: &all_node_ids,
341            zone_ids: &no_zones,
342        };
343        for child in &master.children {
344            walk_node(
345                child,
346                ctx,
347                &mut local_seen,
348                &mut referenced_token_ids,
349                WalkPos {
350                    page_px_bounds: None,
351                    in_flow_parent: false,
352                    enclosing_frame: None,
353                    in_container: false,
354                    parent_box_known: false,
355                },
356                &mut diagnostics,
357            );
358        }
359    }
360
361    // ── Section definitions ───────────────────────────────────────────────
362    // Collect the full set of page ids once (needed for start_page reference
363    // checking). A BTreeSet gives deterministic iteration if we ever need it.
364    let page_ids: BTreeSet<&str> = doc.body.pages.iter().map(|p| p.id.as_str()).collect();
365
366    // Per-page descendant node-id map, built once here and shared with the
367    // variant check. Each entry maps a page id to the BTreeSet of all node ids
368    // (at any depth) within that page. This avoids rebuilding the set once per
369    // variant (which would be O(variants × pages × nodes)).
370    let page_node_ids: BTreeMap<&str, BTreeSet<String>> = doc
371        .body
372        .pages
373        .iter()
374        .map(|p| {
375            let mut ids: BTreeSet<String> = BTreeSet::new();
376            collect_local_ids(&p.children, &mut ids);
377            (p.id.as_str(), ids)
378        })
379        .collect();
380
381    // Track start_page values seen so far: duplicate start_page on a second
382    // section → `section.duplicate_start_page`.
383    let mut seen_section_start_pages: BTreeSet<&str> = BTreeSet::new();
384
385    for section in &doc.sections {
386        // Section id participates in the GLOBAL id-uniqueness set so a section
387        // id colliding with a page / token / master / component id → `id.duplicate`.
388        register_id(&section.id, &mut seen_ids, &mut diagnostics);
389
390        // `start_page` must name an existing page id → hard error if not.
391        if !page_ids.contains(section.start_page.as_str()) {
392            diagnostics.push(Diagnostic::error(
393                "section.unknown_start_page",
394                format!(
395                    "section '{}': start-page '{}' does not reference a declared page",
396                    section.id, section.start_page
397                ),
398                section.source_span,
399                Some(section.id.clone()),
400            ));
401        }
402
403        // No two sections may share the same start_page → hard error on second.
404        if !seen_section_start_pages.insert(section.start_page.as_str()) {
405            diagnostics.push(Diagnostic::error(
406                "section.duplicate_start_page",
407                format!(
408                    "section '{}': start-page '{}' is already used by an earlier section",
409                    section.id, section.start_page
410                ),
411                section.source_span,
412                Some(section.id.clone()),
413            ));
414        }
415
416        // `folio_style`, if present, must be one of the recognized styles →
417        // Warning (forward-compat: an unknown style value is preserved verbatim
418        // rather than rejected, so future styles don't break old validators).
419        if let Some(style) = &section.folio_style
420            && style != "decimal"
421            && style != "lower-roman"
422            && style != "upper-roman"
423        {
424            diagnostics.push(Diagnostic::warning(
425                "section.invalid_folio_style",
426                format!(
427                    "section '{}': folio-style '{}' is unrecognized; \
428                     expected \"decimal\", \"lower-roman\", or \"upper-roman\"",
429                    section.id, style
430                ),
431                section.source_span,
432                Some(section.id.clone()),
433            ));
434        }
435    }
436
437    // ── Variants ──────────────────────────────────────────────────────────
438    // Validate the top-level `variants` block: duplicate ids, unknown source
439    // pages, invalid dimensions, and override-node resolution.
440    check_variants(doc, &page_ids, &page_node_ids, &mut diagnostics);
441
442    // ── Recipes ───────────────────────────────────────────────────────────
443    // Validate the top-level `recipes` block: duplicate ids, unknown/non-color
444    // palette tokens, unknown expanded node ids, and unknown bounds ids.
445    check_recipes(
446        doc,
447        &page_ids,
448        &all_node_ids,
449        &token_type_map,
450        &mut diagnostics,
451    );
452
453    // ── Provenance records ────────────────────────────────────────────────
454    // Each `origin` id participates in the GLOBAL id-uniqueness set. The record
455    // cross-references a target (a document node id OR a declared token id OR a
456    // declared action id) AND a declared library id, all of which must exist
457    // (`all_node_ids` is fully built above, before the page walk;
458    // `declared_token_ids`/`declared_library_ids`/`declared_action_ids` are
459    // collected alongside it).
460    let declared_action_ids: BTreeSet<String> = doc.actions.iter().map(|a| a.id.clone()).collect();
461    for prov in &doc.provenance {
462        register_id(&prov.id, &mut seen_ids, &mut diagnostics);
463        validate_provenance_def(
464            prov,
465            &all_node_ids,
466            &declared_token_ids,
467            &declared_action_ids,
468            &declared_library_ids,
469            &mut diagnostics,
470        );
471    }
472
473    // ── Document body id ──────────────────────────────────────────────────
474    register_id(&doc.body.id, &mut seen_ids, &mut diagnostics);
475    check_block_styles(
476        &doc.body.id,
477        &doc.body.block_styles,
478        &mut referenced_token_ids,
479        resolved_tokens,
480        &mut diagnostics,
481    );
482
483    // ── Pages and their children ──────────────────────────────────────────
484    // The page index is 1-based (recto = odd, verso = even) and threaded into
485    // the margin advisory so it can pick the parity-correct live area.
486    let mirror_margins = doc.mirror_margins.unwrap_or(false);
487    // RTL book: the binding is on the opposite side, mirroring the recto/verso
488    // live-area parity (see `margin::check_margins`).
489    let rtl_book = doc.page_progression.as_deref() == Some("rtl");
490    // ── A document must contain at least one page ─────────────────────────
491    // A zero-page document has no output target; this is a hard error.
492    if doc.body.pages.is_empty() {
493        diagnostics.push(Diagnostic::error(
494            "document.no_pages",
495            format!(
496                "document '{}': a document must contain at least one page",
497                doc.body.id
498            ),
499            None,
500            Some(doc.body.id.clone()),
501        ));
502    }
503    for (page_idx0, page) in doc.body.pages.iter().enumerate() {
504        let page_index_1based = page_idx0 + 1;
505        register_id(&page.id, &mut seen_ids, &mut diagnostics);
506        check_block_styles(
507            &page.id,
508            &page.block_styles,
509            &mut referenced_token_ids,
510            resolved_tokens,
511            &mut diagnostics,
512        );
513
514        // ── Per-page parity override validity ─────────────────────────────
515        // `parity` forces this page's recto/verso. Only "recto"/"verso"
516        // (case-insensitive) are recognized; any other value is a Warning
517        // (forward-compatible — never a hard error) and falls back to the derived
518        // parity (an invalid value resolves to recto, see `Document::page_is_recto`).
519        if let Some(p) = &page.parity
520            && !p.eq_ignore_ascii_case("recto")
521            && !p.eq_ignore_ascii_case("verso")
522        {
523            diagnostics.push(Diagnostic::warning(
524                "page.invalid_parity",
525                format!(
526                    "page '{}': parity '{}' is unrecognized; expected \"recto\" or \
527                     \"verso\" (falling back to the derived page parity)",
528                    page.id, p
529                ),
530                page.source_span,
531                Some(page.id.clone()),
532            ));
533        }
534
535        // ── Per-page line-jump style validity ─────────────────────────────
536        // `line-jumps` selects how connector-vs-connector crossings hop. Only
537        // "none"/"arc"/"gap" are recognized; any other value is a Warning
538        // (forward-compatible — never a hard error) and renders as if absent
539        // (no hops).
540        if let Some(lj) = &page.line_jumps
541            && lj != "none"
542            && lj != "arc"
543            && lj != "gap"
544        {
545            diagnostics.push(Diagnostic::warning(
546                "page.invalid_line_jumps",
547                format!(
548                    "page '{}': line-jumps '{}' is not one of none/arc/gap",
549                    page.id, lj
550                ),
551                page.source_span,
552                Some(page.id.clone()),
553            ));
554        }
555
556        // Single source of truth for this page's parity (drives the margin
557        // advisory's binding side + recto/verso label).
558        let is_recto = doc.page_is_recto(page, page_index_1based);
559
560        // ── Master reference must resolve to a declared master ────────────
561        if let Some(master_id) = &page.master
562            && !declared_master_ids.contains(master_id)
563        {
564            diagnostics.push(Diagnostic::error(
565                "master.unknown_reference",
566                format!(
567                    "page '{}': references master '{}' which is not declared in the \
568                     masters block",
569                    page.id, master_id
570                ),
571                page.source_span,
572                Some(page.id.clone()),
573            ));
574        }
575
576        // ── Check page geometry (unit must be known) ──────────────────────
577        if matches!(page.width.unit, Unit::Unknown(_)) {
578            diagnostics.push(Diagnostic::error(
579                "node.invalid_geometry",
580                format!(
581                    "page '{}': property 'width' has an unrecognized unit; \
582                     allowed units are px, pt, pct, deg",
583                    page.id
584                ),
585                page.source_span,
586                Some(page.id.clone()),
587            ));
588        }
589        if matches!(page.height.unit, Unit::Unknown(_)) {
590            diagnostics.push(Diagnostic::error(
591                "node.invalid_geometry",
592                format!(
593                    "page '{}': property 'height' has an unrecognized unit; \
594                     allowed units are px, pt, pct, deg",
595                    page.id
596                ),
597                page.source_span,
598                Some(page.id.clone()),
599            ));
600        }
601
602        // ── Page dimensions must be a strictly positive, finite length ────
603        // A zero or negative width/height is a degenerate output target (an
604        // empty canvas) and is rejected; `(px)0`, `(px)-100`, NaN, and ∞ all
605        // fail here. The unit is validated separately above.
606        if !page.width.value.is_finite() || page.width.value <= 0.0 {
607            diagnostics.push(Diagnostic::error(
608                "value.out_of_range",
609                format!(
610                    "page '{}': width must be a strictly positive length (got {})",
611                    page.id, page.width.value
612                ),
613                page.source_span,
614                Some(page.id.clone()),
615            ));
616        }
617        if !page.height.value.is_finite() || page.height.value <= 0.0 {
618            diagnostics.push(Diagnostic::error(
619                "value.out_of_range",
620                format!(
621                    "page '{}': height must be a strictly positive length (got {})",
622                    page.id, page.height.value
623                ),
624                page.source_span,
625                Some(page.id.clone()),
626            ));
627        }
628
629        // ── Bleed validation (never a hard error) ─────────────────────────
630        // The bleed margin must resolve to pixels (px/pt) and be non-negative.
631        // An unresolvable unit (pct/deg/unknown) or a negative value is a
632        // Warning: the page still renders, bleed is simply ignored.
633        if let Some(bleed) = &page.bleed {
634            match dim_to_px(bleed.value, &bleed.unit) {
635                None => {
636                    diagnostics.push(Diagnostic::warning(
637                        "page.invalid_bleed",
638                        format!(
639                            "page '{}': bleed uses an unresolvable unit; \
640                             allowed units are px and pt (bleed is ignored)",
641                            page.id
642                        ),
643                        page.source_span,
644                        Some(page.id.clone()),
645                    ));
646                }
647                Some(px) if px < 0.0 => {
648                    diagnostics.push(Diagnostic::warning(
649                        "page.invalid_bleed",
650                        format!(
651                            "page '{}': bleed must be non-negative (bleed is ignored)",
652                            page.id
653                        ),
654                        page.source_span,
655                        Some(page.id.clone()),
656                    ));
657                }
658                Some(_) => {}
659            }
660        }
661
662        // ── Page background token: validate type/existence and record the
663        //    reference so it is not falsely reported as an unused token.
664        check_visual_prop(
665            &page.id,
666            "background",
667            page.background.as_ref(),
668            VisualExpect::ColorOrGradient,
669            &mut referenced_token_ids,
670            resolved_tokens,
671            &mut diagnostics,
672        );
673
674        // ── Resolve page dimensions to px for off_canvas checks ──────────
675        // If either dimension is unresolvable (e.g. Pct/Deg unit — already
676        // diagnosed above as node.invalid_geometry), skip off_canvas checks
677        // for this page to avoid spurious noise.
678        let page_px_bounds = dim_to_px(page.width.value, &page.width.unit)
679            .zip(dim_to_px(page.height.value, &page.height.unit));
680
681        // ── Resolve page background color for contrast checks ────────────
682        // Only a TokenRef → Color token produces a usable RGB triple.
683        // If the page has no background or the token is unresolvable, we
684        // set None and silently skip contrast checks for this page — we
685        // cannot determine what the background is without it.
686        let page_bg_rgb: Option<(u8, u8, u8)> = page.background.as_ref().and_then(|pv| {
687            if let PropertyValue::TokenRef(id) = pv {
688                resolved_tokens.get(id.as_str()).and_then(|rt| {
689                    if let ResolvedValue::Color(hex) = &rt.value {
690                        parse_rgb(hex)
691                    } else {
692                        None
693                    }
694                })
695            } else {
696                None
697            }
698        });
699
700        // ── Walk page children ────────────────────────────────────────────
701        // Page pixel bounds for backdrop bbox math; when the page unit was bad
702        // (already diagnosed) bounds are unresolved and we use (0, 0) — no
703        // shape will contain the text, so contrast falls back to the page bg.
704        let (page_w, page_h) = page_px_bounds.unwrap_or((0.0, 0.0));
705
706        // Build the set of safe-zone ids for this page so that check_anchor
707        // can validate anchor-zone references.
708        let zone_ids: BTreeSet<&str> = page.safe_zones.iter().map(|z| z.id.as_str()).collect();
709
710        let ctx = WalkCtx {
711            resolved_tokens,
712            declared_asset_ids: &declared_asset_ids,
713            declared_style_ids: &declared_style_ids,
714            declared_component_ids: &declared_component_ids,
715            component_local_ids: &component_local_ids,
716            all_node_ids: &all_node_ids,
717            zone_ids: &zone_ids,
718        };
719
720        // Validate the page-children sibling-anchor graph (the top-level scope)
721        // once, before the per-node walk.
722        check_sibling_anchors(&page.children, &mut diagnostics);
723
724        for (i, node) in page.children.iter().enumerate() {
725            walk_node(
726                node,
727                ctx,
728                &mut seen_ids,
729                &mut referenced_token_ids,
730                WalkPos {
731                    page_px_bounds,
732                    in_flow_parent: false,
733                    enclosing_frame: None,
734                    in_container: false,
735                    parent_box_known: false,
736                },
737                &mut diagnostics,
738            );
739            // Contrast check runs after the structural walk so that
740            // token-reference errors are already diagnosed and we can
741            // safely skip nodes whose tokens didn't resolve. The slice
742            // `page.children[..i]` is the set of siblings painted UNDER this
743            // node (lower z-order) — the candidate backdrops.
744            check_text_contrast(
745                node,
746                page_bg_rgb,
747                &page.children[..i],
748                (page_w, page_h),
749                resolved_tokens,
750                &style_map,
751                &mut diagnostics,
752            );
753        }
754
755        // ── Footnote-ref resolution (structural) ──────────────────────────
756        // Collect this page's footnote ids (direct children only — footnotes are
757        // page-level furniture) and check every text span's `footnote-ref`
758        // against that set. An unresolved ref → Warning `footnote.unresolved_ref`.
759        check_footnote_refs(page, &mut diagnostics);
760
761        // ── Safe-zone advisories ──────────────────────────────────────────
762        // Only run when the page dimensions resolved; zone/node geometry is
763        // compared in the same pixel space the off_canvas check uses.
764        if let Some((page_w, page_h)) = page_px_bounds {
765            safezone::check_safe_zones(page, page_w, page_h, &mut diagnostics);
766            fold::check_folds(page, page_w, page_h, &mut diagnostics);
767            margin::check_margins(
768                doc,
769                page,
770                margin::PageMarginCtx {
771                    page_w,
772                    page_h,
773                    is_recto,
774                    mirror_margins,
775                    rtl: rtl_book,
776                },
777                &mut diagnostics,
778            );
779        }
780    }
781
782    // A recipe `palette` entry is a token reference too (the generator recolors
783    // through it), so count palette ids as usage — a token used only by a recipe
784    // palette must not be flagged `token.unused`.
785    for recipe in &doc.recipes {
786        for token_id in &recipe.palette {
787            referenced_token_ids.insert(token_id.clone());
788        }
789    }
790
791    // ── Step 3: unused token check ────────────────────────────────────────
792    // Every token id that appears in `doc.tokens` but is not in
793    // `referenced_token_ids` → advisory `token.unused`.
794    for token in &doc.tokens.tokens {
795        if !referenced_token_ids.contains(&token.id) {
796            diagnostics.push(Diagnostic::advisory(
797                "token.unused",
798                format!(
799                    "token '{}' is defined but never referenced by any node \
800                     visual property or style in this document",
801                    token.id
802                ),
803                token.source_span,
804                Some(token.id.clone()),
805            ));
806        }
807    }
808
809    // ── Step 4: diagnostic policy ─────────────────────────────────────────
810    // Apply the document's `diagnostics { … }` policy to the assembled list
811    // FIRST (allow/deny/warn, with Error severity immutable), THEN append
812    // self-validation diagnostics ABOUT the policy. The ordering matters: the
813    // self-validation is appended after `apply_policy` so a policy can never
814    // suppress the warnings that describe its own entries. With no policy block,
815    // `apply_policy` is an exact identity pass and `check_policy_entries` adds
816    // nothing — the default-off path is byte-identical.
817    let mut diagnostics = apply_policy(diagnostics, policy);
818    check_policy_entries(policy, &mut diagnostics);
819
820    ValidationReport { diagnostics }
821}