facet_svg/
lib.rs

1//! Facet-derived types for SVG parsing and serialization.
2//!
3//! This crate provides strongly-typed SVG elements that can be deserialized
4//! from XML using `facet-xml`.
5//!
6//! # Example
7//!
8//! ```rust
9//! use facet_svg::Svg;
10//!
11//! let svg_str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
12//!     <rect x="10" y="10" width="80" height="80" fill="blue"/>
13//! </svg>"#;
14//!
15//! let svg: Svg = facet_svg::from_str(svg_str).unwrap();
16//! ```
17
18mod tracing_macros;
19
20use facet::Facet;
21use facet_xml as xml;
22use facet_xml::to_vec;
23
24mod path;
25mod points;
26
27pub use path::{PathCommand, PathData, PathDataProxy};
28pub use points::{Point, Points, PointsProxy};
29
30/// SVG namespace URI
31pub const SVG_NS: &str = "http://www.w3.org/2000/svg";
32
33/// Error type for SVG parsing
34pub type Error = facet_xml::DeserializeError<facet_xml::XmlError>;
35
36/// Error type for SVG serialization
37pub type SerializeError = facet_xml::SerializeError<facet_xml::XmlSerializeError>;
38
39/// Deserialize an SVG from a string.
40pub fn from_str<T>(s: &str) -> Result<T, Error>
41where
42    T: for<'a> Facet<'a>,
43{
44    facet_xml::from_str(s)
45}
46
47/// Serialize an SVG value to a string.
48pub fn to_string<'facet, T>(value: &T) -> Result<String, SerializeError>
49where
50    T: Facet<'facet> + ?Sized,
51{
52    let bytes = to_vec(value)?;
53    // SAFETY: XmlSerializer produces valid UTF-8
54    Ok(String::from_utf8(bytes).expect("XmlSerializer produces valid UTF-8"))
55}
56
57/// Root SVG element
58#[derive(Facet, Debug, Clone, Default)]
59#[facet(
60    xml::ns_all = "http://www.w3.org/2000/svg",
61    rename = "svg",
62    rename_all = "camelCase",
63    skip_all_unless_truthy
64)]
65pub struct Svg {
66    // Note: xmlns is handled by ns_all, not as a separate field
67    #[facet(xml::attribute)]
68    pub width: Option<String>,
69    #[facet(xml::attribute)]
70    pub height: Option<String>,
71    #[facet(xml::attribute)]
72    pub view_box: Option<String>,
73    #[facet(flatten)]
74    pub children: Vec<SvgNode>,
75}
76
77/// Any SVG node we care about
78#[derive(Facet, Debug, Clone)]
79#[facet(xml::ns_all = "http://www.w3.org/2000/svg")]
80#[repr(u8)]
81pub enum SvgNode {
82    G(Group),
83    Defs(Defs),
84    Style(Style),
85    Rect(Rect),
86    Circle(Circle),
87    Ellipse(Ellipse),
88    Line(Line),
89    Path(Path),
90    Polygon(Polygon),
91    Polyline(Polyline),
92    Text(Text),
93    Use(Use),
94    Image(Image),
95    Title(Title),
96    Desc(Desc),
97    Symbol(Symbol),
98    Filter(Filter),
99    Marker(Marker),
100    LinearGradient(LinearGradient),
101}
102
103/// SVG group element (`<g>`)
104#[derive(Facet, Debug, Clone, Default)]
105#[facet(xml::ns_all = "http://www.w3.org/2000/svg", skip_all_unless_truthy)]
106pub struct Group {
107    #[facet(xml::attribute)]
108    pub id: Option<String>,
109    #[facet(xml::attribute)]
110    pub class: Option<String>,
111    #[facet(xml::attribute)]
112    pub transform: Option<String>,
113    #[facet(flatten)]
114    pub children: Vec<SvgNode>,
115}
116
117/// SVG defs element (`<defs>`)
118#[derive(Facet, Debug, Clone, Default)]
119#[facet(xml::ns_all = "http://www.w3.org/2000/svg")]
120pub struct Defs {
121    #[facet(flatten)]
122    pub children: Vec<SvgNode>,
123}
124
125/// SVG style element (`<style>`)
126#[derive(Facet, Debug, Clone, Default)]
127#[facet(skip_all_unless_truthy)]
128pub struct Style {
129    #[facet(xml::attribute, rename = "type")]
130    pub type_: Option<String>,
131    #[facet(xml::text)]
132    pub content: Option<String>,
133}
134
135/// SVG rect element (`<rect>`)
136#[derive(Facet, Debug, Clone, Default)]
137#[facet(
138    xml::ns_all = "http://www.w3.org/2000/svg",
139    rename_all = "kebab-case",
140    skip_all_unless_truthy
141)]
142pub struct Rect {
143    #[facet(xml::attribute)]
144    pub x: Option<f64>,
145    #[facet(xml::attribute)]
146    pub y: Option<f64>,
147    #[facet(xml::attribute)]
148    pub width: Option<f64>,
149    #[facet(xml::attribute)]
150    pub height: Option<f64>,
151    #[facet(xml::attribute)]
152    pub rx: Option<f64>,
153    #[facet(xml::attribute)]
154    pub ry: Option<f64>,
155    #[facet(xml::attribute)]
156    pub fill: Option<String>,
157    #[facet(xml::attribute)]
158    pub stroke: Option<String>,
159    #[facet(xml::attribute)]
160    pub stroke_width: Option<String>,
161    #[facet(xml::attribute)]
162    pub stroke_dasharray: Option<String>,
163    #[facet(xml::attribute)]
164    pub style: Option<String>,
165}
166
167/// SVG circle element (`<circle>`)
168#[derive(Facet, Debug, Clone, Default)]
169#[facet(
170    xml::ns_all = "http://www.w3.org/2000/svg",
171    rename_all = "kebab-case",
172    skip_all_unless_truthy
173)]
174pub struct Circle {
175    #[facet(xml::attribute)]
176    pub cx: Option<f64>,
177    #[facet(xml::attribute)]
178    pub cy: Option<f64>,
179    #[facet(xml::attribute)]
180    pub r: Option<f64>,
181    #[facet(xml::attribute)]
182    pub fill: Option<String>,
183    #[facet(xml::attribute)]
184    pub stroke: Option<String>,
185    #[facet(xml::attribute)]
186    pub stroke_width: Option<String>,
187    #[facet(xml::attribute)]
188    pub stroke_dasharray: Option<String>,
189    #[facet(xml::attribute)]
190    pub style: Option<String>,
191}
192
193/// SVG ellipse element (`<ellipse>`)
194#[derive(Facet, Debug, Clone, Default)]
195#[facet(
196    xml::ns_all = "http://www.w3.org/2000/svg",
197    rename_all = "kebab-case",
198    skip_all_unless_truthy
199)]
200pub struct Ellipse {
201    #[facet(xml::attribute)]
202    pub cx: Option<f64>,
203    #[facet(xml::attribute)]
204    pub cy: Option<f64>,
205    #[facet(xml::attribute)]
206    pub rx: Option<f64>,
207    #[facet(xml::attribute)]
208    pub ry: Option<f64>,
209    #[facet(xml::attribute)]
210    pub fill: Option<String>,
211    #[facet(xml::attribute)]
212    pub stroke: Option<String>,
213    #[facet(xml::attribute)]
214    pub stroke_width: Option<String>,
215    #[facet(xml::attribute)]
216    pub stroke_dasharray: Option<String>,
217    #[facet(xml::attribute)]
218    pub style: Option<String>,
219}
220
221/// SVG line element (`<line>`)
222#[derive(Facet, Debug, Clone, Default)]
223#[facet(
224    xml::ns_all = "http://www.w3.org/2000/svg",
225    rename_all = "kebab-case",
226    skip_all_unless_truthy
227)]
228pub struct Line {
229    #[facet(xml::attribute)]
230    pub x1: Option<f64>,
231    #[facet(xml::attribute)]
232    pub y1: Option<f64>,
233    #[facet(xml::attribute)]
234    pub x2: Option<f64>,
235    #[facet(xml::attribute)]
236    pub y2: Option<f64>,
237    #[facet(xml::attribute)]
238    pub fill: Option<String>,
239    #[facet(xml::attribute)]
240    pub stroke: Option<String>,
241    #[facet(xml::attribute)]
242    pub stroke_width: Option<String>,
243    #[facet(xml::attribute)]
244    pub stroke_dasharray: Option<String>,
245    #[facet(xml::attribute)]
246    pub style: Option<String>,
247}
248
249/// SVG path element (`<path>`)
250#[derive(Facet, Debug, Clone, Default)]
251#[facet(
252    xml::ns_all = "http://www.w3.org/2000/svg",
253    rename_all = "kebab-case",
254    skip_all_unless_truthy
255)]
256pub struct Path {
257    #[facet(xml::attribute, proxy = PathDataProxy)]
258    pub d: Option<PathData>,
259    #[facet(xml::attribute)]
260    pub fill: Option<String>,
261    #[facet(xml::attribute)]
262    pub stroke: Option<String>,
263    #[facet(xml::attribute)]
264    pub stroke_width: Option<String>,
265    #[facet(xml::attribute)]
266    pub stroke_dasharray: Option<String>,
267    #[facet(xml::attribute)]
268    pub style: Option<String>,
269}
270
271/// SVG polygon element (`<polygon>`)
272#[derive(Facet, Debug, Clone, Default)]
273#[facet(
274    xml::ns_all = "http://www.w3.org/2000/svg",
275    rename_all = "kebab-case",
276    skip_all_unless_truthy
277)]
278pub struct Polygon {
279    #[facet(xml::attribute, proxy = PointsProxy)]
280    pub points: Points,
281    #[facet(xml::attribute)]
282    pub fill: Option<String>,
283    #[facet(xml::attribute)]
284    pub stroke: Option<String>,
285    #[facet(xml::attribute)]
286    pub stroke_width: Option<String>,
287    #[facet(xml::attribute)]
288    pub stroke_dasharray: Option<String>,
289    #[facet(xml::attribute)]
290    pub style: Option<String>,
291}
292
293/// SVG polyline element (`<polyline>`)
294#[derive(Facet, Debug, Clone, Default)]
295#[facet(
296    xml::ns_all = "http://www.w3.org/2000/svg",
297    rename_all = "kebab-case",
298    skip_all_unless_truthy
299)]
300pub struct Polyline {
301    #[facet(xml::attribute, proxy = PointsProxy)]
302    pub points: Points,
303    #[facet(xml::attribute)]
304    pub fill: Option<String>,
305    #[facet(xml::attribute)]
306    pub stroke: Option<String>,
307    #[facet(xml::attribute)]
308    pub stroke_width: Option<String>,
309    #[facet(xml::attribute)]
310    pub stroke_dasharray: Option<String>,
311    #[facet(xml::attribute)]
312    pub style: Option<String>,
313}
314
315/// SVG text element (`<text>`)
316#[derive(Facet, Debug, Clone, Default)]
317#[facet(
318    xml::ns_all = "http://www.w3.org/2000/svg",
319    rename_all = "kebab-case",
320    skip_all_unless_truthy
321)]
322pub struct Text {
323    #[facet(xml::attribute)]
324    pub x: Option<f64>,
325    #[facet(xml::attribute)]
326    pub y: Option<f64>,
327    #[facet(xml::attribute)]
328    pub transform: Option<String>,
329    #[facet(xml::attribute)]
330    pub fill: Option<String>,
331    #[facet(xml::attribute)]
332    pub stroke: Option<String>,
333    #[facet(xml::attribute)]
334    pub stroke_width: Option<String>,
335    #[facet(xml::attribute)]
336    pub style: Option<String>,
337    #[facet(xml::attribute)]
338    pub font_family: Option<String>,
339    #[facet(xml::attribute)]
340    pub font_style: Option<String>,
341    #[facet(xml::attribute)]
342    pub font_weight: Option<String>,
343    #[facet(xml::attribute)]
344    pub font_size: Option<String>,
345    #[facet(xml::attribute)]
346    pub text_anchor: Option<String>,
347    #[facet(xml::attribute)]
348    pub dominant_baseline: Option<String>,
349    #[facet(xml::text)]
350    pub content: Option<String>,
351}
352
353/// SVG use element (`<use>`)
354#[derive(Facet, Debug, Clone, Default)]
355#[facet(xml::ns_all = "http://www.w3.org/2000/svg", skip_all_unless_truthy)]
356pub struct Use {
357    #[facet(xml::attribute)]
358    pub x: Option<f64>,
359    #[facet(xml::attribute)]
360    pub y: Option<f64>,
361    #[facet(xml::attribute)]
362    pub width: Option<f64>,
363    #[facet(xml::attribute)]
364    pub height: Option<f64>,
365    #[facet(xml::attribute, rename = "xlink:href")]
366    pub href: Option<String>,
367}
368
369/// SVG image element (`<image>`)
370#[derive(Facet, Debug, Clone, Default)]
371#[facet(xml::ns_all = "http://www.w3.org/2000/svg", skip_all_unless_truthy)]
372pub struct Image {
373    #[facet(xml::attribute)]
374    pub x: Option<f64>,
375    #[facet(xml::attribute)]
376    pub y: Option<f64>,
377    #[facet(xml::attribute)]
378    pub width: Option<f64>,
379    #[facet(xml::attribute)]
380    pub height: Option<f64>,
381    #[facet(xml::attribute)]
382    pub href: Option<String>,
383    #[facet(xml::attribute)]
384    pub style: Option<String>,
385}
386
387/// SVG title element (`<title>`)
388#[derive(Facet, Debug, Clone, Default)]
389#[facet(xml::ns_all = "http://www.w3.org/2000/svg")]
390pub struct Title {
391    #[facet(xml::text)]
392    pub content: Option<String>,
393}
394
395/// SVG description element (`<desc>`)
396#[derive(Facet, Debug, Clone, Default)]
397#[facet(xml::ns_all = "http://www.w3.org/2000/svg")]
398pub struct Desc {
399    #[facet(xml::text)]
400    pub content: Option<String>,
401}
402
403/// SVG symbol element (`<symbol>`)
404#[derive(Facet, Debug, Clone, Default)]
405#[facet(
406    xml::ns_all = "http://www.w3.org/2000/svg",
407    rename_all = "camelCase",
408    skip_all_unless_truthy
409)]
410pub struct Symbol {
411    #[facet(xml::attribute)]
412    pub id: Option<String>,
413    #[facet(xml::attribute)]
414    pub view_box: Option<String>,
415    #[facet(xml::attribute)]
416    pub width: Option<String>,
417    #[facet(xml::attribute)]
418    pub height: Option<String>,
419    #[facet(flatten)]
420    pub children: Vec<SvgNode>,
421}
422
423/// SVG filter element (`<filter>`)
424#[derive(Facet, Debug, Clone, Default)]
425#[facet(xml::ns_all = "http://www.w3.org/2000/svg", skip_all_unless_truthy)]
426pub struct Filter {
427    #[facet(xml::attribute)]
428    pub id: Option<String>,
429    #[facet(flatten)]
430    pub children: Vec<FilterPrimitive>,
431}
432
433/// Filter primitive elements
434#[derive(Facet, Debug, Clone)]
435#[facet(xml::ns_all = "http://www.w3.org/2000/svg")]
436#[repr(u8)]
437pub enum FilterPrimitive {
438    #[facet(rename = "feGaussianBlur")]
439    FeGaussianBlur(FeGaussianBlur),
440}
441
442/// feGaussianBlur filter primitive
443#[derive(Facet, Debug, Clone, Default)]
444#[facet(xml::ns_all = "http://www.w3.org/2000/svg", skip_all_unless_truthy)]
445pub struct FeGaussianBlur {
446    #[facet(xml::attribute)]
447    pub r#in: Option<String>,
448    #[facet(xml::attribute, rename = "stdDeviation")]
449    pub std_deviation: Option<String>,
450}
451
452/// SVG marker element (`<marker>`)
453#[derive(Facet, Debug, Clone, Default)]
454#[facet(
455    xml::ns_all = "http://www.w3.org/2000/svg",
456    rename_all = "camelCase",
457    skip_all_unless_truthy
458)]
459pub struct Marker {
460    #[facet(xml::attribute)]
461    pub id: Option<String>,
462    #[facet(xml::attribute)]
463    pub marker_width: Option<String>,
464    #[facet(xml::attribute)]
465    pub marker_height: Option<String>,
466    #[facet(xml::attribute, rename = "refX")]
467    pub ref_x: Option<String>,
468    #[facet(xml::attribute, rename = "refY")]
469    pub ref_y: Option<String>,
470    #[facet(xml::attribute)]
471    pub orient: Option<String>,
472    #[facet(flatten)]
473    pub children: Vec<SvgNode>,
474}
475
476/// SVG linearGradient element (`<linearGradient>`)
477#[derive(Facet, Debug, Clone, Default)]
478#[facet(xml::ns_all = "http://www.w3.org/2000/svg", skip_all_unless_truthy)]
479pub struct LinearGradient {
480    #[facet(xml::attribute)]
481    pub id: Option<String>,
482    #[facet(xml::attribute)]
483    pub x1: Option<String>,
484    #[facet(xml::attribute)]
485    pub y1: Option<String>,
486    #[facet(xml::attribute)]
487    pub x2: Option<String>,
488    #[facet(xml::attribute)]
489    pub y2: Option<String>,
490    #[facet(xml::elements, rename = "stop")]
491    pub stops: Vec<GradientStop>,
492}
493
494/// Gradient stop element (`<stop>`)
495#[derive(Facet, Debug, Clone, Default)]
496#[facet(xml::ns_all = "http://www.w3.org/2000/svg", skip_all_unless_truthy)]
497pub struct GradientStop {
498    #[facet(xml::attribute)]
499    pub offset: Option<String>,
500    #[facet(xml::attribute)]
501    pub style: Option<String>,
502    #[facet(xml::attribute, rename = "stop-color")]
503    pub stop_color: Option<String>,
504    #[facet(xml::attribute, rename = "stop-opacity")]
505    pub stop_opacity: Option<String>,
506}
507
508// Re-export XML utilities for convenience
509pub use facet_xml;
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn test_attributes_are_parsed() {
517        let xml = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
518            <path d="M10,10L50,50" stroke="black"/>
519        </svg>"#;
520
521        let svg: Svg = from_str(xml).unwrap();
522
523        println!("Parsed SVG: {:?}", svg);
524
525        // These should NOT be None!
526        assert!(svg.view_box.is_some(), "viewBox should be parsed");
527
528        if let Some(SvgNode::Path(path)) = svg.children.first() {
529            println!("Parsed Path: {:?}", path);
530            assert!(path.d.is_some(), "path d attribute should be parsed");
531            assert!(
532                path.stroke.is_some(),
533                "path stroke attribute should be parsed"
534            );
535        } else {
536            panic!("Expected a Path element");
537        }
538    }
539
540    #[test]
541    fn test_svg_float_tolerance() {
542        use facet_assert::{SameOptions, SameReport, check_same_with_report};
543
544        // Simulate C vs Rust precision - C has 3 decimals, Rust has 10
545        // (Without style difference to isolate float tolerance test)
546        let c_svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
547            <path d="M118.239,208.239L226.239,208.239Z"/>
548        </svg>"#;
549
550        let rust_svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
551            <path d="M118.2387401575,208.2387401575L226.2387401575,208.2387401575Z"/>
552        </svg>"#;
553
554        let c: Svg = from_str(c_svg).unwrap();
555        let rust: Svg = from_str(rust_svg).unwrap();
556
557        eprintln!("C SVG: {:?}", c);
558        eprintln!("Rust SVG: {:?}", rust);
559
560        let tolerance = 0.002;
561        let options = SameOptions::new().float_tolerance(tolerance);
562
563        let result = check_same_with_report(&c, &rust, options);
564
565        match &result {
566            SameReport::Same => eprintln!("Result: Same"),
567            SameReport::Different(report) => {
568                eprintln!("Result: Different");
569                eprintln!("XML diff:\n{}", report.render_ansi_xml());
570            }
571            SameReport::Opaque { type_name } => eprintln!("Result: Opaque({})", type_name),
572        }
573
574        assert!(
575            matches!(result, SameReport::Same),
576            "SVG values within float tolerance should be considered Same"
577        );
578    }
579
580    #[test]
581    fn test_polygon_points_serialization() {
582        let polygon = Polygon {
583            points: crate::points::Points::parse("50,10 90,90 10,90").unwrap(),
584            fill: Some("lime".to_string()),
585            stroke: None,
586            stroke_width: None,
587            stroke_dasharray: None,
588            style: None,
589        };
590
591        let xml = to_string(&polygon).unwrap();
592
593        // The points attribute should be present
594        assert!(
595            xml.contains("points="),
596            "Serialized polygon should contain points attribute, got: {}",
597            xml
598        );
599        // Check the actual value
600        assert!(
601            xml.contains(r#"points="50,10 90,90 10,90""#),
602            "Points attribute should have correct value, got: {}",
603            xml
604        );
605    }
606}