Skip to main content

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}