Skip to main content

jag_draw/
svg.rs

1use crate::scene::ColorLinPremul;
2use std::collections::{HashMap, VecDeque};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6/// Shared system font database for SVG text rendering.
7///
8/// Loaded once on first use so that `<text>` elements in SVGs can be
9/// resolved to glyph outlines by usvg/resvg. Without fonts the text
10/// is silently dropped.
11///
12/// Uses `usvg::fontdb` (re-exported from usvg) to match the version
13/// expected by `usvg::Options::fontdb`.
14static SYSTEM_FONTDB: std::sync::LazyLock<Arc<usvg::fontdb::Database>> =
15    std::sync::LazyLock::new(|| {
16        let mut db = usvg::fontdb::Database::new();
17        db.load_system_fonts();
18        Arc::new(db)
19    });
20
21/// Return embedded bytes for built-in SVG icons.
22///
23/// Applications can bundle their own icons; this hook is reserved for
24/// future built-in icon support.
25fn builtin_svg_bytes(_path: &Path) -> Option<&'static [u8]> {
26    None
27}
28
29/// Optional style overrides for SVG rendering
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub struct SvgStyle {
32    /// Override fill color (replaces all fill colors in the SVG)
33    pub fill: Option<ColorLinPremul>,
34    /// Override stroke color (replaces all stroke colors in the SVG)
35    pub stroke: Option<ColorLinPremul>,
36    /// Override stroke width (replaces all stroke widths in the SVG)
37    pub stroke_width: Option<f32>,
38}
39
40impl SvgStyle {
41    pub fn new() -> Self {
42        Self {
43            fill: None,
44            stroke: None,
45            stroke_width: None,
46        }
47    }
48
49    pub fn with_stroke(mut self, color: ColorLinPremul) -> Self {
50        self.stroke = Some(color);
51        self
52    }
53
54    pub fn with_fill(mut self, color: ColorLinPremul) -> Self {
55        self.fill = Some(color);
56        self
57    }
58
59    pub fn with_stroke_width(mut self, width: f32) -> Self {
60        self.stroke_width = Some(width);
61        self
62    }
63}
64
65impl Default for SvgStyle {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71/// Hash-friendly version of SvgStyle for cache keys
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
73struct SvgStyleKey {
74    fill: Option<[u8; 4]>,
75    stroke: Option<[u8; 4]>,
76    stroke_width_bits: Option<u32>,
77}
78
79impl From<SvgStyle> for SvgStyleKey {
80    fn from(style: SvgStyle) -> Self {
81        Self {
82            fill: style.fill.map(|c| {
83                let rgba = c.to_srgba_u8();
84                [rgba[0], rgba[1], rgba[2], rgba[3]]
85            }),
86            stroke: style.stroke.map(|c| {
87                let rgba = c.to_srgba_u8();
88                [rgba[0], rgba[1], rgba[2], rgba[3]]
89            }),
90            stroke_width_bits: style.stroke_width.map(|w| w.to_bits()),
91        }
92    }
93}
94
95/// Bucketed scale factor used for raster cache keys.
96/// Provides more granular buckets to support icons at various sizes while maintaining cache efficiency.
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
98pub enum ScaleBucket {
99    X025, // 0.25x
100    X05,  // 0.5x
101    X075, // 0.75x
102    X1,   // 1.0x
103    X125, // 1.25x
104    X15,  // 1.5x
105    X2,   // 2.0x
106    X25,  // 2.5x
107    X3,   // 3.0x
108    X4,   // 4.0x
109    X5,   // 5.0x
110    X6,   // 6.0x
111    X8,   // 8.0x
112}
113
114impl ScaleBucket {
115    pub fn from_scale(s: f32) -> Self {
116        // Bucket to nearest scale factor
117        if s < 0.375 {
118            ScaleBucket::X025
119        } else if s < 0.625 {
120            ScaleBucket::X05
121        } else if s < 0.875 {
122            ScaleBucket::X075
123        } else if s < 1.125 {
124            ScaleBucket::X1
125        } else if s < 1.375 {
126            ScaleBucket::X125
127        } else if s < 1.75 {
128            ScaleBucket::X15
129        } else if s < 2.25 {
130            ScaleBucket::X2
131        } else if s < 2.75 {
132            ScaleBucket::X25
133        } else if s < 3.5 {
134            ScaleBucket::X3
135        } else if s < 4.5 {
136            ScaleBucket::X4
137        } else if s < 5.5 {
138            ScaleBucket::X5
139        } else if s < 7.0 {
140            ScaleBucket::X6
141        } else {
142            ScaleBucket::X8
143        }
144    }
145
146    pub fn as_f32(self) -> f32 {
147        match self {
148            ScaleBucket::X025 => 0.25,
149            ScaleBucket::X05 => 0.5,
150            ScaleBucket::X075 => 0.75,
151            ScaleBucket::X1 => 1.0,
152            ScaleBucket::X125 => 1.25,
153            ScaleBucket::X15 => 1.5,
154            ScaleBucket::X2 => 2.0,
155            ScaleBucket::X25 => 2.5,
156            ScaleBucket::X3 => 3.0,
157            ScaleBucket::X4 => 4.0,
158            ScaleBucket::X5 => 5.0,
159            ScaleBucket::X6 => 6.0,
160            ScaleBucket::X8 => 8.0,
161        }
162    }
163}
164
165#[derive(Clone, Debug, Eq, PartialEq, Hash)]
166struct CacheKey {
167    path: PathBuf,
168    scale: ScaleBucket,
169    style: SvgStyleKey,
170}
171
172struct CacheEntry {
173    tex: std::sync::Arc<wgpu::Texture>,
174    width: u32,
175    height: u32,
176    last_tick: u64,
177    bytes: usize,
178}
179
180/// Simple SVG rasterization cache backed by usvg+resvg, with LRU eviction.
181///
182/// Notes:
183/// - Animated SVG (SMIL/CSS/JS) is not supported; files are rasterized as-is.
184/// - External resources referenced by relative hrefs are resolved from the SVG's directory.
185pub struct SvgRasterCache {
186    device: Arc<wgpu::Device>,
187    // LRU state
188    map: HashMap<CacheKey, CacheEntry>,
189    lru: VecDeque<CacheKey>,
190    current_tick: u64,
191    // guardrails
192    max_bytes: usize,
193    total_bytes: usize,
194    max_tex_size: u32,
195}
196
197impl SvgRasterCache {
198    pub fn new(device: Arc<wgpu::Device>) -> Self {
199        // Conservative default budget: 128 MiB for cached rasters
200        let max_bytes = 128 * 1024 * 1024;
201        let limits = device.limits();
202        let max_tex_size = limits.max_texture_dimension_2d;
203        Self {
204            device,
205            map: HashMap::new(),
206            lru: VecDeque::new(),
207            current_tick: 0,
208            max_bytes,
209            total_bytes: 0,
210            max_tex_size,
211        }
212    }
213
214    pub fn set_max_bytes(&mut self, bytes: usize) {
215        self.max_bytes = bytes;
216        self.evict_if_needed();
217    }
218
219    fn touch(&mut self, key: &CacheKey) {
220        self.current_tick = self.current_tick.wrapping_add(1);
221        if let Some(entry) = self.map.get_mut(key) {
222            entry.last_tick = self.current_tick;
223        }
224        // update LRU order: move key to back
225        if let Some(pos) = self.lru.iter().position(|k| k == key) {
226            let k = self.lru.remove(pos).unwrap();
227            self.lru.push_back(k);
228        }
229    }
230
231    fn insert(&mut self, key: CacheKey, entry: CacheEntry) {
232        self.current_tick = self.current_tick.wrapping_add(1);
233        self.total_bytes += entry.bytes;
234        self.map.insert(key.clone(), entry);
235        self.lru.push_back(key);
236        self.evict_if_needed();
237    }
238
239    fn evict_if_needed(&mut self) {
240        while self.total_bytes > self.max_bytes {
241            if let Some(old_key) = self.lru.pop_front() {
242                if let Some(entry) = self.map.remove(&old_key) {
243                    self.total_bytes = self.total_bytes.saturating_sub(entry.bytes);
244                    // dropping `entry.tex` releases GPU memory eventually
245                }
246            } else {
247                break;
248            }
249        }
250    }
251
252    /// Rasterize (or fetch from cache) an SVG file to an RGBA8 sRGB texture for a given scale.
253    /// Returns a cloneable `wgpu::Texture` and its dimensions.
254    /// Optional style parameter allows overriding fill, stroke, and stroke-width.
255    pub fn get_or_rasterize(
256        &mut self,
257        path: &Path,
258        scale: f32,
259        style: SvgStyle,
260        queue: &wgpu::Queue,
261    ) -> Option<(std::sync::Arc<wgpu::Texture>, u32, u32)> {
262        let scale_b = ScaleBucket::from_scale(scale);
263        let style_key = SvgStyleKey::from(style);
264        let key = CacheKey {
265            path: path.to_path_buf(),
266            scale: scale_b,
267            style: style_key,
268        };
269        if self.map.contains_key(&key) {
270            self.touch(&key);
271            let e = self.map.get(&key).unwrap();
272            return Some((e.tex.clone(), e.width, e.height));
273        }
274
275        // Read SVG data. Prefer the actual file on disk when it exists,
276        // falling back to built-in embedded bytes for chrome icons that
277        // may not have a corresponding file (e.g. bare "icon.svg" names).
278        let mut data: Vec<u8> = if path.exists() {
279            std::fs::read(path).ok()?
280        } else if let Some(bytes) = builtin_svg_bytes(path) {
281            bytes.to_vec()
282        } else {
283            std::fs::read(path).ok()?
284        };
285
286        // Apply style overrides by modifying the SVG XML if needed
287        if style.fill.is_some() || style.stroke.is_some() || style.stroke_width.is_some() {
288            data = apply_style_overrides_to_xml(&data, style)?;
289        }
290
291        let mut opt = usvg::Options::default();
292        opt.resources_dir = path.parent().map(|p| p.to_path_buf());
293        opt.fontdb = SYSTEM_FONTDB.clone();
294        let tree = usvg::Tree::from_data(&data, &opt).ok()?;
295        let size = tree.size().to_int_size();
296        let (w0, h0): (u32, u32) = (size.width().max(1), size.height().max(1));
297        let s = scale_b.as_f32();
298        let w = ((w0 as f32) * s).round() as u32;
299        let h = ((h0 as f32) * s).round() as u32;
300        if w == 0 || h == 0 {
301            return None;
302        }
303        if w > self.max_tex_size || h > self.max_tex_size {
304            return None;
305        }
306
307        let mut pixmap = tiny_skia::Pixmap::new(w, h)?;
308        let mut pm = pixmap.as_mut();
309        let ts = tiny_skia::Transform::from_scale(s, s);
310        resvg::render(&tree, ts, &mut pm);
311
312        let rgba = pixmap.take();
313        let tex = self.device.create_texture(&wgpu::TextureDescriptor {
314            label: Some("svg-raster"),
315            size: wgpu::Extent3d {
316                width: w,
317                height: h,
318                depth_or_array_layers: 1,
319            },
320            mip_level_count: 1,
321            sample_count: 1,
322            dimension: wgpu::TextureDimension::D2,
323            format: wgpu::TextureFormat::Rgba8UnormSrgb,
324            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
325            view_formats: &[],
326        });
327        queue.write_texture(
328            wgpu::ImageCopyTexture {
329                texture: &tex,
330                mip_level: 0,
331                origin: wgpu::Origin3d::ZERO,
332                aspect: wgpu::TextureAspect::All,
333            },
334            &rgba,
335            wgpu::ImageDataLayout {
336                offset: 0,
337                bytes_per_row: Some(w * 4),
338                rows_per_image: Some(h),
339            },
340            wgpu::Extent3d {
341                width: w,
342                height: h,
343                depth_or_array_layers: 1,
344            },
345        );
346
347        let bytes = (w as usize) * (h as usize) * 4;
348        let tex_arc = Arc::new(tex);
349        let entry = CacheEntry {
350            tex: tex_arc.clone(),
351            width: w,
352            height: h,
353            last_tick: self.current_tick,
354            bytes,
355        };
356        self.insert(key, entry);
357        Some((tex_arc, w, h))
358    }
359}
360
361/// Apply style overrides by modifying the SVG XML
362/// This replaces stroke="currentColor", fill colors, and stroke-width attributes
363fn apply_style_overrides_to_xml(data: &[u8], style: SvgStyle) -> Option<Vec<u8>> {
364    let mut svg_str = String::from_utf8(data.to_vec()).ok()?;
365
366    // Replace stroke color
367    if let Some(stroke_color) = style.stroke {
368        let rgba = stroke_color.to_srgba_u8();
369        let hex_color = format!("#{:02x}{:02x}{:02x}", rgba[0], rgba[1], rgba[2]);
370
371        // Replace stroke="currentColor" with the actual color
372        svg_str = svg_str.replace(
373            "stroke=\"currentColor\"",
374            &format!("stroke=\"{}\"", hex_color),
375        );
376        svg_str = svg_str.replace("stroke='currentColor'", &format!("stroke='{}'", hex_color));
377    }
378
379    // Replace ALL fill colors with the override color.
380    // Two steps: (1) set fill on the root <svg> element so paths without
381    // an explicit fill attribute inherit it (SVG defaults to black),
382    // and (2) replace all existing fill="..." values (except "none").
383    if let Some(fill_color) = style.fill {
384        let rgba = fill_color.to_srgba_u8();
385        let hex_color = format!("#{:02x}{:02x}{:02x}", rgba[0], rgba[1], rgba[2]);
386
387        // Step 1: Inject fill on the root <svg> element for inheritance.
388        // This handles paths that have no explicit fill attribute (SVG
389        // defaults to black). Only inject if the <svg> tag doesn't already
390        // have a fill attribute — otherwise we'd create a duplicate.
391        if let Some(svg_start) = svg_str.find("<svg ") {
392            let tag_end = svg_str[svg_start..].find('>').unwrap_or(svg_str.len());
393            let svg_tag = &svg_str[svg_start..svg_start + tag_end];
394            if !svg_tag.contains("fill=") {
395                svg_str.insert_str(svg_start + 5, &format!("fill=\"{}\" ", hex_color));
396            }
397        }
398
399        // Step 2: Replace all explicit fill="..." values (except "none").
400        let mut result = String::new();
401        let mut remaining = svg_str.as_str();
402
403        while let Some(start) = remaining.find("fill=\"") {
404            result.push_str(&remaining[..start]);
405            let after_attr = &remaining[start + 6..]; // skip `fill="`
406            if let Some(end_pos) = after_attr.find('"') {
407                let old_val = &after_attr[..end_pos];
408                if old_val == "none" {
409                    result.push_str("fill=\"none\"");
410                } else {
411                    result.push_str(&format!("fill=\"{}\"", hex_color));
412                }
413                remaining = &after_attr[end_pos + 1..]; // skip past closing quote
414            } else {
415                result.push_str("fill=\"");
416                result.push_str(after_attr);
417                break;
418            }
419        }
420        result.push_str(remaining);
421        svg_str = result;
422    }
423
424    // Replace stroke-width - handle all occurrences
425    if let Some(width) = style.stroke_width {
426        // Replace all stroke-width attributes
427        let mut result = String::new();
428        let mut remaining = svg_str.as_str();
429
430        while let Some(start) = remaining.find("stroke-width=\"") {
431            // Add everything before stroke-width
432            result.push_str(&remaining[..start]);
433            result.push_str("stroke-width=\"");
434
435            // Find the end quote
436            let after_attr = &remaining[start + 14..];
437            if let Some(end_pos) = after_attr.find('"') {
438                // Add the new width value
439                result.push_str(&width.to_string());
440                // Continue from after the closing quote
441                remaining = &after_attr[end_pos..];
442            } else {
443                // Malformed SVG, just copy the rest
444                result.push_str(after_attr);
445                break;
446            }
447        }
448        // Add any remaining content
449        result.push_str(remaining);
450        svg_str = result;
451    }
452
453    Some(svg_str.into_bytes())
454}
455
456// --- SVG → Geometry import (Phase 7.5.2) ---
457
458/// Import result counters for basic visibility/debugging.
459#[derive(Clone, Copy, Debug, Default)]
460pub struct SvgImportStats {
461    pub rects: u32,
462    pub rounded_rects: u32,
463    pub ellipses: u32,
464    pub paths: u32,
465    pub strokes: u32,
466    pub skipped: u32,
467}
468
469fn color_from_usvg(color: usvg::Color, opacity: f32) -> crate::scene::ColorLinPremul {
470    crate::scene::ColorLinPremul::from_srgba(color.red, color.green, color.blue, opacity)
471}
472
473fn transform2d_from_usvg(t: usvg::Transform) -> crate::scene::Transform2D {
474    // tiny_skia_path::Transform uses fields (sx, kx, ky, sy, tx, ty)
475    crate::scene::Transform2D {
476        m: [
477            t.sx as f32,
478            t.ky as f32,
479            t.kx as f32,
480            t.sy as f32,
481            t.tx as f32,
482            t.ty as f32,
483        ],
484    }
485}
486
487fn fill_rule_from_usvg(rule: usvg::FillRule) -> crate::scene::FillRule {
488    match rule {
489        usvg::FillRule::NonZero => crate::scene::FillRule::NonZero,
490        usvg::FillRule::EvenOdd => crate::scene::FillRule::EvenOdd,
491    }
492}
493
494// Note: usvg outputs only Path/Image/Text/Group nodes; basic shapes are already converted to paths.
495
496fn import_path_fill(
497    painter: &mut crate::painter::Painter,
498    node_transform: usvg::Transform,
499    p: &usvg::Path,
500    color: crate::scene::ColorLinPremul,
501    stats: &mut SvgImportStats,
502) {
503    use crate::scene::{Path, PathCmd};
504    let mut cmds: Vec<PathCmd> = Vec::new();
505    // Convert usvg path data → our PathCmd. This covers move/line/quad/cubic/close.
506    for seg in p.data().segments() {
507        use usvg::tiny_skia_path::PathSegment;
508        match seg {
509            PathSegment::MoveTo(pt) => cmds.push(PathCmd::MoveTo([pt.x as f32, pt.y as f32])),
510            PathSegment::LineTo(pt) => cmds.push(PathCmd::LineTo([pt.x as f32, pt.y as f32])),
511            PathSegment::QuadTo(c, p) => cmds.push(PathCmd::QuadTo(
512                [c.x as f32, c.y as f32],
513                [p.x as f32, p.y as f32],
514            )),
515            PathSegment::CubicTo(c1, c2, p) => cmds.push(PathCmd::CubicTo(
516                [c1.x as f32, c1.y as f32],
517                [c2.x as f32, c2.y as f32],
518                [p.x as f32, p.y as f32],
519            )),
520            PathSegment::Close => cmds.push(PathCmd::Close),
521        }
522    }
523    let fill_rule = p
524        .fill()
525        .map(|f| fill_rule_from_usvg(f.rule()))
526        .unwrap_or(crate::scene::FillRule::NonZero);
527    let path = Path { cmds, fill_rule };
528    let t = transform2d_from_usvg(node_transform);
529    painter.push_transform(t);
530    painter.fill_path(path, color, 0);
531    painter.pop_transform();
532    stats.paths += 1;
533}
534
535/// If the given usvg path is an axis-aligned rectangle made of straight
536/// line segments (MoveTo + 3x LineTo + Close), return it as a Rect in
537/// local coordinates. Rounded corners and curves are not considered a match.
538fn detect_axis_aligned_rect(p: &usvg::Path) -> Option<crate::scene::Rect> {
539    use usvg::tiny_skia_path::PathSegment;
540    // Collect the first closed subpath consisting only of MoveTo/LineTo/Close
541    let mut points: Vec<[f32; 2]> = Vec::new();
542    let mut started = false;
543    for seg in p.data().segments() {
544        match seg {
545            PathSegment::MoveTo(pt) => {
546                if started {
547                    break;
548                } // Only consider first subpath
549                started = true;
550                points.clear();
551                points.push([pt.x as f32, pt.y as f32]);
552            }
553            PathSegment::LineTo(pt) => {
554                if !started {
555                    return None;
556                }
557                let q = [pt.x as f32, pt.y as f32];
558                // Skip exact duplicates
559                if points
560                    .last()
561                    .map_or(true, |last| last[0] != q[0] || last[1] != q[1])
562                {
563                    points.push(q);
564                }
565            }
566            PathSegment::QuadTo(..) | PathSegment::CubicTo(..) => {
567                // Curves present → not a simple rect
568                return None;
569            }
570            PathSegment::Close => {
571                break;
572            }
573        }
574    }
575    if points.len() != 4 {
576        return None;
577    }
578    // Verify axis alignment: each edge must be horizontal or vertical
579    for i in 0..4 {
580        let a = points[i];
581        let b = points[(i + 1) % 4];
582        let dx = (a[0] - b[0]).abs();
583        let dy = (a[1] - b[1]).abs();
584        if dx > 1e-4 && dy > 1e-4 {
585            return None;
586        }
587    }
588    // Build rect from min/max
589    let mut minx = f32::INFINITY;
590    let mut miny = f32::INFINITY;
591    let mut maxx = f32::NEG_INFINITY;
592    let mut maxy = f32::NEG_INFINITY;
593    for p in &points {
594        minx = minx.min(p[0]);
595        miny = miny.min(p[1]);
596        maxx = maxx.max(p[0]);
597        maxy = maxy.max(p[1]);
598    }
599    let w = (maxx - minx).abs();
600    let h = (maxy - miny).abs();
601    if w <= 0.0 || h <= 0.0 {
602        return None;
603    }
604    Some(crate::scene::Rect {
605        x: minx.min(maxx),
606        y: miny.min(maxy),
607        w,
608        h,
609    })
610}
611
612fn paint_from_fill(fill: &usvg::Fill) -> Option<crate::scene::Brush> {
613    match fill.paint() {
614        usvg::Paint::Color(c) => Some(crate::scene::Brush::Solid(color_from_usvg(
615            *c,
616            fill.opacity().get() as f32,
617        ))),
618        _ => None,
619    }
620}
621
622/// Import an SVG file into the display list as vector geometry.
623///
624/// Notes:
625/// - Supports Rect/RoundedRect/Circle/Ellipse and basic filled Paths.
626/// - Only solid fills are mapped. Unsupported paints/filters/masks/text are skipped.
627pub fn import_svg_geometry_to_painter(
628    painter: &mut crate::painter::Painter,
629    path: &Path,
630) -> Option<SvgImportStats> {
631    let data = std::fs::read(path).ok()?;
632    let mut opt = usvg::Options::default();
633    opt.resources_dir = path.parent().map(|p| p.to_path_buf());
634    opt.fontdb = SYSTEM_FONTDB.clone();
635    let tree = usvg::Tree::from_data(&data, &opt).ok()?;
636    let mut stats = SvgImportStats::default();
637
638    // Traverse the tree in document order; apply node-local transforms only for now.
639    fn walk(
640        group: &usvg::Group,
641        painter: &mut crate::painter::Painter,
642        stats: &mut SvgImportStats,
643    ) {
644        for node in group.children() {
645            match node {
646                usvg::Node::Path(p) => {
647                    if let Some(fill) = p.fill() {
648                        if let Some(crate::scene::Brush::Solid(col)) = paint_from_fill(fill) {
649                            // Try fast-path: detect simple axis-aligned rectangle and emit as a primitive
650                            if let Some(rect) = detect_axis_aligned_rect(p) {
651                                let t = transform2d_from_usvg(p.abs_transform());
652                                painter.push_transform(t);
653                                painter.rect(rect, crate::scene::Brush::Solid(col), 0);
654                                painter.pop_transform();
655                                stats.rects += 1;
656                            } else {
657                                import_path_fill(painter, p.abs_transform(), p, col, stats);
658                            }
659                        } else {
660                            // Unsupported paint servers (gradients/patterns) are skipped for geometry import.
661                            stats.skipped += 1;
662                        }
663                    }
664                    // Stroke (solid-only for now)
665                    if let Some(st) = p.stroke() {
666                        if let usvg::Paint::Color(c) = st.paint() {
667                            let col = color_from_usvg(*c, st.opacity().get() as f32);
668                            // If the path is a simple rect, stroke it via the rect stroke primitive
669                            if let Some(rect) = detect_axis_aligned_rect(p) {
670                                let t = transform2d_from_usvg(p.abs_transform());
671                                painter.push_transform(t);
672                                painter.stroke_rect(
673                                    rect,
674                                    crate::scene::Stroke {
675                                        width: st.width().get() as f32,
676                                    },
677                                    crate::scene::Brush::Solid(col),
678                                    0,
679                                );
680                                painter.pop_transform();
681                                stats.strokes += 1;
682                            } else {
683                                // Build a Path copy from usvg data for stroke as well
684                                use crate::scene::{Path as EPath, PathCmd};
685                                let mut cmds: Vec<PathCmd> = Vec::new();
686                                for seg in p.data().segments() {
687                                    use usvg::tiny_skia_path::PathSegment;
688                                    match seg {
689                                        PathSegment::MoveTo(pt) => {
690                                            cmds.push(PathCmd::MoveTo([pt.x as f32, pt.y as f32]))
691                                        }
692                                        PathSegment::LineTo(pt) => {
693                                            cmds.push(PathCmd::LineTo([pt.x as f32, pt.y as f32]))
694                                        }
695                                        PathSegment::QuadTo(c, q) => cmds.push(PathCmd::QuadTo(
696                                            [c.x as f32, c.y as f32],
697                                            [q.x as f32, q.y as f32],
698                                        )),
699                                        PathSegment::CubicTo(c1, c2, q) => {
700                                            cmds.push(PathCmd::CubicTo(
701                                                [c1.x as f32, c1.y as f32],
702                                                [c2.x as f32, c2.y as f32],
703                                                [q.x as f32, q.y as f32],
704                                            ))
705                                        }
706                                        PathSegment::Close => cmds.push(PathCmd::Close),
707                                    }
708                                }
709                                let epath = EPath {
710                                    cmds,
711                                    fill_rule: crate::scene::FillRule::NonZero,
712                                };
713                                let t = transform2d_from_usvg(p.abs_transform());
714                                painter.push_transform(t);
715                                painter.stroke_path(
716                                    epath,
717                                    crate::scene::Stroke {
718                                        width: st.width().get() as f32,
719                                    },
720                                    col,
721                                    0,
722                                );
723                                painter.pop_transform();
724                                stats.strokes += 1;
725                            }
726                        } else {
727                            stats.skipped += 1;
728                        }
729                    }
730                }
731                usvg::Node::Group(g) => {
732                    // Render group contents normally.
733                    walk(g, painter, stats);
734                }
735                usvg::Node::Image(_img) => {
736                    // Only traverse subroots for embedded SVG images.
737                    // This avoids drawing clipPath/mask/pattern definition subtrees.
738                    node.subroots(|subroot| walk(subroot, painter, stats));
739                }
740                usvg::Node::Text(_) => {
741                    // Text-as-geometry not supported yet.
742                }
743            }
744        }
745    }
746
747    let root = tree.root();
748    walk(root, painter, &mut stats);
749
750    Some(stats)
751}
752
753/// Get the intrinsic pixel size of an SVG file according to usvg's parsing
754/// of width/height/viewBox. Returns (width,height) rounded to integers.
755pub fn svg_intrinsic_size(path: &Path) -> Option<(u32, u32)> {
756    let data = std::fs::read(path).ok()?;
757    let mut opt = usvg::Options::default();
758    opt.resources_dir = path.parent().map(|p| p.to_path_buf());
759    opt.fontdb = SYSTEM_FONTDB.clone();
760    let tree = usvg::Tree::from_data(&data, &opt).ok()?;
761    let size = tree.size().to_int_size();
762    Some((size.width().max(1), size.height().max(1)))
763}
764
765/// Determine if an SVG requires rasterization or can be rendered as vector geometry.
766/// Returns true if the SVG uses features that cannot be expressed analytically
767/// (filters, patterns, masks, gradients, images, text, etc.)
768pub fn svg_requires_rasterization(path: &Path) -> Option<bool> {
769    let data = std::fs::read(path).ok()?;
770    let mut opt = usvg::Options::default();
771    opt.resources_dir = path.parent().map(|p| p.to_path_buf());
772    opt.fontdb = SYSTEM_FONTDB.clone();
773    let tree = usvg::Tree::from_data(&data, &opt).ok()?;
774
775    fn check_node(node: &usvg::Node) -> bool {
776        match node {
777            usvg::Node::Path(p) => {
778                // Check if fill uses non-solid paint (gradients, patterns)
779                if let Some(fill) = p.fill() {
780                    if !matches!(fill.paint(), usvg::Paint::Color(_)) {
781                        return true; // Gradient or pattern fill
782                    }
783                }
784
785                // Check if stroke uses non-solid paint
786                if let Some(stroke) = p.stroke() {
787                    if !matches!(stroke.paint(), usvg::Paint::Color(_)) {
788                        return true; // Gradient or pattern stroke
789                    }
790                }
791
792                // Check subroots (e.g., clipPath definitions)
793                let mut needs_raster = false;
794                node.subroots(|subroot| {
795                    if check_group(subroot) {
796                        needs_raster = true;
797                    }
798                });
799                needs_raster
800            }
801            usvg::Node::Image(_) => {
802                // Embedded images require rasterization
803                true
804            }
805            usvg::Node::Text(_) => {
806                // Text-as-graphics requires rasterization
807                true
808            }
809            usvg::Node::Group(g) => check_group(g),
810        }
811    }
812
813    fn check_group(group: &usvg::Group) -> bool {
814        // Check if group has filters, masks, or other complex features
815        // Note: usvg pre-flattens many attributes, so we check children
816        for child in group.children() {
817            if check_node(&child) {
818                return true;
819            }
820        }
821        false
822    }
823
824    let requires_raster = check_group(tree.root());
825    Some(requires_raster)
826}