use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LineCap {
Butt,
Round,
Square,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum StrokeAlign {
#[default]
Center,
Inside,
Outside,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum BlendMode {
Normal,
Multiply,
Screen,
Overlay,
Darken,
Lighten,
ColorDodge,
ColorBurn,
HardLight,
SoftLight,
Difference,
Exclusion,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cmyk: Option<[f32; 4]>,
}
impl Color {
pub const fn srgb(r: u8, g: u8, b: u8, a: u8) -> Self {
Self {
r,
g,
b,
a,
cmyk: None,
}
}
pub const fn cmyk(c: f32, m: f32, y: f32, k: f32, r: u8, g: u8, b: u8) -> Self {
Self {
r,
g,
b,
a: 255,
cmyk: Some([c, m, y, k]),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GradientStop {
pub offset: f64,
pub color: Color,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GradientPaint {
pub angle_deg: f64,
pub stops: Vec<GradientStop>,
#[serde(default, skip_serializing_if = "is_false")]
pub radial: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub center_x: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub center_y: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub radius_frac: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Paint {
Solid {
color: Color,
},
Gradient(GradientPaint),
}
impl Paint {
pub fn solid(color: Color) -> Self {
Paint::Solid { color }
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ShadowSpec {
pub dx: f64,
pub dy: f64,
pub blur: f64,
pub color: Color,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub enum FilterSpec {
Grayscale(f64),
Invert(f64),
Sepia(f64),
Saturate(f64),
Brightness(f64),
Contrast(f64),
HueRotate(f64),
Duotone {
amount: f64,
shadow: Color,
highlight: Color,
},
Noise {
amount: f64,
seed: i64,
scale: f64,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub enum MaskShape {
Rect,
RoundedRect,
Ellipse,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct MaskSpec {
pub shape: MaskShape,
pub radius: f64,
pub feather: f64,
pub invert: bool,
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FitMode {
Contain,
Cover,
Stretch,
None,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SrcRect {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "shape")]
pub enum ImageClip {
Ellipse,
RoundedRect { radius: f64 },
}
fn is_center(a: &StrokeAlign) -> bool {
matches!(a, StrokeAlign::Center)
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "op")]
pub enum SceneCommand {
FillRect {
x: f64,
y: f64,
w: f64,
h: f64,
paint: Paint,
},
StrokeRect {
x: f64,
y: f64,
w: f64,
h: f64,
color: Color,
stroke_width: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_dash: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_gap: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_linecap: Option<LineCap>,
},
FillRoundedRect {
x: f64,
y: f64,
w: f64,
h: f64,
radius: f64,
paint: Paint,
#[serde(default, skip_serializing_if = "Option::is_none")]
radii: Option<[f64; 4]>,
},
StrokeRoundedRect {
x: f64,
y: f64,
w: f64,
h: f64,
radius: f64,
color: Color,
stroke_width: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_dash: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_gap: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_linecap: Option<LineCap>,
#[serde(default, skip_serializing_if = "Option::is_none")]
radii: Option<[f64; 4]>,
},
FillEllipse {
x: f64,
y: f64,
w: f64,
h: f64,
paint: Paint,
#[serde(default, skip_serializing_if = "Option::is_none")]
rx: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ry: Option<f64>,
},
StrokeEllipse {
x: f64,
y: f64,
w: f64,
h: f64,
color: Color,
stroke_width: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_dash: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_gap: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_linecap: Option<LineCap>,
#[serde(default, skip_serializing_if = "Option::is_none")]
rx: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
ry: Option<f64>,
},
StrokeLine {
x1: f64,
y1: f64,
x2: f64,
y2: f64,
color: Color,
stroke_width: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_dash: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_gap: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_linecap: Option<LineCap>,
},
FillPolygon {
points: Vec<f64>,
paint: Paint,
#[serde(default)]
even_odd: bool,
},
StrokePolyline {
points: Vec<f64>,
color: Color,
stroke_width: f64,
#[serde(default)]
closed: bool,
#[serde(default, skip_serializing_if = "is_center")]
align: StrokeAlign,
#[serde(default, skip_serializing_if = "is_false")]
fill_even_odd: bool,
},
DrawImage {
x: f64,
y: f64,
w: f64,
h: f64,
asset_id: String,
fit: FitMode,
pos_x: f64,
pos_y: f64,
opacity: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
clip_shape: Option<ImageClip>,
#[serde(default, skip_serializing_if = "Option::is_none")]
src_rect: Option<SrcRect>,
},
DrawSvgAsset {
x: f64,
y: f64,
w: f64,
h: f64,
asset: String,
},
DrawGlyphRun {
x: f64,
y: f64,
font_id: String,
font_size: f32,
color: Color,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_color: Option<Color>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stroke_width: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
link: Option<String>,
#[serde(skip_serializing_if = "is_selectable")]
selectable: bool,
glyphs: Vec<SceneGlyph>,
},
PushClip { x: f64, y: f64, w: f64, h: f64 },
PopClip,
PushLayer {
opacity: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
blend_mode: Option<BlendMode>,
},
PopLayer,
PushTransform { angle_deg: f64, cx: f64, cy: f64 },
PopTransform,
BeginShadow { shadows: Vec<ShadowSpec> },
EndShadow,
BeginBlur { radius: f64 },
EndBlur,
BeginFilter { filters: Vec<FilterSpec> },
EndFilter,
BeginMask { mask: MaskSpec },
EndMask,
}
fn is_selectable(selectable: &bool) -> bool {
*selectable
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SceneGlyph {
pub glyph_id: u16,
pub dx: f32,
pub dy: f32,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct Rect {
pub x: f64,
pub y: f64,
pub w: f64,
pub h: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Scene {
pub schema: &'static str,
pub width: f64,
pub height: f64,
pub commands: Vec<SceneCommand>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trim: Option<Rect>,
}
impl Scene {
pub fn new(width: f64, height: f64) -> Self {
Self {
schema: "zenith-scene-v1",
width,
height,
commands: Vec::new(),
trim: None,
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scene_new_sets_schema() {
let s = Scene::new(800.0, 600.0);
assert_eq!(s.schema, "zenith-scene-v1");
assert_eq!(s.width, 800.0);
assert_eq!(s.height, 600.0);
assert!(s.commands.is_empty());
}
#[test]
fn to_json_schema_is_first_key() {
let s = Scene::new(100.0, 200.0);
let json = s.to_json().expect("serialization must succeed");
let trimmed = json.trim_start_matches('{').trim_start();
assert!(
trimmed.starts_with(r#""schema""#),
"schema must be the first JSON key; got: {trimmed}"
);
}
#[test]
fn to_json_deterministic() {
let mut s = Scene::new(640.0, 360.0);
s.commands.push(SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 640.0,
h: 360.0,
paint: Paint::solid(Color::srgb(10, 20, 30, 255)),
});
let a = s.to_json().expect("first serialize");
let b = s.to_json().expect("second serialize");
assert_eq!(a, b, "serialization must be deterministic");
}
#[test]
fn fill_rect_serializes_op_tag() {
let cmd = SceneCommand::FillRect {
x: 1.0,
y: 2.0,
w: 3.0,
h: 4.0,
paint: Paint::solid(Color::srgb(255, 0, 0, 255)),
};
let json = serde_json::to_string(&cmd).expect("serialize");
assert!(
json.contains(r#""op":"FillRect""#),
"op tag must be FillRect; got: {json}"
);
}
#[test]
fn srgb_color_omits_cmyk_in_json() {
let cmd = SceneCommand::FillRect {
x: 0.0,
y: 0.0,
w: 1.0,
h: 1.0,
paint: Paint::solid(Color::srgb(1, 2, 3, 255)),
};
let json = serde_json::to_string(&cmd).expect("serialize");
assert!(
!json.contains("cmyk"),
"sRGB-origin color must not serialize a cmyk key; got: {json}"
);
}
#[test]
fn cmyk_color_carries_channels_and_serializes() {
let c = Color::cmyk(59.0, 85.0, 0.0, 7.0, 97, 36, 237);
assert_eq!((c.r, c.g, c.b, c.a), (97, 36, 237, 255));
assert_eq!(c.cmyk, Some([59.0, 85.0, 0.0, 7.0]));
let json = serde_json::to_string(&c).expect("serialize");
assert!(
json.contains(r#""cmyk":[59.0,85.0,0.0,7.0]"#),
"got: {json}"
);
}
}