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(§ion.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) = §ion.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}