1use crate::{Path2D, PathVerb, Point, Rect, ValidationError};
4
5pub const MAX_STROKE_VERBS: usize = 256;
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
10pub enum LineCap {
11 #[default]
13 Butt,
14 Round,
16 Square,
18}
19
20#[derive(Clone, Copy, Debug, PartialEq)]
22pub enum LineJoin {
23 Miter {
25 limit: f64,
27 },
28 Round,
30 Bevel,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq)]
68pub struct StrokeStyle {
69 pub width: f64,
71 pub cap: LineCap,
73 pub join: LineJoin,
75}
76
77impl Default for StrokeStyle {
78 fn default() -> Self {
79 Self {
80 width: 1.0,
81 cap: LineCap::Butt,
82 join: LineJoin::Miter { limit: 4.0 },
83 }
84 }
85}
86
87impl StrokeStyle {
88 pub fn validate(&self, path: &str) -> Result<(), ValidationError> {
90 if !self.width.is_finite() || self.width < 0.0 {
91 return Err(ValidationError::invalid(
92 path,
93 "width",
94 "Stroke width must be finite and nonnegative",
95 ));
96 }
97 if let LineJoin::Miter { limit } = self.join
98 && (!limit.is_finite() || !(1.0..=1024.0).contains(&limit))
99 {
100 return Err(ValidationError::invalid(
101 path,
102 "miter_limit",
103 "Require a finite miter limit in 1..=1024",
104 ));
105 }
106 Ok(())
107 }
108
109 pub(crate) fn bounds(&self, bounds: Rect) -> Rect {
110 let join = match self.join {
111 LineJoin::Miter { limit } => limit,
112 _ => 1.0,
113 };
114 let cap = if self.cap == LineCap::Square {
115 std::f64::consts::SQRT_2
116 } else {
117 1.0
118 };
119 let margin = self.width * 0.5 * join.max(cap);
120 Rect::new(
121 bounds.left - margin,
122 bounds.top - margin,
123 bounds.right + margin,
124 bounds.bottom + margin,
125 )
126 }
127
128 pub fn outline(&self, source: &Path2D, path: &str) -> Result<Path2D, ValidationError> {
130 self.validate(path)?;
131 source.validate()?;
132 if source.verbs().len() > MAX_STROKE_VERBS {
133 return Err(ValidationError::limit(
134 path,
135 "stroke_verbs",
136 "stroke input exceeds 256 verbs",
137 ));
138 }
139 let (join, limit) = match self.join {
140 LineJoin::Miter { limit } => (0, limit.to_bits()),
141 LineJoin::Round => (1, 0),
142 LineJoin::Bevel => (2, 0),
143 };
144 let key = [self.width.to_bits(), self.cap as u64, join, limit];
145 if let Some(outline) = source.cached_stroke(key) {
146 return Ok(outline);
147 }
148 let unsupported = |message| ValidationError::unsupported(path, "stroke_geometry", message);
149 let mut current = None;
150 let mut start = None;
151 let mut scale = self.width;
152 let mut has_length = false;
153 for verb in source.verbs() {
154 for point in verb.points().into_iter().flatten() {
155 scale = scale.max(point.x.abs()).max(point.y.abs());
156 if point.x.abs() > 1e30 || point.y.abs() > 1e30 {
157 return Err(unsupported(
158 "Stroke coordinates exceed the supported numerical range",
159 ));
160 }
161 }
162 let endpoint = match *verb {
163 PathVerb::MoveTo(point) => {
164 start = Some(point);
165 current = None;
166 point
167 }
168 PathVerb::LineTo(point) => point,
169 PathVerb::Close => start.expect("checked subpath"),
170 PathVerb::QuadraticTo(_, end) | PathVerb::CubicTo(_, _, end) => end,
171 };
172 scale = scale.max(endpoint.x.abs()).max(endpoint.y.abs());
173 if endpoint.x.abs() > 1e30 || endpoint.y.abs() > 1e30 {
174 return Err(unsupported(
175 "Stroke coordinates exceed the supported numerical range",
176 ));
177 }
178 if let Some(previous) = current {
179 let previous: Point = previous;
180 has_length |= endpoint != previous
181 || verb
182 .points()
183 .into_iter()
184 .flatten()
185 .any(|point| point != previous);
186 let length = libm::hypot(endpoint.x - previous.x, endpoint.y - previous.y);
187 if length != 0.0 && !(1e-30..=1e30).contains(&length) {
188 return Err(unsupported(
189 "Stroke segment length exceeds the supported numerical range",
190 ));
191 }
192 }
193 current = Some(endpoint);
194 }
195 if self.width == 0.0 {
196 return Ok(Path2D::new());
197 }
198 if !(1e-30..=1e30).contains(&self.width) || self.width < scale * (128.0 * f64::EPSILON) {
199 return Err(unsupported(
200 "Stroke width exceeds the supported numerical range or local precision",
201 ));
202 }
203 if !has_length {
204 return Ok(Path2D::new());
205 }
206 let cap = match self.cap {
207 LineCap::Butt => kurbo::Cap::Butt,
208 LineCap::Round => kurbo::Cap::Round,
209 LineCap::Square => kurbo::Cap::Square,
210 };
211 let (join, limit) = match self.join {
212 LineJoin::Miter { limit } => (kurbo::Join::Miter, limit),
213 LineJoin::Round => (kurbo::Join::Round, 4.0),
214 LineJoin::Bevel => (kurbo::Join::Bevel, 4.0),
215 };
216 let style = kurbo::Stroke::new(self.width)
217 .with_caps(cap)
218 .with_join(join)
219 .with_miter_limit(limit);
220 let point = |point: Point| kurbo::Point::new(point.x, point.y);
221 let mut current = Point::ORIGIN;
222 let mut start = current;
223 let elements = source.verbs().iter().filter_map(|verb| {
224 Some(match *verb {
225 PathVerb::MoveTo(value) => {
226 current = value;
227 start = value;
228 kurbo::PathEl::MoveTo(point(value))
229 }
230 PathVerb::LineTo(value) => {
231 if value == current {
232 return None;
233 }
234 current = value;
235 kurbo::PathEl::LineTo(point(value))
236 }
237 PathVerb::QuadraticTo(control, end) => {
238 if control == current && end == current {
239 return None;
240 }
241 current = end;
242 kurbo::PathEl::QuadTo(point(control), point(end))
243 }
244 PathVerb::CubicTo(first, second, end) => {
245 if first == current && second == current && end == current {
246 return None;
247 }
248 current = end;
249 kurbo::PathEl::CurveTo(point(first), point(second), point(end))
250 }
251 PathVerb::Close => {
252 current = start;
253 kurbo::PathEl::ClosePath
254 }
255 })
256 });
257 let tolerance = 1e-3_f64
258 .min(self.width * 1e-3)
259 .max(scale * (128.0 * f64::EPSILON));
260 let expanded = kurbo::stroke(elements, &style, &kurbo::StrokeOpts::default(), tolerance);
261 let mut outline = Path2D::new();
262 let point = |point: kurbo::Point| Point::new(point.x, point.y);
263 for element in expanded.elements() {
264 match *element {
265 kurbo::PathEl::MoveTo(value) => outline.try_move_to(point(value))?,
266 kurbo::PathEl::LineTo(value) => outline.try_line_to(point(value))?,
267 kurbo::PathEl::QuadTo(control, end) => {
268 outline.try_quadratic_to(point(control), point(end))?
269 }
270 kurbo::PathEl::CurveTo(first, second, end) => {
271 outline.try_cubic_to(point(first), point(second), point(end))?
272 }
273 kurbo::PathEl::ClosePath => outline.try_close()?,
274 }
275 }
276 source.retain_stroke(key, &outline);
277 Ok(outline)
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use crate::ValidationErrorKind;
285
286 #[test]
287 fn static_stroke_cache_reuses_verbs_and_invalidates_only_edited_snapshot()
288 -> Result<(), ValidationError> {
289 use std::sync::Arc;
290 let mut source = Path2D::parse_svg("M0 0C5 12 15 -4 20 4")?;
291 let style = StrokeStyle {
292 width: 2.0,
293 cap: LineCap::Round,
294 join: LineJoin::Round,
295 };
296 let first = style.outline(&source, "first")?;
297 let again = style.outline(&source.clone(), "again")?;
298 assert!(Arc::ptr_eq(&first.shared_verbs(), &again.shared_verbs()));
299 let original = source.clone();
300 assert!(source.try_line_to(Point::new(f64::NAN, 1.0)).is_err());
301 assert!(Arc::ptr_eq(
302 &first.shared_verbs(),
303 &style.outline(&source, "failed edit")?.shared_verbs()
304 ));
305 source.try_line_to(Point::new(25.0, 3.0))?;
306 let edited = style.outline(&source, "edited")?;
307 assert_ne!(first, edited);
308 assert!(Arc::ptr_eq(
309 &first.shared_verbs(),
310 &style.outline(&original, "old snapshot")?.shared_verbs()
311 ));
312 let different = StrokeStyle {
313 width: 3.0,
314 ..style
315 }
316 .outline(&original, "style")?;
317 assert_ne!(first, different);
318 for width in 4..12 {
319 StrokeStyle {
320 width: f64::from(width),
321 ..style
322 }
323 .outline(&original, "evict")?;
324 }
325 assert_eq!(style.outline(&original, "retry")?, first);
326 assert!(
327 StrokeStyle {
328 width: f64::NAN,
329 ..style
330 }
331 .outline(&original, "invalid")
332 .is_err()
333 );
334 Ok(())
335 }
336
337 use crate::{
338 Canvas, Color, DrawCommand, DrawContext, Drawable, Node, NodeModifiers, PixelSize, Shape,
339 Size, ValidationLimits,
340 };
341
342 fn line() -> Path2D {
343 let mut path = Path2D::new();
344 path.try_move_to(Point::new(1.0, 1.0)).unwrap();
345 path.try_line_to(Point::new(5.0, 1.0)).unwrap();
346 path
347 }
348
349 #[test]
350 fn source_and_outline_charge_exact_shared_structural_limits() {
351 let drawable = line().stroke(2.0, Color::from_rgb_float(2.0, -0.1, 0.5));
352 let recorded = DrawContext::default()
353 .record(&drawable, ValidationLimits::default())
354 .unwrap();
355 let DrawCommand::Fill { path: geometry, .. } = &recorded[0] else {
356 panic!("expected fill")
357 };
358 let limits = ValidationLimits {
359 max_nodes: 1 + line().verbs().len() + 1 + geometry.verbs().len(),
360 max_depth: 2,
361 };
362 assert_eq!(
363 DrawContext::default().record(&drawable, limits).unwrap(),
364 recorded
365 );
366 let error = DrawContext::default()
367 .record(
368 &drawable,
369 ValidationLimits {
370 max_nodes: limits.max_nodes - 1,
371 ..limits
372 },
373 )
374 .unwrap_err();
375 assert_eq!(error.field, "max_nodes");
376 let mut source = Path2D::new();
377 source.try_move_to(Point::ORIGIN).unwrap();
378 for index in 1..MAX_STROKE_VERBS {
379 source.try_line_to(Point::new(index as f64, 0.0)).unwrap();
380 }
381 let style = StrokeStyle::default();
382 assert!(style.outline(&source, "test").is_ok());
383 let retained = source.clone();
384 source.try_line_to(Point::new(256.0, 0.0)).unwrap();
385 assert_eq!(
386 style.outline(&source, "test").unwrap_err().kind,
387 ValidationErrorKind::StructuralLimit
388 );
389 assert_eq!(retained.verbs().len(), MAX_STROKE_VERBS);
390 }
391
392 #[test]
393 fn zero_width_and_degenerate_subpaths_are_empty_but_invalid_input_is_not_skipped() {
394 for width in [0.0, 1.0] {
395 let mut source = Path2D::new();
396 source.try_move_to(Point::ORIGIN).unwrap();
397 source.try_line_to(Point::ORIGIN).unwrap();
398 source.try_close().unwrap();
399 assert!(
400 StrokeStyle {
401 width,
402 cap: LineCap::Round,
403 ..StrokeStyle::default()
404 }
405 .outline(&source, "test")
406 .unwrap()
407 .verbs()
408 .is_empty()
409 );
410 }
411 assert!(
412 DrawContext::default()
413 .record(
414 &line().stroke(0.0, Color::WHITE),
415 ValidationLimits::default()
416 )
417 .unwrap()
418 .is_empty()
419 );
420 let invalid = Rect::new(4.0, 0.0, 2.0, 2.0).stroke(10.0, Color::WHITE);
421 assert!(
422 Node::from(invalid)
423 .measure(ValidationLimits::default())
424 .is_err()
425 );
426 for width in [-1.0, f64::NAN, f64::INFINITY] {
427 assert_eq!(
428 StrokeStyle {
429 width,
430 ..StrokeStyle::default()
431 }
432 .outline(&line(), "test")
433 .unwrap_err()
434 .kind,
435 ValidationErrorKind::InvalidValue
436 );
437 }
438 for limit in [0.0, 1025.0, f64::NAN, f64::INFINITY] {
439 assert!(
440 StrokeStyle {
441 width: 0.0,
442 join: LineJoin::Miter { limit },
443 ..StrokeStyle::default()
444 }
445 .outline(&line(), "test")
446 .is_err()
447 );
448 }
449 let mut curve = line();
450 curve
451 .try_quadratic_to(Point::new(6.0, 0.0), Point::new(7.0, 1.0))
452 .unwrap();
453 for width in [0.0, 2.0] {
454 let node = Node::from(curve.clone().stroke(width, Color::WHITE)).opacity(0.0);
455 assert!(
456 node.compile(
457 Size::new(8.0, 8.0),
458 PixelSize::new(8, 8),
459 ValidationLimits::default()
460 )
461 .is_ok()
462 );
463 }
464 }
465
466 #[test]
467 fn ignored_canvas_stroke_failures_poison_the_whole_recording() {
468 struct Ignores;
469
470 impl Drawable for Ignores {
471 fn bounds(&self, _: &DrawContext) -> Rect {
472 Rect::new(0.0, 0.0, 8.0, 8.0)
473 }
474
475 fn draw(
476 &self,
477 _: &DrawContext,
478 canvas: &mut Canvas<'_>,
479 ) -> Result<(), ValidationError> {
480 let _ = canvas.stroke(
481 &line(),
482 &StrokeStyle {
483 width: -1.0,
484 ..StrokeStyle::default()
485 },
486 Color::WHITE,
487 );
488 let _ = canvas.fill_rect(Rect::new(0.0, 0.0, 1.0, 1.0), Color::WHITE);
489 Ok(())
490 }
491 }
492 assert_eq!(
493 DrawContext::default()
494 .record(&Ignores, ValidationLimits::default())
495 .unwrap_err()
496 .field,
497 "width"
498 );
499 }
500
501 #[test]
502 fn close_resumption_and_multiple_subpaths_keep_their_own_origins() {
503 let mut source = Path2D::from_rect(Rect::new(1.0, 1.0, 3.0, 3.0)).unwrap();
504 source.try_line_to(Point::new(6.0, 1.0)).unwrap();
505 let mut explicit = Path2D::from_rect(Rect::new(1.0, 1.0, 3.0, 3.0)).unwrap();
506 explicit.try_move_to(Point::new(1.0, 1.0)).unwrap();
507 explicit.try_line_to(Point::new(6.0, 1.0)).unwrap();
508 let style = StrokeStyle::default();
509 assert_eq!(
510 style.outline(&source, "test").unwrap(),
511 style.outline(&explicit, "test").unwrap()
512 );
513 }
514
515 #[test]
516 fn unsupported_numeric_ranges_fail_before_expansion_and_leave_source_reusable() {
517 let style = StrokeStyle::default();
518 for (start, end) in [
519 (Point::ORIGIN, Point::new(1e-31, 0.0)),
520 (Point::new(1e31, 0.0), Point::new(1e31, 1.0)),
521 (Point::new(1e20, 0.0), Point::new(1e20, 1.0)),
522 ] {
523 let mut source = Path2D::new();
524 source.try_move_to(start).unwrap();
525 source.try_line_to(end).unwrap();
526 let snapshot = source.clone();
527 assert_eq!(
528 style.outline(&source, "test").unwrap_err().kind,
529 ValidationErrorKind::Unsupported
530 );
531 assert_eq!(source, snapshot);
532 }
533 for width in [1e-31, 1e31] {
534 assert_eq!(
535 StrokeStyle { width, ..style }
536 .outline(&line(), "test")
537 .unwrap_err()
538 .kind,
539 ValidationErrorKind::Unsupported
540 );
541 }
542 assert!(style.outline(&line(), "test").is_ok());
543 }
544}