1mod rasterize;
25
26pub use rasterize::{RasterResult, rasterize_from_bytes};
27
28use std::collections::HashMap;
29
30use crate::engine::css::values::CssValue;
31use crate::engine::layouter::css_resolver::resolve_inline_value;
32use crate::engine::layouter::dom_snapshot::{DomSnapshot, NodeId};
33use crate::engine::layouter::resolve_css_color;
34use crate::engine::layouter::types::{
35 Color, ColorScheme, ColorStop, Gradient, GradientKind, GradientSpread,
36};
37use crate::engine::renderer_model::Path;
38use crate::engine::renderer_model::{
39 AffineTransform, Brush, DrawCommand, FillRule, Paint, StrokeCap, StrokeJoin, ellipse_path,
40 line_path, polygon_path, polyline_path, rect_path, rounded_rect_path, stroke_path,
41 transform_path,
42};
43
44pub type ViewBox = (f32, f32, f32, f32);
46
47#[derive(Debug, Clone)]
52pub struct SvgShape {
53 pub path: Path,
54 pub fill: Brush,
55 pub rule: FillRule,
56 pub stroke: Option<SvgStroke>,
57 pub clip_path: Option<ClipPath>,
58}
59
60#[derive(Debug, Clone)]
63pub struct ClipPath {
64 pub path: Path,
65 pub rule: FillRule,
66}
67
68#[derive(Debug, Clone, PartialEq)]
72pub struct SvgStroke {
73 pub color: Color,
74 pub gradient: Option<Gradient>,
75 pub width: f32,
76 pub cap: StrokeCap,
77 pub join: StrokeJoin,
78 pub miter_limit: f32,
79 pub dash: Option<Vec<f32>>,
80 pub dash_offset: f32,
81}
82
83impl SvgStroke {
84 pub(crate) fn inherited_from(inherited: Option<&SvgStroke>) -> SvgStroke {
87 inherited.cloned().unwrap_or(SvgStroke {
88 color: Color(0, 0, 0, 255),
89 gradient: None,
90 width: 1.0,
91 cap: StrokeCap::Butt,
92 join: StrokeJoin::Miter,
93 miter_limit: 4.0,
94 dash: None,
95 dash_offset: 0.0,
96 })
97 }
98
99 pub(crate) fn to_stroke(&self) -> crate::engine::renderer_model::Stroke {
101 crate::engine::renderer_model::Stroke {
102 width: self.width,
103 cap: self.cap,
104 join: self.join,
105 miter_limit: self.miter_limit,
106 dash: self.dash.clone(),
107 dash_offset: self.dash_offset,
108 }
109 }
110}
111
112#[derive(Debug, Clone)]
115pub struct SvgContent {
116 pub view_box: ViewBox,
117 pub shapes: Vec<SvgShape>,
118}
119
120pub fn path_from_d(d: &str) -> Path {
131 let mut path = Path::new();
132 let mut cur: Option<(f32, f32)> = None;
133 let mut start: Option<(f32, f32)> = None;
134 let mut prev_control: Option<(f32, f32)> = None;
137 let mut prev_was_curve = false;
138
139 let mut nums = SvgNum::new(d);
140
141 while let Some(cmd) = nums.next_command() {
142 match cmd {
143 'M' | 'm' => {
144 let rel = cmd == 'm';
145 if let Some((x, y)) = nums.next_point(rel, cur) {
146 path.move_to(x, y);
147 cur = Some((x, y));
148 start = Some((x, y));
149 while let Some((x, y)) = nums.next_point(rel, cur) {
151 path.line_to(x, y);
152 cur = Some((x, y));
153 }
154 prev_was_curve = false;
155 }
156 }
157 'L' | 'l' => {
158 let rel = cmd == 'l';
159 while let Some((x, y)) = nums.next_point(rel, cur) {
160 path.line_to(x, y);
161 cur = Some((x, y));
162 }
163 prev_was_curve = false;
164 }
165 'H' | 'h' => {
166 let rel = cmd == 'h';
167 while let Some(x) = nums.next_number() {
168 let nx = if rel {
169 cur.unwrap_or((0.0, 0.0)).0 + x
170 } else {
171 x
172 };
173 path.line_to(nx, cur.map_or(0.0, |p| p.1));
174 cur = Some((nx, cur.map_or(0.0, |p| p.1)));
175 }
176 prev_was_curve = false;
177 }
178 'V' | 'v' => {
179 let rel = cmd == 'v';
180 while let Some(y) = nums.next_number() {
181 let ny = if rel {
182 cur.unwrap_or((0.0, 0.0)).1 + y
183 } else {
184 y
185 };
186 path.line_to(cur.map_or(0.0, |p| p.0), ny);
187 cur = Some((cur.map_or(0.0, |p| p.0), ny));
188 }
189 prev_was_curve = false;
190 }
191 'C' | 'c' => {
192 let rel = cmd == 'c';
193 let rel_ref = cur.unwrap_or((0.0, 0.0));
194 while let (Some(c1), Some(c2), Some(p)) = (
195 nums.next_point(rel, Some(rel_ref)),
196 nums.next_point(rel, Some(rel_ref)),
197 nums.next_point(rel, cur),
198 ) {
199 path.cubic_to(c1, c2, p);
200 cur = Some(p);
201 prev_control = Some(c2);
202 prev_was_curve = true;
203 }
204 }
205 'S' | 's' => {
206 let rel = cmd == 's';
207 let rel_ref = cur.unwrap_or((0.0, 0.0));
208 while let (Some(c2), Some(p)) = (
209 nums.next_point(rel, Some(rel_ref)),
210 nums.next_point(rel, cur),
211 ) {
212 let c1 = if prev_was_curve {
213 let c2_ref = prev_control.unwrap_or(rel_ref);
214 (2.0 * rel_ref.0 - c2_ref.0, 2.0 * rel_ref.1 - c2_ref.1)
215 } else {
216 rel_ref
217 };
218 path.cubic_to(c1, c2, p);
219 cur = Some(p);
220 prev_control = Some(c2);
221 prev_was_curve = true;
222 }
223 }
224 'Q' | 'q' => {
225 let rel = cmd == 'q';
226 let rel_ref = cur.unwrap_or((0.0, 0.0));
227 while let (Some(c), Some(p)) = (
228 nums.next_point(rel, Some(rel_ref)),
229 nums.next_point(rel, cur),
230 ) {
231 path.quad_to(c, p);
232 cur = Some(p);
233 prev_control = Some(c);
234 prev_was_curve = true;
235 }
236 }
237 'T' | 't' => {
238 let rel = cmd == 't';
239 let rel_ref = cur.unwrap_or((0.0, 0.0));
240 while let Some(p) = nums.next_point(rel, cur) {
241 let c = if prev_was_curve {
242 let c_ref = prev_control.unwrap_or(rel_ref);
243 (2.0 * rel_ref.0 - c_ref.0, 2.0 * rel_ref.1 - c_ref.1)
244 } else {
245 rel_ref
246 };
247 path.quad_to(c, p);
248 cur = Some(p);
249 prev_control = Some(c);
250 prev_was_curve = true;
251 }
252 }
253 'A' | 'a' => {
254 let rel = cmd == 'a';
255 while let (Some(rx), Some(ry), Some(rot), Some(large), Some(sweep), Some(p)) = (
256 nums.next_number(),
257 nums.next_number(),
258 nums.next_number(),
259 nums.next_number(),
260 nums.next_number(),
261 nums.next_point(rel, cur),
262 ) {
263 match cur {
264 Some(start) => append_arc(
265 &mut path,
266 start,
267 rx.abs(),
268 ry.abs(),
269 rot,
270 large != 0.0,
271 sweep != 0.0,
272 p,
273 ),
274 None => path.move_to(p.0, p.1),
276 }
277 cur = Some(p);
278 }
279 prev_was_curve = false;
280 }
281 'Z' | 'z' => {
282 path.close();
283 cur = start;
284 prev_was_curve = false;
285 }
286 _ => break,
287 }
288 }
289
290 path
291}
292
293#[allow(clippy::too_many_arguments)]
300fn append_arc(
301 path: &mut Path,
302 from: (f32, f32),
303 rx: f32,
304 ry: f32,
305 phi_deg: f32,
306 large_arc: bool,
307 sweep: bool,
308 to: (f32, f32),
309) {
310 let (x1, y1) = from;
311 let (x2, y2) = to;
312 if (rx == 0.0 || ry == 0.0)
313 || ((x1 - x2).abs() < f32::EPSILON && (y1 - y2).abs() < f32::EPSILON)
314 {
315 path.line_to(x2, y2);
316 return;
317 }
318 let phi = phi_deg.to_radians();
319 let (cos_phi, sin_phi) = (phi.cos(), phi.sin());
320
321 let (dx2, dy2) = ((x1 - x2) * 0.5, (y1 - y2) * 0.5);
323 let x1p = cos_phi * dx2 + sin_phi * dy2;
324 let y1p = -sin_phi * dx2 + cos_phi * dy2;
325
326 let (mut rx, mut ry) = (rx, ry);
328 let lambda = (x1p / rx).powi(2) + (y1p / ry).powi(2);
329 if lambda > 1.0 {
330 let scale = lambda.sqrt();
331 rx *= scale;
332 ry *= scale;
333 }
334
335 let rx2 = rx * rx;
337 let ry2 = ry * ry;
338 let numerator = rx2 * ry2 - rx2 * y1p * y1p - ry2 * x1p * x1p;
339 let denominator = rx2 * y1p * y1p + ry2 * x1p * x1p;
340 let coef = if denominator <= 0.0 {
341 0.0
342 } else {
343 (numerator / denominator).max(0.0).sqrt()
344 };
345 let sign = if large_arc == sweep { -1.0 } else { 1.0 };
346 let (cxp, cyp) = (
347 sign * coef * (rx * y1p / ry),
348 sign * coef * (-ry * x1p / rx),
349 );
350
351 let cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) * 0.5;
355 let cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) * 0.5;
356 let theta1 = ((y1p - cyp) / ry).atan2((x1p - cxp) / rx);
357 let mut delta = ((-y1p - cyp) / ry).atan2((-x1p - cxp) / rx) - theta1;
358 if sweep && delta < 0.0 {
359 delta += std::f32::consts::TAU;
360 } else if !sweep && delta > 0.0 {
361 delta -= std::f32::consts::TAU;
362 }
363
364 let segments = (delta.abs() / std::f32::consts::FRAC_PI_2).ceil().max(1.0) as usize;
366 for i in 0..segments {
367 let a = theta1 + delta * (i as f32 / segments as f32);
368 let b = theta1 + delta * ((i + 1) as f32 / segments as f32);
369 let k = (4.0 / 3.0) * ((b - a) * 0.25).tan();
370 let (sin_a, cos_a) = (a.sin(), a.cos());
371 let (sin_b, cos_b) = (b.sin(), b.cos());
372 let world = |(px, py): (f32, f32)| {
373 (
374 cos_phi * px - sin_phi * py + cx,
375 sin_phi * px + cos_phi * py + cy,
376 )
377 };
378 let (p3x, p3y) = if i + 1 == segments { to } else { world((rx * cos_b, ry * sin_b)) };
381 let (p1x, p1y) = world((rx * (cos_a - k * sin_a), ry * (sin_a + k * cos_a)));
382 let (p2x, p2y) = world((rx * (cos_b + k * sin_b), ry * (sin_b - k * cos_b)));
383 path.cubic_to((p1x, p1y), (p2x, p2y), (p3x, p3y));
384 }
385}
386
387pub fn parse_view_box(value: &str) -> ViewBox {
390 let mut nums = value
391 .split(|c: char| c.is_ascii_whitespace() || c == ',')
392 .filter_map(|token| token.parse::<f32>().ok());
393 (
394 nums.next().unwrap_or(0.0),
395 nums.next().unwrap_or(0.0),
396 nums.next().unwrap_or(0.0),
397 nums.next().unwrap_or(0.0),
398 )
399}
400
401pub(crate) fn parse_points(value: &str) -> Vec<(f32, f32)> {
404 let mut nums = SvgNum::new(value);
405 let mut points = Vec::new();
406 while let (Some(x), Some(y)) = (nums.next_number(), nums.next_number()) {
407 points.push((x, y));
408 }
409 points
410}
411
412pub(crate) fn parse_len(value: &str) -> Option<f32> {
415 let value = value.trim().trim_start_matches('+');
416 let bytes = value.as_bytes();
417 let mut i = 0;
418 if i < bytes.len() && bytes[i] == b'-' {
419 i += 1;
420 }
421 let digits_start = i;
422 while i < bytes.len() && bytes[i].is_ascii_digit() {
423 i += 1;
424 }
425 if i < bytes.len() && bytes[i] == b'.' {
426 i += 1;
427 while i < bytes.len() && bytes[i].is_ascii_digit() {
428 i += 1;
429 }
430 }
431 if i == digits_start {
432 return None;
433 }
434 if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
435 let mut j = i + 1;
436 if j < bytes.len() && (bytes[j] == b'+' || bytes[j] == b'-') {
437 j += 1;
438 }
439 if j < bytes.len() && bytes[j].is_ascii_digit() {
440 while j < bytes.len() && bytes[j].is_ascii_digit() {
441 j += 1;
442 }
443 i = j;
444 }
445 }
446 value[..i].parse::<f32>().ok()
447}
448
449pub(crate) fn parse_transform(value: &str) -> Result<AffineTransform, String> {
454 let mut out = AffineTransform::identity();
455 let mut rest = value;
456 loop {
457 let trimmed = rest.trim_start();
458 if trimmed.is_empty() {
459 return Ok(out);
460 }
461 let open = trimmed
462 .find('(')
463 .ok_or_else(|| format!("transform: missing '(' in {value:?}"))?;
464 let function = trimmed[..open].trim();
465 let inner = &trimmed[open + 1..];
466 let close = inner
467 .find(')')
468 .ok_or_else(|| format!("transform: missing ')' after {function:?} in {value:?}"))?;
469 let args = &inner[..close];
470 let next = parse_function(function, args).ok_or_else(|| {
471 format!("transform: unsupported or malformed {function:?} in {value:?}")
472 })?;
473 out = out.then(&next);
474 rest = &inner[close + 1..];
475 }
476}
477
478fn parse_function(name: &str, args: &str) -> Option<AffineTransform> {
479 let nums = args
480 .split(|c: char| c == ',' || c.is_whitespace())
481 .filter(|token| !token.trim().is_empty())
482 .filter_map(|token| token.trim().parse::<f32>().ok())
483 .collect::<Vec<f32>>();
484 match name {
485 "matrix" if nums.len() >= 6 => Some(AffineTransform {
486 m11: nums[0],
487 m12: nums[2],
488 m21: nums[1],
489 m22: nums[3],
490 dx: nums[4],
491 dy: nums[5],
492 }),
493 "translate" if !nums.is_empty() => Some(AffineTransform::translate(
494 nums[0],
495 nums.get(1).copied().unwrap_or(0.0),
496 )),
497 "scale" if !nums.is_empty() => Some(AffineTransform::scale(
498 nums[0],
499 nums.get(1).copied().unwrap_or(nums[0]),
500 )),
501 "rotate" if !nums.is_empty() => {
502 let angle = nums[0].to_radians();
503 if nums.len() >= 3 {
504 let (cx, cy) = (nums[1], nums[2]);
505 Some(
506 AffineTransform::identity()
507 .then(&AffineTransform::translate(-cx, -cy))
508 .then(&AffineTransform::rotate(angle))
509 .then(&AffineTransform::translate(cx, cy)),
510 )
511 } else {
512 Some(AffineTransform::rotate(angle))
513 }
514 }
515 "skewX" if nums.len() == 1 => Some(AffineTransform {
516 m12: nums[0].to_radians().tan(),
517 ..AffineTransform::identity()
518 }),
519 "skewY" if nums.len() == 1 => Some(AffineTransform {
520 m21: nums[0].to_radians().tan(),
521 ..AffineTransform::identity()
522 }),
523 _ => None,
524 }
525}
526
527pub(crate) fn parse_svg_fill(
537 value: &str,
538 color_scheme: ColorScheme,
539 current_color: Color,
540) -> Option<Color> {
541 let css = resolve_inline_value(value.trim())?;
542 match &css {
543 CssValue::Keyword(kw) if is_transparent_keyword(kw) => Some(Color(0, 0, 0, 0)),
544 CssValue::Keyword(kw) if kw.eq_ignore_ascii_case("currentColor") => Some(current_color),
545 CssValue::Keyword(kw) if kw.eq_ignore_ascii_case("inherit") => None,
546 CssValue::Function(name, _) if name.eq_ignore_ascii_case("url") => None,
550 _ => resolve_css_color("fill", &css, color_scheme),
551 }
552}
553
554pub(crate) fn parse_svg_url(value: &str) -> Option<String> {
557 let css = resolve_inline_value(value.trim())?;
558 let CssValue::Function(name, args) = &css else {
559 return None;
560 };
561 if !name.eq_ignore_ascii_case("url") {
562 return None;
563 }
564 let fragment = args.first()?.iter().find_map(|arg| match arg {
566 CssValue::Keyword(kw) => Some(kw.to_string()),
567 CssValue::String(s) => Some(s.clone()),
568 _ => None,
569 })?;
570 let fragment = fragment.strip_prefix('#').unwrap_or(&fragment);
571 (!fragment.is_empty()).then(|| fragment.to_owned())
572}
573
574pub(crate) fn parse_use_ref(value: &str) -> Option<String> {
577 let value = value.trim();
578 if let Some(id) = value.strip_prefix('#') {
579 return (!id.is_empty()).then_some(id.to_owned());
580 }
581 parse_svg_url(value)
582}
583
584fn is_transparent_keyword(keyword: &str) -> bool {
585 keyword.eq_ignore_ascii_case("none")
586 || keyword.eq_ignore_ascii_case("transparent")
587 || keyword.eq_ignore_ascii_case("initial")
588 || keyword.eq_ignore_ascii_case("unset")
589}
590
591pub(crate) trait SvgElement {
595 fn svg_attr(&self, name: &str) -> Option<&str>;
596 fn svg_tag_name(&self) -> Option<&str>;
597}
598
599impl SvgElement for crate::engine::html::HtmlNodeType {
600 fn svg_attr(&self, name: &str) -> Option<&str> {
601 self.get_attr(name)
602 }
603 fn svg_tag_name(&self) -> Option<&str> {
604 self.tag_name()
605 }
606}
607
608impl SvgElement for crate::engine::html::xml::XmlElement {
609 fn svg_attr(&self, name: &str) -> Option<&str> {
610 self.attr(name)
611 }
612 fn svg_tag_name(&self) -> Option<&str> {
613 Some(&self.name)
614 }
615}
616
617#[cfg(test)]
622pub(crate) fn resolve_shape_fill(
623 el: &impl SvgElement,
624 color_scheme: ColorScheme,
625 current_color: Color,
626) -> Color {
627 match presentation_value(el, "fill") {
628 Some(value) => {
629 parse_svg_fill(value, color_scheme, current_color).unwrap_or(Color(0, 0, 0, 255))
630 }
631 None => Color(0, 0, 0, 255),
632 }
633}
634
635pub(crate) fn resolve_fill_rule(el: &impl SvgElement) -> FillRule {
638 match presentation_value(el, "fill-rule") {
639 Some(value) if value.eq_ignore_ascii_case("evenodd") => FillRule::EvenOdd,
640 _ => FillRule::NonZero,
641 }
642}
643
644pub(crate) fn resolve_stroke(
651 el: &impl SvgElement,
652 color_scheme: ColorScheme,
653 current_color: Color,
654 gradients: &HashMap<String, Gradient>,
655 inherited: Option<&SvgStroke>,
656) -> Option<SvgStroke> {
657 let mut stroke = SvgStroke::inherited_from(inherited);
658 let mut declared = inherited.is_some();
661 if let Some(value) = presentation_value(el, "stroke") {
662 if is_transparent_keyword(value.trim()) {
663 return None;
664 }
665 declared = true;
666 if let Some(id) = parse_svg_url(value) {
667 stroke.gradient = Some(gradients.get(&id).cloned()?);
670 } else {
671 stroke.gradient = None;
672 match parse_svg_fill(value, color_scheme, current_color) {
673 Some(color) => stroke.color = color,
674 None => stroke.color = Color(0, 0, 0, 255),
675 }
676 }
677 }
678 if let Some(width) = numeric_presentation_value(el, "stroke-width") {
679 stroke.width = width;
680 }
681 if let Some(value) = presentation_value(el, "stroke-linecap") {
682 stroke.cap = match value.trim() {
683 v if v.eq_ignore_ascii_case("round") => StrokeCap::Round,
684 v if v.eq_ignore_ascii_case("square") => StrokeCap::Square,
685 _ => StrokeCap::Butt,
686 };
687 }
688 if let Some(value) = presentation_value(el, "stroke-linejoin") {
689 stroke.join = match value.trim() {
690 v if v.eq_ignore_ascii_case("round") => StrokeJoin::Round,
691 v if v.eq_ignore_ascii_case("bevel") => StrokeJoin::Bevel,
692 _ => StrokeJoin::Miter,
693 };
694 }
695 if let Some(limit) = numeric_presentation_value(el, "stroke-miterlimit") {
696 stroke.miter_limit = limit.max(1.0);
697 }
698 if let Some(value) = presentation_value(el, "stroke-dasharray") {
699 stroke.dash = parse_dasharray(value);
700 }
701 if let Some(offset) = numeric_presentation_value(el, "stroke-dashoffset") {
702 stroke.dash_offset = offset;
703 }
704 if !declared || stroke.width <= 0.0 {
705 return None;
706 }
707 let paints = stroke.gradient.is_some() || stroke.color.3 != 0;
708 if !paints {
709 return None;
710 }
711 Some(stroke)
712}
713
714pub(crate) fn parse_dasharray(value: &str) -> Option<Vec<f32>> {
718 let value = value.trim();
719 if value.is_empty() || value.eq_ignore_ascii_case("none") {
720 return None;
721 }
722 let lengths: Vec<f32> = value
723 .split(',')
724 .flat_map(|part| part.split_ascii_whitespace())
725 .filter_map(|n| n.parse::<f32>().ok())
726 .map(|n| n.max(0.0))
727 .collect();
728 if lengths.is_empty() || lengths.iter().all(|n| *n <= 0.0) {
729 None
730 } else {
731 Some(lengths)
732 }
733}
734
735#[derive(Debug, Clone)]
738pub(crate) struct PaintState {
739 pub fill: Option<Color>,
740 pub fill_gradient: Option<Gradient>,
741 pub fill_opacity: f32,
742 pub rule: FillRule,
743 pub stroke: Option<SvgStroke>,
744 pub stroke_opacity: f32,
745}
746
747impl PaintState {
748 pub(crate) fn new() -> Self {
749 PaintState {
750 fill: None,
751 fill_gradient: None,
752 fill_opacity: 1.0,
753 rule: FillRule::NonZero,
754 stroke: None,
755 stroke_opacity: 1.0,
756 }
757 }
758
759 pub(crate) fn apply(
762 &mut self,
763 el: &impl SvgElement,
764 color_scheme: ColorScheme,
765 current_color: Color,
766 gradients: &HashMap<String, Gradient>,
767 ) {
768 if let Some(value) = presentation_value(el, "fill") {
769 match parse_svg_url(value) {
770 Some(id) => {
771 if let Some(gradient) = gradients.get(&id) {
774 self.fill_gradient = Some(gradient.clone());
775 self.fill = None;
776 } else {
777 self.fill_gradient = None;
778 self.fill = Some(Color(0, 0, 0, 0));
779 }
780 }
781 None => {
782 self.fill_gradient = None;
783 self.fill = parse_svg_fill(value, color_scheme, current_color)
784 .or(Some(Color(0, 0, 0, 255)));
785 }
786 }
787 }
788 self.fill_opacity *= numeric_presentation_value(el, "fill-opacity")
789 .unwrap_or(1.0)
790 .clamp(0.0, 1.0);
791 if let Some(value) = presentation_value(el, "fill-rule") {
792 self.rule = if value.eq_ignore_ascii_case("evenodd") {
793 FillRule::EvenOdd
794 } else {
795 FillRule::NonZero
796 };
797 }
798 self.stroke_opacity *= numeric_presentation_value(el, "stroke-opacity")
799 .unwrap_or(1.0)
800 .clamp(0.0, 1.0);
801 self.stroke = resolve_stroke(
802 el,
803 color_scheme,
804 current_color,
805 gradients,
806 self.stroke.as_ref(),
807 );
808 }
809}
810
811pub(crate) fn numeric_presentation_value(el: &impl SvgElement, name: &str) -> Option<f32> {
815 presentation_value(el, name)?
816 .parse::<f32>()
817 .ok()
818 .filter(|v| v.is_finite())
819}
820
821fn presentation_value<'a>(el: &'a impl SvgElement, name: &str) -> Option<&'a str> {
824 el.svg_attr(name)
825 .map(str::trim)
826 .filter(|value| !value.is_empty())
827 .or_else(|| {
828 el.svg_attr("style")
829 .and_then(|style| style_property(style, name))
830 .map(str::trim)
831 })
832}
833
834pub(crate) fn style_property<'a>(style: &'a str, name: &str) -> Option<&'a str> {
836 style.split(';').find_map(|pair| {
837 let (key, value) = pair.split_once(':')?;
838 key.trim().eq_ignore_ascii_case(name).then_some(value)
839 })
840}
841
842pub(crate) fn with_alpha(color: Color, alpha: f32) -> Color {
844 let a = (color.3 as f32 * alpha.clamp(0.0, 1.0)).round() as u8;
845 Color(color.0, color.1, color.2, a)
846}
847
848pub fn collect_svg(
859 snapshot: &DomSnapshot,
860 svg_id: NodeId,
861 color_scheme: ColorScheme,
862 text_color: Color,
863) -> Option<SvgContent> {
864 let view_box = parse_view_box(snapshot.node(svg_id).kind.get_attr("viewBox")?);
865 if view_box.2 <= 0.0 || view_box.3 <= 0.0 {
866 return None;
867 }
868 let mut gradients = HashMap::new();
869 for &child in snapshot.children(svg_id) {
870 collect_gradient_defs(snapshot, child, color_scheme, text_color, &mut gradients);
871 }
872 let mut id_map = HashMap::new();
873 for &child in snapshot.children(svg_id) {
874 collect_id_map(snapshot, child, &mut id_map);
875 }
876 let mut clip_paths = HashMap::new();
877 for &child in snapshot.children(svg_id) {
878 collect_clip_path_defs(snapshot, child, &mut clip_paths);
879 }
880 let mut shapes = Vec::new();
881 let mut use_stack: Vec<NodeId> = Vec::new();
882 for &child in snapshot.children(svg_id) {
883 collect_content(
884 snapshot,
885 child,
886 &AffineTransform::identity(),
887 1.0,
888 color_scheme,
889 text_color,
890 PaintState::new(),
891 &gradients,
892 &id_map,
893 &clip_paths,
894 &mut use_stack,
895 &mut shapes,
896 );
897 }
898 if shapes.is_empty() {
899 return None;
900 }
901 Some(SvgContent { view_box, shapes })
902}
903
904fn collect_clip_path_defs(
908 snapshot: &DomSnapshot,
909 id: NodeId,
910 out: &mut HashMap<String, ClipPath>,
911) {
912 let el = &snapshot.node(id).kind;
913 if el.tag_name() == Some("clipPath")
914 && let Some(clip_id) = el.get_attr("id").filter(|id| !id.is_empty())
915 {
916 let mut path = Path::new();
917 let transform = match el.get_attr("transform") {
918 Some(value) => parse_transform(value).unwrap_or(AffineTransform::identity()),
919 None => AffineTransform::identity(),
920 };
921 let rule = resolve_fill_rule(el);
922 collect_clip_path_shapes(snapshot, id, &transform, &mut path);
923 if !path.commands().is_empty() {
924 out.entry(clip_id.to_owned()).or_insert(ClipPath { path, rule });
925 }
926 }
927 for &child in snapshot.children(id) {
928 collect_clip_path_defs(snapshot, child, out);
929 }
930}
931
932fn collect_clip_path_shapes(
935 snapshot: &DomSnapshot,
936 id: NodeId,
937 transform: &AffineTransform,
938 path: &mut Path,
939) {
940 let el = &snapshot.node(id).kind;
941 if let Some(shape_path) = element_shape(el) {
942 let transformed = transform_path(&shape_path, transform);
943 for subpath in transformed.subpaths() {
945 if let Some(&first) = subpath.first() {
946 path.move_to(first.0, first.1);
947 for &(x, y) in &subpath[1..] {
948 path.line_to(x, y);
949 }
950 path.close();
951 }
952 }
953 }
954 for &child in snapshot.children(id) {
955 collect_clip_path_shapes(snapshot, child, transform, path);
956 }
957}
958
959pub(crate) fn collect_id_map(
962 snapshot: &DomSnapshot,
963 id: NodeId,
964 out: &mut HashMap<String, NodeId>,
965) {
966 let el = &snapshot.node(id).kind;
967 if let Some(id_value) = el.get_attr("id")
968 && !id_value.is_empty()
969 {
970 out.entry(id_value.to_owned()).or_insert(id);
971 }
972 for &child in snapshot.children(id) {
973 collect_id_map(snapshot, child, out);
974 }
975}
976
977fn collect_gradient_defs(
981 snapshot: &DomSnapshot,
982 id: NodeId,
983 color_scheme: ColorScheme,
984 text_color: Color,
985 out: &mut HashMap<String, Gradient>,
986) {
987 let el = &snapshot.node(id).kind;
988 match el.tag_name() {
989 Some("linearGradient") => {
990 let stops = collect_stops(snapshot, id, color_scheme, text_color);
991 if let Some(gradient) = parse_linear_gradient(el, &stops, color_scheme, text_color)
992 && let Some(gid) = el.get_attr("id").filter(|id| !id.is_empty())
993 {
994 out.entry(gid.to_owned()).or_insert(gradient);
995 }
996 }
997 Some("radialGradient") => {
998 let stops = collect_stops(snapshot, id, color_scheme, text_color);
999 if let Some(gradient) = parse_radial_gradient(el, &stops, color_scheme, text_color)
1000 && let Some(gid) = el.get_attr("id").filter(|id| !id.is_empty())
1001 {
1002 out.entry(gid.to_owned()).or_insert(gradient);
1003 }
1004 }
1005 _ => {}
1006 }
1007 for &child in snapshot.children(id) {
1008 collect_gradient_defs(snapshot, child, color_scheme, text_color, out);
1009 }
1010}
1011
1012fn gradient_units_and_spread(el: &impl SvgElement) -> (bool, GradientSpread) {
1015 let user_space = el
1016 .svg_attr("gradientUnits")
1017 .map(|v| v.trim().eq_ignore_ascii_case("userSpaceOnUse"))
1018 .unwrap_or(false);
1019 let spread = match el
1020 .svg_attr("spreadMethod")
1021 .map(|v| v.trim())
1022 .unwrap_or("pad")
1023 {
1024 v if v.eq_ignore_ascii_case("repeat") => GradientSpread::Repeat,
1025 v if v.eq_ignore_ascii_case("reflect") => GradientSpread::Reflect,
1026 _ => GradientSpread::Pad,
1027 };
1028 (user_space, spread)
1029}
1030
1031fn parse_linear_gradient(
1035 el: &impl SvgElement,
1036 stops: &[ColorStop],
1037 _color_scheme: ColorScheme,
1038 _text_color: Color,
1039) -> Option<Gradient> {
1040 let (user_space, spread) = gradient_units_and_spread(el);
1041 if stops.is_empty() {
1042 return None;
1043 }
1044 Some(Gradient {
1045 kind: GradientKind::SvgLinear {
1046 x1: el.svg_attr("x1").and_then(parse_len).unwrap_or(0.0),
1047 y1: el.svg_attr("y1").and_then(parse_len).unwrap_or(0.0),
1048 x2: el.svg_attr("x2").and_then(parse_len).unwrap_or(1.0),
1049 y2: el.svg_attr("y2").and_then(parse_len).unwrap_or(0.0),
1050 user_space,
1051 },
1052 stops: stops.to_vec(),
1053 spread,
1054 })
1055}
1056
1057fn parse_radial_gradient(
1060 el: &impl SvgElement,
1061 stops: &[ColorStop],
1062 _color_scheme: ColorScheme,
1063 _text_color: Color,
1064) -> Option<Gradient> {
1065 let (user_space, spread) = gradient_units_and_spread(el);
1066 let cx = el.svg_attr("cx").and_then(parse_len).unwrap_or(0.5);
1067 let cy = el.svg_attr("cy").and_then(parse_len).unwrap_or(0.5);
1068 if stops.is_empty() {
1069 return None;
1070 }
1071 Some(Gradient {
1072 kind: GradientKind::SvgRadial {
1073 cx,
1074 cy,
1075 r: el.svg_attr("r").and_then(parse_len).unwrap_or(0.5),
1076 fx: el.svg_attr("fx").and_then(parse_len).unwrap_or(cx),
1077 fy: el.svg_attr("fy").and_then(parse_len).unwrap_or(cy),
1078 fr: el.svg_attr("fr").and_then(parse_len).unwrap_or(0.0),
1079 user_space,
1080 },
1081 stops: stops.to_vec(),
1082 spread,
1083 })
1084}
1085
1086fn collect_stops(
1090 snapshot: &DomSnapshot,
1091 id: NodeId,
1092 color_scheme: ColorScheme,
1093 text_color: Color,
1094) -> Vec<ColorStop> {
1095 let mut out = Vec::new();
1096 for &child in snapshot.children(id) {
1097 let el = &snapshot.node(child).kind;
1098 if el.tag_name() != Some("stop") {
1099 continue;
1100 }
1101 let offset = el.get_attr("offset").and_then(parse_stop_offset);
1102 let color = presentation_value(el, "stop-color")
1103 .and_then(|value| parse_svg_fill(value, color_scheme, text_color))
1104 .unwrap_or(Color(0, 0, 0, 255));
1105 let opacity = numeric_presentation_value(el, "stop-opacity")
1106 .unwrap_or(1.0)
1107 .clamp(0.0, 1.0);
1108 out.push(ColorStop {
1109 color: with_alpha(color, opacity),
1110 position: offset,
1111 });
1112 }
1113 out
1114}
1115
1116fn parse_stop_offset(value: &str) -> Option<f32> {
1118 let value = value.trim();
1119 if let Some(percent) = value.strip_suffix('%') {
1120 return parse_len(percent).map(|v| (v / 100.0).clamp(0.0, 1.0));
1121 }
1122 parse_len(value).map(|v| v.clamp(0.0, 1.0))
1123}
1124
1125#[allow(clippy::too_many_arguments)]
1130fn collect_content(
1131 snapshot: &DomSnapshot,
1132 id: NodeId,
1133 inherited: &AffineTransform,
1134 opacity: f32,
1135 color_scheme: ColorScheme,
1136 text_color: Color,
1137 mut paint: PaintState,
1138 gradients: &HashMap<String, Gradient>,
1139 id_map: &HashMap<String, NodeId>,
1140 clip_paths: &HashMap<String, ClipPath>,
1141 use_stack: &mut Vec<NodeId>,
1142 shapes: &mut Vec<SvgShape>,
1143) {
1144 let el = &snapshot.node(id).kind;
1145 let eff = match el.get_attr("transform") {
1146 Some(value) => match parse_transform(value) {
1147 Ok(transform) => inherited.then(&transform),
1148 Err(_) => *inherited,
1149 },
1150 None => *inherited,
1151 };
1152 let element_opacity = numeric_presentation_value(el, "opacity").unwrap_or(1.0);
1153 let opacity = opacity * element_opacity.clamp(0.0, 1.0);
1154 paint.apply(el, color_scheme, text_color, gradients);
1155
1156 match el.tag_name() {
1157 Some("g" | "a" | "symbol") => {
1158 for &child in snapshot.children(id) {
1159 collect_content(
1160 snapshot,
1161 child,
1162 &eff,
1163 opacity,
1164 color_scheme,
1165 text_color,
1166 paint.clone(),
1167 gradients,
1168 id_map,
1169 clip_paths,
1170 use_stack,
1171 shapes,
1172 );
1173 }
1174 }
1175 Some("use") => {
1176 let href = el
1179 .svg_attr("href")
1180 .or_else(|| el.svg_attr("xlink:href"));
1181 let target_id = href
1182 .and_then(parse_use_ref)
1183 .and_then(|frag| id_map.get(&frag).copied());
1184 if let Some(target) = target_id {
1185 if !use_stack.contains(&target) {
1188 let x = el.svg_attr("x").and_then(parse_len).unwrap_or(0.0);
1189 let y = el.svg_attr("y").and_then(parse_len).unwrap_or(0.0);
1190 let use_transform = eff.then(&AffineTransform::translate(x, y));
1191 use_stack.push(target);
1192 collect_content(
1193 snapshot,
1194 target,
1195 &use_transform,
1196 opacity,
1197 color_scheme,
1198 text_color,
1199 paint.clone(),
1200 gradients,
1201 id_map,
1202 clip_paths,
1203 use_stack,
1204 shapes,
1205 );
1206 use_stack.pop();
1207 }
1208 }
1209 }
1210 Some("path" | "rect" | "circle" | "ellipse" | "polygon" | "polyline" | "line") => {
1211 let Some(path) = element_shape(el) else {
1212 return;
1213 };
1214 let path = transform_path(&path, &eff);
1215 if path.commands().is_empty() {
1216 return;
1217 }
1218 let fill_alpha = opacity * paint.fill_opacity;
1219 let fill = match &paint.fill_gradient {
1220 Some(gradient) => Brush::Gradient(with_stop_alpha(gradient.clone(), fill_alpha)),
1221 None => Brush::Solid(with_alpha(
1222 paint.fill.unwrap_or(Color(0, 0, 0, 255)),
1223 fill_alpha,
1224 )),
1225 };
1226 let stroke = paint.stroke.clone().map(|mut stroke| {
1227 if let Some(gradient) = &mut stroke.gradient {
1228 *gradient = with_stop_alpha(gradient.clone(), opacity * paint.stroke_opacity);
1229 } else {
1230 stroke.color = with_alpha(stroke.color, opacity * paint.stroke_opacity);
1231 }
1232 stroke
1233 });
1234 let clip_path = el
1235 .svg_attr("clip-path")
1236 .and_then(parse_use_ref)
1237 .and_then(|frag| clip_paths.get(&frag).cloned());
1238 shapes.push(SvgShape {
1239 path,
1240 fill,
1241 rule: paint.rule,
1242 stroke,
1243 clip_path,
1244 });
1245 }
1246 _ => {}
1247 }
1248}
1249
1250fn with_stop_alpha(gradient: Gradient, alpha: f32) -> Gradient {
1254 let mut gradient = gradient;
1255 for stop in &mut gradient.stops {
1256 stop.color = with_alpha(stop.color, alpha);
1257 }
1258 gradient
1259}
1260
1261fn element_shape(el: &impl SvgElement) -> Option<Path> {
1264 match el.svg_tag_name()? {
1265 "path" => {
1266 let d = el.svg_attr("d")?;
1267 let path = path_from_d(d);
1268 (!path.commands().is_empty()).then_some(path)
1269 }
1270 "rect" => {
1271 let x = el.svg_attr("x").and_then(parse_len).unwrap_or(0.0);
1272 let y = el.svg_attr("y").and_then(parse_len).unwrap_or(0.0);
1273 let w = el.svg_attr("width").and_then(parse_len).unwrap_or(0.0);
1274 let h = el.svg_attr("height").and_then(parse_len).unwrap_or(0.0);
1275 let rx = el
1276 .svg_attr("rx")
1277 .and_then(parse_len)
1278 .unwrap_or(0.0)
1279 .max(0.0);
1280 let ry = el
1281 .svg_attr("ry")
1282 .and_then(parse_len)
1283 .unwrap_or(0.0)
1284 .max(0.0);
1285 if w <= 0.0 || h <= 0.0 {
1286 return None;
1287 }
1288 if rx > 0.0 || ry > 0.0 {
1289 let radius = (rx, ry);
1291 Some(rounded_rect_path(
1292 x, y, w, h, radius, radius, radius, radius,
1293 ))
1294 } else {
1295 Some(rect_path(x, y, w, h))
1296 }
1297 }
1298 "circle" | "ellipse" => {
1299 let cx = el.svg_attr("cx").and_then(parse_len).unwrap_or(0.0);
1300 let cy = el.svg_attr("cy").and_then(parse_len).unwrap_or(0.0);
1301 let (rx, ry) = if el.svg_tag_name() == Some("circle") {
1302 let r = el.svg_attr("r").and_then(parse_len).unwrap_or(0.0);
1303 (r, r)
1304 } else {
1305 (
1306 el.svg_attr("rx").and_then(parse_len).unwrap_or(0.0),
1307 el.svg_attr("ry").and_then(parse_len).unwrap_or(0.0),
1308 )
1309 };
1310 (rx > 0.0 && ry > 0.0).then_some(ellipse_path(cx, cy, rx, ry))
1311 }
1312 "polygon" => {
1313 let points = parse_points(el.svg_attr("points").unwrap_or_default());
1314 (!points.is_empty()).then(|| polygon_path(&points))
1315 }
1316 "polyline" => {
1317 let points = parse_points(el.svg_attr("points").unwrap_or_default());
1318 (!points.is_empty()).then(|| polyline_path(&points))
1319 }
1320 "line" => {
1321 let (x1, y1) = (
1322 el.svg_attr("x1").and_then(parse_len).unwrap_or(0.0),
1323 el.svg_attr("y1").and_then(parse_len).unwrap_or(0.0),
1324 );
1325 let (x2, y2) = (
1326 el.svg_attr("x2").and_then(parse_len).unwrap_or(0.0),
1327 el.svg_attr("y2").and_then(parse_len).unwrap_or(0.0),
1328 );
1329 Some(line_path(x1, y1, x2, y2))
1330 }
1331 _ => None,
1332 }
1333}
1334
1335pub fn viewbox_meet_transform(view_box: ViewBox, vw: f32, vh: f32) -> AffineTransform {
1339 let (vx, vy, vbw, vbh) = view_box;
1340 if vbw <= 0.0 || vbh <= 0.0 {
1341 return AffineTransform::identity();
1342 }
1343 let scale = (vw / vbw).min(vh / vbh);
1344 let tx = (vw - vbw * scale) * 0.5;
1345 let ty = (vh - vbh * scale) * 0.5;
1346 AffineTransform {
1347 m11: scale,
1348 m22: scale,
1349 dx: tx - vx * scale,
1350 dy: ty - vy * scale,
1351 ..AffineTransform::identity()
1352 }
1353}
1354
1355pub fn emit_commands(
1365 cmd_buf: &mut Vec<DrawCommand>,
1366 content_box: (f32, f32, f32, f32),
1367 is_inline: bool,
1368 view_box: ViewBox,
1369 shapes: &[SvgShape],
1370 opacity: f32,
1371) {
1372 let (cx, cy, cw, ch) = content_box;
1373 if cw <= 0.0 || ch <= 0.0 {
1374 return;
1375 }
1376 let (ox, oy) = if is_inline { (cx, cy) } else { (0.0, 0.0) };
1377 let mut transform = viewbox_meet_transform(view_box, cw, ch);
1378 transform.dx += ox;
1379 transform.dy += oy;
1380 cmd_buf.push(DrawCommand::PushTransform { transform });
1381 for shape in shapes {
1382 if let Some(clip) = &shape.clip_path {
1383 cmd_buf.push(DrawCommand::PushClip {
1384 path: clip.path.clone(),
1385 rule: clip.rule,
1386 });
1387 }
1388 cmd_buf.push(DrawCommand::Fill {
1389 path: shape.path.clone(),
1390 paint: Paint {
1391 brush: shape.fill.clone(),
1392 opacity,
1393 },
1394 rule: shape.rule,
1395 });
1396 if let Some(stroke) = &shape.stroke {
1397 let brush = match &stroke.gradient {
1401 Some(gradient) => Brush::Gradient(gradient.clone()),
1402 None => Brush::Solid(stroke.color),
1403 };
1404 cmd_buf.push(DrawCommand::Fill {
1405 path: stroke_path(&shape.path, &stroke.to_stroke()),
1406 paint: Paint { brush, opacity },
1407 rule: FillRule::NonZero,
1408 });
1409 }
1410 if shape.clip_path.is_some() {
1411 cmd_buf.push(DrawCommand::PopClip);
1412 }
1413 }
1414 cmd_buf.push(DrawCommand::PopTransform);
1415}
1416
1417struct SvgNum {
1420 chars: Vec<char>,
1421 pos: usize,
1422}
1423
1424impl SvgNum {
1425 fn new(d: &str) -> Self {
1426 SvgNum {
1427 chars: d.chars().collect(),
1428 pos: 0,
1429 }
1430 }
1431
1432 fn skip_ws(&mut self) {
1433 while self.pos < self.chars.len() {
1434 let c = self.chars[self.pos];
1435 if c.is_ascii_whitespace() || c == ',' {
1436 self.pos += 1;
1437 } else {
1438 break;
1439 }
1440 }
1441 }
1442
1443 fn next_command(&mut self) -> Option<char> {
1445 self.skip_ws();
1446 let c = *self.chars.get(self.pos)?;
1447 if c.is_ascii_alphabetic() {
1448 self.pos += 1;
1449 Some(c)
1450 } else {
1451 None
1452 }
1453 }
1454
1455 fn next_number(&mut self) -> Option<f32> {
1456 self.skip_ws();
1457 if self.pos >= self.chars.len() {
1458 return None;
1459 }
1460 if self.chars[self.pos].is_ascii_alphabetic() {
1462 return None;
1463 }
1464 let start = self.pos;
1465 let mut i = self.pos;
1466 if i < self.chars.len() && (self.chars[i] == '+' || self.chars[i] == '-') {
1467 i += 1;
1468 }
1469 let mut digits = 0;
1470 while i < self.chars.len() && self.chars[i].is_ascii_digit() {
1471 i += 1;
1472 digits += 1;
1473 }
1474 if i < self.chars.len() && self.chars[i] == '.' {
1475 i += 1;
1476 while i < self.chars.len() && self.chars[i].is_ascii_digit() {
1477 i += 1;
1478 digits += 1;
1479 }
1480 }
1481 if digits == 0 && start == self.pos {
1483 return None;
1484 }
1485 if i < self.chars.len() && (self.chars[i] == 'e' || self.chars[i] == 'E') {
1487 let mut j = i + 1;
1488 if j < self.chars.len() && (self.chars[j] == '+' || self.chars[j] == '-') {
1489 j += 1;
1490 }
1491 if j < self.chars.len() && self.chars[j].is_ascii_digit() {
1492 i = j;
1493 while i < self.chars.len() && self.chars[i].is_ascii_digit() {
1494 i += 1;
1495 }
1496 }
1497 }
1498 let text: String = self.chars[start..i].iter().collect();
1500 match text.parse::<f32>() {
1501 Ok(v) => {
1502 self.pos = i;
1503 Some(v)
1504 }
1505 Err(_) => None,
1506 }
1507 }
1508
1509 fn next_value(&mut self, rel: bool, ref_val: Option<f32>) -> Option<f32> {
1511 let v = self.next_number()?;
1512 if rel {
1513 Some(ref_val.unwrap_or(0.0) + v)
1514 } else {
1515 Some(v)
1516 }
1517 }
1518
1519 fn next_point(&mut self, rel: bool, cur: Option<(f32, f32)>) -> Option<(f32, f32)> {
1520 let ref_pt = cur.unwrap_or((0.0, 0.0));
1521 let x = self.next_value(rel, Some(ref_pt.0))?;
1522 let y = self.next_value(rel, Some(ref_pt.1))?;
1523 Some((x, y))
1524 }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529 use super::*;
1530 use crate::engine::html::HtmlNodeType;
1531 use crate::engine::html::tokenizer::Attribute;
1532 use crate::engine::layouter::dom_snapshot::DomSnapshot;
1533 use crate::engine::renderer_model::PathCommand;
1534 use crate::engine::tree::TreeNode;
1535 use std::rc::Rc;
1536
1537 fn element(tag: &str, attrs: &[(&str, &str)]) -> HtmlNodeType {
1538 HtmlNodeType::Element {
1539 tag_name: tag.to_string(),
1540 attributes: attrs
1541 .iter()
1542 .map(|(name, value)| Attribute {
1543 name: name.to_string(),
1544 value: value.to_string(),
1545 })
1546 .collect(),
1547 }
1548 }
1549
1550 #[test]
1551 fn smooth_curve_path_skips_previous_non_curve() {
1552 let path = path_from_d("M 10 10 L 20 10 S 30 0, 40 10");
1555 match &path.commands()[2] {
1556 PathCommand::CubicTo { c1x, c1y, .. } => {
1557 assert_eq!((*c1x, *c1y), (20.0, 10.0));
1558 }
1559 other => panic!("expected cubic, got {other:?}"),
1560 }
1561 }
1562
1563 #[test]
1564 fn path_parses_absolute_moveto_lineto_and_close() {
1565 let path = path_from_d("M 0 0 L 10 0 L 10 10 L 0 10 Z");
1566 assert_eq!(
1567 path.subpaths(),
1568 vec![vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]]
1569 );
1570 }
1571
1572 #[test]
1573 fn path_parses_relative_implicit_lineto_and_hv() {
1574 let path = path_from_d("m 1 1 3 0 h 2 v 2 z");
1577 assert_eq!(
1578 path.subpaths(),
1579 vec![vec![(1.0, 1.0), (4.0, 1.0), (6.0, 1.0), (6.0, 3.0)]]
1580 );
1581 }
1582
1583 #[test]
1584 fn path_cubic_s_reflects_previous_control_point() {
1585 let path = path_from_d("M 0 0 C 10 10, 20 10, 30 0 S 50 -10, 60 0");
1587 match &path.commands()[2] {
1588 PathCommand::CubicTo {
1589 c1x,
1590 c1y,
1591 c2x,
1592 c2y,
1593 x,
1594 y,
1595 } => {
1596 assert_eq!((*c1x, *c1y), (40.0, -10.0));
1597 assert_eq!((*c2x, *c2y), (50.0, -10.0));
1598 assert_eq!((*x, *y), (60.0, 0.0));
1599 }
1600 other => panic!("expected cubic, got {other:?}"),
1601 }
1602 }
1603
1604 #[test]
1605 fn path_quad_t_reflects_previous_control_point() {
1606 let path = path_from_d("M 0 0 Q 10 20 20 0 T 40 0");
1608 match &path.commands()[2] {
1609 PathCommand::QuadTo { cx, cy, x, y } => {
1610 assert_eq!((*cx, *cy), (30.0, -20.0));
1611 assert_eq!((*x, *y), (40.0, 0.0));
1612 }
1613 other => panic!("expected quad, got {other:?}"),
1614 }
1615 }
1616
1617 #[test]
1618 fn arc_is_approximated_by_cubics_staying_on_the_circle() {
1619 let path = path_from_d("M 0 0 A 5 5 0 0 1 10 0");
1621 assert!(matches!(&path.commands()[0], PathCommand::MoveTo { .. }));
1622 let cubics = path
1623 .commands()
1624 .iter()
1625 .filter(|c| matches!(c, PathCommand::CubicTo { .. }))
1626 .count();
1627 assert!(cubics >= 2, "expected ≥ 2 cubics, got {cubics}");
1628 let ring = path.subpaths().into_iter().next().expect("one ring");
1629 assert_close(ring.first().copied().unwrap(), (0.0, 0.0));
1630 assert_close(ring.last().copied().unwrap(), (10.0, 0.0));
1631 for &(x, y) in &ring {
1632 let v = ((x - 5.0) / 5.0).powi(2) + (y / 5.0).powi(2);
1633 assert!((v - 1.0).abs() < 0.05, "point ({x},{y}) off circle: {v}");
1634 }
1635 assert!(
1637 ring.iter().map(|&(_, y)| y.abs()).fold(0.0f32, f32::max) > 4.0,
1638 "arc did not bulge"
1639 );
1640 }
1641
1642 #[test]
1643 fn arc_sweep_flag_selects_the_complementary_side() {
1644 let sweep1 = path_from_d("M 0 0 A 5 5 0 0 1 10 0");
1647 let sweep0 = path_from_d("M 0 0 A 5 5 0 0 0 10 0");
1648 let extreme_y = |p: &Path, f: fn(f32, f32) -> f32| {
1649 p.subpaths()[0].iter().map(|&(_, y)| y).fold(f32::NAN, &f)
1650 };
1651 assert!(
1653 extreme_y(&sweep1, f32::min) < -4.0,
1654 "sweep=1 should go over the top"
1655 );
1656 assert!(
1657 extreme_y(&sweep0, f32::max) > 4.0,
1658 "sweep=0 should go under the bottom"
1659 );
1660 }
1661
1662 #[test]
1663 fn arc_large_flag_takes_the_long_way_around() {
1664 let small = path_from_d("M 0 0 A 5 5 0 0 1 6 0");
1667 let large = path_from_d("M 0 0 A 5 5 0 1 1 6 0");
1668 let cubics = |p: &Path| {
1669 p.commands()
1670 .iter()
1671 .filter(|c| matches!(c, PathCommand::CubicTo { .. }))
1672 .count()
1673 };
1674 assert!(
1675 cubics(&large) > cubics(&small),
1676 "large arc should split more"
1677 );
1678 for p in [&small, &large] {
1679 let ring = &p.subpaths()[0];
1680 assert_close(ring.first().copied().unwrap(), (0.0, 0.0));
1681 assert_close(ring.last().copied().unwrap(), (6.0, 0.0));
1682 }
1683 }
1684
1685 #[test]
1686 fn arc_scales_radii_up_when_the_chord_exceeds_the_radius() {
1687 let path = path_from_d("M 0 0 A 3 3 0 0 1 10 0");
1689 let ring = &path.subpaths()[0];
1690 assert_close(ring.first().copied().unwrap(), (0.0, 0.0));
1691 assert_close(ring.last().copied().unwrap(), (10.0, 0.0));
1692 for &(x, y) in ring {
1693 let v = ((x - 5.0) / 5.0).powi(2) + (y / 5.0).powi(2);
1694 assert!((v - 1.0).abs() < 0.05, "point ({x},{y}) off circle: {v}");
1695 }
1696 }
1697
1698 #[test]
1699 fn arc_rotates_ellipse_with_x_axis_rotation() {
1700 let path = path_from_d("M 0 0 A 20 10 45 0 1 20 0");
1705 let ring = &path.subpaths()[0];
1706 let phi = 45f32.to_radians();
1707 let (cos_p, sin_p) = (phi.cos(), phi.sin());
1708 let (x1p, y1p) = (cos_p * -10.0, -sin_p * -10.0);
1709 let num = 400.0 * 100.0 - 400.0 * y1p * y1p - 100.0 * x1p * x1p;
1710 let den = 400.0 * y1p * y1p + 100.0 * x1p * x1p;
1711 let coef = (num / den).max(0.0).sqrt();
1712 let (cxp, cyp) = (coef * (20.0 * y1p / 10.0), coef * (-10.0 * x1p / 20.0));
1714 let (cx, cy) = (cos_p * cxp - sin_p * cyp + 10.0, sin_p * cxp + cos_p * cyp);
1715 for &(x, y) in ring {
1716 let (dx, dy) = (x - cx, y - cy);
1717 let (rx_local, ry_local) = (dx * cos_p + dy * sin_p, -dx * sin_p + dy * cos_p);
1718 let v = (rx_local / 20.0).powi(2) + (ry_local / 10.0).powi(2);
1719 assert!((v - 1.0).abs() < 0.05, "point ({x},{y}) off ellipse: {v}");
1720 }
1721 }
1722
1723 fn assert_close(a: (f32, f32), b: (f32, f32)) {
1724 assert!(
1725 (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3,
1726 "expected {b:?}, got {a:?}"
1727 );
1728 }
1729
1730 #[test]
1731 fn relative_arc_and_radii_of_zero_fall_back_to_lines() {
1732 let path = path_from_d("M 0 0 a 5 5 0 0 1 6 0");
1734 assert_eq!(path.subpaths()[0].last().copied(), Some((6.0, 0.0)));
1735 let degenerate = path_from_d("M 0 0 A 0 0 0 0 1 10 10");
1737 assert_eq!(degenerate.subpaths(), vec![vec![(0.0, 0.0), (10.0, 10.0)]]);
1738 }
1739
1740 #[test]
1741 fn path_number_lexer_handles_exponents_commas_and_fractions() {
1742 let path = path_from_d("M1e2,1.5 L2E1,-.25 z");
1743 assert_eq!(path.subpaths(), vec![vec![(100.0, 1.5), (20.0, -0.25)]]);
1744 }
1745
1746 #[test]
1747 fn parse_view_box_defaults_missing_components_to_zero() {
1748 assert_eq!(parse_view_box("0 0 100 50"), (0.0, 0.0, 100.0, 50.0));
1749 assert_eq!(parse_view_box("10,20,30,40"), (10.0, 20.0, 30.0, 40.0));
1750 assert_eq!(parse_view_box("5 6"), (5.0, 6.0, 0.0, 0.0));
1751 assert_eq!(parse_view_box(""), (0.0, 0.0, 0.0, 0.0));
1752 }
1753
1754 #[test]
1755 fn parse_svg_fill_resolves_hex_named_and_function_colors() {
1756 let black = Color::default();
1757 assert_eq!(
1758 parse_svg_fill("#ff0000", ColorScheme::Light, black),
1759 Some(Color(255, 0, 0, 255))
1760 );
1761 assert_eq!(
1762 parse_svg_fill("red", ColorScheme::Light, black),
1763 Some(Color(255, 0, 0, 255))
1764 );
1765 assert_eq!(
1766 parse_svg_fill("rgb(0, 255, 0)", ColorScheme::Light, black),
1767 Some(Color(0, 255, 0, 255))
1768 );
1769 }
1770
1771 #[test]
1772 fn parse_svg_fill_resolves_current_color_and_transparency() {
1773 let text_color = Color(1, 2, 3, 255);
1774 assert_eq!(
1775 parse_svg_fill("currentColor", ColorScheme::Light, text_color),
1776 Some(text_color)
1777 );
1778 assert_eq!(
1779 parse_svg_fill("none", ColorScheme::Light, text_color),
1780 Some(Color(0, 0, 0, 0))
1781 );
1782 assert_eq!(
1783 parse_svg_fill("transparent", ColorScheme::Light, text_color),
1784 Some(Color(0, 0, 0, 0))
1785 );
1786 assert_eq!(
1787 parse_svg_fill("gibberish", ColorScheme::Light, text_color),
1788 None
1789 );
1790 }
1791
1792 #[test]
1793 fn resolve_shape_fill_prefers_attribute_over_style() {
1794 let el = element("path", &[("fill", "#ff0000"), ("style", "fill:#00ff00")]);
1795 assert_eq!(
1796 resolve_shape_fill(&el, ColorScheme::Light, Color::default()),
1797 Color(255, 0, 0, 255)
1798 );
1799 let style_only = element("path", &[("style", "fill: rgb(0, 0, 255)")]);
1800 assert_eq!(
1801 resolve_shape_fill(&style_only, ColorScheme::Light, Color::default()),
1802 Color(0, 0, 255, 255)
1803 );
1804 let inheriting = element("path", &[("fill", "currentColor")]);
1805 assert_eq!(
1806 resolve_shape_fill(&inheriting, ColorScheme::Light, Color(9, 8, 7, 255)),
1807 Color(9, 8, 7, 255)
1808 );
1809 }
1810
1811 #[test]
1812 fn resolve_fill_rule_supports_attribute_and_style() {
1813 assert!(matches!(
1814 resolve_fill_rule(&element("path", &[])),
1815 FillRule::NonZero
1816 ));
1817 assert!(matches!(
1818 resolve_fill_rule(&element("path", &[("fill-rule", "evenodd")])),
1819 FillRule::EvenOdd
1820 ));
1821 assert!(matches!(
1822 resolve_fill_rule(&element("path", &[("style", "fill-rule:evenodd")])),
1823 FillRule::EvenOdd
1824 ));
1825 }
1826
1827 #[test]
1828 fn resolve_stroke_defaults_to_none_without_any_declared_stroke() {
1829 assert_eq!(
1830 resolve_stroke(
1831 &element("path", &[]),
1832 ColorScheme::Light,
1833 Color::default(),
1834 &HashMap::new(),
1835 None
1836 ),
1837 None
1838 );
1839 assert_eq!(
1841 resolve_stroke(
1842 &element("path", &[("stroke-width", "2")]),
1843 ColorScheme::Light,
1844 Color::default(),
1845 &HashMap::new(),
1846 None
1847 ),
1848 None
1849 );
1850 }
1851
1852 #[test]
1853 fn resolve_stroke_honors_declared_parameters_including_style() {
1854 let el = element(
1855 "line",
1856 &[
1857 ("stroke", "#ff0000"),
1858 ("stroke-width", "3"),
1859 ("stroke-linecap", "round"),
1860 ("stroke-linejoin", "bevel"),
1861 ("stroke-miterlimit", "8"),
1862 ("stroke-dasharray", "4 2"),
1863 ("stroke-dashoffset", "1"),
1864 ],
1865 );
1866 let stroke = resolve_stroke(
1867 &el,
1868 ColorScheme::Light,
1869 Color::default(),
1870 &HashMap::new(),
1871 None,
1872 )
1873 .expect("stroke");
1874 assert_eq!(stroke.color, Color(255, 0, 0, 255));
1875 assert_eq!(stroke.width, 3.0);
1876 assert!(matches!(stroke.cap, StrokeCap::Round));
1877 assert!(matches!(stroke.join, StrokeJoin::Bevel));
1878 assert_eq!(stroke.miter_limit, 8.0);
1879 assert_eq!(stroke.dash, Some(vec![4.0, 2.0]));
1880 assert_eq!(stroke.dash_offset, 1.0);
1881
1882 let style = element(
1884 "path",
1885 &[(
1886 "style",
1887 "stroke:#00ff00;stroke-width:5;stroke-linecap:square;\
1888 stroke-linejoin:round;stroke-dasharray:none",
1889 )],
1890 );
1891 let stroke = resolve_stroke(
1892 &style,
1893 ColorScheme::Light,
1894 Color::default(),
1895 &HashMap::new(),
1896 None,
1897 )
1898 .expect("stroke");
1899 assert_eq!(stroke.color, Color(0, 255, 0, 255));
1900 assert!(matches!(stroke.cap, StrokeCap::Square));
1901 assert!(matches!(stroke.join, StrokeJoin::Round));
1902 assert_eq!(stroke.dash, None);
1903 }
1904
1905 #[test]
1906 fn resolve_stroke_none_and_namewidth_zero_suppress() {
1907 assert_eq!(
1908 resolve_stroke(
1909 &element("path", &[("stroke", "none")]),
1910 ColorScheme::Light,
1911 Color::default(),
1912 &HashMap::new(),
1913 None
1914 ),
1915 None
1916 );
1917 assert_eq!(
1918 resolve_stroke(
1919 &element("path", &[("stroke", "red"), ("stroke-width", "0")]),
1920 ColorScheme::Light,
1921 Color::default(),
1922 &HashMap::new(),
1923 None
1924 ),
1925 None
1926 );
1927 assert_eq!(
1928 resolve_stroke(
1929 &element("path", &[("stroke", "transparent")]),
1930 ColorScheme::Light,
1931 Color::default(),
1932 &HashMap::new(),
1933 None
1934 ),
1935 None
1936 );
1937 }
1938
1939 #[test]
1940 fn resolve_stroke_inherits_through_containers_until_overridden() {
1941 let parent = element(
1942 "g",
1943 &[
1944 ("stroke", "#00ff00"),
1945 ("stroke-width", "2"),
1946 ("stroke-linecap", "round"),
1947 ],
1948 );
1949 let inherited = resolve_stroke(
1950 &parent,
1951 ColorScheme::Light,
1952 Color::default(),
1953 &HashMap::new(),
1954 None,
1955 )
1956 .expect("stroke");
1957 let child = resolve_stroke(
1959 &element("rect", &[]),
1960 ColorScheme::Light,
1961 Color::default(),
1962 &HashMap::new(),
1963 Some(&inherited),
1964 )
1965 .expect("inherits");
1966 assert_eq!(child.color, Color(0, 255, 0, 255));
1967 assert_eq!(child.width, 2.0);
1968 assert!(matches!(child.cap, StrokeCap::Round));
1969 assert_eq!(
1971 resolve_stroke(
1972 &element("rect", &[("stroke", "none")]),
1973 ColorScheme::Light,
1974 Color::default(),
1975 &HashMap::new(),
1976 Some(&inherited)
1977 ),
1978 None
1979 );
1980 let cc = resolve_stroke(
1982 &element("line", &[("stroke", "currentColor")]),
1983 ColorScheme::Light,
1984 Color(9, 8, 7, 255),
1985 &HashMap::new(),
1986 None,
1987 )
1988 .expect("currentColor");
1989 assert_eq!(cc.color, Color(9, 8, 7, 255));
1990 }
1991
1992 #[test]
1993 fn parse_dasharray_handles_none_lists_and_odd_patterns() {
1994 assert_eq!(parse_dasharray("none"), None);
1995 assert_eq!(parse_dasharray(""), None);
1996 assert_eq!(parse_dasharray("0 0"), None);
1997 assert_eq!(parse_dasharray("4,2"), Some(vec![4.0, 2.0]));
1998 assert_eq!(parse_dasharray("4 2 1"), Some(vec![4.0, 2.0, 1.0]));
1999 assert_eq!(parse_dasharray("2,-1"), Some(vec![2.0, 0.0]));
2000 assert_eq!(parse_dasharray("bogus"), None);
2001 }
2002
2003 #[test]
2004 fn viewbox_meet_centers_content_and_preserves_aspect() {
2005 let t = viewbox_meet_transform((0.0, 0.0, 10.0, 10.0), 20.0, 10.0);
2007 assert!((t.m11 - 1.0).abs() < 1e-6);
2008 assert!((t.dx - 5.0).abs() < 1e-6);
2009 assert!((t.dy - 0.0).abs() < 1e-6);
2010 let t = viewbox_meet_transform((2.0, 3.0, 10.0, 10.0), 20.0, 10.0);
2012 assert!((t.dx - 3.0).abs() < 1e-6);
2013 assert!((t.dy - -3.0).abs() < 1e-6);
2014 assert!(viewbox_meet_transform((0.0, 0.0, 0.0, 10.0), 5.0, 5.0).m11 == 1.0);
2015 }
2016
2017 #[test]
2018 fn emit_commands_letterboxes_and_honors_per_shape_fill_rule() {
2019 let shape = SvgShape {
2020 path: path_from_d("M0 0 L10 0 L10 10 Z"),
2021 fill: Brush::Solid(Color(255, 0, 0, 255)),
2022 rule: FillRule::EvenOdd,
2023 stroke: None,
2024 clip_path: None,
2025 };
2026 let mut buf = Vec::new();
2027 emit_commands(
2028 &mut buf,
2029 (0.0, 0.0, 20.0, 10.0),
2030 false,
2031 (0.0, 0.0, 10.0, 10.0),
2032 std::slice::from_ref(&shape),
2033 0.5,
2034 );
2035 assert_eq!(buf.len(), 3);
2036 let DrawCommand::PushTransform { transform } = &buf[0] else {
2037 panic!("expected transform");
2038 };
2039 assert!((transform.m11 - 1.0).abs() < 1e-6);
2040 assert!((transform.dx - 5.0).abs() < 1e-6);
2041 let DrawCommand::Fill { paint, rule, .. } = &buf[1] else {
2042 panic!("expected fill");
2043 };
2044 assert!(matches!(*rule, FillRule::EvenOdd));
2045 assert!((paint.opacity - 0.5).abs() < 1e-6);
2046 assert!(matches!(&buf[2], DrawCommand::PopTransform));
2047 }
2048
2049 #[test]
2050 fn emit_commands_paints_inline_at_absolute_position() {
2051 let shape = SvgShape {
2052 path: path_from_d("M0 0 L10 0 L10 10 Z"),
2053 fill: Brush::Solid(Color(255, 0, 0, 255)),
2054 rule: FillRule::NonZero,
2055 stroke: None,
2056 clip_path: None,
2057 };
2058 let mut buf = Vec::new();
2059 emit_commands(
2060 &mut buf,
2061 (30.0, 40.0, 10.0, 10.0),
2062 true,
2063 (0.0, 0.0, 10.0, 10.0),
2064 std::slice::from_ref(&shape),
2065 1.0,
2066 );
2067 let DrawCommand::PushTransform { transform } = &buf[0] else {
2068 panic!("expected transform");
2069 };
2070 assert!((transform.dx - 30.0).abs() < 1e-6);
2071 assert!((transform.dy - 40.0).abs() < 1e-6);
2072 }
2073
2074 #[test]
2075 fn emit_commands_paints_nothing_for_degenerate_box() {
2076 let shape = SvgShape {
2077 path: path_from_d("M0 0 L10 0 L10 10 Z"),
2078 fill: Brush::Solid(Color(255, 0, 0, 255)),
2079 rule: FillRule::NonZero,
2080 stroke: None,
2081 clip_path: None,
2082 };
2083 let mut buf = Vec::new();
2084 emit_commands(
2085 &mut buf,
2086 (0.0, 0.0, 0.0, 10.0),
2087 false,
2088 (0.0, 0.0, 10.0, 10.0),
2089 std::slice::from_ref(&shape),
2090 1.0,
2091 );
2092 assert!(buf.is_empty());
2093 }
2094
2095 #[test]
2096 fn collect_svg_gathers_paths_with_fill_rule_and_opacity() {
2097 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2098 TreeNode::add_child(
2099 &svg,
2100 TreeNode::new(element(
2101 "path",
2102 &[("d", "M0 0 L10 0 L10 10 Z"), ("fill", "currentColor")],
2103 )),
2104 );
2105 TreeNode::add_child(
2106 &svg,
2107 TreeNode::new(element(
2108 "path",
2109 &[
2110 ("d", "M1 1 L5 1 L5 5 Z"),
2111 ("fill", "#00ff00"),
2112 ("fill-rule", "evenodd"),
2113 ("fill-opacity", "0.5"),
2114 ],
2115 )),
2116 );
2117 TreeNode::add_child(&svg, TreeNode::new(element("rect", &[("x", "1")])));
2118
2119 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2120 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color(1, 2, 3, 255)).unwrap();
2121
2122 assert_eq!(content.view_box, (0.0, 0.0, 10.0, 10.0));
2123 assert_eq!(content.shapes.len(), 2);
2124 assert_eq!(content.shapes[0].fill, Brush::Solid(Color(1, 2, 3, 255)));
2125 assert!(matches!(content.shapes[0].rule, FillRule::NonZero));
2126 assert_eq!(content.shapes[1].fill, Brush::Solid(Color(0, 255, 0, 128)));
2127 assert!(matches!(content.shapes[1].rule, FillRule::EvenOdd));
2128 }
2129
2130 #[test]
2131 fn collect_svg_returns_none_without_usable_viewbox_or_paths() {
2132 let no_viewbox = TreeNode::new(element("svg", &[]));
2133 let (snapshot, _) = DomSnapshot::from_tree(&no_viewbox);
2134 assert!(collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).is_none());
2135
2136 let empty = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2137 let (snapshot, _) = DomSnapshot::from_tree(&empty);
2138 assert!(collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).is_none());
2139 }
2140
2141 #[test]
2142 fn collect_svg_walks_groups_applies_transforms_and_shapes() {
2143 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 12 8")]));
2144 let g = TreeNode::new(element(
2145 "g",
2146 &[("transform", "translate(2 1)"), ("opacity", "0.5")],
2147 ));
2148 TreeNode::add_child(&svg, Rc::clone(&g));
2149 TreeNode::add_child(
2150 &g,
2151 TreeNode::new(element(
2152 "path",
2153 &[("d", "M1 1 L5 1 L3 5 Z"), ("fill", "#ff0000")],
2154 )),
2155 );
2156 TreeNode::add_child(
2157 &g,
2158 TreeNode::new(element(
2159 "rect",
2160 &[
2161 ("x", "0"),
2162 ("y", "6"),
2163 ("width", "3"),
2164 ("height", "2"),
2165 ("fill", "#00ff00"),
2166 ],
2167 )),
2168 );
2169 TreeNode::add_child(&svg, TreeNode::new(element("circle", &[("r", "2")])));
2170
2171 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2172 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color(9, 8, 7, 255)).unwrap();
2173
2174 assert_eq!(content.shapes.len(), 3);
2175 assert_eq!(
2177 content.shapes[0].path.subpaths()[0][..3],
2178 [(3.0, 2.0), (7.0, 2.0), (5.0, 6.0)]
2179 );
2180 assert_eq!(content.shapes[0].fill, Brush::Solid(Color(255, 0, 0, 128)));
2182 assert_eq!(
2184 content.shapes[1].path.subpaths()[0][..3],
2185 [(2.0, 7.0), (5.0, 7.0), (5.0, 9.0)]
2186 );
2187 assert_eq!(
2189 content.shapes[2].path.bounding_box().unwrap(),
2190 crate::engine::renderer_model::Rect::new(-2.0, -2.0, 4.0, 4.0)
2191 );
2192 }
2193
2194 #[test]
2195 fn collect_svg_supports_polygon_polyline_line_and_rounded_rect() {
2196 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 20 20")]));
2197 TreeNode::add_child(
2198 &svg,
2199 TreeNode::new(element(
2200 "rect",
2201 &[
2202 ("x", "1"),
2203 ("y", "2"),
2204 ("width", "8"),
2205 ("height", "6"),
2206 ("rx", "2"),
2207 ("ry", "2"),
2208 ],
2209 )),
2210 );
2211 TreeNode::add_child(
2212 &svg,
2213 TreeNode::new(element("polygon", &[("points", "0,0 10,0 10,10")])),
2214 );
2215 TreeNode::add_child(
2216 &svg,
2217 TreeNode::new(element("polyline", &[("points", "0,0 5,5 10,0")])),
2218 );
2219 TreeNode::add_child(
2220 &svg,
2221 TreeNode::new(element(
2222 "line",
2223 &[("x1", "0"), ("y1", "0"), ("x2", "10"), ("y2", "0")],
2224 )),
2225 );
2226
2227 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2228 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
2229 assert_eq!(content.shapes.len(), 4);
2230 let arc_ok = content.shapes[0].path.subpaths()[0].iter().any(|&(x, y)| {
2233 ((x - 7.0).powi(2) + (y - 4.0).powi(2) - 4.0).abs() < 0.1 && x > 7.5 && y < 3.0
2234 });
2235 assert!(
2236 arc_ok,
2237 "rounded rect must keep arc points on the corner circle"
2238 );
2239 assert_eq!(
2241 content.shapes[1].path.subpaths()[0][..3],
2242 [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]
2243 );
2244 assert_eq!(
2245 content.shapes[2].path.subpaths()[0][..3],
2246 [(0.0, 0.0), (5.0, 5.0), (10.0, 0.0)]
2247 );
2248 assert_eq!(
2249 content.shapes[3].path.subpaths()[0][..2],
2250 [(0.0, 0.0), (10.0, 0.0)]
2251 );
2252 }
2253
2254 #[test]
2255 fn collect_svg_inherits_stroke_and_fill_through_containers() {
2256 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2257 let g = TreeNode::new(element(
2258 "g",
2259 &[
2260 ("fill", "#0000ff"),
2261 ("stroke", "#00ff00"),
2262 ("stroke-width", "2"),
2263 ("stroke-linecap", "round"),
2264 ],
2265 ));
2266 TreeNode::add_child(&svg, Rc::clone(&g));
2267 let inherits = TreeNode::new(element(
2268 "line",
2269 &[("x1", "0"), ("y1", "0"), ("x2", "5"), ("y2", "0")],
2270 ));
2271 let overrides = TreeNode::new(element(
2272 "rect",
2273 &[
2274 ("x", "0"),
2275 ("y", "1"),
2276 ("width", "3"),
2277 ("height", "3"),
2278 ("stroke", "none"),
2279 ],
2280 ));
2281 TreeNode::add_child(&g, inherits);
2282 TreeNode::add_child(&g, overrides);
2283
2284 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2285 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
2286 assert_eq!(content.shapes.len(), 2);
2287
2288 let inherited_stroke = content.shapes[0].stroke.as_ref().expect("stroke inherited");
2289 assert_eq!(inherited_stroke.color, Color(0, 255, 0, 255));
2290 assert_eq!(inherited_stroke.width, 2.0);
2291 assert!(matches!(inherited_stroke.cap, StrokeCap::Round));
2292 assert_eq!(content.shapes[0].fill, Brush::Solid(Color(0, 0, 255, 255)));
2293
2294 assert_eq!(
2295 content.shapes[1].stroke, None,
2296 "stroke:none cancels inheritance"
2297 );
2298 assert_eq!(content.shapes[1].fill, Brush::Solid(Color(0, 0, 255, 255)));
2299 }
2300
2301 #[test]
2302 fn emit_commands_paints_a_stroke_outline_after_the_fill() {
2303 let shape = SvgShape {
2304 path: path_from_d("M0 5 L10 5"),
2305 fill: Brush::Solid(Color(0, 0, 0, 0)),
2306 rule: FillRule::NonZero,
2307 stroke: Some(SvgStroke {
2308 color: Color(255, 0, 0, 255),
2309 gradient: None,
2310 width: 2.0,
2311 cap: StrokeCap::Butt,
2312 join: StrokeJoin::Miter,
2313 miter_limit: 4.0,
2314 dash: None,
2315 dash_offset: 0.0,
2316 }),
2317 clip_path: None,
2318 };
2319 let mut buf = Vec::new();
2320 emit_commands(
2321 &mut buf,
2322 (0.0, 0.0, 10.0, 10.0),
2323 false,
2324 (0.0, 0.0, 10.0, 10.0),
2325 std::slice::from_ref(&shape),
2326 1.0,
2327 );
2328 assert_eq!(buf.len(), 4);
2330 let DrawCommand::Fill {
2331 paint: fill_paint, ..
2332 } = &buf[1]
2333 else {
2334 panic!("expected shape fill");
2335 };
2336 let fill_alpha = match &fill_paint.brush {
2337 Brush::Solid(color) => color.3,
2338 _ => panic!("solid"),
2339 };
2340 assert_eq!(fill_alpha, 0);
2341 let DrawCommand::Fill {
2342 path: stroke_path,
2343 paint,
2344 rule,
2345 } = &buf[2]
2346 else {
2347 panic!("expected stroke fill");
2348 };
2349 assert!(matches!(rule, FillRule::NonZero));
2350 let Brush::Solid(color) = &paint.brush else {
2351 panic!("solid stroke");
2352 };
2353 assert_eq!(*color, Color(255, 0, 0, 255));
2354 let bb = stroke_path.bounding_box().unwrap();
2357 assert!((bb.y - 4.0).abs() < 1e-3);
2358 assert!((bb.height - 2.0).abs() < 1e-3);
2359 assert!((bb.x - 0.0).abs() < 1e-3);
2360 assert!((bb.width - 10.0).abs() < 1e-3);
2361 }
2362
2363 #[test]
2364 fn parse_transform_composes_functions_left_to_right() {
2365 let t = parse_transform("translate(10, 20) scale(2)").expect("parses");
2366 assert_eq!(t.apply(1.0, 1.0), (12.0, 22.0));
2367 let rotated = parse_transform("rotate(90)").expect("parses");
2368 assert!((rotated.apply(1.0, 0.0).0).abs() < 1e-5);
2369 assert!((rotated.apply(1.0, 0.0).1 - 1.0).abs() < 1e-5);
2370 assert!(parse_transform("bogus(1)").is_err());
2371 }
2372
2373 fn rasterize_commands(commands: &[DrawCommand], width: u32, height: u32) -> Vec<u8> {
2376 let mut rgba = vec![0u8; width as usize * height as usize * 4];
2377 let mut stack = vec![AffineTransform::identity()];
2378 let mut clip_stack: Vec<(Path, FillRule)> = Vec::new();
2379 for command in commands {
2380 match command {
2381 DrawCommand::PushTransform { transform } => {
2382 let next = stack.last().unwrap().then(transform);
2383 stack.push(next);
2384 }
2385 DrawCommand::PopTransform => {
2386 stack.pop();
2387 }
2388 DrawCommand::PushClip { path, rule } => {
2389 clip_stack.push((path.clone(), *rule));
2390 }
2391 DrawCommand::PopClip => {
2392 clip_stack.pop();
2393 }
2394 DrawCommand::Fill { path, paint, rule } => {
2395 let Brush::Solid(color) = &paint.brush else {
2396 continue;
2397 };
2398 let fill = with_alpha(*color, paint.opacity);
2399 let transformed = transform_path(path, stack.last().unwrap());
2400 let clip_mask = if !clip_stack.is_empty() {
2401 let mut combined = vec![255u8; (width as usize) * (height as usize) * 4];
2402 for (clip_path, clip_rule) in &clip_stack {
2403 let mut tmp = vec![0u8; (width as usize) * (height as usize) * 4];
2404 let clip_transformed =
2405 transform_path(clip_path, stack.last().unwrap());
2406 super::rasterize::fill_path_into(
2407 &mut tmp,
2408 width,
2409 height,
2410 &clip_transformed,
2411 Color(255, 255, 255, 255),
2412 *clip_rule,
2413 None,
2414 );
2415 for i in (3..combined.len()).step_by(4) {
2417 combined[i] =
2418 ((combined[i] as u16 * tmp[i] as u16) / 255) as u8;
2419 }
2420 }
2421 Some(combined)
2422 } else {
2423 None
2424 };
2425 super::rasterize::fill_path_into(
2426 &mut rgba,
2427 width,
2428 height,
2429 &transformed,
2430 fill,
2431 *rule,
2432 clip_mask.as_deref(),
2433 );
2434 }
2435 _ => {}
2436 }
2437 }
2438 rgba
2439 }
2440
2441 #[test]
2442 fn parse_svg_fill_url_reference_is_handled_without_color_pipeline() {
2443 assert_eq!(
2446 parse_svg_fill(
2447 "url(#blinearGradient971)",
2448 ColorScheme::Light,
2449 Color::default()
2450 ),
2451 None
2452 );
2453 assert_eq!(
2454 parse_svg_url("url(#gradient-1)"),
2455 Some("gradient-1".to_owned())
2456 );
2457 assert_eq!(
2458 parse_svg_url("url(\"quoted-id\")"),
2459 Some("quoted-id".to_owned())
2460 );
2461 assert_eq!(parse_svg_url("#ff0000"), None);
2462 assert_eq!(parse_svg_url("red"), None);
2463 }
2464
2465 #[test]
2466 fn collect_svg_resolves_url_fill_to_gradient_brushes() {
2467 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2468 let defs = TreeNode::new(element("defs", &[]));
2469 TreeNode::add_child(&svg, Rc::clone(&defs));
2470
2471 let linear = TreeNode::new(element(
2472 "linearGradient",
2473 &[
2474 ("id", "g1"),
2475 ("x1", "0"),
2476 ("y1", "0"),
2477 ("x2", "1"),
2478 ("y2", "0"),
2479 ("spreadMethod", "reflect"),
2480 ],
2481 ));
2482 TreeNode::add_child(
2483 &linear,
2484 TreeNode::new(element(
2485 "stop",
2486 &[("offset", "0"), ("stop-color", "#ff0000")],
2487 )),
2488 );
2489 TreeNode::add_child(
2490 &linear,
2491 TreeNode::new(element(
2492 "stop",
2493 &[
2494 ("offset", "1"),
2495 ("style", "stop-color:#0000ff;stop-opacity:0.5"),
2496 ],
2497 )),
2498 );
2499 TreeNode::add_child(&defs, linear);
2500
2501 let radial = TreeNode::new(element(
2502 "radialGradient",
2503 &[
2504 ("id", "g2"),
2505 ("gradientUnits", "userSpaceOnUse"),
2506 ("cx", "5"),
2507 ("cy", "5"),
2508 ("r", "5"),
2509 ("fx", "4"),
2510 ("fy", "4"),
2511 ],
2512 ));
2513 TreeNode::add_child(
2514 &radial,
2515 TreeNode::new(element("stop", &[("offset", "0"), ("stop-color", "green")])),
2516 );
2517 TreeNode::add_child(
2518 &radial,
2519 TreeNode::new(element(
2520 "stop",
2521 &[("offset", "100%"), ("stop-color", "blue")],
2522 )),
2523 );
2524 TreeNode::add_child(&defs, radial);
2525
2526 TreeNode::add_child(
2527 &svg,
2528 TreeNode::new(element(
2529 "rect",
2530 &[
2531 ("x", "1"),
2532 ("y", "1"),
2533 ("width", "8"),
2534 ("height", "8"),
2535 ("fill", "url(#g1)"),
2536 ],
2537 )),
2538 );
2539 TreeNode::add_child(
2540 &svg,
2541 TreeNode::new(element(
2542 "circle",
2543 &[("cx", "5"), ("cy", "5"), ("r", "4"), ("fill", "url(#g2)")],
2544 )),
2545 );
2546
2547 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2548 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
2549 assert_eq!(content.shapes.len(), 2);
2550
2551 let Brush::Gradient(g1) = &content.shapes[0].fill else {
2552 panic!("rect should be a gradient fill");
2553 };
2554 assert!(matches!(
2555 g1.kind,
2556 GradientKind::SvgLinear {
2557 x1: 0.0,
2558 y1: 0.0,
2559 x2: 1.0,
2560 y2: 0.0,
2561 user_space: false
2562 }
2563 ));
2564 assert_eq!(g1.spread, GradientSpread::Reflect);
2565 assert_eq!(g1.stops.len(), 2);
2566 assert_eq!(g1.stops[0].color, Color(255, 0, 0, 255));
2567 assert_eq!(g1.stops[0].position, Some(0.0));
2568 assert_eq!(g1.stops[1].color, Color(0, 0, 255, 128));
2569 assert_eq!(g1.stops[1].position, Some(1.0));
2570
2571 let Brush::Gradient(g2) = &content.shapes[1].fill else {
2572 panic!("circle should be a gradient fill");
2573 };
2574 assert!(matches!(
2575 g2.kind,
2576 GradientKind::SvgRadial {
2577 cx: 5.0,
2578 cy: 5.0,
2579 r: 5.0,
2580 fx: 4.0,
2581 fy: 4.0,
2582 fr: 0.0,
2583 user_space: true
2584 }
2585 ));
2586 assert_eq!(g2.spread, GradientSpread::Pad);
2587 assert_eq!(g2.stops.len(), 2);
2588 assert_eq!(g2.stops[0].color, Color(0, 128, 0, 255));
2589 assert_eq!(g2.stops[1].position, Some(1.0));
2590 }
2591
2592 #[test]
2593 fn collect_svg_unresolved_url_fill_paints_nothing() {
2594 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2595 TreeNode::add_child(
2596 &svg,
2597 TreeNode::new(element(
2598 "rect",
2599 &[
2600 ("x", "1"),
2601 ("y", "1"),
2602 ("width", "8"),
2603 ("height", "8"),
2604 ("fill", "url(#missing)"),
2605 ],
2606 )),
2607 );
2608 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2609 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
2610 assert_eq!(content.shapes[0].fill, Brush::Solid(Color(0, 0, 0, 0)));
2611 }
2612
2613 #[test]
2614 fn resolve_stroke_resolves_url_paint_servers() {
2615 let mut gradients = HashMap::new();
2616 gradients.insert(
2617 "g".to_owned(),
2618 Gradient {
2619 kind: GradientKind::SvgLinear {
2620 x1: 0.0,
2621 y1: 0.0,
2622 x2: 1.0,
2623 y2: 0.0,
2624 user_space: false,
2625 },
2626 stops: vec![ColorStop {
2627 color: Color(255, 0, 0, 255),
2628 position: Some(0.0),
2629 }],
2630 spread: GradientSpread::Pad,
2631 },
2632 );
2633 let stroke = resolve_stroke(
2634 &element("path", &[("stroke", "url(#g)"), ("stroke-width", "2")]),
2635 ColorScheme::Light,
2636 Color::default(),
2637 &gradients,
2638 None,
2639 )
2640 .expect("gradient stroke");
2641 assert!(stroke.gradient.is_some());
2642 assert_eq!(stroke.width, 2.0);
2643 assert_eq!(
2645 resolve_stroke(
2646 &element("path", &[("stroke", "url(#nope)")]),
2647 ColorScheme::Light,
2648 Color::default(),
2649 &HashMap::new(),
2650 None,
2651 ),
2652 None
2653 );
2654 }
2655
2656 #[test]
2657 fn emit_commands_paints_gradient_brushes_for_fill_and_stroke() {
2658 let shape = SvgShape {
2659 path: path_from_d("M0 0 L10 0 L10 10 Z"),
2660 fill: Brush::Gradient(Gradient {
2661 kind: GradientKind::SvgLinear {
2662 x1: 0.0,
2663 y1: 0.0,
2664 x2: 1.0,
2665 y2: 0.0,
2666 user_space: false,
2667 },
2668 stops: vec![
2669 ColorStop {
2670 color: Color(255, 0, 0, 255),
2671 position: Some(0.0),
2672 },
2673 ColorStop {
2674 color: Color(0, 0, 255, 255),
2675 position: Some(1.0),
2676 },
2677 ],
2678 spread: GradientSpread::Pad,
2679 }),
2680 rule: FillRule::NonZero,
2681 stroke: Some(SvgStroke {
2682 color: Color(0, 0, 0, 255),
2683 gradient: Some(Gradient {
2684 kind: GradientKind::SvgLinear {
2685 x1: 0.0,
2686 y1: 0.0,
2687 x2: 1.0,
2688 y2: 0.0,
2689 user_space: false,
2690 },
2691 stops: vec![ColorStop {
2692 color: Color(0, 255, 0, 255),
2693 position: Some(0.0),
2694 }],
2695 spread: GradientSpread::Pad,
2696 }),
2697 width: 2.0,
2698 cap: StrokeCap::Butt,
2699 join: StrokeJoin::Miter,
2700 miter_limit: 4.0,
2701 dash: None,
2702 dash_offset: 0.0,
2703 }),
2704 clip_path: None,
2705 };
2706 let mut buf = Vec::new();
2707 emit_commands(
2708 &mut buf,
2709 (0.0, 0.0, 10.0, 10.0),
2710 false,
2711 (0.0, 0.0, 10.0, 10.0),
2712 std::slice::from_ref(&shape),
2713 1.0,
2714 );
2715 let DrawCommand::Fill { paint, .. } = &buf[1] else {
2716 panic!("expected fill");
2717 };
2718 assert!(matches!(paint.brush, Brush::Gradient(_)));
2719 let DrawCommand::Fill {
2720 paint: stroke_paint,
2721 ..
2722 } = &buf[2]
2723 else {
2724 panic!("expected stroke fill");
2725 };
2726 assert!(matches!(stroke_paint.brush, Brush::Gradient(_)));
2727 }
2728
2729 #[test]
2730 fn inline_content_rasterizes_identically_to_standalone_document() {
2731 let markup = r##"<svg width="24" height="12" viewBox="0 0 24 12">
2736 <g transform="translate(2 2) skewX(10)" opacity="0.8">
2737 <path d="M2 2 L22 6 L2 10 Z M8 4 L16 4 L12 6 Z" fill="#ff0000" fill-rule="evenodd"/>
2738 <rect x="0" y="0" width="4" height="12" fill="#00ff00" fill-opacity="0.5"/>
2739 <circle cx="20" cy="3" r="2" fill="#0000ff"/>
2740 </g>
2741 <g transform="translate(4 6)">
2742 <line x1="0" y1="2" x2="18" y2="2" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-dasharray="4 2" fill="none"/>
2743 <rect x="0" y="0" width="6" height="4" fill="none" stroke="#00ffff" stroke-width="1"/>
2744 </g>
2745 </svg>"##;
2746 let standalone = super::rasterize::rasterize(markup, Some((24, 12))).expect("rasterizes");
2747
2748 let svg = TreeNode::new(element(
2749 "svg",
2750 &[("width", "24"), ("height", "12"), ("viewBox", "0 0 24 12")],
2751 ));
2752 let g = TreeNode::new(element(
2753 "g",
2754 &[
2755 ("transform", "translate(2 2) skewX(10)"),
2756 ("opacity", "0.8"),
2757 ],
2758 ));
2759 TreeNode::add_child(&svg, Rc::clone(&g));
2760 TreeNode::add_child(
2761 &g,
2762 TreeNode::new(element(
2763 "path",
2764 &[
2765 ("d", "M2 2 L22 6 L2 10 Z M8 4 L16 4 L12 6 Z"),
2766 ("fill", "#ff0000"),
2767 ("fill-rule", "evenodd"),
2768 ],
2769 )),
2770 );
2771 TreeNode::add_child(
2772 &g,
2773 TreeNode::new(element(
2774 "rect",
2775 &[
2776 ("x", "0"),
2777 ("y", "0"),
2778 ("width", "4"),
2779 ("height", "12"),
2780 ("fill", "#00ff00"),
2781 ("fill-opacity", "0.5"),
2782 ],
2783 )),
2784 );
2785 TreeNode::add_child(
2786 &g,
2787 TreeNode::new(element(
2788 "circle",
2789 &[("cx", "20"), ("cy", "3"), ("r", "2"), ("fill", "#0000ff")],
2790 )),
2791 );
2792 let g2 = TreeNode::new(element("g", &[("transform", "translate(4 6)")]));
2793 TreeNode::add_child(&svg, Rc::clone(&g2));
2794 TreeNode::add_child(
2795 &g2,
2796 TreeNode::new(element(
2797 "line",
2798 &[
2799 ("x1", "0"),
2800 ("y1", "2"),
2801 ("x2", "18"),
2802 ("y2", "2"),
2803 ("stroke", "#ffffff"),
2804 ("stroke-width", "2"),
2805 ("stroke-linecap", "round"),
2806 ("stroke-dasharray", "4 2"),
2807 ("fill", "none"),
2808 ],
2809 )),
2810 );
2811 TreeNode::add_child(
2812 &g2,
2813 TreeNode::new(element(
2814 "rect",
2815 &[
2816 ("x", "0"),
2817 ("y", "0"),
2818 ("width", "6"),
2819 ("height", "4"),
2820 ("fill", "none"),
2821 ("stroke", "#00ffff"),
2822 ("stroke-width", "1"),
2823 ],
2824 )),
2825 );
2826
2827 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2828 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default())
2829 .expect("collects inline SVG");
2830 let mut commands = Vec::new();
2831 emit_commands(
2832 &mut commands,
2833 (0.0, 0.0, 24.0, 12.0),
2834 false,
2835 content.view_box,
2836 &content.shapes,
2837 1.0,
2838 );
2839 let inline_pixels = rasterize_commands(&commands, 24, 12);
2840
2841 assert_eq!(
2842 inline_pixels, standalone.rgba,
2843 "inline and standalone SVG rasterization differ"
2844 );
2845 assert!(
2847 inline_pixels.chunks(4).any(|p| p[3] != 0),
2848 "expected non-transparent pixels"
2849 );
2850 }
2851
2852 fn xml_to_html_tree(
2855 node: &crate::engine::tree::NodeRef<crate::engine::html::xml::XmlElement>,
2856 ) -> crate::engine::tree::NodeRef<crate::engine::html::HtmlNodeType> {
2857 let el = node.borrow();
2858 let html_value = crate::engine::html::HtmlNodeType::Element {
2859 tag_name: el.value.name.clone(),
2860 attributes: el
2861 .value
2862 .attributes
2863 .iter()
2864 .map(|attr| crate::engine::html::tokenizer::Attribute {
2865 name: attr.name.clone(),
2866 value: attr.value.clone(),
2867 })
2868 .collect(),
2869 };
2870 let html_node = TreeNode::new(html_value);
2871 for child in el.children() {
2872 TreeNode::add_child(&html_node, xml_to_html_tree(child));
2873 }
2874 html_node
2875 }
2876
2877 #[test]
2881 fn icon_svg_inline_matches_standalone() {
2882 fn run_icon_test(file_path: &str, target_size: (u32, u32)) {
2883 let svg_bytes = std::fs::read(file_path).expect("read icon svg");
2884 let markup = std::str::from_utf8(&svg_bytes).expect("valid UTF-8");
2885
2886 let standalone = super::rasterize::rasterize(markup, Some(target_size))
2888 .expect("rasterizes standalone");
2889
2890 let tree =
2893 crate::engine::html::xml::parse(markup).expect("parses SVG XML");
2894 let root = tree.root.borrow();
2895 assert_eq!(root.value.name, "svg");
2896 let html_root = xml_to_html_tree(&tree.root);
2897 let (snapshot, _refs) = DomSnapshot::from_tree(&html_root);
2898
2899 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default())
2900 .expect("collects inline SVG");
2901 let mut commands = Vec::new();
2902 emit_commands(
2903 &mut commands,
2904 (0.0, 0.0, target_size.0 as f32, target_size.1 as f32),
2905 false,
2906 content.view_box,
2907 &content.shapes,
2908 1.0,
2909 );
2910 let inline_pixels = rasterize_commands(&commands, target_size.0, target_size.1);
2911
2912 assert_eq!(
2913 inline_pixels, standalone.rgba,
2914 "inline and standalone SVG rasterization differ for {file_path}"
2915 );
2916 assert!(
2917 inline_pixels.chunks(4).any(|p| p[3] != 0),
2918 "expected non-transparent pixels for {file_path}"
2919 );
2920 }
2921
2922 run_icon_test("resource/icons/audio_play.svg", (256, 256));
2923 run_icon_test("resource/icons/audio_stop.svg", (256, 256));
2924 run_icon_test("resource/icon.svg", (512, 512));
2925 }
2926
2927 #[test]
2930 fn path_from_d_truncated_command_does_not_panic() {
2931 let path = path_from_d("M");
2933 assert!(path.commands().is_empty());
2934 let path = path_from_d("M 0 0 L");
2936 assert_eq!(path.subpaths(), vec![vec![(0.0, 0.0)]]);
2937 let path = path_from_d("M 0 0 C 10 10");
2939 assert_eq!(path.subpaths(), vec![vec![(0.0, 0.0)]]);
2940 }
2941
2942 #[test]
2945 fn collect_svg_invalid_transform_is_ignored() {
2946 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
2947 let g = TreeNode::new(element(
2948 "g",
2949 &[("transform", "not-a-valid-transform")],
2950 ));
2951 TreeNode::add_child(&svg, Rc::clone(&g));
2952 TreeNode::add_child(
2953 &g,
2954 TreeNode::new(element(
2955 "rect",
2956 &[
2957 ("x", "0"),
2958 ("y", "0"),
2959 ("width", "5"),
2960 ("height", "5"),
2961 ("fill", "#ff0000"),
2962 ],
2963 )),
2964 );
2965
2966 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
2967 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
2968 assert_eq!(content.shapes.len(), 1);
2969 let bb = content.shapes[0].path.bounding_box().unwrap();
2971 assert!((bb.x - 0.0).abs() < 1e-6);
2972 assert!((bb.y - 0.0).abs() < 1e-6);
2973 assert!((bb.width - 5.0).abs() < 1e-6);
2974 assert!((bb.height - 5.0).abs() < 1e-6);
2975 }
2976
2977 #[test]
2979 fn element_shape_missing_d_attribute_returns_none() {
2980 let el = element("path", &[("fill", "#ff0000")]);
2981 assert!(crate::engine::svg::element_shape(&el).is_none());
2982 }
2983
2984 #[test]
2987 fn rasterize_malformed_svg_returns_error() {
2988 assert!(super::rasterize::rasterize("<svg><unclosed>", None).is_err());
2989 assert!(super::rasterize::rasterize("<notsvg></notsvg>", None).is_err());
2990 assert!(super::rasterize::rasterize(
2992 r##"<svg width="1e300" height="10"></svg>"##,
2993 None
2994 )
2995 .is_err());
2996 }
2997
2998 #[test]
3001 fn collect_svg_use_element_references_target() {
3002 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
3003 let defs = TreeNode::new(element("defs", &[]));
3004 TreeNode::add_child(&svg, Rc::clone(&defs));
3005 let rect = TreeNode::new(element(
3006 "rect",
3007 &[
3008 ("id", "myRect"),
3009 ("x", "1"),
3010 ("y", "1"),
3011 ("width", "3"),
3012 ("height", "3"),
3013 ("fill", "#ff0000"),
3014 ],
3015 ));
3016 TreeNode::add_child(&defs, Rc::clone(&rect));
3017 TreeNode::add_child(
3018 &svg,
3019 TreeNode::new(element(
3020 "use",
3021 &[("href", "#myRect"), ("x", "5"), ("y", "5")],
3022 )),
3023 );
3024
3025 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
3026 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
3027 assert_eq!(content.shapes.len(), 1);
3028 let bb = content.shapes[0].path.bounding_box().unwrap();
3030 assert!((bb.x - 6.0).abs() < 1e-6);
3031 assert!((bb.y - 6.0).abs() < 1e-6);
3032 assert!((bb.width - 3.0).abs() < 1e-6);
3033 assert!((bb.height - 3.0).abs() < 1e-6);
3034 assert_eq!(content.shapes[0].fill, Brush::Solid(Color(255, 0, 0, 255)));
3035 }
3036
3037 #[test]
3039 fn collect_svg_use_element_references_group() {
3040 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 20 10")]));
3041 let defs = TreeNode::new(element("defs", &[]));
3042 TreeNode::add_child(&svg, Rc::clone(&defs));
3043 let g = TreeNode::new(element("g", &[("id", "myGroup")]));
3044 TreeNode::add_child(
3045 &g,
3046 TreeNode::new(element(
3047 "rect",
3048 &[("x", "0"), ("y", "0"), ("width", "2"), ("height", "2"), ("fill", "#ff0000")],
3049 )),
3050 );
3051 TreeNode::add_child(
3052 &g,
3053 TreeNode::new(element(
3054 "circle",
3055 &[("cx", "5"), ("cy", "5"), ("r", "2"), ("fill", "#00ff00")],
3056 )),
3057 );
3058 TreeNode::add_child(&defs, Rc::clone(&g));
3059 TreeNode::add_child(
3060 &svg,
3061 TreeNode::new(element("use", &[("href", "#myGroup")])),
3062 );
3063
3064 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
3065 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
3066 assert_eq!(content.shapes.len(), 2);
3067 assert_eq!(content.shapes[0].fill, Brush::Solid(Color(255, 0, 0, 255)));
3068 assert_eq!(content.shapes[1].fill, Brush::Solid(Color(0, 255, 0, 255)));
3069 }
3070
3071 #[test]
3073 fn collect_svg_use_circular_reference() {
3074 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
3075 let use_a = TreeNode::new(element("use", &[("id", "a"), ("href", "#b")]));
3076 let use_b = TreeNode::new(element("use", &[("id", "b"), ("href", "#a")]));
3077 TreeNode::add_child(&svg, Rc::clone(&use_a));
3078 TreeNode::add_child(&use_a, Rc::clone(&use_b));
3079
3080 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
3081 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default());
3082 assert!(content.is_none());
3084 }
3085
3086 #[test]
3088 fn use_svg_inline_matches_standalone() {
3089 let markup = r##"<svg width="20" height="10" viewBox="0 0 20 10">
3090 <defs>
3091 <rect id="r1" x="0" y="0" width="4" height="4" fill="#ff0000"/>
3092 <circle id="c1" cx="8" cy="5" r="2" fill="#00ff00"/>
3093 </defs>
3094 <use href="#r1" x="2" y="2"/>
3095 <use href="#c1" x="4" y="0"/>
3096 </svg>"##;
3097
3098 let standalone = super::rasterize::rasterize(markup, Some((20, 10)))
3100 .expect("rasterizes standalone");
3101
3102 let tree =
3104 crate::engine::html::xml::parse(markup).expect("parses SVG XML");
3105 let html_root = xml_to_html_tree(&tree.root);
3106 let (snapshot, _refs) = DomSnapshot::from_tree(&html_root);
3107
3108 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default())
3109 .expect("collects inline SVG");
3110 let mut commands = Vec::new();
3111 emit_commands(
3112 &mut commands,
3113 (0.0, 0.0, 20.0, 10.0),
3114 false,
3115 content.view_box,
3116 &content.shapes,
3117 1.0,
3118 );
3119 let inline_pixels = rasterize_commands(&commands, 20, 10);
3120
3121 assert_eq!(
3122 inline_pixels, standalone.rgba,
3123 "inline and standalone SVG rasterization differ for <use>"
3124 );
3125 assert!(
3126 inline_pixels.chunks(4).any(|p| p[3] != 0),
3127 "expected non-transparent pixels"
3128 );
3129 }
3130
3131 #[test]
3134 fn collect_svg_clip_path() {
3135 let svg = TreeNode::new(element("svg", &[("viewBox", "0 0 10 10")]));
3136 let clip = TreeNode::new(element("clipPath", &[("id", "myClip")]));
3137 TreeNode::add_child(
3138 &clip,
3139 TreeNode::new(element(
3140 "rect",
3141 &[("x", "2"), ("y", "2"), ("width", "4"), ("height", "4")],
3142 )),
3143 );
3144 TreeNode::add_child(&svg, Rc::clone(&clip));
3145 TreeNode::add_child(
3146 &svg,
3147 TreeNode::new(element(
3148 "rect",
3149 &[
3150 ("x", "0"),
3151 ("y", "0"),
3152 ("width", "10"),
3153 ("height", "10"),
3154 ("fill", "#ff0000"),
3155 ("clip-path", "#myClip"),
3156 ],
3157 )),
3158 );
3159
3160 let (snapshot, _refs) = DomSnapshot::from_tree(&svg);
3161 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default()).unwrap();
3162 assert_eq!(content.shapes.len(), 1);
3163 let shape = &content.shapes[0];
3164 assert!(shape.clip_path.is_some());
3165 let clip = shape.clip_path.as_ref().unwrap();
3166 let bb = clip.path.bounding_box().unwrap();
3168 assert!((bb.x - 2.0).abs() < 1e-6);
3169 assert!((bb.y - 2.0).abs() < 1e-6);
3170 assert!((bb.width - 4.0).abs() < 1e-6);
3171 assert!((bb.height - 4.0).abs() < 1e-6);
3172 }
3173
3174 #[test]
3176 fn clip_path_svg_inline_matches_standalone() {
3177 let markup = r##"<svg width="10" height="10" viewBox="0 0 10 10">
3178 <clipPath id="c1">
3179 <rect x="2" y="2" width="4" height="4"/>
3180 </clipPath>
3181 <rect x="0" y="0" width="10" height="10" fill="#ff0000" clip-path="#c1"/>
3182 </svg>"##;
3183
3184 let standalone = super::rasterize::rasterize(markup, Some((10, 10)))
3185 .expect("rasterizes standalone");
3186
3187 let tree =
3188 crate::engine::html::xml::parse(markup).expect("parses SVG XML");
3189 let html_root = xml_to_html_tree(&tree.root);
3190 let (snapshot, _refs) = DomSnapshot::from_tree(&html_root);
3191
3192 let content = collect_svg(&snapshot, 0, ColorScheme::Light, Color::default())
3193 .expect("collects inline SVG");
3194 let mut commands = Vec::new();
3195 emit_commands(
3196 &mut commands,
3197 (0.0, 0.0, 10.0, 10.0),
3198 false,
3199 content.view_box,
3200 &content.shapes,
3201 1.0,
3202 );
3203 let inline_pixels = rasterize_commands(&commands, 10, 10);
3204
3205 assert_eq!(
3206 inline_pixels, standalone.rgba,
3207 "inline and standalone SVG rasterization differ for <clipPath>"
3208 );
3209 }
3210}