oxideav_core/vector.rs
1//! Vector graphics frame and primitive types.
2//!
3//! This module models a resolution-independent, scene-graph-style vector
4//! frame so the same [`VectorFrame`] can round-trip through both SVG 1.1
5//! and PDF 1.4 without lossy conversion. The primitive set is the
6//! intersection of what those two formats represent natively:
7//!
8//! * paths built from move / line / quadratic / cubic / elliptic-arc / close
9//! commands,
10//! * solid + linear-gradient + radial-gradient paints,
11//! * stroke style (width, cap, join, miter limit, dash),
12//! * even-odd / non-zero fill rules,
13//! * 2D affine transforms,
14//! * group nodes (transform, opacity, optional clip),
15//! * embedded raster passthrough via [`ImageRef`] (carries a child
16//! [`VideoFrame`](crate::VideoFrame) — the rasterizer paints the image
17//! into vector space).
18//!
19//! Text nodes are intentionally **deferred to round 2** — text needs
20//! font handling and tight scribe coupling that will land alongside the
21//! `oxideav-svg` parser (#349). Round 1 is shape-only.
22//!
23//! No rasterizer / SVG parser / PDF writer lives in `oxideav-core`; those
24//! are downstream tasks (#349 / #350 / #351). This module ships only the
25//! data types every consumer of the vector pipeline needs to agree on.
26
27use crate::time::TimeBase;
28
29/// A decoded vector-graphics frame.
30///
31/// The `width` / `height` define the natural rendering canvas size in
32/// user units. `view_box` lets a producer separate the user-coordinate
33/// system from the canvas (an SVG `viewBox` attribute, or the PDF
34/// `MediaBox` vs. `CropBox`); when `None`, callers should treat it as
35/// `(0, 0, width, height)`.
36#[derive(Clone, Debug)]
37pub struct VectorFrame {
38 /// Viewport width in user units.
39 pub width: f32,
40 /// Viewport height in user units.
41 pub height: f32,
42 /// Optional view box. `None` defaults to `(0, 0, width, height)`.
43 pub view_box: Option<ViewBox>,
44 /// Root group of the scene.
45 pub root: Group,
46 /// Presentation timestamp in `time_base` units, or `None` if unknown.
47 pub pts: Option<i64>,
48 /// Time base for `pts`. Consumers that don't care about timing
49 /// (e.g. a one-shot SVG render) can use `TimeBase::new(1, 1)`.
50 pub time_base: TimeBase,
51}
52
53/// User-coordinate system rectangle. Mirrors the SVG `viewBox` attribute
54/// and the PDF `MediaBox` / `CropBox` rectangles.
55#[derive(Clone, Copy, Debug, PartialEq)]
56pub struct ViewBox {
57 pub min_x: f32,
58 pub min_y: f32,
59 pub width: f32,
60 pub height: f32,
61}
62
63/// One node in the scene tree.
64///
65/// Marked `#[non_exhaustive]` so future variants (text, filters) can
66/// be added without breaking downstream `match` arms.
67#[derive(Clone, Debug)]
68#[non_exhaustive]
69pub enum Node {
70 Path(PathNode),
71 Group(Group),
72 /// An embedded raster image painted into vector space.
73 Image(ImageRef),
74 /// A soft-mask composite. The `mask` subtree is rasterised and
75 /// converted to a per-pixel alpha multiplier (luminance or alpha,
76 /// per [`MaskKind`]), then applied to the rasterised `content`
77 /// subtree. Mirrors SVG `<mask>` and PDF `SMask` (subtype `Luminosity`
78 /// vs. `Alpha`).
79 SoftMask {
80 /// Subtree rasterised to produce the per-pixel opacity
81 /// modulator.
82 mask: Box<Node>,
83 /// How to convert the rasterised mask to a coverage value.
84 mask_kind: MaskKind,
85 /// Subtree whose pixels are modulated by the mask.
86 content: Box<Node>,
87 },
88}
89
90/// How to interpret a soft mask's rasterised pixels as a coverage
91/// modulator.
92#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
93pub enum MaskKind {
94 /// Convert the mask's RGB to luminance (ITU-R BT.709 coefficients
95 /// — Y = 0.2126·R + 0.7152·G + 0.0722·B) and use Y as the
96 /// per-pixel alpha multiplier. Matches SVG `<mask>` default
97 /// (`mask-type="luminance"`) and PDF `SMask` `/Luminosity`.
98 #[default]
99 Luminance,
100 /// Use the mask's own alpha channel as the multiplier. Matches
101 /// SVG `<mask mask-type="alpha">` and PDF `SMask` `/Alpha`.
102 Alpha,
103}
104
105/// A grouping node — applies a transform / opacity / optional clip path
106/// to all descendants. Mirrors SVG `<g>` and PDF `q ... Q` graphic-state
107/// blocks.
108#[derive(Clone, Debug)]
109pub struct Group {
110 /// Coordinate transform applied to children. Identity by default.
111 pub transform: Transform2D,
112 /// Group opacity in `0.0..=1.0`. `1.0` is fully opaque.
113 pub opacity: f32,
114 /// Optional clip path. Children are clipped to this path's interior
115 /// (using the path's own fill rule). `None` means "no clip".
116 pub clip: Option<Path>,
117 pub children: Vec<Node>,
118 /// Opaque cache key. When `Some(k)`, a downstream rasterizer is free
119 /// to memoise the rendered bitmap of this group's content (after
120 /// `transform` is applied) under key `k`, so re-rendering the same
121 /// group at the same effective resolution returns the cached bitmap.
122 ///
123 /// Producers that emit cacheable content (e.g. scribe shaping a
124 /// glyph at `(face_id, glyph_id, size_q8, subpixel_x)`) compute a
125 /// deterministic hash of their identity tuple and put it here. The
126 /// rasterizer treats it as a black box — `oxideav-core` never
127 /// inspects the value, so each producer's namespace stays private.
128 ///
129 /// `None` (the default) means "do not cache; render fresh every
130 /// time". Most synthesised vector content (a one-off rectangle, a
131 /// gradient panel) leaves this `None`.
132 pub cache_key: Option<u64>,
133}
134
135impl Default for Group {
136 fn default() -> Self {
137 Self {
138 transform: Transform2D::identity(),
139 opacity: 1.0,
140 clip: None,
141 children: Vec::new(),
142 cache_key: None,
143 }
144 }
145}
146
147/// A drawn path with optional fill and stroke.
148///
149/// SVG `<path>` and PDF path-painting operators (`f`, `S`, `B`, `f*`,
150/// `B*`) both express "one path, optional fill, optional stroke", so a
151/// single struct covers both formats. At least one of `fill` / `stroke`
152/// would normally be `Some` to produce visible output.
153#[derive(Clone, Debug)]
154pub struct PathNode {
155 pub path: Path,
156 pub fill: Option<Paint>,
157 pub stroke: Option<Stroke>,
158 pub fill_rule: FillRule,
159}
160
161/// A geometric path expressed as a sequence of drawing commands.
162///
163/// All coordinates are in the local user space of the enclosing group.
164#[derive(Clone, Debug, Default)]
165pub struct Path {
166 pub commands: Vec<PathCommand>,
167}
168
169impl Path {
170 pub fn new() -> Self {
171 Self::default()
172 }
173
174 pub fn move_to(&mut self, p: Point) -> &mut Self {
175 self.commands.push(PathCommand::MoveTo(p));
176 self
177 }
178
179 pub fn line_to(&mut self, p: Point) -> &mut Self {
180 self.commands.push(PathCommand::LineTo(p));
181 self
182 }
183
184 pub fn quad_to(&mut self, control: Point, end: Point) -> &mut Self {
185 self.commands
186 .push(PathCommand::QuadCurveTo { control, end });
187 self
188 }
189
190 pub fn cubic_to(&mut self, c1: Point, c2: Point, end: Point) -> &mut Self {
191 self.commands
192 .push(PathCommand::CubicCurveTo { c1, c2, end });
193 self
194 }
195
196 pub fn close(&mut self) -> &mut Self {
197 self.commands.push(PathCommand::Close);
198 self
199 }
200}
201
202/// A single path-construction command.
203///
204/// Marked `#[non_exhaustive]` so smooth-curve / Bezier-shorthand
205/// variants can be added later without breaking match arms.
206///
207/// Note on `ArcTo`: SVG and PDF both accept elliptic-arc segments in
208/// their path syntax (SVG `A` command, PDF via cubic approximation in
209/// the writer). We keep the variant in the round-1 IR — converting an
210/// arc to its spec-correct cubic-Bezier flattening is a pure function
211/// of the arc parameters that downstream rasterizers / writers can do
212/// independently.
213#[derive(Clone, Copy, Debug, PartialEq)]
214#[non_exhaustive]
215pub enum PathCommand {
216 MoveTo(Point),
217 LineTo(Point),
218 QuadCurveTo {
219 control: Point,
220 end: Point,
221 },
222 CubicCurveTo {
223 c1: Point,
224 c2: Point,
225 end: Point,
226 },
227 /// SVG `A`-style elliptic arc segment. `x_axis_rot` is in radians
228 /// (consistent with `Transform2D::rotate`); `large_arc` / `sweep`
229 /// match the SVG flag semantics.
230 ArcTo {
231 rx: f32,
232 ry: f32,
233 x_axis_rot: f32,
234 large_arc: bool,
235 sweep: bool,
236 end: Point,
237 },
238 Close,
239}
240
241/// 2D point in user-space coordinates.
242#[derive(Clone, Copy, Debug, Default, PartialEq)]
243pub struct Point {
244 pub x: f32,
245 pub y: f32,
246}
247
248impl Point {
249 pub const fn new(x: f32, y: f32) -> Self {
250 Self { x, y }
251 }
252}
253
254/// A paint server — what fills the inside of a path or strokes its
255/// outline. The variant set is the SVG/PDF intersection.
256#[derive(Clone, Debug)]
257#[non_exhaustive]
258pub enum Paint {
259 Solid(Rgba),
260 LinearGradient(LinearGradient),
261 RadialGradient(RadialGradient),
262}
263
264/// 32-bit straight (non-premultiplied) RGBA color.
265///
266/// Matches SVG's `rgb()` + `opacity` model and PDF's `RGB` + `CA`/`ca`
267/// graphic-state model. Premultiplication is a rasterizer concern; this
268/// IR carries straight alpha to avoid lossy round-trips.
269#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
270pub struct Rgba {
271 pub r: u8,
272 pub g: u8,
273 pub b: u8,
274 pub a: u8,
275}
276
277impl Rgba {
278 pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self {
279 Self { r, g, b, a }
280 }
281
282 /// Fully-opaque color with the given RGB triple.
283 pub const fn opaque(r: u8, g: u8, b: u8) -> Self {
284 Self { r, g, b, a: 255 }
285 }
286}
287
288/// A linear gradient: color stops sweep along the line `start` → `end`.
289#[derive(Clone, Debug)]
290pub struct LinearGradient {
291 pub start: Point,
292 pub end: Point,
293 pub stops: Vec<GradientStop>,
294 pub spread: SpreadMethod,
295}
296
297/// A radial gradient: color stops sweep from `focal` outward to a
298/// circle of radius `radius` centered on `center`. When `focal` is
299/// `None`, it defaults to `center` (the common case).
300#[derive(Clone, Debug)]
301pub struct RadialGradient {
302 pub center: Point,
303 pub radius: f32,
304 pub focal: Option<Point>,
305 pub stops: Vec<GradientStop>,
306 pub spread: SpreadMethod,
307}
308
309/// One color stop along a gradient. `offset` is in `0.0..=1.0`.
310#[derive(Clone, Copy, Debug, PartialEq)]
311pub struct GradientStop {
312 /// Position of the stop along the gradient axis. `0.0` is the
313 /// start, `1.0` is the end.
314 pub offset: f32,
315 pub color: Rgba,
316}
317
318impl GradientStop {
319 pub const fn new(offset: f32, color: Rgba) -> Self {
320 Self { offset, color }
321 }
322}
323
324/// What happens past the gradient endpoints. Mirrors SVG
325/// `spreadMethod="pad|reflect|repeat"` and PDF gradient `Extend` arrays.
326#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
327pub enum SpreadMethod {
328 /// Final stop colors extend forever. SVG default.
329 #[default]
330 Pad,
331 /// Gradient mirrors at each boundary.
332 Reflect,
333 /// Gradient repeats periodically.
334 Repeat,
335}
336
337/// Stroke style for a path's outline.
338#[derive(Clone, Debug)]
339pub struct Stroke {
340 pub width: f32,
341 pub paint: Paint,
342 pub cap: LineCap,
343 pub join: LineJoin,
344 /// Miter limit ratio. SVG / PDF default is `4.0`.
345 pub miter_limit: f32,
346 pub dash: Option<DashPattern>,
347}
348
349impl Stroke {
350 /// Build a default solid-paint stroke with width `width`.
351 pub fn solid(width: f32, color: Rgba) -> Self {
352 Self {
353 width,
354 paint: Paint::Solid(color),
355 cap: LineCap::Butt,
356 join: LineJoin::Miter,
357 miter_limit: 4.0,
358 dash: None,
359 }
360 }
361}
362
363/// How an open path's endpoints are drawn.
364#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
365pub enum LineCap {
366 #[default]
367 Butt,
368 Round,
369 Square,
370}
371
372/// How two stroke segments meet at a corner.
373#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
374pub enum LineJoin {
375 #[default]
376 Miter,
377 Round,
378 Bevel,
379}
380
381/// Dash pattern for a stroke. `array` is an alternating
382/// dash-on / dash-off length list (in user units); `offset` is the
383/// phase offset from the path start.
384#[derive(Clone, Debug, Default)]
385pub struct DashPattern {
386 pub array: Vec<f32>,
387 pub offset: f32,
388}
389
390/// Fill rule for self-intersecting and compound paths. Matches SVG's
391/// `fill-rule` attribute and PDF's `f` (non-zero) vs. `f*` (even-odd)
392/// painting operators.
393#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
394pub enum FillRule {
395 #[default]
396 NonZero,
397 EvenOdd,
398}
399
400/// A 2D affine transform stored as the column-major matrix
401///
402/// ```text
403/// | a c e | | x |
404/// | b d f | * | y |
405/// | 0 0 1 | | 1 |
406/// ```
407///
408/// — i.e. `(x', y') = (a*x + c*y + e, b*x + d*y + f)`. The layout
409/// matches SVG's `matrix(a, b, c, d, e, f)` and PDF's `cm` operator
410/// argument order, so emitters can serialize fields directly.
411#[derive(Clone, Copy, Debug, PartialEq)]
412pub struct Transform2D {
413 pub a: f32,
414 pub b: f32,
415 pub c: f32,
416 pub d: f32,
417 pub e: f32,
418 pub f: f32,
419}
420
421impl Transform2D {
422 /// The identity transform. `compose(identity, x) == x`.
423 pub const fn identity() -> Self {
424 Self {
425 a: 1.0,
426 b: 0.0,
427 c: 0.0,
428 d: 1.0,
429 e: 0.0,
430 f: 0.0,
431 }
432 }
433
434 /// Build a translation by `(tx, ty)`.
435 pub const fn translate(tx: f32, ty: f32) -> Self {
436 Self {
437 a: 1.0,
438 b: 0.0,
439 c: 0.0,
440 d: 1.0,
441 e: tx,
442 f: ty,
443 }
444 }
445
446 /// Build a non-uniform scale by `(sx, sy)` about the origin.
447 pub const fn scale(sx: f32, sy: f32) -> Self {
448 Self {
449 a: sx,
450 b: 0.0,
451 c: 0.0,
452 d: sy,
453 e: 0.0,
454 f: 0.0,
455 }
456 }
457
458 /// Build a rotation by `angle_radians` about the origin
459 /// (counter-clockwise in a Y-up system, clockwise visually under
460 /// the SVG / PDF Y-down convention — this matches both formats).
461 pub fn rotate(angle_radians: f32) -> Self {
462 let (s, c) = angle_radians.sin_cos();
463 Self {
464 a: c,
465 b: s,
466 c: -s,
467 d: c,
468 e: 0.0,
469 f: 0.0,
470 }
471 }
472
473 /// Build a horizontal skew (shear along X) by `angle_radians`.
474 pub fn skew_x(angle_radians: f32) -> Self {
475 Self {
476 a: 1.0,
477 b: 0.0,
478 c: angle_radians.tan(),
479 d: 1.0,
480 e: 0.0,
481 f: 0.0,
482 }
483 }
484
485 /// Build a vertical skew (shear along Y) by `angle_radians`.
486 pub fn skew_y(angle_radians: f32) -> Self {
487 Self {
488 a: 1.0,
489 b: angle_radians.tan(),
490 c: 0.0,
491 d: 1.0,
492 e: 0.0,
493 f: 0.0,
494 }
495 }
496
497 /// Compose `self ∘ other` — the resulting transform applies
498 /// `other` first, then `self`, to a point. Equivalent to
499 /// `self.matrix() * other.matrix()` in column-vector form.
500 pub fn compose(&self, other: &Self) -> Self {
501 Self {
502 a: self.a * other.a + self.c * other.b,
503 b: self.b * other.a + self.d * other.b,
504 c: self.a * other.c + self.c * other.d,
505 d: self.b * other.c + self.d * other.d,
506 e: self.a * other.e + self.c * other.f + self.e,
507 f: self.b * other.e + self.d * other.f + self.f,
508 }
509 }
510
511 /// Apply this transform to a point.
512 pub fn apply(&self, p: Point) -> Point {
513 Point {
514 x: self.a * p.x + self.c * p.y + self.e,
515 y: self.b * p.x + self.d * p.y + self.f,
516 }
517 }
518
519 /// `true` when this transform is bit-identical to the identity.
520 /// Useful for emitters that want to skip a no-op `matrix(...)` /
521 /// `cm` write.
522 pub fn is_identity(&self) -> bool {
523 *self == Self::identity()
524 }
525}
526
527impl Default for Transform2D {
528 fn default() -> Self {
529 Self::identity()
530 }
531}
532
533/// An embedded raster image painted into vector space.
534///
535/// `bounds` is the axis-aligned rectangle (in the local user space,
536/// before `transform`) that the image is painted into; SVG `<image>`
537/// `x/y/width/height` and PDF `Do` with a matrix-pre-positioned
538/// `Image` XObject both reduce to this shape.
539#[derive(Clone, Debug)]
540pub struct ImageRef {
541 /// Embedded raster payload. Boxed so a `Node::Image` variant
542 /// doesn't bloat every other [`Node`] case.
543 pub frame: Box<crate::VideoFrame>,
544 pub bounds: Rect,
545 pub transform: Transform2D,
546}
547
548/// Axis-aligned rectangle in user-space coordinates.
549#[derive(Clone, Copy, Debug, Default, PartialEq)]
550pub struct Rect {
551 pub x: f32,
552 pub y: f32,
553 pub width: f32,
554 pub height: f32,
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560 use crate::time::TimeBase;
561
562 fn approx_point(a: Point, b: Point) -> bool {
563 (a.x - b.x).abs() < 1e-5 && (a.y - b.y).abs() < 1e-5
564 }
565
566 #[test]
567 fn path_builder_produces_command_sequence() {
568 let mut p = Path::new();
569 p.move_to(Point::new(0.0, 0.0))
570 .line_to(Point::new(10.0, 0.0))
571 .quad_to(Point::new(15.0, 5.0), Point::new(10.0, 10.0))
572 .cubic_to(
573 Point::new(5.0, 15.0),
574 Point::new(0.0, 10.0),
575 Point::new(0.0, 0.0),
576 )
577 .close();
578 assert_eq!(p.commands.len(), 5);
579 assert_eq!(p.commands[0], PathCommand::MoveTo(Point::new(0.0, 0.0)));
580 assert_eq!(p.commands[4], PathCommand::Close);
581 }
582
583 #[test]
584 fn transform_identity_round_trips() {
585 let id = Transform2D::identity();
586 assert!(id.is_identity());
587 let p = Point::new(3.5, -2.25);
588 assert_eq!(id.apply(p), p);
589 }
590
591 #[test]
592 fn transform_translate_round_trip() {
593 let t = Transform2D::translate(10.0, -5.0);
594 assert_eq!(t.apply(Point::new(0.0, 0.0)), Point::new(10.0, -5.0));
595 assert_eq!(t.apply(Point::new(1.0, 1.0)), Point::new(11.0, -4.0));
596 }
597
598 #[test]
599 fn transform_scale_round_trip() {
600 let s = Transform2D::scale(2.0, 3.0);
601 assert_eq!(s.apply(Point::new(1.0, 1.0)), Point::new(2.0, 3.0));
602 assert_eq!(s.apply(Point::new(0.0, 0.0)), Point::new(0.0, 0.0));
603 }
604
605 #[test]
606 fn transform_rotate_quarter_turn() {
607 let r = Transform2D::rotate(std::f32::consts::FRAC_PI_2);
608 // Under SVG/PDF Y-down with matrix(c,s,-s,c,0,0):
609 // (1, 0) rotates to (cos, sin) = (0, 1).
610 assert!(approx_point(
611 r.apply(Point::new(1.0, 0.0)),
612 Point::new(0.0, 1.0)
613 ));
614 // (0, 1) rotates to (-sin, cos) = (-1, 0).
615 assert!(approx_point(
616 r.apply(Point::new(0.0, 1.0)),
617 Point::new(-1.0, 0.0)
618 ));
619 }
620
621 #[test]
622 fn transform_compose_identity_is_left_and_right_unit() {
623 let t = Transform2D::translate(7.0, 11.0);
624 let id = Transform2D::identity();
625 assert_eq!(id.compose(&t), t);
626 assert_eq!(t.compose(&id), t);
627 }
628
629 #[test]
630 fn transform_compose_translate_then_scale() {
631 // Apply translate(2,3) first, then scale(10,10):
632 // p -> p + (2,3) -> 10*(p+(2,3)) = 10p + (20,30).
633 let scale = Transform2D::scale(10.0, 10.0);
634 let translate = Transform2D::translate(2.0, 3.0);
635 let composed = scale.compose(&translate);
636 let result = composed.apply(Point::new(1.0, 1.0));
637 assert!(approx_point(result, Point::new(30.0, 40.0)));
638 }
639
640 #[test]
641 fn transform_compose_matches_sequential_apply() {
642 // Composition equivalence: composed.apply(p) == a.apply(b.apply(p)).
643 let a = Transform2D::rotate(0.5);
644 let b = Transform2D::translate(3.0, -1.0);
645 let composed = a.compose(&b);
646 let p = Point::new(2.0, 5.0);
647 let direct = composed.apply(p);
648 let stepwise = a.apply(b.apply(p));
649 assert!(approx_point(direct, stepwise));
650 }
651
652 #[test]
653 fn group_default_is_identity_opacity_one_no_clip() {
654 let g = Group::default();
655 assert!(g.transform.is_identity());
656 assert_eq!(g.opacity, 1.0);
657 assert!(g.clip.is_none());
658 assert!(g.children.is_empty());
659 }
660
661 #[test]
662 fn group_nesting_with_transforms() {
663 // Outer group translates by (10, 10); inner group scales by 2.
664 // A point (1, 1) drawn at the inner level should land at
665 // (12, 12) after the outer transform is also applied — but the
666 // tree itself only stores the local transforms. This test
667 // pins down that the nested data is preserved verbatim, since
668 // composing transforms is a rasterizer responsibility.
669 let inner = Group {
670 transform: Transform2D::scale(2.0, 2.0),
671 children: vec![Node::Path(PathNode {
672 path: {
673 let mut p = Path::new();
674 p.move_to(Point::new(1.0, 1.0));
675 p
676 },
677 fill: Some(Paint::Solid(Rgba::opaque(255, 0, 0))),
678 stroke: None,
679 fill_rule: FillRule::NonZero,
680 })],
681 ..Group::default()
682 };
683 let outer = Group {
684 transform: Transform2D::translate(10.0, 10.0),
685 children: vec![Node::Group(inner)],
686 ..Group::default()
687 };
688 match &outer.children[0] {
689 Node::Group(g) => {
690 assert_eq!(g.transform, Transform2D::scale(2.0, 2.0));
691 assert_eq!(g.children.len(), 1);
692 }
693 _ => panic!("expected a Group child"),
694 }
695 assert_eq!(outer.transform, Transform2D::translate(10.0, 10.0));
696 }
697
698 #[test]
699 fn vector_frame_construction() {
700 let frame = VectorFrame {
701 width: 100.0,
702 height: 50.0,
703 view_box: Some(ViewBox {
704 min_x: 0.0,
705 min_y: 0.0,
706 width: 100.0,
707 height: 50.0,
708 }),
709 root: Group::default(),
710 pts: Some(0),
711 time_base: TimeBase::new(1, 1000),
712 };
713 assert_eq!(frame.width, 100.0);
714 assert_eq!(frame.height, 50.0);
715 assert!(frame.view_box.is_some());
716 assert_eq!(frame.pts, Some(0));
717 }
718
719 #[test]
720 fn rgba_constructors() {
721 let c = Rgba::opaque(10, 20, 30);
722 assert_eq!(c.a, 255);
723 let c2 = Rgba::new(10, 20, 30, 128);
724 assert_eq!(c2.a, 128);
725 }
726
727 #[test]
728 fn gradient_stop_round_trips() {
729 let s = GradientStop::new(0.5, Rgba::opaque(255, 0, 0));
730 assert_eq!(s.offset, 0.5);
731 let s2 = GradientStop::new(0.5, Rgba::opaque(255, 0, 0));
732 assert_eq!(s, s2);
733 }
734
735 #[test]
736 fn stroke_solid_defaults() {
737 let s = Stroke::solid(2.0, Rgba::opaque(0, 0, 0));
738 assert_eq!(s.width, 2.0);
739 assert_eq!(s.cap, LineCap::Butt);
740 assert_eq!(s.join, LineJoin::Miter);
741 assert_eq!(s.miter_limit, 4.0);
742 assert!(s.dash.is_none());
743 }
744
745 #[test]
746 fn soft_mask_construction_and_inspection() {
747 // Wrap a path in a SoftMask node with a luminance mask. Round-
748 // trips both children verbatim through clone + match.
749 fn rect_path() -> PathNode {
750 let mut p = Path::new();
751 p.move_to(Point::new(0.0, 0.0))
752 .line_to(Point::new(10.0, 0.0))
753 .line_to(Point::new(10.0, 10.0))
754 .line_to(Point::new(0.0, 10.0))
755 .close();
756 PathNode {
757 path: p,
758 fill: Some(Paint::Solid(Rgba::opaque(255, 255, 255))),
759 stroke: None,
760 fill_rule: FillRule::NonZero,
761 }
762 }
763 let n = Node::SoftMask {
764 mask: Box::new(Node::Path(rect_path())),
765 mask_kind: MaskKind::Luminance,
766 content: Box::new(Node::Path(rect_path())),
767 };
768 match &n {
769 Node::SoftMask {
770 mask_kind, content, ..
771 } => {
772 assert_eq!(*mask_kind, MaskKind::Luminance);
773 match content.as_ref() {
774 Node::Path(_) => {}
775 _ => panic!("expected Path content"),
776 }
777 }
778 _ => panic!("expected SoftMask"),
779 }
780 }
781
782 #[test]
783 fn mask_kind_default_is_luminance() {
784 assert_eq!(MaskKind::default(), MaskKind::Luminance);
785 }
786}