zenith_core/ast/document.rs
1//! Top-level document AST types.
2
3use super::Span;
4use super::action::ActionDef;
5use super::asset::AssetBlock;
6use super::block_style::BlockStyle;
7use super::brand::BrandContract;
8use super::library::LibraryDef;
9use super::node::Node;
10use super::policy::DiagnosticPolicy;
11use super::provenance::ProvenanceDef;
12use super::recipe::RecipeDef;
13use super::style::StyleBlock;
14use super::token::TokenBlock;
15use super::value::Dimension;
16use super::value::PropertyValue;
17use super::variant::VariantDef;
18
19/// Metadata for the project.
20#[derive(Debug, Clone, PartialEq)]
21pub struct Project {
22 pub id: String,
23 pub name: String,
24 pub author: Option<String>,
25}
26
27/// A single page within a document body.
28#[derive(Debug, Clone, PartialEq)]
29pub struct Page {
30 pub id: String,
31 pub name: Option<String>,
32 /// Page width — required.
33 pub width: Dimension,
34 /// Page height — required.
35 pub height: Dimension,
36 pub background: Option<PropertyValue>,
37 /// Optional uniform print-bleed margin applied to all four sides. When this
38 /// resolves to a positive pixel value `b`, the rendered media box expands to
39 /// `(width + 2b) × (height + 2b)`, all page content shifts into the inner
40 /// trim box `[b, b, width, height]`, the background fills the entire media
41 /// box (bleeding off the trim edge), and crop/trim marks are auto-drawn in
42 /// the bleed margin at the four trim corners. `None` or a non-positive value
43 /// renders byte-identically to a page with no bleed.
44 pub bleed: Option<Dimension>,
45 /// Book live-area margin (gutter side). With document `mirror_margins=true`
46 /// this is the BINDING-side margin: it sits on the LEFT for a recto (odd,
47 /// 1-based) page and on the RIGHT for a verso (even) page. Without mirroring
48 /// it is treated uniformly as the left margin. `None` → no inner margin.
49 ///
50 /// Margins are v0 METADATA + VALIDATION ONLY: they describe the intended
51 /// live area and drive the `margin.violation` advisory, but they do NOT
52 /// auto-reposition arbitrary page nodes (that is the job of master pages /
53 /// flow frames). See [`crate::validate()`]'s margin check.
54 pub margin_inner: Option<Dimension>,
55 /// Book live-area margin (fore-edge side). The mirror of [`Page::margin_inner`]:
56 /// with `mirror_margins=true` it sits on the RIGHT for a recto page and on
57 /// the LEFT for a verso page; without mirroring it is the right margin.
58 /// `None` → no outer margin. Metadata + validation only (see `margin_inner`).
59 pub margin_outer: Option<Dimension>,
60 /// Book live-area top margin. `None` → no top margin. Metadata + validation
61 /// only (see [`Page::margin_inner`]).
62 pub margin_top: Option<Dimension>,
63 /// Book live-area bottom margin. `None` → no bottom margin. Metadata +
64 /// validation only (see [`Page::margin_inner`]).
65 pub margin_bottom: Option<Dimension>,
66 /// Optional baseline-grid pitch in pixels. When this resolves to a positive
67 /// pixel value `g`, every text node on this page snaps its line baselines onto
68 /// the grid `{ 0, g, 2g, ... }` measured from the page top (y=0): the first
69 /// line's baseline moves DOWN to the next grid line at or below its natural
70 /// position, and the effective inter-line advance becomes the smallest multiple
71 /// of `g` that is ≥ the resolved line-height, so corresponding lines align
72 /// horizontally across columns. `None` or a non-positive value renders
73 /// byte-identically to a page with no grid. KDL: `baseline-grid=(px)14`.
74 pub baseline_grid: Option<Dimension>,
75 /// Optional page-level line-jump style for connector-vs-connector crossings.
76 /// When `Some("arc")` or `Some("gap")`, every place where two top-level
77 /// connectors cross gains a deterministic hop on one of the two strokes (a
78 /// small semicircular bump for `arc`, a broken gap for `gap`) so overlapping
79 /// connectors read clearly. `Some("none")`, `None`, or any unrecognized value
80 /// renders byte-identically to a page with no line jumps. An unrecognized
81 /// value is surfaced as a validation warning (`page.invalid_line_jumps`).
82 pub line_jumps: Option<String>,
83 /// Author-declared safe/dead zones for this page. These are not rendering
84 /// nodes; the validator checks page children against them.
85 pub safe_zones: Vec<SafeZone>,
86 /// Author-declared fold-line positions for this page (tri-fold/bi-fold
87 /// print). These are non-printing page metadata, not rendering nodes; the
88 /// validator advises when content crosses a fold line.
89 pub folds: Vec<Fold>,
90 /// Per-role markdown block style declarations at page scope. Empty when no
91 /// `block role="…"` children are declared on this page. Cascade precedence:
92 /// page < text (the text node's own decls override these). `block` decls are
93 /// data-only in this unit; the layout engine consumes them later.
94 pub block_styles: Vec<BlockStyle>,
95 /// Optional explicit recto/verso parity OVERRIDE for this page. `Some("recto")`
96 /// or `Some("verso")` forces this page's parity regardless of its 1-based
97 /// position and the document `page_parity_start`. `None` (default) → parity is
98 /// derived from the page position and the document start parity. An invalid
99 /// value is preserved verbatim and surfaced as a validation warning
100 /// (`page.invalid_parity`); it then falls through to the derived parity. See
101 /// [`Document::page_is_recto`].
102 pub parity: Option<String>,
103 /// Optional master-page reference. When `Some(id)` names a declared
104 /// [`MasterDef`], the master's nodes (running heads, folios, TOC refs) are
105 /// projected UNDER this page's own children at compile time — the master's
106 /// [`Field`](super::node::Node::Field) nodes are resolved against this page's
107 /// index/parity. An unknown reference is a hard `master.unknown_reference`
108 /// validation error. `None` → the page has no master (renders as before).
109 pub master: Option<String>,
110 /// Child content nodes in z-order (first = bottommost, last = topmost).
111 pub children: Vec<Node>,
112 /// Source declaration span, when available.
113 pub source_span: Option<Span>,
114}
115
116/// The kind of a [`SafeZone`].
117#[derive(Debug, Clone, PartialEq)]
118pub enum SafeZoneType {
119 /// Content must NOT overlap this zone (e.g. a platform UI dead zone).
120 Exclusion,
121 /// Content must overlap this zone (e.g. a guaranteed-visible region).
122 Required,
123}
124
125/// A named safe/dead zone declared on a [`Page`].
126///
127/// Declared as a `safe-zone` child of a `page`; it is a sibling of rendering
128/// nodes but is itself not rendered.
129#[derive(Debug, Clone, PartialEq)]
130pub struct SafeZone {
131 pub id: String,
132 pub zone_type: SafeZoneType,
133 pub x: Dimension,
134 pub y: Dimension,
135 pub w: Dimension,
136 pub h: Dimension,
137 pub label: Option<String>,
138 pub source_span: Option<Span>,
139}
140
141/// A non-printing fold-line position declared on a [`Page`].
142///
143/// Declared as a `fold` child of a `page`; it is a sibling of rendering nodes
144/// but is itself never rendered. A vertical fold has an `x` position; a
145/// horizontal fold has a `y` position. Used for tri-fold / bi-fold print
146/// layouts so the validator can advise when content crosses a fold line.
147#[derive(Debug, Clone, PartialEq)]
148pub struct Fold {
149 pub id: String,
150 /// `"vertical"` (position is an x coordinate) or `"horizontal"` (position
151 /// is a y coordinate). Any other / absent value defaults to `"vertical"`.
152 pub orientation: String,
153 /// The fold-line position: x for a vertical fold, y for a horizontal fold.
154 /// `None` when the author omitted `position`.
155 pub position: Option<Dimension>,
156 pub source_span: Option<Span>,
157}
158
159/// The `document` child of the root `zenith` node.
160///
161/// Named `DocumentBody` to avoid clashing with the root `Document` type.
162#[derive(Debug, Clone, PartialEq)]
163pub struct DocumentBody {
164 pub id: String,
165 pub title: Option<String>,
166 /// Per-role markdown block style declarations at document scope. Empty when
167 /// no `block role="…"` children are declared on the document node. Lowest
168 /// cascade precedence: document < page < text. Data-only in this unit.
169 pub block_styles: Vec<BlockStyle>,
170 pub pages: Vec<Page>,
171}
172
173/// A reusable component definition: a named child-node subtree declared once
174/// (in the document-level `components` block) and instanced into multiple places
175/// via [`Node::Instance`].
176///
177/// Declared as `component id="logo.block" { <any child nodes> }`. The component's
178/// child node ids are LOCAL to the component: they are validated for uniqueness
179/// only WITHIN the component, not globally, and they are prefixed with the
180/// instance id when an instance is expanded at compile time. The `component` id
181/// itself participates in the global id-uniqueness set.
182#[derive(Debug, Clone, PartialEq)]
183pub struct ComponentDef {
184 pub id: String,
185 /// The component's child nodes in source order (the reusable subtree).
186 pub children: Vec<super::node::Node>,
187 /// Source declaration span, when available.
188 pub source_span: Option<Span>,
189}
190
191/// A reusable master-page definition: a named child-node subtree declared once
192/// (in the document-level `masters` block) and projected onto every [`Page`]
193/// whose `master` attribute names it.
194///
195/// Declared as `master id="m.body" { <any child nodes, incl. field nodes> }`.
196/// Structurally mirrors [`ComponentDef`]: the master's child node ids are LOCAL
197/// to the master (validated for uniqueness only WITHIN the master) and are
198/// prefixed with the page id when the master is projected at compile time. The
199/// `master` id itself participates in the global id-uniqueness set.
200///
201/// Unlike a component, a master is not instanced explicitly: a page opts in via
202/// `page ... master="m.body"`, and the master's [`Field`](super::node::Node::Field)
203/// nodes are resolved against that page's index/parity/live-area at compile time.
204#[derive(Debug, Clone, PartialEq)]
205pub struct MasterDef {
206 pub id: String,
207 /// The master's child nodes in source order (the projected subtree).
208 pub children: Vec<super::node::Node>,
209 /// Source declaration span, when available.
210 pub source_span: Option<Span>,
211}
212
213/// A `section` — a named, contiguous range of pages with its own folio
214/// numbering, used for front-matter / chapters / appendices. A section LABELS
215/// pages (like PDF page labels); it does not contain them. The range runs from
216/// `start_page` until the next section's `start_page` (or the document end).
217///
218/// Declared in the document-level `sections` block as a leaf entry:
219/// `section id="sec.front" name="Front Matter" start-page="page.cover"`.
220/// The `section` id itself participates in the global id-uniqueness set.
221#[derive(Debug, Clone, PartialEq)]
222pub struct SectionDef {
223 /// Globally-unique section id.
224 pub id: String,
225 /// Human-readable section name (e.g. "Front Matter", "Chapter 1"). Usable
226 /// as section-aware running-head text in a later unit.
227 pub name: String,
228 /// First folio number for this section (1-based). `None` defaults to 1.
229 pub folio_start: Option<usize>,
230 /// Folio numbering style for this section: `"decimal"` (default),
231 /// `"lower-roman"`, `"upper-roman"`. `None` defaults to decimal.
232 pub folio_style: Option<String>,
233 /// Id of the page that begins this section.
234 pub start_page: String,
235 /// Source declaration span, when available.
236 pub source_span: Option<Span>,
237}
238
239/// The root `zenith` node — the complete parsed `.zen` document.
240#[derive(Debug, Clone, PartialEq)]
241pub struct Document {
242 /// Must be `1` in v0.
243 pub version: u32,
244 /// Declared export color space: `Some("srgb")` (default) or `Some("cmyk")`.
245 /// `None` when the author omitted the `colorspace` attribute. In v0 this is
246 /// informational export metadata only — it does NOT change PNG output (the
247 /// PNG is always sRGB); a future PDF backend consults it. An invalid value
248 /// is preserved here verbatim and surfaced as a validation warning.
249 pub colorspace: Option<String>,
250 /// Stable document identity: an optional ULID minted at document creation,
251 /// stored verbatim as a Crockford base-32 string (no special characters, no
252 /// escaping). `None` when the author omitted `doc-id`. This is pure
253 /// metadata — render and compile code must not read it.
254 pub doc_id: Option<String>,
255 /// Mirrored book margins toggle. `Some(true)` → page margins mirror by page
256 /// parity (recto = odd 1-based page → inner margin on LEFT; verso = even →
257 /// inner margin on RIGHT). `Some(false)` or `None` (default) → margins are
258 /// uniform (inner = left, outer = right on every page). This only affects
259 /// how [`Page::margin_inner`]/[`Page::margin_outer`] are interpreted by the
260 /// `margin.violation` validation advisory; it is metadata, not layout.
261 pub mirror_margins: Option<bool>,
262 /// Declared page progression for export: `Some("ltr")` (default) or
263 /// `Some("rtl")` (right-to-left book page order). `None` when the author
264 /// omitted the attribute. v0: metadata for export (e.g. a PDF
265 /// `/ViewerPreferences /Direction /R2L`); it does NOT change page render
266 /// order or PNG output. An invalid value is preserved verbatim and surfaced
267 /// as a validation warning.
268 pub page_progression: Option<String>,
269 /// Declared STARTING parity for page 1: `Some("recto")` (default behavior) or
270 /// `Some("verso")` (page 1 is a verso, shifting the whole recto/verso sequence
271 /// by one). `None` when the author omitted the attribute — page 1 is then a
272 /// recto, exactly as before. An invalid value is preserved verbatim and
273 /// surfaced as a validation warning (`document.invalid_page_parity_start`); it
274 /// then falls through to the default (page 1 = recto). This drives the
275 /// mirrored-margin binding side and the master/field running-head recto/verso
276 /// selection via [`Document::page_is_recto`].
277 pub page_parity_start: Option<String>,
278 /// When `true`, the document is designed as facing-page spreads (recto/verso
279 /// pairs viewed together). Informational metadata; pages still render
280 /// independently. Parsed from `facing-pages=#true` on the document node.
281 pub facing_pages: Option<bool>,
282 /// The gutter (gap) between the two pages of a spread composite, e.g.
283 /// `spread-gutter=(px)40`. Used by the `--spread` render path. `None` = no gap.
284 pub spread_gutter: Option<Dimension>,
285 /// Document-level DEFAULT book live-area inner (gutter/binding) margin. When
286 /// a [`Page`] omits its own [`Page::margin_inner`], it inherits this value.
287 /// `None` (default) → no document default; the page's own value (possibly
288 /// `None`) is used verbatim, so a document with no margins is byte-identical
289 /// to before this attribute existed. Same KDL syntax as on a page
290 /// (`margin-inner=(px)225`). See [`Document::effective_margins`].
291 pub margin_inner: Option<Dimension>,
292 /// Document-level DEFAULT book live-area outer (fore-edge) margin. Cascades
293 /// to a page that omits [`Page::margin_outer`]. See [`Document::margin_inner`].
294 pub margin_outer: Option<Dimension>,
295 /// Document-level DEFAULT book live-area top margin. Cascades to a page that
296 /// omits [`Page::margin_top`]. See [`Document::margin_inner`].
297 pub margin_top: Option<Dimension>,
298 /// Document-level DEFAULT book live-area bottom margin. Cascades to a page
299 /// that omits [`Page::margin_bottom`]. See [`Document::margin_inner`].
300 pub margin_bottom: Option<Dimension>,
301 pub project: Option<Project>,
302 /// Asset declarations; empty when the `assets` block is absent.
303 pub assets: AssetBlock,
304 /// Imported-package manifest; empty when the `libraries` block is absent. Each
305 /// entry declares an external library dependency (id/version/hash). The engine
306 /// preserves and validates these but does not fetch package content.
307 pub libraries: Vec<LibraryDef>,
308 /// Action declarations; empty when the `actions` block is absent. Each entry
309 /// declares a named transaction script (id/label/version/tx_json). The engine
310 /// round-trips the `tx` payload verbatim without parsing it.
311 pub actions: Vec<ActionDef>,
312 pub tokens: TokenBlock,
313 pub styles: StyleBlock,
314 /// Reusable component definitions; empty when the `components` block is
315 /// absent. Instanced via [`Node::Instance`].
316 pub components: Vec<ComponentDef>,
317 /// Reusable master-page definitions; empty when the `masters` block is
318 /// absent. Projected onto pages via [`Page::master`].
319 pub masters: Vec<MasterDef>,
320 /// Section label ranges; empty when the `sections` block is absent. Each
321 /// entry labels a contiguous run of pages starting at [`SectionDef::start_page`]
322 /// and running to the next section's start page (or document end). Sections
323 /// do NOT contain pages; they are metadata ranges over the flat page list,
324 /// analogous to PDF PageLabels. Declaration order is preserved; range
325 /// computation (sorting by page index) is deferred to the field-resolution unit.
326 pub sections: Vec<SectionDef>,
327 /// Per-node origin records; empty when the `provenance` block is absent. Each
328 /// entry records where a document node came from: the node id it describes,
329 /// the declared library/package it originated from, the optional item name,
330 /// and an optional link state. Both the node id and the library id are
331 /// cross-validated against the document (the node must exist; the library
332 /// must be declared in the `libraries` block). Declaration order is preserved.
333 /// This is metadata about nodes — the engine round-trips and validates it but
334 /// does not act on the link state.
335 pub provenance: Vec<ProvenanceDef>,
336 /// Variant declarations; empty when the `variants` block is absent. Each
337 /// entry declares a named size/override variant derived from a source page
338 /// (`id`, `source`, `w`, `h`, optional `override` children). Core
339 /// round-trips and validates these records; variant generation is performed
340 /// by the CLI engine (`zenith variant`).
341 pub variants: Vec<VariantDef>,
342 /// Recipe declarations; empty when the `recipes` block is absent. Each
343 /// entry declares a named generative recipe (`id`, `kind`, optional
344 /// `seed`/`generator`/`bounds`/`detached`, optional `param`/`palette`/
345 /// `expanded` children). The engine round-trips and validates these records
346 /// but does NOT act on them; expansion is a later unit.
347 pub recipes: Vec<RecipeDef>,
348 /// Document-level diagnostic policy parsed from the root `diagnostics { … }`
349 /// block; empty (the default) when the block is absent. The policy adjusts
350 /// how specific diagnostic codes are *reported* during validation (allow /
351 /// deny / warn, with Error severity immutable). It is consulted ONLY in
352 /// [`crate::validate()`] — the scene compiler and render path never read it, so
353 /// it can never change rendered output. An empty policy is an identity pass,
354 /// so a document with no `diagnostics` block validates and round-trips
355 /// byte-identically to before this field existed.
356 pub diagnostic_policy: DiagnosticPolicy,
357 /// Brand contract parsed from the root `brand { … }` block; empty (the
358 /// default) when the block is absent. Declares approved colors, font
359 /// families, and font weights. The validator emits `brand.*` Warning
360 /// diagnostics when a resolved token's value is off-contract. An empty
361 /// (default) contract is an identity pass — a document with no `brand` block
362 /// validates and round-trips byte-identically to before this field existed.
363 pub brand_contract: BrandContract,
364 pub body: DocumentBody,
365}
366
367impl Document {
368 /// True when the given page (at its 1-based position in document order) is a
369 /// recto (right-hand) page; false for a verso (left-hand) page. This is the
370 /// SINGLE source of truth for page parity across the workspace (mirrored
371 /// margins + master/field running-head selection).
372 ///
373 /// Precedence (highest first):
374 /// 1. An explicit per-page [`Page::parity`] override (`"recto"`/`"verso"`).
375 /// Any value other than `"verso"` (case-insensitive) — including an
376 /// invalid one — is treated as recto, matching the validator's
377 /// forward-compatible warning behavior.
378 /// 2. The document [`Document::page_parity_start`] offset: `"verso"`
379 /// (case-insensitive) makes page 1 a verso and shifts the whole sequence
380 /// by one; any other / absent value keeps the default.
381 /// 3. Default: page 1 is a recto — `page_index_1based % 2 == 1`, exactly the
382 /// pre-feature behavior. With no parity attributes this returns
383 /// `index % 2 == 1` byte-identically.
384 ///
385 /// Pure and deterministic.
386 pub fn page_is_recto(&self, page: &Page, page_index_1based: usize) -> bool {
387 if let Some(p) = page.parity.as_deref() {
388 // Explicit per-page override: "verso" → verso, anything else → recto.
389 return !p.eq_ignore_ascii_case("verso");
390 }
391 let base_recto = page_index_1based % 2 == 1;
392 match self.page_parity_start.as_deref() {
393 Some(s) if s.eq_ignore_ascii_case("verso") => !base_recto,
394 _ => base_recto,
395 }
396 }
397
398 /// The page's EFFECTIVE book live-area margins, as
399 /// `(inner, outer, top, bottom)`: each side is the page's own value when set,
400 /// else the document-level default ([`Document::margin_inner`] etc.). This is
401 /// the SINGLE source of truth for the document→page margin cascade; every
402 /// live-area / margin computation reads margins through here so per-page
403 /// overrides and document defaults resolve identically everywhere.
404 ///
405 /// With no document margins set, this returns exactly the page's own values
406 /// (including `None`), so the default-off path is byte-identical to reading
407 /// `page.margin_*` directly. Pure and deterministic.
408 pub fn effective_margins(
409 &self,
410 page: &Page,
411 ) -> (
412 Option<Dimension>,
413 Option<Dimension>,
414 Option<Dimension>,
415 Option<Dimension>,
416 ) {
417 (
418 page.margin_inner
419 .clone()
420 .or_else(|| self.margin_inner.clone()),
421 page.margin_outer
422 .clone()
423 .or_else(|| self.margin_outer.clone()),
424 page.margin_top.clone().or_else(|| self.margin_top.clone()),
425 page.margin_bottom
426 .clone()
427 .or_else(|| self.margin_bottom.clone()),
428 )
429 }
430}
431
432#[cfg(test)]
433mod parity_tests {
434 use super::*;
435 use crate::ast::value::Dimension;
436 use crate::ast::value::Unit;
437
438 fn px(v: f64) -> Dimension {
439 Dimension {
440 value: v,
441 unit: Unit::Px,
442 }
443 }
444
445 fn page(id: &str, parity: Option<&str>) -> Page {
446 Page {
447 id: id.to_owned(),
448 name: None,
449 width: px(100.0),
450 height: px(100.0),
451 background: None,
452 bleed: None,
453 margin_inner: None,
454 margin_outer: None,
455 margin_top: None,
456 margin_bottom: None,
457 baseline_grid: None,
458 line_jumps: None,
459 parity: parity.map(str::to_owned),
460 master: None,
461 safe_zones: Vec::new(),
462 folds: Vec::new(),
463 block_styles: Vec::new(),
464 children: Vec::new(),
465 source_span: None,
466 }
467 }
468
469 fn doc(start: Option<&str>) -> Document {
470 Document {
471 version: 1,
472 colorspace: None,
473 doc_id: None,
474 mirror_margins: None,
475 facing_pages: None,
476 spread_gutter: None,
477 page_progression: None,
478 page_parity_start: start.map(str::to_owned),
479 margin_inner: None,
480 margin_outer: None,
481 margin_top: None,
482 margin_bottom: None,
483 project: None,
484 assets: AssetBlock::default(),
485 libraries: Vec::new(),
486 actions: Vec::new(),
487 tokens: TokenBlock::default(),
488 styles: StyleBlock::default(),
489 components: Vec::new(),
490 masters: Vec::new(),
491 sections: Vec::new(),
492 provenance: Vec::new(),
493 variants: Vec::new(),
494 recipes: Vec::new(),
495 diagnostic_policy: DiagnosticPolicy::default(),
496 brand_contract: BrandContract::default(),
497 body: DocumentBody {
498 id: "body".to_owned(),
499 title: None,
500 block_styles: Vec::new(),
501 pages: Vec::new(),
502 },
503 }
504 }
505
506 #[test]
507 fn default_page_one_recto_page_two_verso() {
508 let d = doc(None);
509 assert!(d.page_is_recto(&page("p1", None), 1), "page 1 is recto");
510 assert!(!d.page_is_recto(&page("p2", None), 2), "page 2 is verso");
511 assert!(d.page_is_recto(&page("p3", None), 3), "page 3 is recto");
512 }
513
514 #[test]
515 fn start_verso_flips_the_sequence() {
516 let d = doc(Some("verso"));
517 assert!(!d.page_is_recto(&page("p1", None), 1), "page 1 is verso");
518 assert!(d.page_is_recto(&page("p2", None), 2), "page 2 is recto");
519 }
520
521 #[test]
522 fn start_recto_matches_default() {
523 let d = doc(Some("recto"));
524 assert!(d.page_is_recto(&page("p1", None), 1));
525 assert!(!d.page_is_recto(&page("p2", None), 2));
526 }
527
528 #[test]
529 fn page_override_verso_wins_over_start() {
530 // Default start (recto), but page 1 forced to verso.
531 let d = doc(None);
532 assert!(!d.page_is_recto(&page("p1", Some("verso")), 1));
533 // Even with start=verso, an explicit recto on page 1 forces recto.
534 let d2 = doc(Some("verso"));
535 assert!(d2.page_is_recto(&page("p1", Some("recto")), 1));
536 }
537
538 #[test]
539 fn page_override_recto_on_even_page() {
540 let d = doc(None);
541 assert!(
542 d.page_is_recto(&page("p2", Some("recto")), 2),
543 "page 2 forced recto"
544 );
545 }
546
547 #[test]
548 fn invalid_start_falls_back_to_default() {
549 let d = doc(Some("sideways"));
550 assert!(d.page_is_recto(&page("p1", None), 1), "page 1 stays recto");
551 assert!(!d.page_is_recto(&page("p2", None), 2));
552 }
553
554 #[test]
555 fn invalid_page_parity_treated_as_recto() {
556 let d = doc(None);
557 assert!(
558 d.page_is_recto(&page("p2", Some("nonsense")), 2),
559 "an invalid override is treated as recto"
560 );
561 }
562
563 #[test]
564 fn effective_margins_page_value_wins_when_both_set() {
565 let mut d = doc(None);
566 d.margin_inner = Some(px(10.0));
567 d.margin_outer = Some(px(20.0));
568 d.margin_top = Some(px(30.0));
569 d.margin_bottom = Some(px(40.0));
570 let mut p = page("p", None);
571 p.margin_inner = Some(px(1.0));
572 p.margin_outer = Some(px(2.0));
573 p.margin_top = Some(px(3.0));
574 p.margin_bottom = Some(px(4.0));
575 let (i, o, t, b) = d.effective_margins(&p);
576 assert_eq!(i, Some(px(1.0)));
577 assert_eq!(o, Some(px(2.0)));
578 assert_eq!(t, Some(px(3.0)));
579 assert_eq!(b, Some(px(4.0)));
580 }
581
582 #[test]
583 fn effective_margins_doc_default_used_when_page_none() {
584 let mut d = doc(None);
585 d.margin_inner = Some(px(10.0));
586 d.margin_outer = Some(px(20.0));
587 d.margin_top = Some(px(30.0));
588 d.margin_bottom = Some(px(40.0));
589 let p = page("p", None);
590 let (i, o, t, b) = d.effective_margins(&p);
591 assert_eq!(i, Some(px(10.0)));
592 assert_eq!(o, Some(px(20.0)));
593 assert_eq!(t, Some(px(30.0)));
594 assert_eq!(b, Some(px(40.0)));
595 }
596
597 #[test]
598 fn effective_margins_mixed_override() {
599 // Doc sets all four; page overrides only inner → page inner + doc rest.
600 let mut d = doc(None);
601 d.margin_inner = Some(px(10.0));
602 d.margin_outer = Some(px(20.0));
603 d.margin_top = Some(px(30.0));
604 d.margin_bottom = Some(px(40.0));
605 let mut p = page("p", None);
606 p.margin_inner = Some(px(99.0));
607 let (i, o, t, b) = d.effective_margins(&p);
608 assert_eq!(i, Some(px(99.0)));
609 assert_eq!(o, Some(px(20.0)));
610 assert_eq!(t, Some(px(30.0)));
611 assert_eq!(b, Some(px(40.0)));
612 }
613
614 #[test]
615 fn effective_margins_none_when_both_none() {
616 let d = doc(None);
617 let p = page("p", None);
618 assert_eq!(d.effective_margins(&p), (None, None, None, None));
619 }
620
621 #[test]
622 fn effective_margins_default_off_is_page_values_verbatim() {
623 // The regression guard: with NO doc margins, effective == page's own
624 // values exactly (including None), so the default-off path is identical.
625 let d = doc(None);
626 let mut p = page("p", None);
627 p.margin_inner = Some(px(225.0));
628 p.margin_top = Some(px(210.0));
629 let (i, o, t, b) = d.effective_margins(&p);
630 assert_eq!(i, p.margin_inner);
631 assert_eq!(o, p.margin_outer);
632 assert_eq!(t, p.margin_top);
633 assert_eq!(b, p.margin_bottom);
634 }
635
636 #[test]
637 fn default_is_byte_identical_to_index_parity() {
638 // The regression guard: with no parity attrs anywhere, page_is_recto MUST
639 // equal `index % 2 == 1` for every index.
640 let d = doc(None);
641 for idx in 1..=64usize {
642 assert_eq!(
643 d.page_is_recto(&page("p", None), idx),
644 idx % 2 == 1,
645 "default parity must equal index%2==1 at index {idx}"
646 );
647 }
648 }
649}