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