Skip to main content

zenith_scene/
ir.rs

1//! Scene IR — the backend-neutral display-list primitives.
2//!
3//! Every type in this module derives `Debug`, `Clone`, `PartialEq`, and
4//! `serde::Serialize`.  No `HashMap` or `HashSet` is used anywhere in this
5//! module, so JSON serialization is deterministic (struct field order is
6//! stable; `BTreeMap` would be used if maps were ever needed).
7//!
8//! The `scene` field name is always the first field in `Scene` so the
9//! `schema` key appears first in the serialized JSON.
10
11use serde::Serialize;
12
13// ── LineCap ───────────────────────────────────────────────────────────────────
14
15/// Dash end-cap style for dashed strokes.
16///
17/// Maps directly to the `tiny_skia::LineCap` values; serialized in lowercase
18/// JSON so the scene JSON is human-readable and matches the KDL attribute values.
19#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
20#[serde(rename_all = "lowercase")]
21pub enum LineCap {
22    Butt,
23    Round,
24    Square,
25}
26
27// ── StrokeAlign ─────────────────────────────────────────────────────────────────
28
29/// Stroke alignment relative to a closed polygon's boundary.
30///
31/// `Center` (the default) strokes centered on the path — identical to the prior
32/// IR and the only alignment valid for open polylines. `Inside`/`Outside` shift
33/// the visible stroke fully inside / outside the fill boundary; the renderer
34/// implements them via a fill-region clip mask, so self-intersecting shapes
35/// (stars) and rotation are handled without geometry offsetting. Serialized in
36/// lowercase JSON to match the KDL `stroke-alignment` attribute values.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
38#[serde(rename_all = "lowercase")]
39pub enum StrokeAlign {
40    #[default]
41    Center,
42    Inside,
43    Outside,
44}
45
46// ── BlendMode ─────────────────────────────────────────────────────────────────
47
48/// Compositing blend mode for a layer's ink onto what lies beneath it.
49///
50/// `Normal` is plain source-over compositing (the default). Every other variant
51/// is a separable Porter-Duff/PDF blend that maps directly onto the
52/// `tiny_skia::BlendMode` of the same name. Serialized in kebab-case so the JSON
53/// matches the KDL attribute values (`color-dodge`, `hard-light`, …).
54#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum BlendMode {
57    Normal,
58    Multiply,
59    Screen,
60    Overlay,
61    Darken,
62    Lighten,
63    ColorDodge,
64    ColorBurn,
65    HardLight,
66    SoftLight,
67    Difference,
68    Exclusion,
69}
70
71// ── Color ─────────────────────────────────────────────────────────────────────
72
73/// An sRGB 8-bit color with pre-multiplied-independent alpha.
74///
75/// `r`, `g`, `b`, `a` are all in `0..=255` (linear 8-bit sRGB per channel,
76/// straight / un-pre-multiplied alpha).
77///
78/// `cmyk` is `None` for sRGB-origin colors. When a color token was declared in
79/// CMYK (`cmyk(c,m,y,k)`), this carries the original `[c, m, y, k]` percentages
80/// (`0.0..=100.0`) so a future PDF backend can emit native DeviceCMYK; the
81/// `r`/`g`/`b` channels then hold the naive device sRGB conversion. The PNG
82/// renderer ignores `cmyk` entirely and paints with `r`/`g`/`b`/`a`.
83#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
84pub struct Color {
85    pub r: u8,
86    pub g: u8,
87    pub b: u8,
88    pub a: u8,
89    /// Original CMYK channels `[c, m, y, k]` (percentages) when this color was
90    /// declared in CMYK; `None` for sRGB-origin colors. Skipped in JSON when
91    /// absent so existing sRGB scenes serialize byte-identically.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub cmyk: Option<[f32; 4]>,
94}
95
96impl Color {
97    /// Construct an sRGB-origin color (`cmyk` is `None`).
98    pub const fn srgb(r: u8, g: u8, b: u8, a: u8) -> Self {
99        Self {
100            r,
101            g,
102            b,
103            a,
104            cmyk: None,
105        }
106    }
107
108    /// Construct a CMYK-origin opaque color from the original channels plus the
109    /// already-converted sRGB triple. `c`/`m`/`y`/`k` are percentages
110    /// (`0.0..=100.0`); the converted `r`/`g`/`b` are supplied by the caller so
111    /// the deterministic conversion lives in exactly one place
112    /// (`zenith_core::cmyk_to_srgb`). Alpha is always `255`.
113    pub const fn cmyk(c: f32, m: f32, y: f32, k: f32, r: u8, g: u8, b: u8) -> Self {
114        Self {
115            r,
116            g,
117            b,
118            a: 255,
119            cmyk: Some([c, m, y, k]),
120        }
121    }
122}
123
124// ── Gradient paint ────────────────────────────────────────────────────────────
125
126/// A single color stop within a [`GradientPaint`].
127///
128/// `offset` is the normalized position along the gradient line in `0.0..=1.0`;
129/// `color` is the (alpha-cascaded) stop color.
130#[derive(Debug, Clone, PartialEq, Serialize)]
131pub struct GradientStop {
132    /// Normalized position along the gradient line, `0.0..=1.0`.
133    pub offset: f64,
134    /// Stop color (straight / un-pre-multiplied alpha).
135    pub color: Color,
136}
137
138fn is_false(b: &bool) -> bool {
139    !*b
140}
141
142/// A gradient fill paint — either linear (default) or radial.
143///
144/// For linear gradients, `angle_deg` controls the gradient line.
145/// For radial gradients, `radial=true` is set and `center_x/center_y/radius_frac`
146/// control the radial geometry (all as fractions of the bounding box).
147///
148/// The `radial` field and the three radial-geometry fields are omitted from
149/// JSON when they hold their zero/none defaults, so existing linear `GradientPaint`
150/// values serialize byte-identically.
151#[derive(Debug, Clone, PartialEq, Serialize)]
152pub struct GradientPaint {
153    /// Gradient-line angle in degrees, clockwise from +x. Ignored for radial.
154    pub angle_deg: f64,
155    /// Ordered color stops (at least two).
156    pub stops: Vec<GradientStop>,
157    /// `true` when this is a radial gradient. Omitted (default false) for linear.
158    #[serde(default, skip_serializing_if = "is_false")]
159    pub radial: bool,
160    /// Radial center X as a fraction of bounding-box width. `None` → 0.5.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub center_x: Option<f64>,
163    /// Radial center Y as a fraction of bounding-box height. `None` → 0.5.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub center_y: Option<f64>,
166    /// Radial radius as a fraction of `hypot(w, h) / 2`. `None` → 1.0.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub radius_frac: Option<f64>,
169}
170
171// ── Paint ───────────────────────────────────────────────────────────────────
172
173/// How a filled region is painted.
174///
175/// Every fill command carries a `Paint`, so any geometry (rectangle, rounded
176/// rectangle, ellipse, polygon, …) can be filled with a flat color or a
177/// gradient through one uniform model — there is no per-geometry gradient
178/// command. New fill kinds (e.g. patterns) are added here as one more variant,
179/// and the exhaustive matches over `Paint` force every backend to handle them.
180///
181/// Serialized internally-tagged on `kind` so the JSON is self-describing:
182/// `{ "kind": "solid", "color": {…} }` or
183/// `{ "kind": "gradient", "angle_deg": …, "stops": [...] }`.
184#[derive(Debug, Clone, PartialEq, Serialize)]
185#[serde(tag = "kind", rename_all = "lowercase")]
186pub enum Paint {
187    /// A flat fill color.
188    Solid {
189        /// The fill color (straight / un-pre-multiplied alpha).
190        color: Color,
191    },
192    /// A linear or radial gradient.
193    Gradient(GradientPaint),
194}
195
196impl Paint {
197    /// Construct a solid paint from a color.
198    pub fn solid(color: Color) -> Self {
199        Paint::Solid { color }
200    }
201}
202
203// ── Shadow ────────────────────────────────────────────────────────────────────
204
205/// A single drop-shadow / outer-glow layer.
206///
207/// `dx`/`dy` are the offset (pixels) of the shadow relative to the ink; `blur`
208/// is the Gaussian blur sigma (pixels, `>= 0`); `color` is the shadow color
209/// (straight / un-pre-multiplied alpha). A node may carry several layers, all
210/// painted behind the ink.
211#[derive(Debug, Clone, PartialEq, Serialize)]
212pub struct ShadowSpec {
213    /// Horizontal offset in pixels (positive = rightward).
214    pub dx: f64,
215    /// Vertical offset in pixels (positive = downward).
216    pub dy: f64,
217    /// Gaussian blur sigma in pixels (`>= 0`).
218    pub blur: f64,
219    /// Shadow color (straight / un-pre-multiplied alpha).
220    pub color: Color,
221}
222
223// ── Filter ──────────────────────────────────────────────────────────────────
224
225/// A single color-filter operation applied to captured ink (straight-alpha math).
226///
227/// Each variant carries its already-resolved scalar payload (the per-kind
228/// `amount`, defaults substituted at compile time). `Duotone` additionally
229/// carries its two resolved colors — the scene IR stays decoupled from the core
230/// AST, exactly as [`ShadowSpec`] carries a scene-local [`Color`] rather than a
231/// color-token id. The compile step maps core → scene.
232#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
233pub enum FilterSpec {
234    Grayscale(f64),
235    Invert(f64),
236    Sepia(f64),
237    Saturate(f64),
238    Brightness(f64),
239    Contrast(f64),
240    HueRotate(f64),
241    /// Maps luma to a blend between `shadow` (dark) and `highlight` (light),
242    /// then mixes with the original by `amount`.
243    Duotone {
244        amount: f64,
245        shadow: Color,
246        highlight: Color,
247    },
248    /// Deterministic monochrome additive film grain: adds the same per-pixel
249    /// delta to R, G, and B, derived from an integer hash of the page-absolute
250    /// pixel cell and `seed`. `amount` scales the grain magnitude; `scale` is the
251    /// grain cell size in pixels. Same inputs → same grain on any machine.
252    Noise {
253        amount: f64,
254        seed: i64,
255        scale: f64,
256    },
257}
258
259// ── Mask ──────────────────────────────────────────────────────────────────────
260
261/// The spatial coverage shape of a node mask.
262///
263/// Mirrors `zenith_core::MaskShape`; the compile step maps core → scene so the
264/// scene IR stays decoupled from the core AST (exactly as [`FilterSpec`] carries
265/// scene-local payloads rather than core token ids).
266#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
267pub enum MaskShape {
268    Rect,
269    RoundedRect,
270    Ellipse,
271}
272
273/// A resolved soft-mask applied to a node's draws.
274///
275/// The mask coverage is the `shape` inscribed in the node box `[x, y, w, h]`
276/// (page-absolute pixels), optionally with a corner `radius` (RoundedRect),
277/// a Gaussian `feather` sigma (`>= 0`), and an `invert` flag. The renderer
278/// brackets the node's draws with [`SceneCommand::BeginMask`] /
279/// [`SceneCommand::EndMask`] and composites the captured ink through the
280/// feathered coverage.
281#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
282pub struct MaskSpec {
283    pub shape: MaskShape,
284    /// Resolved corner radius in pixels (RoundedRect; `0.0` otherwise).
285    pub radius: f64,
286    /// Gaussian feather sigma in pixels (`>= 0`).
287    pub feather: f64,
288    pub invert: bool,
289    /// Node box, page-absolute pixels.
290    pub x: f64,
291    pub y: f64,
292    pub w: f64,
293    pub h: f64,
294}
295
296// ── Fit mode ────────────────────────────────────────────────────────────────
297
298/// How a raster image asset scales to fill its declared box.
299///
300/// - `Contain` — scale to fit entirely inside the box (letterboxed).
301/// - `Cover` — scale to cover the whole box (cropped, clipped to the box).
302/// - `Stretch` — scale each axis independently to exactly fill the box.
303/// - `None` — draw at native pixel size, anchored by object-position.
304#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
305#[serde(rename_all = "lowercase")]
306pub enum FitMode {
307    Contain,
308    Cover,
309    Stretch,
310    None,
311}
312
313// ── Image source rect ─────────────────────────────────────────────────────────
314
315/// A sub-rectangle within the source image used as the effective source for a
316/// [`SceneCommand::DrawImage`] command.
317///
318/// All four coordinates are in source-image pixels (top-left origin). The rect
319/// is clamped to the source image bounds at render time; a degenerate rect (zero
320/// width or height after clamping) causes the draw to be skipped.
321///
322/// Applies to raster `kind="image"` assets only; ignored for SVG assets (vector
323/// assets are resolution-independent and src-rect is a raster concept). This is
324/// a documented v0 limitation.
325#[derive(Debug, Clone, PartialEq, Serialize)]
326pub struct SrcRect {
327    /// Left edge of the crop in source pixels.
328    pub x: f64,
329    /// Top edge of the crop in source pixels.
330    pub y: f64,
331    /// Width of the crop in source pixels (> 0).
332    pub w: f64,
333    /// Height of the crop in source pixels (> 0).
334    pub h: f64,
335}
336
337// ── Image clip shape ──────────────────────────────────────────────────────────
338
339/// A non-rectangular clip shape applied to a [`SceneCommand::DrawImage`].
340///
341/// `None` on the `DrawImage` (no `clip_shape`) means the default rectangular
342/// box-clip (the raster is clipped to its declared `[x, y, w, h]` box). A
343/// `Some` value constrains the blit to a shape INSCRIBED in that box:
344///
345/// - `Ellipse` — the ellipse inscribed in the box (a circle when the box is
346///   square): the circular-avatar case.
347/// - `RoundedRect { radius }` — a rounded rectangle with uniform corner radius.
348///
349/// Tagged in JSON via `#[serde(tag = "shape")]` for a self-describing form,
350/// consistent with the `op`-tagged [`SceneCommand`].
351#[derive(Debug, Clone, PartialEq, Serialize)]
352#[serde(tag = "shape")]
353pub enum ImageClip {
354    /// Clip to the ellipse inscribed in the image's `[x, y, w, h]` box.
355    Ellipse,
356    /// Clip to a rounded rectangle with uniform corner `radius` (pixels).
357    RoundedRect { radius: f64 },
358}
359
360fn is_center(a: &StrokeAlign) -> bool {
361    matches!(a, StrokeAlign::Center)
362}
363
364// ── Scene commands ────────────────────────────────────────────────────────────
365
366/// A single display-list command in the scene.
367///
368/// All variants are tagged in JSON via `#[serde(tag = "op")]` so that each
369/// serialized command carries an `"op"` field naming the primitive, e.g.
370/// `{ "op": "FillRect", "x": 0.0, … }`.
371#[derive(Debug, Clone, PartialEq, Serialize)]
372#[serde(tag = "op")]
373pub enum SceneCommand {
374    // ── Filled shapes ─────────────────────────────────────────────────────
375    /// Fill an axis-aligned rectangle.
376    FillRect {
377        x: f64,
378        y: f64,
379        w: f64,
380        h: f64,
381        paint: Paint,
382    },
383    /// Stroke an axis-aligned rectangle (inside the declared edge by default).
384    StrokeRect {
385        x: f64,
386        y: f64,
387        w: f64,
388        h: f64,
389        color: Color,
390        stroke_width: f64,
391        /// Dash segment length in pixels. `None` = solid stroke (byte-identical to prior IR).
392        #[serde(default, skip_serializing_if = "Option::is_none")]
393        stroke_dash: Option<f64>,
394        /// Gap length in pixels between dashes. `None` = solid stroke.
395        #[serde(default, skip_serializing_if = "Option::is_none")]
396        stroke_gap: Option<f64>,
397        /// Dash end-cap style. `None` = Butt (default, byte-identical to prior IR).
398        #[serde(default, skip_serializing_if = "Option::is_none")]
399        stroke_linecap: Option<LineCap>,
400    },
401    /// Fill a rectangle with uniform corner radius (and optional per-corner overrides).
402    FillRoundedRect {
403        x: f64,
404        y: f64,
405        w: f64,
406        h: f64,
407        radius: f64,
408        paint: Paint,
409        /// Per-corner radii `[tl, tr, br, bl]`. `None` = use uniform `radius` for all
410        /// corners (byte-identical to prior IR when absent).
411        #[serde(default, skip_serializing_if = "Option::is_none")]
412        radii: Option<[f64; 4]>,
413    },
414    /// Stroke a rectangle with uniform corner radius (and optional per-corner overrides).
415    StrokeRoundedRect {
416        x: f64,
417        y: f64,
418        w: f64,
419        h: f64,
420        radius: f64,
421        color: Color,
422        stroke_width: f64,
423        /// Dash segment length in pixels. `None` = solid stroke (byte-identical to prior IR).
424        #[serde(default, skip_serializing_if = "Option::is_none")]
425        stroke_dash: Option<f64>,
426        /// Gap length in pixels between dashes. `None` = solid stroke.
427        #[serde(default, skip_serializing_if = "Option::is_none")]
428        stroke_gap: Option<f64>,
429        /// Dash end-cap style. `None` = Butt (default, byte-identical to prior IR).
430        #[serde(default, skip_serializing_if = "Option::is_none")]
431        stroke_linecap: Option<LineCap>,
432        /// Per-corner radii `[tl, tr, br, bl]`. `None` = use uniform `radius` for all
433        /// corners (byte-identical to prior IR when absent).
434        #[serde(default, skip_serializing_if = "Option::is_none")]
435        radii: Option<[f64; 4]>,
436    },
437    /// Fill an axis-aligned ellipse.
438    FillEllipse {
439        x: f64,
440        y: f64,
441        w: f64,
442        h: f64,
443        paint: Paint,
444        /// Explicit x-radius (overrides w/2). `None` = inscribed ellipse (byte-identical).
445        #[serde(default, skip_serializing_if = "Option::is_none")]
446        rx: Option<f64>,
447        /// Explicit y-radius (overrides h/2). `None` = inscribed ellipse (byte-identical).
448        #[serde(default, skip_serializing_if = "Option::is_none")]
449        ry: Option<f64>,
450    },
451    /// Stroke an axis-aligned ellipse (centered on the ellipse path; no
452    /// stroke-alignment in v0).
453    StrokeEllipse {
454        x: f64,
455        y: f64,
456        w: f64,
457        h: f64,
458        color: Color,
459        stroke_width: f64,
460        /// Dash segment length in pixels. `None` = solid stroke (byte-identical to prior IR).
461        #[serde(default, skip_serializing_if = "Option::is_none")]
462        stroke_dash: Option<f64>,
463        /// Gap length in pixels between dashes. `None` = solid stroke.
464        #[serde(default, skip_serializing_if = "Option::is_none")]
465        stroke_gap: Option<f64>,
466        /// Dash end-cap style. `None` = Butt (default, byte-identical to prior IR).
467        #[serde(default, skip_serializing_if = "Option::is_none")]
468        stroke_linecap: Option<LineCap>,
469        /// Explicit x-radius (overrides w/2). `None` = inscribed ellipse (byte-identical).
470        #[serde(default, skip_serializing_if = "Option::is_none")]
471        rx: Option<f64>,
472        /// Explicit y-radius (overrides h/2). `None` = inscribed ellipse (byte-identical).
473        #[serde(default, skip_serializing_if = "Option::is_none")]
474        ry: Option<f64>,
475    },
476    /// Stroke a line segment.
477    StrokeLine {
478        x1: f64,
479        y1: f64,
480        x2: f64,
481        y2: f64,
482        color: Color,
483        stroke_width: f64,
484        /// Dash segment length in pixels. `None` = solid stroke (byte-identical to prior IR).
485        #[serde(default, skip_serializing_if = "Option::is_none")]
486        stroke_dash: Option<f64>,
487        /// Gap length in pixels between dashes. `None` = solid stroke.
488        #[serde(default, skip_serializing_if = "Option::is_none")]
489        stroke_gap: Option<f64>,
490        /// Dash end-cap style. `None` = Butt (default, byte-identical to prior IR).
491        #[serde(default, skip_serializing_if = "Option::is_none")]
492        stroke_linecap: Option<LineCap>,
493    },
494    /// Fill a closed polygon.
495    FillPolygon {
496        /// Flat list of `[x0, y0, x1, y1, …]` vertex coordinates.
497        points: Vec<f64>,
498        paint: Paint,
499        /// When `true`, use the even-odd fill rule; otherwise nonzero (winding).
500        #[serde(default)]
501        even_odd: bool,
502    },
503    /// Stroke a polyline (open or closed depending on `closed`).
504    StrokePolyline {
505        /// Flat list of `[x0, y0, x1, y1, …]` vertex coordinates.
506        points: Vec<f64>,
507        color: Color,
508        stroke_width: f64,
509        /// When `true`, the path is closed before stroking (polygon outline).
510        #[serde(default)]
511        closed: bool,
512        /// Stroke alignment relative to the closed-path boundary. Only meaningful
513        /// when `closed` is `true`; `Center` is the open-path/default behavior.
514        /// Skipped in JSON when `Center` so existing scenes serialize byte-identically.
515        #[serde(default, skip_serializing_if = "is_center")]
516        align: StrokeAlign,
517        /// Fill rule of the clip region used for `Inside`/`Outside` alignment.
518        /// `true` = even-odd, `false` = nonzero. Only meaningful when
519        /// `align != Center` and `closed` is `true`.
520        #[serde(default, skip_serializing_if = "is_false")]
521        fill_even_odd: bool,
522    },
523    // ── Asset commands ────────────────────────────────────────────────────
524    /// Draw a raster image asset clipped to its declared box.
525    ///
526    /// The renderer re-resolves bytes via `AssetProvider::by_id` using only the
527    /// `asset_id` string — no raw image bytes appear in the IR. `pos_x`/`pos_y`
528    /// are the object-position anchors resolved to `0.0..=100.0`.
529    DrawImage {
530        x: f64,
531        y: f64,
532        w: f64,
533        h: f64,
534        /// Stable asset id; renderer resolves bytes via `AssetProvider::by_id`.
535        asset_id: String,
536        /// How the image scales to fill the box.
537        fit: FitMode,
538        /// Horizontal object-position anchor in `0.0..=100.0`.
539        pos_x: f64,
540        /// Vertical object-position anchor in `0.0..=100.0`.
541        pos_y: f64,
542        /// Effective opacity (node opacity × cascaded ctx opacity), `0.0..=1.0`.
543        opacity: f64,
544        /// Optional non-rectangular clip shape inscribed in the box. `None` =
545        /// the default rectangular box-clip (existing behavior, unchanged).
546        #[serde(default, skip_serializing_if = "Option::is_none")]
547        clip_shape: Option<ImageClip>,
548        /// Optional source sub-rectangle selecting a crop of the source image
549        /// before the fit/object-position math is applied. `None` = use the
550        /// full source image (byte-identical to scenes without `src_rect`).
551        /// Applies to raster assets only; ignored for SVG.
552        #[serde(default, skip_serializing_if = "Option::is_none")]
553        src_rect: Option<SrcRect>,
554    },
555    /// Draw a pre-resolved SVG asset.
556    DrawSvgAsset {
557        x: f64,
558        y: f64,
559        w: f64,
560        h: f64,
561        /// Asset path (project-relative).
562        asset: String,
563    },
564    // ── Text ──────────────────────────────────────────────────────────────
565    /// Draw a shaped, positioned glyph run.
566    ///
567    /// `x` is the text-box origin x in pixels; `y` is the baseline y in
568    /// pixels (`text_box_top + ascent`).  The renderer re-resolves font bytes
569    /// via `FontProvider::by_id` using only the `font_id` string — no raw
570    /// font bytes appear in the IR.
571    DrawGlyphRun {
572        /// Text-box origin x in pixels.
573        x: f64,
574        /// Baseline y in pixels (`text_box_top + ascent`).
575        y: f64,
576        /// Stable font-face identifier; renderer resolves bytes via
577        /// `FontProvider::by_id`.
578        font_id: String,
579        /// Font size at which glyphs were shaped, in pixels.
580        font_size: f32,
581        /// Fill color of the glyph run.
582        color: Color,
583        /// Optional stroke (outline) color applied after the fill.
584        /// `None` means no outline — byte-identical to a run without stroke.
585        #[serde(default, skip_serializing_if = "Option::is_none")]
586        stroke_color: Option<Color>,
587        /// Stroke width in pixels. Ignored (and serialized as absent) when
588        /// `stroke_color` is `None` or `stroke_width` is `<= 0`.
589        #[serde(default, skip_serializing_if = "Option::is_none")]
590        stroke_width: Option<f64>,
591        /// Optional hyperlink URL for this run. When set and the run is
592        /// `selectable`, the PDF backend emits a clickable Link annotation over
593        /// the run's bounds. `None` = no link — byte-identical to a run without
594        /// one. The raster backend ignores it (no clickable concept).
595        #[serde(default, skip_serializing_if = "Option::is_none")]
596        link: Option<String>,
597        /// Whether this run's text is selectable / searchable / indexable in the
598        /// PDF backend. `true` (default) → real embedded text + ToUnicode;
599        /// `false` → filled glyph outlines (visually identical, not extractable).
600        /// The raster backend ignores it. Serialized only when `false`, so
601        /// default runs stay byte-identical.
602        #[serde(skip_serializing_if = "is_selectable")]
603        selectable: bool,
604        /// Positioned glyphs, baseline-relative.
605        glyphs: Vec<SceneGlyph>,
606    },
607    // ── Clip / layer stack ────────────────────────────────────────────────
608    /// Push an axis-aligned clip rectangle onto the clip stack.
609    PushClip { x: f64, y: f64, w: f64, h: f64 },
610    /// Pop the most-recently pushed clip rectangle.
611    PopClip,
612    /// Push a compositing layer (for opacity, blend, mask).
613    ///
614    /// `opacity` is the layer alpha applied when the layer is composited back
615    /// onto its parent. `blend_mode` selects the compositing operator used for
616    /// that composite; `None` (and `Some(BlendMode::Normal)`) mean plain
617    /// source-over and serialize identically to a layer with no blend.
618    PushLayer {
619        opacity: f64,
620        #[serde(default, skip_serializing_if = "Option::is_none")]
621        blend_mode: Option<BlendMode>,
622    },
623    /// Pop the most-recently pushed compositing layer.
624    PopLayer,
625    /// Push an affine rotation around a pivot; composes onto the renderer's transform stack.
626    PushTransform { angle_deg: f64, cx: f64, cy: f64 },
627    /// Pop the most recent pushed transform.
628    PopTransform,
629    // ── Shadow capture ────────────────────────────────────────────────────
630    /// Open an isolated capture of the following draw commands. The captured
631    /// ink is buffered offscreen until the matching [`SceneCommand::EndShadow`].
632    ///
633    /// `shadows` are painted in *reverse* order at `EndShadow` (so the
634    /// first-declared layer ends up on top of later layers), all *behind* the
635    /// crisp ink.
636    BeginShadow { shadows: Vec<ShadowSpec> },
637    /// Close the active shadow capture: paint the blurred shadow layers, then
638    /// composite the captured ink on top.
639    EndShadow,
640    // ── Gaussian blur capture ─────────────────────────────────────────────
641    /// Open an offscreen capture of the following draw commands and apply a
642    /// Gaussian blur with `radius` (sigma in pixels) to the captured ink at
643    /// [`SceneCommand::EndBlur`]. `radius == 0` is a no-op (no capture opened).
644    BeginBlur { radius: f64 },
645    /// Close the active blur capture: blur the captured ink in place, then
646    /// composite it onto the current target.
647    EndBlur,
648    // ── Color filter capture ──────────────────────────────────────────────
649    /// Open an offscreen capture; apply `filters` in order to the captured ink
650    /// at the matching EndFilter, then composite back. Empty `filters` opens no capture.
651    BeginFilter { filters: Vec<FilterSpec> },
652    /// Close the active filter capture: transform the captured ink in place, composite onto the target.
653    EndFilter,
654    // ── Soft-mask capture ─────────────────────────────────────────────────
655    /// Open an offscreen capture of the following draw commands; at the
656    /// matching [`SceneCommand::EndMask`] the captured ink is composited back
657    /// through the feathered coverage described by `mask`.
658    BeginMask { mask: MaskSpec },
659    /// Close the active mask capture: composite the captured ink through the
660    /// mask coverage onto the current target.
661    EndMask,
662}
663
664/// Serde skip predicate for `DrawGlyphRun::selectable`: omit the default `true`.
665fn is_selectable(selectable: &bool) -> bool {
666    *selectable
667}
668
669// ── Scene glyph ───────────────────────────────────────────────────────────────
670
671/// A single positioned glyph within a [`SceneCommand::DrawGlyphRun`].
672///
673/// Offsets `dx` and `dy` are pen offsets from the run origin, baseline-relative.
674/// Positive `dx` is rightward; positive `dy` is downward (0 = on the baseline).
675/// No font bytes appear here — only the glyph ID within the resolved font face.
676#[derive(Debug, Clone, PartialEq, Serialize)]
677pub struct SceneGlyph {
678    /// Glyph identifier within the resolved font face.
679    pub glyph_id: u16,
680    /// Horizontal pen offset from the run origin, in pixels.
681    pub dx: f32,
682    /// Vertical offset from the baseline, in pixels (positive = below baseline).
683    pub dy: f32,
684    /// Source Unicode text this glyph maps back to, for text extraction
685    /// (PDF ToUnicode CMap). Empty for the trailing glyphs of a multi-glyph
686    /// cluster and for runs that carry no source mapping. Serialized only when
687    /// non-empty, so scenes without it stay byte-identical.
688    #[serde(default, skip_serializing_if = "String::is_empty")]
689    pub text: String,
690}
691
692// ── Trim rect ───────────────────────────────────────────────────────────────
693
694/// An axis-aligned rectangle in scene (top-left origin, y-down) coordinates,
695/// in pixels.
696///
697/// Used to carry the print **trim box** on a [`Scene`] when a page declares a
698/// positive `bleed` margin. The scene canvas (`width`/`height`) is the full
699/// media box *including* the bleed; the trim rect is the inner rectangle the
700/// finished piece is cut down to.
701#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
702pub struct Rect {
703    /// Left edge in pixels (scene coordinates).
704    pub x: f64,
705    /// Top edge in pixels (scene coordinates).
706    pub y: f64,
707    /// Width in pixels.
708    pub w: f64,
709    /// Height in pixels.
710    pub h: f64,
711}
712
713// ── Scene ─────────────────────────────────────────────────────────────────────
714
715/// A fully resolved, backend-neutral display list.
716///
717/// The `schema` field is always `"zenith-scene-v1"` and is declared first so
718/// that it serializes as the first key in the JSON output, satisfying the
719/// normative requirement from the format spec.
720#[derive(Debug, Clone, PartialEq, Serialize)]
721pub struct Scene {
722    /// Always `"zenith-scene-v1"`.  Declared first so it appears first in JSON.
723    pub schema: &'static str,
724    /// Page / canvas width in pixels.
725    pub width: f64,
726    /// Page / canvas height in pixels.
727    pub height: f64,
728    /// Ordered display list.  Paint order: index 0 is painted first (bottom).
729    pub commands: Vec<SceneCommand>,
730    /// Print **trim box** in scene (top-left origin, y-down) pixel coordinates.
731    ///
732    /// `Some` only when the page declared a positive `bleed` margin: then
733    /// `width`/`height` are the full media box (including bleed) and `trim` is
734    /// the inner page rectangle `[b, b, page_w, page_h]`. `None` when there is
735    /// no bleed (trim == media box). Skipped in JSON when absent so existing
736    /// non-bleed scenes serialize byte-identically.
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub trim: Option<Rect>,
739}
740
741impl Scene {
742    /// Construct an empty scene for the given page dimensions.
743    ///
744    /// `schema` is always set to `"zenith-scene-v1"`.
745    pub fn new(width: f64, height: f64) -> Self {
746        Self {
747            schema: "zenith-scene-v1",
748            width,
749            height,
750            commands: Vec::new(),
751            trim: None,
752        }
753    }
754
755    /// Serialize this scene to a pretty-printed JSON string.
756    ///
757    /// Uses `serde_json::to_string_pretty` which produces deterministic output
758    /// because `Scene` and its fields use only `Vec` (ordered) and `struct`
759    /// (stable field order in Rust + serde), never `HashMap`.
760    ///
761    /// # Errors
762    ///
763    /// Returns an error only if serialization fails, which cannot happen for
764    /// the types used in `Scene` (all fields are plain numerics, strings, and
765    /// `u8`s).  The `Result` is kept for API robustness.
766    pub fn to_json(&self) -> Result<String, serde_json::Error> {
767        serde_json::to_string_pretty(self)
768    }
769}
770
771// ── Tests ─────────────────────────────────────────────────────────────────────
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    #[test]
778    fn scene_new_sets_schema() {
779        let s = Scene::new(800.0, 600.0);
780        assert_eq!(s.schema, "zenith-scene-v1");
781        assert_eq!(s.width, 800.0);
782        assert_eq!(s.height, 600.0);
783        assert!(s.commands.is_empty());
784    }
785
786    #[test]
787    fn to_json_schema_is_first_key() {
788        let s = Scene::new(100.0, 200.0);
789        let json = s.to_json().expect("serialization must succeed");
790        // The very first `"` after `{` must be `"schema"`.
791        let trimmed = json.trim_start_matches('{').trim_start();
792        assert!(
793            trimmed.starts_with(r#""schema""#),
794            "schema must be the first JSON key; got: {trimmed}"
795        );
796    }
797
798    #[test]
799    fn to_json_deterministic() {
800        let mut s = Scene::new(640.0, 360.0);
801        s.commands.push(SceneCommand::FillRect {
802            x: 0.0,
803            y: 0.0,
804            w: 640.0,
805            h: 360.0,
806            paint: Paint::solid(Color::srgb(10, 20, 30, 255)),
807        });
808        let a = s.to_json().expect("first serialize");
809        let b = s.to_json().expect("second serialize");
810        assert_eq!(a, b, "serialization must be deterministic");
811    }
812
813    #[test]
814    fn fill_rect_serializes_op_tag() {
815        let cmd = SceneCommand::FillRect {
816            x: 1.0,
817            y: 2.0,
818            w: 3.0,
819            h: 4.0,
820            paint: Paint::solid(Color::srgb(255, 0, 0, 255)),
821        };
822        let json = serde_json::to_string(&cmd).expect("serialize");
823        assert!(
824            json.contains(r#""op":"FillRect""#),
825            "op tag must be FillRect; got: {json}"
826        );
827    }
828
829    #[test]
830    fn srgb_color_omits_cmyk_in_json() {
831        let cmd = SceneCommand::FillRect {
832            x: 0.0,
833            y: 0.0,
834            w: 1.0,
835            h: 1.0,
836            paint: Paint::solid(Color::srgb(1, 2, 3, 255)),
837        };
838        let json = serde_json::to_string(&cmd).expect("serialize");
839        assert!(
840            !json.contains("cmyk"),
841            "sRGB-origin color must not serialize a cmyk key; got: {json}"
842        );
843    }
844
845    #[test]
846    fn cmyk_color_carries_channels_and_serializes() {
847        // cmyk(59,85,0,7) → #6124ed (97,36,237).
848        let c = Color::cmyk(59.0, 85.0, 0.0, 7.0, 97, 36, 237);
849        assert_eq!((c.r, c.g, c.b, c.a), (97, 36, 237, 255));
850        assert_eq!(c.cmyk, Some([59.0, 85.0, 0.0, 7.0]));
851        let json = serde_json::to_string(&c).expect("serialize");
852        assert!(
853            json.contains(r#""cmyk":[59.0,85.0,0.0,7.0]"#),
854            "got: {json}"
855        );
856    }
857}