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}