1use crate::geom::{Bbox, Point};
9use crate::ir::*;
10
11const PPI: f64 = 96.0;
12const FONT_PT: f64 = FONT_PT_CLASSIC;
16
17pub fn to_svg(d: &Drawing) -> String {
19 let mut r = Svg::new(d);
20 r.render(d);
21 r.finish()
22}
23
24struct Svg {
25 out: String,
26 west: f64,
27 north: f64,
28 pad: f64,
29 margin: CanvasMargin,
30 next_pattern: usize,
31 type_targets: std::collections::HashMap<usize, bool>,
35 cur_type: Option<bool>,
38}
39
40impl Svg {
41 fn new(d: &Drawing) -> Self {
42 let raw = d.canvas.unwrap_or_else(|| drawing_svg_bounds(&d.shapes));
44 let (west, north) = if raw.is_empty() {
45 (0.0, 0.0)
46 } else {
47 (raw.min.x, raw.max.y)
48 };
49 let type_targets = d
50 .anims
51 .iter()
52 .filter(|a| a.effect == "type")
53 .map(|a| (a.shape, a.type_word))
54 .collect();
55 Svg {
56 out: String::new(),
57 west,
58 north,
59 pad: d.prelude_thick.max(0.0) / 144.0,
60 margin: d.canvas_margin,
61 next_pattern: 0,
62 type_targets,
63 cur_type: None,
64 }
65 }
66
67 fn p(&self, p: Point) -> Point {
69 Point::new(
70 (p.x - self.west + 2.0 * self.pad + self.margin.left) * PPI,
71 (self.north - p.y + self.pad + self.margin.top) * PPI,
72 )
73 }
74
75 fn render(&mut self, d: &Drawing) {
76 let raw = d.canvas.unwrap_or_else(|| drawing_svg_bounds(&d.shapes));
77 let raw_w = if raw.is_empty() { 0.0 } else { raw.width() };
78 let raw_h = if raw.is_empty() { 0.0 } else { raw.height() };
79 let (w, h) = if raw.is_empty() {
80 (
81 positive_extent(6.0 * self.pad + self.margin.horizontal()) * PPI,
82 positive_extent(6.0 * self.pad + self.margin.vertical()) * PPI,
83 )
84 } else {
85 (
86 positive_extent(raw_w + 6.0 * self.pad + self.margin.horizontal()) * PPI,
87 positive_extent(raw_h + 6.0 * self.pad + self.margin.vertical()) * PPI,
88 )
89 };
90 self.out.push_str(&format!(
91 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\" font-family=\"sans-serif\" font-size=\"{}\" fill=\"none\">\n",
92 num(w),
93 num(h),
94 num(w),
95 num(h),
96 num(FONT_PT * PPI / 72.0),
97 ));
98 let mut order: Vec<usize> = (0..d.shapes.len()).collect();
99 order.sort_by_key(|&i| (d.shape_layers.get(i).copied().unwrap_or(0), i));
100 for i in order {
101 let s = &d.shapes[i];
102 let link = d.shape_links.get(i).and_then(|l| l.as_deref());
110 if let Some(url) = link {
111 self.out.push_str(&format!(
112 "<a href=\"{}\" class=\"rpic-link\">\n",
113 escape_attr(url)
114 ));
115 }
116 match d.shape_classes.get(i).and_then(|c| c.as_deref()) {
119 Some(class) => self.out.push_str(&format!(
120 "<g id=\"s{i}\" class=\"{}\">\n",
121 escape_attr(class)
122 )),
123 None => self.out.push_str(&format!("<g id=\"s{i}\">\n")),
124 }
125 self.cur_type = self.type_targets.get(&i).copied();
126 self.shape(s);
127 self.out.push_str("</g>\n");
128 if link.is_some() {
129 self.out.push_str("</a>\n");
130 }
131 }
132 }
133
134 fn finish(mut self) -> String {
135 self.out.push_str("</svg>\n");
136 self.out
137 }
138
139 fn shape(&mut self, s: &Shape) {
140 match s {
141 Shape::Box {
142 c,
143 w,
144 h,
145 rad,
146 style,
147 text,
148 } => {
149 if closed_shape_is_visible(style) {
150 let box_w = w.abs();
151 let box_h = h.abs();
152 let tl = self.p(Point::new(c.x - box_w / 2.0, c.y + box_h / 2.0));
153 let mut attrs = format!(
154 "x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"",
155 num(tl.x),
156 num(tl.y),
157 num(box_w * PPI),
158 num(box_h * PPI)
159 );
160 if *rad > 0.0 {
161 attrs.push_str(&format!(" rx=\"{}\"", num(rad * PPI)));
162 }
163 if let Some(fill) = self.underlay_fill(style) {
164 self.out.push_str(&format!(
165 "<rect {} fill=\"{}\"{}/>\n",
166 attrs,
167 fill,
168 fill_opacity_attr(style)
169 ));
170 }
171 let paint = self.paint(style);
172 self.out.push_str(&format!("<rect {} {}/>\n", attrs, paint));
173 }
174 self.text(*c, text);
175 }
176 Shape::Circle {
177 c, r, style, text, ..
178 } => {
179 if closed_shape_is_visible(style) {
180 let cc = self.p(*c);
181 let geo = format!(
182 "cx=\"{}\" cy=\"{}\" r=\"{}\"",
183 num(cc.x),
184 num(cc.y),
185 num(r.abs() * PPI)
186 );
187 if let Some(fill) = self.underlay_fill(style) {
188 self.out.push_str(&format!(
189 "<circle {} fill=\"{}\"{}/>\n",
190 geo,
191 fill,
192 fill_opacity_attr(style)
193 ));
194 }
195 let paint = self.paint(style);
196 self.out.push_str(&format!("<circle {} {}/>\n", geo, paint));
197 }
198 self.text(*c, text);
199 }
200 Shape::Ellipse {
201 c,
202 w,
203 h,
204 style,
205 text,
206 } => {
207 if closed_shape_is_visible(style) {
208 let cc = self.p(*c);
209 let geo = format!(
210 "cx=\"{}\" cy=\"{}\" rx=\"{}\" ry=\"{}\"",
211 num(cc.x),
212 num(cc.y),
213 num(w.abs() / 2.0 * PPI),
214 num(h.abs() / 2.0 * PPI)
215 );
216 if let Some(fill) = self.underlay_fill(style) {
217 self.out.push_str(&format!(
218 "<ellipse {} fill=\"{}\"{}/>\n",
219 geo,
220 fill,
221 fill_opacity_attr(style)
222 ));
223 }
224 let paint = self.paint(style);
225 self.out
226 .push_str(&format!("<ellipse {} {}/>\n", geo, paint));
227 }
228 self.text(*c, text);
229 }
230 Shape::Path {
231 pts,
232 closed,
233 arrows,
234 style,
235 text,
236 } => {
237 if pts.len() >= 2 {
238 let stroke_pts = self.path_stroke_points(pts, *arrows, style);
239 if pts.len() == 2 {
240 if !style.invis {
241 let a = stroke_pts[0];
242 let b = stroke_pts[1];
243 self.out.push_str(&format!(
244 "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" {}/>\n",
245 num(a.x),
246 num(a.y),
247 num(b.x),
248 num(b.y),
249 self.stroke(style)
250 ));
251 }
252 } else {
253 let pstr: Vec<String> = pts
254 .iter()
255 .map(|p| {
256 let q = self.p(*p);
257 format!("{},{}", num(q.x), num(q.y))
258 })
259 .collect();
260 if style.fill_open {
261 let el = if *closed { "polygon" } else { "polyline" };
262 if let Some(fill) = self.underlay_fill(style) {
263 self.out.push_str(&format!(
264 "<{} points=\"{}\" fill=\"{}\"{} stroke-width=\"0\" stroke=\"black\"/>\n",
265 el,
266 pstr.join(" "),
267 fill,
268 fill_opacity_attr(style)
269 ));
270 }
271 let fill = self.fill_attr(style);
272 self.out.push_str(&format!(
273 "<{} points=\"{}\" fill=\"{}\"{} stroke-width=\"0\" stroke=\"black\"/>\n",
274 el,
275 pstr.join(" "),
276 fill,
277 fill_opacity_attr(style)
278 ));
279 }
280 if !style.invis {
281 let stroke_pstr: Vec<String> = stroke_pts
282 .iter()
283 .map(|p| format!("{},{}", num(p.x), num(p.y)))
284 .collect();
285 if *closed {
286 self.out.push_str(&format!(
287 "<polygon points=\"{}\" fill=\"none\" {}/>\n",
288 stroke_pstr.join(" "),
289 self.stroke(style)
290 ));
291 } else {
292 self.out.push_str(&format!(
293 "<polyline points=\"{}\" fill=\"none\" {}/>\n",
294 stroke_pstr.join(" "),
295 self.stroke(style)
296 ));
297 }
298 }
299 }
300 if !style.invis {
301 self.arrowheads(pts, *arrows, style);
302 }
303 }
304 if let Some(c) = path_text_point(pts, *closed) {
305 self.text(c, text);
306 }
307 }
308 Shape::Spline {
309 pts,
310 tension,
311 arrows,
312 style,
313 text,
314 } => {
315 if pts.len() >= 2 {
316 if style.fill_open {
317 let d = self.spline_path(pts, *tension);
318 if let Some(fill) = self.underlay_fill(style) {
319 self.out.push_str(&format!(
320 "<path d=\"{}\" fill=\"{}\"{} stroke-width=\"0\" stroke=\"black\"/>\n",
321 d,
322 fill,
323 fill_opacity_attr(style)
324 ));
325 }
326 let fill = self.fill_attr(style);
327 self.out.push_str(&format!(
328 "<path d=\"{}\" fill=\"{}\"{} stroke-width=\"0\" stroke=\"black\"/>\n",
329 d,
330 fill,
331 fill_opacity_attr(style)
332 ));
333 }
334 if !style.invis {
335 let stroke_pts = self.path_stroke_points(pts, *arrows, style);
336 let d = spline_path_points(&stroke_pts, *tension);
337 self.out.push_str(&format!(
338 "<path d=\"{}\" fill=\"none\" {}/>\n",
339 d,
340 self.stroke(style)
341 ));
342 self.arrowheads(pts, *arrows, style);
343 }
344 }
345 if let Some(c) = midpoint(pts) {
346 self.text(c, text);
347 }
348 }
349 Shape::Arc {
350 c,
351 r,
352 a0,
353 a1,
354 cw: _,
355 arrows,
356 style,
357 text,
358 } => {
359 let start0 = *c + Point::new(a0.cos(), a0.sin()) * *r;
360 let end0 = *c + Point::new(a1.cos(), a1.sin()) * *r;
361 let arc_angle0 = *a1 - *a0;
362 if style.fill_open {
363 let d = self.arc_path(start0, end0, *r, arc_angle0);
364 if let Some(fill) = self.underlay_fill(style) {
365 self.out.push_str(&format!(
366 "<path d=\"{}\" fill=\"{}\"{} stroke-width=\"0\" stroke=\"black\"/>\n",
367 d,
368 fill,
369 fill_opacity_attr(style)
370 ));
371 }
372 let fill = self.fill_attr(style);
373 self.out.push_str(&format!(
374 "<path d=\"{}\" fill=\"{}\"{} stroke-width=\"0\" stroke=\"black\"/>\n",
375 d,
376 fill,
377 fill_opacity_attr(style)
378 ));
379 }
380 if !style.invis {
381 let mut start = start0;
382 let mut end = end0;
383 let color = escape_attr(style.stroke.as_deref().unwrap_or("black"));
384 let mut head_paths = String::new();
385 if *r > 1e-9 && matches!(arrows, Arrowheads::Start | Arrowheads::Both) {
386 let head =
387 self.arc_arrowhead_path(*c, start, *r, arc_angle0, style, &color);
388 start = head.point;
389 head_paths.push_str(&head.path);
390 }
391 if *r > 1e-9 && matches!(arrows, Arrowheads::End | Arrowheads::Both) {
392 let head = self.arc_arrowhead_path(*c, end, -*r, arc_angle0, style, &color);
393 end = head.point;
394 head_paths.push_str(&head.path);
395 }
396 self.out.push_str(&head_paths);
397 let arc_angle = arc_angle_between(*c, start, end, arc_angle0);
398 let d = self.arc_path(start, end, *r, arc_angle);
399 self.out.push_str(&format!(
400 "<path d=\"{}\" fill=\"none\" {}/>\n",
401 d,
402 self.stroke(style)
403 ));
404 }
405 self.text(*c, text);
406 }
407 Shape::Brace {
408 cubics,
409 label_at,
410 style,
411 text,
412 ..
413 } => {
414 if !style.invis && !cubics.is_empty() {
415 let mut d = String::new();
416 let p0 = self.p(cubics[0][0]);
417 d.push_str(&format!("M {} {}", num(p0.x), num(p0.y)));
418 for cubic in cubics {
419 let c1 = self.p(cubic[1]);
420 let c2 = self.p(cubic[2]);
421 let p = self.p(cubic[3]);
422 d.push_str(&format!(
423 " C {} {}, {} {}, {} {}",
424 num(c1.x),
425 num(c1.y),
426 num(c2.x),
427 num(c2.y),
428 num(p.x),
429 num(p.y)
430 ));
431 }
432 self.out.push_str(&format!(
433 "<path d=\"{}\" fill=\"none\" {}/>\n",
434 d,
435 self.stroke(style)
436 ));
437 }
438 self.text(*label_at, text);
439 }
440 Shape::Text {
441 at,
442 text,
443 w,
444 h,
445 standalone,
446 ..
447 } => {
448 if *standalone {
449 self.standalone_text(*at, text, *w, *h);
450 } else {
451 self.text(*at, text);
452 }
453 }
454 }
455 }
456
457 fn path_stroke_points(&self, pts: &[Point], arrows: Arrowheads, style: &Style) -> Vec<Point> {
460 let mut out: Vec<Point> = pts.iter().map(|p| self.p(*p)).collect();
461 if out.len() < 2 {
462 return out;
463 }
464 let n = out.len();
465 if style.arrow_filled {
466 if matches!(arrows, Arrowheads::End | Arrowheads::Both)
467 && let Some((_, _, _, p)) = filled_arrowhead_points(out[n - 1], out[n - 2], style)
468 {
469 out[n - 1] = p;
470 }
471 if matches!(arrows, Arrowheads::Start | Arrowheads::Both)
472 && let Some((_, _, _, p)) = filled_arrowhead_points(out[0], out[1], style)
473 {
474 out[0] = p;
475 }
476 } else {
477 if matches!(arrows, Arrowheads::End | Arrowheads::Both)
478 && let Some((_, p, _)) = open_arrowhead_points(out[n - 1], out[n - 2], style)
479 {
480 out[n - 1] = p;
481 }
482 if matches!(arrows, Arrowheads::Start | Arrowheads::Both)
483 && let Some((_, p, _)) = open_arrowhead_points(out[0], out[1], style)
484 {
485 out[0] = p;
486 }
487 }
488 out
489 }
490
491 fn stroke(&self, style: &Style) -> String {
492 let color = escape_attr(style.stroke.as_deref().unwrap_or("black"));
493 let mut s = format!(
494 "stroke=\"{}\" stroke-width=\"{}\"",
495 color,
496 num(thick_px(style))
497 );
498 let thick = thick_px(style);
499 match style.dash {
500 Dash::Solid => {}
501 Dash::Dashed(w) => s.push_str(&format!(
502 " stroke-dasharray=\"{},{}\"",
503 num(w * PPI * 7.0 / 6.0),
504 num(w * PPI * 5.0 / 6.0)
505 )),
506 Dash::Dotted(w) => {
507 let gap = w.map(|w| w * PPI).unwrap_or(thick * 5.0);
508 s.push_str(&format!(
509 " stroke-dasharray=\"0.5,{}\" stroke-linecap=\"round\"",
510 num(gap)
511 ));
512 }
513 }
514 s
515 }
516
517 fn paint(&mut self, style: &Style) -> String {
519 let fill = self.fill_attr(style);
520 let fill_opacity = fill_opacity_attr(style);
521 if style.invis {
522 return format!("fill=\"{}\"{} stroke=\"none\"", fill, fill_opacity);
523 }
524 format!("fill=\"{}\"{} {}", fill, fill_opacity, self.stroke(style))
525 }
526
527 fn fill_attr(&mut self, style: &Style) -> String {
528 match &style.hatch {
529 Some(hatch) => format!("url(#{})", self.define_hatch_pattern(style, hatch)),
530 None => match &style.gradient {
531 Some(g) => format!("url(#{})", self.define_linear_gradient(g)),
532 None => self.fill_value(style),
533 },
534 }
535 }
536
537 fn underlay_fill(&mut self, style: &Style) -> Option<String> {
542 match (&style.hatch, &style.gradient) {
543 (Some(_), Some(g)) => Some(format!("url(#{})", self.define_linear_gradient(g))),
544 _ => None,
545 }
546 }
547
548 fn define_linear_gradient(&mut self, g: &Gradient) -> String {
549 let id = format!("grad{}", self.next_pattern);
550 self.next_pattern += 1;
551 let a = g.angle.to_radians();
555 let (dx, dy) = (a.cos(), -a.sin());
556 let (x1, y1) = (0.5 - dx / 2.0, 0.5 - dy / 2.0);
557 let (x2, y2) = (0.5 + dx / 2.0, 0.5 + dy / 2.0);
558 self.out.push_str(&format!(
559 "<defs><linearGradient id=\"{}\" gradientUnits=\"objectBoundingBox\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\">\n<stop offset=\"0\" stop-color=\"{}\"/>\n<stop offset=\"1\" stop-color=\"{}\"/>\n</linearGradient></defs>\n",
560 id,
561 num(x1),
562 num(y1),
563 num(x2),
564 num(y2),
565 escape_attr(&g.from),
566 escape_attr(&g.to)
567 ));
568 id
569 }
570
571 fn fill_value(&self, style: &Style) -> String {
572 match &style.fill {
573 None => "none".to_string(),
574 Some(Fill::Gray(g)) => {
575 let v = (g.clamp(0.0, 1.0) * 255.0).round() as u32;
576 format!("rgb({v},{v},{v})")
577 }
578 Some(Fill::Color(c)) => escape_attr(c),
579 }
580 }
581
582 fn define_hatch_pattern(&mut self, style: &Style, hatch: &Hatch) -> String {
583 let id = format!("hatch{}", self.next_pattern);
584 self.next_pattern += 1;
585 let sep = positive_extent(hatch.sep * PPI).max(1.0);
586 let width = (hatch.width.max(0.0) * PPI / 72.0).max(0.0);
587 let color = escape_attr(&hatch.color);
588 let bg = self.fill_value(style);
592 self.out.push_str(&format!(
593 "<defs><pattern id=\"{}\" patternUnits=\"userSpaceOnUse\" width=\"{}\" height=\"{}\" patternTransform=\"rotate({})\">\n",
594 id,
595 num(sep),
596 num(sep),
597 num(-hatch.angle)
598 ));
599 if bg != "none" {
600 self.out.push_str(&format!(
601 "<rect x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"{}\"/>\n",
602 num(sep),
603 num(sep),
604 bg
605 ));
606 }
607 self.out.push_str(&format!(
608 "<line x1=\"{}\" y1=\"0\" x2=\"{}\" y2=\"0\" stroke=\"{}\" stroke-width=\"{}\"/>\n",
609 num(-sep),
610 num(2.0 * sep),
611 color,
612 num(width)
613 ));
614 if hatch.cross {
615 self.out.push_str(&format!(
616 "<line x1=\"0\" y1=\"{}\" x2=\"0\" y2=\"{}\" stroke=\"{}\" stroke-width=\"{}\"/>\n",
617 num(-sep),
618 num(2.0 * sep),
619 color,
620 num(width)
621 ));
622 }
623 self.out.push_str("</pattern></defs>\n");
624 id
625 }
626
627 fn arrowheads(&mut self, pts: &[Point], arrows: Arrowheads, style: &Style) {
628 if pts.len() < 2 {
629 return;
630 }
631 let color = escape_attr(style.stroke.as_deref().unwrap_or("black"));
632 let head = |tip: Point, from: Point, out: &mut String| {
633 let t = self.p(tip);
634 let f = self.p(from);
635 if style.arrow_filled {
636 let Some((l, p, r, _)) = filled_arrowhead_points(t, f, style) else {
637 return;
638 };
639 out.push_str(&format!(
640 "<polygon stroke-width=\"0\" points=\"{},{} {},{} {},{}\" fill=\"{}\"/>\n",
641 num(l.x),
642 num(l.y),
643 num(p.x),
644 num(p.y),
645 num(r.x),
646 num(r.y),
647 color
648 ));
649 } else {
650 let Some((l, p, r)) = open_arrowhead_points(t, f, style) else {
651 return;
652 };
653 out.push_str(&format!(
654 "<polyline points=\"{},{} {},{} {},{}\" fill=\"none\" {}/>\n",
655 num(l.x),
656 num(l.y),
657 num(p.x),
658 num(p.y),
659 num(r.x),
660 num(r.y),
661 self.stroke(style)
662 ));
663 }
664 };
665 let mut buf = String::new();
666 if matches!(arrows, Arrowheads::End | Arrowheads::Both) {
667 head(pts[pts.len() - 1], pts[pts.len() - 2], &mut buf);
668 }
669 if matches!(arrows, Arrowheads::Start | Arrowheads::Both) {
670 head(pts[0], pts[1], &mut buf);
671 }
672 self.out.push_str(&buf);
673 }
674
675 fn arc_path(&self, start: Point, end: Point, r: f64, angle: f64) -> String {
676 let start = self.p(start);
677 let end = self.p(end);
678 let large = if angle.abs() > std::f64::consts::PI {
679 1
680 } else {
681 0
682 };
683 let sweep = if angle >= 0.0 { 0 } else { 1 };
684 format!(
685 "M {} {} A {} {} 0 {} {} {} {}",
686 num(start.x),
687 num(start.y),
688 num(r.abs() * PPI),
689 num(r.abs() * PPI),
690 large,
691 sweep,
692 num(end.x),
693 num(end.y)
694 )
695 }
696
697 fn arc_to(&self, end: Point, r: f64, angle: f64, ccw: f64) -> String {
698 let end = self.p(end);
699 let large = if angle.abs() > std::f64::consts::PI {
700 1
701 } else {
702 0
703 };
704 let sweep = if ccw > 0.0 { 0 } else { 1 };
705 format!(
706 " A {} {} 0 {} {} {} {}",
707 num(r.abs() * PPI),
708 num(r.abs() * PPI),
709 large,
710 sweep,
711 num(end.x),
712 num(end.y)
713 )
714 }
715
716 fn arc_arrowhead_path(
717 &self,
718 c: Point,
719 point: Point,
720 signed_r: f64,
721 angle: f64,
722 style: &Style,
723 color: &str,
724 ) -> ArcHead {
725 let atyp = if style.arrow_filled { 2 } else { 0 };
726 let mut geom = arc_head_geometry(
727 c,
728 point,
729 atyp,
730 style.arrow_ht,
731 style.arrow_wid,
732 style.thick.filter(|t| *t > 0.0).unwrap_or(0.8),
733 signed_r,
734 angle,
735 );
736 let r = signed_r.abs();
737 let mut d = String::new();
738 if atyp == 0 && geom.lwi < ((geom.wid - geom.lwi) / 2.0) {
739 d.push_str(&format!("M {}", self.pos(geom.px)));
740 let q = prop(geom.ai, geom.ci, r + geom.lwi, -geom.lwi, r);
741 d.push_str(&self.arc_to(q, r + geom.lwi, 0.0, geom.ccw));
742 d.push_str(&format!(" L {}", self.pos(geom.ai)));
743 d.push_str(&self.arc_to(point, r, 0.0, -geom.ccw));
744 d.push_str(&self.arc_to(geom.ao, r, 0.0, geom.ccw));
745 d.push_str(&format!(
746 " L {}",
747 self.pos(prop(geom.ao, geom.co, r - geom.lwi, geom.lwi, r))
748 ));
749 d.push_str(&self.arc_to(geom.px, r - geom.lwi, 1.0, -geom.ccw));
750 } else {
751 let q = (geom.ao + geom.ai) * 0.5;
752 d.push_str(&format!("M {} L {}", self.pos(q), self.pos(geom.ai)));
753 d.push_str(&self.arc_to(point, r, 0.0, -geom.ccw));
754 d.push_str(&self.arc_to(geom.ao, r, 0.0, geom.ccw));
755 d.push_str(&format!(" L {}", self.pos(q)));
756 }
757 geom.path = format!(
758 "<path stroke-width=\"0\" stroke=\"{}\" fill=\"{}\" d=\"{}\"/>\n",
759 color, color, d
760 );
761 geom
762 }
763
764 fn pos(&self, p: Point) -> String {
765 let p = self.p(p);
766 format!("{},{}", num(p.x), num(p.y))
767 }
768
769 fn spline_path(&self, pts: &[Point], tension: Option<f64>) -> String {
774 let q: Vec<Point> = pts.iter().map(|p| self.p(*p)).collect();
775 spline_path_points(&q, tension)
776 }
777
778 fn text(&mut self, center: Point, lines: &[TextLine]) {
779 self.write_text(center, lines, None);
780 }
781
782 fn standalone_text(&mut self, center: Point, lines: &[TextLine], w: f64, h: f64) {
783 self.write_text(center, lines, Some((w, h)));
784 }
785
786 fn write_text(
787 &mut self,
788 center: Point,
789 lines: &[TextLine],
790 standalone_dims: Option<(f64, f64)>,
791 ) {
792 if lines.is_empty() {
793 return;
794 }
795 let standalone = standalone_dims.is_some();
796 let n = lines.len() as f64;
797 let v = n - 1.0 + DP_TEXT_RATIO;
798 let lineskip = standalone_dims
799 .map(|(_, h)| h)
800 .filter(|_| v.abs() > 1e-12)
801 .map(|h| h / v)
802 .unwrap_or(FONT_PT / 72.0);
803 let xheight = lineskip * DP_TEXT_RATIO;
804 let font_pt = lineskip * 72.0;
805 let mut y = center.y + (v * lineskip / 2.0) - xheight;
806 for line in lines {
807 let anchor = match line.halign {
808 -1 => "start",
809 1 => "end",
810 _ => "middle",
811 };
812 let just_offset = xheight / 2.0 + line.text_offset;
813 let standalone_half_width = standalone_dims
814 .map(|(w, _)| {
815 if w.abs() > 1e-12 {
816 w / 2.0
817 } else {
818 line.text_offset
819 }
820 })
821 .unwrap_or(0.0);
822 let x = center.x
823 + match line.halign {
824 -1 if standalone => standalone_half_width,
825 1 if standalone => -standalone_half_width,
826 -1 => line.text_offset,
827 1 => -line.text_offset,
828 _ => 0.0,
829 };
830 let baseline_y = y + (line.valign as f64) * just_offset;
831 let p = self.p(Point::new(x, baseline_y));
832 if let Some(m) = &line.math {
836 let w = m.width * PPI;
837 let x_left = match anchor {
838 "start" => p.x,
839 "end" => p.x - w,
840 _ => p.x - w / 2.0,
841 };
842 let half = (m.height + m.depth) * PPI / 2.0;
849 let grid = self.p(Point::new(x, y));
850 let ink_center = match line.valign {
851 1 => grid.y - (xheight / 2.0 + line.text_offset) * PPI - half,
852 -1 => grid.y + (xheight / 2.0 + line.text_offset) * PPI + half,
853 _ => grid.y - xheight * PPI / 2.0,
854 };
855 let y_top = ink_center - half;
856 let frag = m.svg.replacen(
857 "<svg ",
858 &format!("<svg x=\"{}\" y=\"{}\" ", num(x_left), num(y_top)),
859 1,
860 );
861 self.out.push_str(&frag);
862 if !frag.ends_with('\n') {
863 self.out.push('\n');
864 }
865 y -= lineskip;
866 continue;
867 }
868 let text_stroke = if standalone {
869 format!("stroke-width=\"{}\"", num(0.2 * PPI / 72.0))
870 } else {
871 "stroke-width=\"0.2pt\"".to_string()
872 };
873 let mut style_attrs = String::new();
876 if let Some(f) = &line.family {
877 style_attrs.push_str(&format!(" font-family=\"{}\"", escape_attr(f)));
878 }
879 if line.bold {
880 style_attrs.push_str(" font-weight=\"bold\"");
881 }
882 if line.italic {
883 style_attrs.push_str(" font-style=\"italic\"");
884 }
885 if let Some(deg) = line.rotate {
886 style_attrs.push_str(&format!(
888 " transform=\"rotate({} {} {})\"",
889 num(-deg),
890 num(p.x),
891 num(p.y)
892 ));
893 }
894 let content = match self.cur_type {
895 Some(by_word) => split_type_units(&line.s, by_word),
896 None => escape_text(&line.s),
897 };
898 self.out.push_str(&format!(
899 "<text font-size=\"{}pt\"{} {} fill=\"black\" x=\"{}\" y=\"{}\" text-anchor=\"{}\">{}</text>\n",
900 num(line.size_pt.unwrap_or(font_pt)),
901 style_attrs,
902 text_stroke,
903 num(p.x),
904 num(p.y),
905 anchor,
906 content
907 ));
908 y -= lineskip;
909 }
910 }
911}
912
913pub struct ObjectGeometry {
920 pub kind: &'static str,
922 pub bbox: Option<(f64, f64, f64, f64)>,
924}
925
926pub fn object_geometries(d: &Drawing) -> Vec<ObjectGeometry> {
929 let svg = Svg::new(d);
930 d.shapes
931 .iter()
932 .map(|sh| {
933 let raw = shape_svg_bounds(sh);
934 let bbox = if raw.is_empty() {
935 None
936 } else {
937 let tl = svg.p(Point::new(raw.min.x, raw.max.y));
938 Some((tl.x, tl.y, raw.width() * PPI, raw.height() * PPI))
939 };
940 ObjectGeometry {
941 kind: shape_kind(sh),
942 bbox,
943 }
944 })
945 .collect()
946}
947
948fn shape_kind(sh: &Shape) -> &'static str {
949 match sh {
950 Shape::Box { .. } => "box",
951 Shape::Circle { .. } => "circle",
952 Shape::Ellipse { .. } => "ellipse",
953 Shape::Path { .. } => "path",
954 Shape::Spline { .. } => "spline",
955 Shape::Arc { .. } => "arc",
956 Shape::Brace { .. } => "brace",
957 Shape::Text { .. } => "text",
958 }
959}
960
961fn drawing_svg_bounds(shapes: &[Shape]) -> Bbox {
962 let mut out = Bbox::new();
963 for sh in shapes {
964 out.union(&shape_svg_bounds(sh));
965 }
966 out
967}
968
969fn positive_extent(v: f64) -> f64 {
970 if v.is_finite() && v > 0.0 { v } else { 0.0 }
971}
972
973fn shape_svg_bounds(sh: &Shape) -> Bbox {
974 let mut out = Bbox::new();
975 match sh {
976 Shape::Box { c, w, h, style, .. } => {
977 if closed_shape_is_visible(style) {
978 out.add(*c - Point::new(*w / 2.0, *h / 2.0));
979 out.add(*c + Point::new(*w / 2.0, *h / 2.0));
980 }
981 }
982 Shape::Circle {
983 c,
984 r,
985 style,
986 text: _,
987 } => {
988 if closed_shape_is_visible(style) {
989 out.add(*c - Point::new(*r, *r));
990 out.add(*c + Point::new(*r, *r));
991 }
992 }
993 Shape::Ellipse {
994 c,
995 w,
996 h,
997 style,
998 text: _,
999 } => {
1000 if closed_shape_is_visible(style) {
1001 out.add(*c - Point::new(*w / 2.0, *h / 2.0));
1002 out.add(*c + Point::new(*w / 2.0, *h / 2.0));
1003 }
1004 }
1005 Shape::Path {
1006 pts, arrows, style, ..
1007 }
1008 | Shape::Spline {
1009 pts, arrows, style, ..
1010 } => {
1011 if !style.invis {
1012 for p in path_stroke_points_model(pts, *arrows, style) {
1013 out.add(p);
1014 }
1015 out.union(&arrowheads_bounds_model(pts, *arrows, style));
1016 }
1017 if style.invis_bounds || open_fill_is_visible(style) {
1018 for p in pts {
1019 out.add(*p);
1020 }
1021 }
1022 }
1023 Shape::Arc {
1024 c,
1025 r,
1026 a0,
1027 a1,
1028 arrows,
1029 style,
1030 ..
1031 } => {
1032 if !style.invis || open_fill_is_visible(style) {
1033 for k in 0..=12 {
1034 let t = *a0 + (*a1 - *a0) * (k as f64 / 12.0);
1035 out.add(*c + Point::new(t.cos(), t.sin()) * *r);
1036 }
1037 }
1038 if !style.invis {
1039 out.union(&arc_arrowheads_bounds_model(
1040 *c, *r, *a0, *a1, *arrows, style,
1041 ));
1042 }
1043 }
1044 Shape::Brace {
1045 cubics,
1046 label_at,
1047 style,
1048 text,
1049 ..
1050 } => {
1051 if !style.invis || style.invis_bounds {
1052 for cubic in cubics {
1053 for p in cubic {
1054 out.add(*p);
1055 }
1056 }
1057 }
1058 out.union(&attached_text_bounds(*label_at, text));
1059 }
1060 Shape::Text {
1061 at,
1062 text,
1063 bbox,
1064 w,
1065 h,
1066 standalone,
1067 ..
1068 } => {
1069 if *standalone {
1070 out.union(&standalone_text_bounds(*at, text, *w, *h));
1071 } else {
1072 out.union(bbox);
1073 }
1074 }
1075 }
1076 out
1077}
1078
1079fn standalone_text_bounds(at: Point, text: &[TextLine], w: f64, h: f64) -> Bbox {
1080 let mut bb = Bbox::new();
1081 if text.is_empty() {
1082 return bb;
1083 }
1084 let explicit_w = w.abs() > 1e-12;
1088 let half_w = w.abs() / 2.0;
1089 let n = text.len() as f64;
1090 let v = n - 1.0 + DP_TEXT_RATIO;
1091 let lineskip = if h.abs() > 1e-12 && v.abs() > 1e-12 {
1092 h / v
1093 } else {
1094 FONT_PT / 72.0
1095 };
1096 let xheight = lineskip * DP_TEXT_RATIO;
1097 let mut baseline_y = at.y + (v * lineskip / 2.0) - xheight;
1098 for line in text {
1099 if line.s.is_empty() {
1100 baseline_y -= lineskip;
1101 continue;
1102 }
1103 let just_offset = xheight / 2.0 + line.text_offset;
1104 let standalone_half_width = if w.abs() > 1e-12 {
1105 w / 2.0
1106 } else {
1107 line.text_offset
1108 };
1109 let x = at.x
1110 + match line.halign {
1111 -1 => standalone_half_width,
1112 1 => -standalone_half_width,
1113 _ => 0.0,
1114 };
1115 if let Some(m) = &line.math {
1116 let (min_x, max_x) = match line.halign {
1117 -1 => (x, x + m.width),
1118 1 => (x - m.width, x),
1119 _ => (x - m.width / 2.0, x + m.width / 2.0),
1120 };
1121 let half = (m.height + m.depth) / 2.0;
1122 let ink_center = match line.valign {
1123 1 => baseline_y + just_offset + half,
1124 -1 => baseline_y - just_offset - half,
1125 _ => baseline_y + xheight / 2.0,
1126 };
1127 bb.add(Point::new(min_x, ink_center - half));
1128 bb.add(Point::new(max_x, ink_center + half));
1129 baseline_y -= lineskip;
1130 continue;
1131 }
1132 let y = baseline_y + (line.valign as f64) * just_offset;
1133 let (min_x, max_x) = if explicit_w {
1134 (at.x - half_w, at.x + half_w)
1135 } else {
1136 let gw = line.ink_width_in();
1137 match line.halign {
1138 -1 => (x, x + gw),
1139 1 => (x - gw, x),
1140 _ => (x - gw / 2.0, x + gw / 2.0),
1141 }
1142 };
1143 let (min, max) = (Point::new(min_x, y), Point::new(max_x, y + xheight));
1144 match line.rotate {
1145 Some(deg) => bb.add_rect_rotated_about(min, max, Point::new(x, y), deg),
1147 None => {
1148 bb.add(min);
1149 bb.add(max);
1150 }
1151 }
1152 baseline_y -= lineskip;
1153 }
1154 bb
1155}
1156
1157fn attached_text_bounds(center: Point, text: &[TextLine]) -> Bbox {
1158 let mut bb = Bbox::new();
1159 if text.iter().all(|line| line.s.is_empty()) {
1160 return bb;
1161 }
1162 let em = TEXT_EM_IN;
1163 let line_h = TEXT_LINE_H_RATIO * em;
1164 let xheight = DP_TEXT_RATIO * em;
1165 let n = text.len() as f64;
1166 let v = n - 1.0 + DP_TEXT_RATIO;
1167 for (i, line) in text.iter().enumerate() {
1168 if line.s.is_empty() {
1169 continue;
1170 }
1171 let w = line.ink_width_in();
1172 let base_y = center.y - (i as f64 - (n - 1.0) / 2.0) * line_h;
1173 let y = base_y + line.valign as f64 * (xheight / 2.0 + line.text_offset);
1174 let x = center.x
1175 + match line.halign {
1176 -1 => line.text_offset,
1177 1 => -line.text_offset,
1178 _ => 0.0,
1179 };
1180 if let Some(m) = &line.math {
1181 let (min_x, max_x) = match line.halign {
1182 -1 => (x, x + m.width),
1183 1 => (x - m.width, x),
1184 _ => (x - m.width / 2.0, x + m.width / 2.0),
1185 };
1186 let half = (m.height + m.depth) / 2.0;
1187 let baseline_y = center.y + (v * em / 2.0) - xheight - (i as f64 * em);
1188 let just_offset = xheight / 2.0 + line.text_offset;
1189 let ink_center = match line.valign {
1190 1 => baseline_y + just_offset + half,
1191 -1 => baseline_y - just_offset - half,
1192 _ => baseline_y + xheight / 2.0,
1193 };
1194 bb.add(Point::new(min_x, ink_center - half));
1195 bb.add(Point::new(max_x, ink_center + half));
1196 continue;
1197 }
1198 let (min_x, max_x) = match line.halign {
1199 -1 => (x, x + w),
1200 1 => (x - w, x),
1201 _ => (x - w / 2.0, x + w / 2.0),
1202 };
1203 let half_h = line_h * line.height_factor() / 2.0;
1204 let (min, max) = (Point::new(min_x, y - half_h), Point::new(max_x, y + half_h));
1205 match line.rotate {
1206 Some(deg) => bb.add_rect_rotated_about(min, max, Point::new(x, y), deg),
1208 None => {
1209 bb.add(min);
1210 bb.add(max);
1211 }
1212 }
1213 }
1214 bb
1215}
1216
1217fn spline_path_points(q: &[Point], tension: Option<f64>) -> String {
1218 let n = q.len();
1219 if n < 3 {
1221 let mut d = format!("M {} {}", num(q[0].x), num(q[0].y));
1222 for p in &q[1..] {
1223 d.push_str(&format!(" L {} {}", num(p.x), num(p.y)));
1224 }
1225 return d;
1226 }
1227 match tension {
1228 None => classic_spline(q),
1229 Some(t) => tensioned_spline(q, t),
1230 }
1231}
1232
1233fn thick_px(style: &Style) -> f64 {
1234 let pt = style.thick.filter(|t| *t > 0.0).unwrap_or(0.8);
1236 pt * PPI / 72.0
1237}
1238
1239fn thick_in(style: &Style) -> f64 {
1240 style.thick.filter(|t| *t > 0.0).unwrap_or(0.8) / 72.0
1242}
1243
1244fn path_stroke_points_model(pts: &[Point], arrows: Arrowheads, style: &Style) -> Vec<Point> {
1245 let mut out = pts.to_vec();
1246 if out.len() < 2 {
1247 return out;
1248 }
1249 let n = out.len();
1250 if style.arrow_filled {
1251 if matches!(arrows, Arrowheads::End | Arrowheads::Both)
1252 && let Some((_, _, _, p)) = filled_arrowhead_points_model(out[n - 1], out[n - 2], style)
1253 {
1254 out[n - 1] = p;
1255 }
1256 if matches!(arrows, Arrowheads::Start | Arrowheads::Both)
1257 && let Some((_, _, _, p)) = filled_arrowhead_points_model(out[0], out[1], style)
1258 {
1259 out[0] = p;
1260 }
1261 } else {
1262 if matches!(arrows, Arrowheads::End | Arrowheads::Both)
1263 && let Some((_, p, _)) = open_arrowhead_points_model(out[n - 1], out[n - 2], style)
1264 {
1265 out[n - 1] = p;
1266 }
1267 if matches!(arrows, Arrowheads::Start | Arrowheads::Both)
1268 && let Some((_, p, _)) = open_arrowhead_points_model(out[0], out[1], style)
1269 {
1270 out[0] = p;
1271 }
1272 }
1273 out
1274}
1275
1276fn arrowheads_bounds_model(pts: &[Point], arrows: Arrowheads, style: &Style) -> Bbox {
1277 let mut bb = Bbox::new();
1278 if pts.len() < 2 {
1279 return bb;
1280 }
1281 if matches!(arrows, Arrowheads::End | Arrowheads::Both) {
1282 add_arrowhead_bounds_model(&mut bb, pts[pts.len() - 1], pts[pts.len() - 2], style);
1283 }
1284 if matches!(arrows, Arrowheads::Start | Arrowheads::Both) {
1285 add_arrowhead_bounds_model(&mut bb, pts[0], pts[1], style);
1286 }
1287 bb
1288}
1289
1290fn add_arrowhead_bounds_model(bb: &mut Bbox, tip: Point, shaft: Point, style: &Style) {
1291 if style.arrow_filled {
1292 if let Some((left, point, right, stroke_end)) =
1293 filled_arrowhead_points_model(tip, shaft, style)
1294 {
1295 bb.add(left);
1296 bb.add(point);
1297 bb.add(right);
1298 bb.add(stroke_end);
1299 }
1300 } else if let Some((left, point, right)) = open_arrowhead_points_model(tip, shaft, style) {
1301 bb.add(left);
1302 bb.add(point);
1303 bb.add(right);
1304 }
1305}
1306
1307fn arc_arrowheads_bounds_model(
1308 c: Point,
1309 r: f64,
1310 a0: f64,
1311 a1: f64,
1312 arrows: Arrowheads,
1313 style: &Style,
1314) -> Bbox {
1315 let mut bb = Bbox::new();
1316 if r.abs() <= 1e-9 {
1317 return bb;
1318 }
1319 let start = c + Point::new(a0.cos(), a0.sin()) * r;
1320 let end = c + Point::new(a1.cos(), a1.sin()) * r;
1321 let angle = a1 - a0;
1322 if matches!(arrows, Arrowheads::Start | Arrowheads::Both) {
1323 add_arc_arrowhead_bounds_model(&mut bb, c, start, r, angle, style);
1324 }
1325 if matches!(arrows, Arrowheads::End | Arrowheads::Both) {
1326 add_arc_arrowhead_bounds_model(&mut bb, c, end, -r, angle, style);
1327 }
1328 bb
1329}
1330
1331fn add_arc_arrowhead_bounds_model(
1332 bb: &mut Bbox,
1333 c: Point,
1334 point: Point,
1335 signed_r: f64,
1336 angle: f64,
1337 style: &Style,
1338) {
1339 let geom = arc_head_geometry(
1340 c,
1341 point,
1342 if style.arrow_filled { 2 } else { 0 },
1343 style.arrow_ht,
1344 style.arrow_wid,
1345 style.thick.filter(|t| *t > 0.0).unwrap_or(0.8),
1346 signed_r,
1347 angle,
1348 );
1349 let r = signed_r.abs();
1350 bb.add(point);
1351 bb.add(geom.point);
1352 bb.add(geom.ao);
1353 bb.add(geom.ai);
1354 bb.add(geom.px);
1355 if !style.arrow_filled && geom.lwi < ((geom.wid - geom.lwi) / 2.0) {
1356 bb.add(prop(geom.ai, geom.ci, r + geom.lwi, -geom.lwi, r));
1357 bb.add(prop(geom.ao, geom.co, r - geom.lwi, geom.lwi, r));
1358 } else {
1359 bb.add((geom.ao + geom.ai) * 0.5);
1360 }
1361}
1362
1363struct ArcHead {
1364 point: Point,
1365 path: String,
1366 ao: Point,
1367 ai: Point,
1368 co: Point,
1369 ci: Point,
1370 px: Point,
1371 ccw: f64,
1372 lwi: f64,
1373 wid: f64,
1374}
1375
1376#[allow(clippy::too_many_arguments)]
1377fn arc_head_geometry(
1378 c: Point,
1379 point: Point,
1380 atyp: i32,
1381 ht: f64,
1382 wid: f64,
1383 lth: f64,
1384 signed_r: f64,
1385 angle: f64,
1386) -> ArcHead {
1387 let ccw = if signed_r * angle > 0.0 { 1.0 } else { -1.0 };
1388 let r = signed_r.abs();
1389 let ht = ht.abs().min(2.0 * r);
1390 let mut wid = if atyp == 0 {
1391 wid.abs().min(r)
1392 } else {
1393 wid.abs()
1394 };
1395 let lwi = lth.abs() / 72.0;
1396 wid = wid.max(lwi);
1397
1398 let ha = if r == 0.0 { 0.0 } else { ht / r };
1399 let q = Point::new(ha.cos(), ccw * ha.sin());
1400 let ac = affine(point.x - c.x, point.y - c.y, c, q);
1401 let ao = prop(c, ac, wid / -2.0, r + wid / 2.0, r);
1402 let ai = prop(c, ac, wid / 2.0, r - wid / 2.0, r);
1403 let co = arc_ctr(ao, point, c, ccw);
1404 let ci = arc_ctr(ai, point, c, ccw);
1405
1406 let adjusted = if wid == 0.0 {
1407 ao
1408 } else if r == 0.0 {
1409 c
1410 } else {
1411 let t = (wid.min(lwi) / wid) * ht / r;
1412 let q = Point::new(t.cos(), ccw * t.sin());
1413 affine(point.x - c.x, point.y - c.y, c, q)
1414 };
1415
1416 let px = if atyp == 0 {
1417 let mut px = c_intersect(co, r - lwi, ci, r + lwi, ccw);
1418 if px.dist(point) > ac.dist(point) {
1419 px = ac;
1420 }
1421 px
1422 } else {
1423 let t = if r == 0.0 {
1424 0.0
1425 } else {
1426 std::f64::consts::FRAC_PI_2.min((ht / r) * 2.0 / 3.0)
1427 };
1428 let q = Point::new(t.cos(), ccw * t.sin());
1429 let mut px = affine(point.x - c.x, point.y - c.y, c, q);
1430 if px.dist(point) < adjusted.dist(point) {
1431 px = adjusted;
1432 }
1433 px
1434 };
1435
1436 ArcHead {
1437 point: adjusted,
1438 path: String::new(),
1439 ao,
1440 ai,
1441 co,
1442 ci,
1443 px,
1444 ccw,
1445 lwi,
1446 wid,
1447 }
1448}
1449
1450fn affine(x: f64, y: f64, origin: Point, cs: Point) -> Point {
1451 Point::new(
1452 origin.x + cs.x * x - cs.y * y,
1453 origin.y + cs.y * x + cs.x * y,
1454 )
1455}
1456
1457fn arc_ctr(aa: Point, p: Point, cc: Point, ccw: f64) -> Point {
1458 let a = aa - p;
1459 let c = cc - p;
1460 let asq = a.x * a.x + a.y * a.y;
1461 let rsq = c.x * c.x + c.y * c.y;
1462 if asq == 0.0 || rsq == 0.0 {
1463 return cc;
1464 }
1465 let qy = ccw * (a.x * c.x + a.y * c.y) / (asq * rsq).sqrt();
1466 let qx = (1.0 - qy * qy).max(0.0).sqrt();
1467 let br = (1.0 - (asq / (rsq * 4.0))).max(0.0).sqrt();
1468 let ax = (aa + p) * 0.5;
1469 affine(br * c.x, br * c.y, ax, Point::new(qx, qy))
1470}
1471
1472fn c_intersect(c1: Point, r1: f64, c2: Point, r2: f64, ccw: f64) -> Point {
1473 let dx = c1.x - c2.x;
1474 let dy = c1.y - c2.y;
1475 let cls = dx * dx + dy * dy;
1476 if cls == 0.0 {
1477 return c1;
1478 }
1479 let cq = (cls + r1 * r1 - r2 * r2) / 2.0;
1480 let mut f = cq / cls;
1481 let x = Point::new((1.0 - f) * c1.x + f * c2.x, (1.0 - f) * c1.y + f * c2.y);
1482 f = ((cls * r1 * r1 - cq * cq).max(0.0)).sqrt() / cls;
1483 Point::new(x.x + dy * f * ccw, x.y - dx * f * ccw)
1484}
1485
1486fn arc_angle_between(c: Point, start: Point, end: Point, old_angle: f64) -> f64 {
1487 let a0 = (start - c).y.atan2((start - c).x);
1488 let mut da = (end - c).y.atan2((end - c).x) - a0;
1489 while da <= -std::f64::consts::PI {
1490 da += 2.0 * std::f64::consts::PI;
1491 }
1492 while da > std::f64::consts::PI {
1493 da -= 2.0 * std::f64::consts::PI;
1494 }
1495 if da < 0.0 && old_angle > 0.0 {
1496 da += 2.0 * std::f64::consts::PI;
1497 } else if da > 0.0 && old_angle < 0.0 {
1498 da -= 2.0 * std::f64::consts::PI;
1499 }
1500 da
1501}
1502
1503fn open_arrowhead_points(tip: Point, shaft: Point, style: &Style) -> Option<(Point, Point, Point)> {
1504 open_arrowhead_points_scaled(
1505 tip,
1506 shaft,
1507 style.arrow_ht * PPI,
1508 style.arrow_wid * PPI,
1509 thick_px(style),
1510 )
1511}
1512
1513fn open_arrowhead_points_model(
1514 tip: Point,
1515 shaft: Point,
1516 style: &Style,
1517) -> Option<(Point, Point, Point)> {
1518 open_arrowhead_points_scaled(tip, shaft, style.arrow_ht, style.arrow_wid, thick_in(style))
1519}
1520
1521fn open_arrowhead_points_scaled(
1522 tip: Point,
1523 shaft: Point,
1524 ht: f64,
1525 wid: f64,
1526 ltu: f64,
1527) -> Option<(Point, Point, Point)> {
1528 let mut u = tip - shaft;
1529 let len = u.len();
1530 if len < 1e-9 {
1531 return None;
1532 }
1533 u = u / len;
1534 let perp = Point::new(-u.y, u.x);
1535 let po = if wid.abs() < 1e-12 {
1536 0.0
1537 } else {
1538 (ltu * (ht * ht + wid * wid / 4.0).sqrt() / wid).min(ht)
1539 };
1540 let point = tip - u * po;
1541 let h = ht - ltu / 2.0;
1542 let x = h - po;
1543 let v = if ht.abs() < 1e-12 {
1544 0.0
1545 } else {
1546 (wid / 2.0) * x / ht
1547 };
1548 let left = tip - u * h - perp * v;
1549 let right = tip - u * h + perp * v;
1550 let y = if ht.abs() < 1e-12 {
1551 0.0
1552 } else {
1553 ht - po + (ltu * wid / ht / 4.0)
1554 };
1555 Some((
1556 prop(point, left, x - y, y, x),
1557 point,
1558 prop(point, right, x - y, y, x),
1559 ))
1560}
1561
1562fn filled_arrowhead_points(
1563 tip: Point,
1564 shaft: Point,
1565 style: &Style,
1566) -> Option<(Point, Point, Point, Point)> {
1567 filled_arrowhead_points_scaled(
1568 tip,
1569 shaft,
1570 style.arrow_ht * PPI,
1571 style.arrow_wid * PPI,
1572 thick_px(style),
1573 )
1574}
1575
1576fn filled_arrowhead_points_model(
1577 tip: Point,
1578 shaft: Point,
1579 style: &Style,
1580) -> Option<(Point, Point, Point, Point)> {
1581 filled_arrowhead_points_scaled(tip, shaft, style.arrow_ht, style.arrow_wid, thick_in(style))
1582}
1583
1584fn filled_arrowhead_points_scaled(
1585 tip: Point,
1586 shaft: Point,
1587 ht: f64,
1588 wid: f64,
1589 ltu: f64,
1590) -> Option<(Point, Point, Point, Point)> {
1591 let mut u = tip - shaft;
1592 let len = u.len();
1593 if len < 1e-9 {
1594 return None;
1595 }
1596 u = u / len;
1597 let perp = Point::new(-u.y, u.x);
1598 let po = if wid.abs() < 1e-12 {
1599 0.0
1600 } else {
1601 (ltu * (ht * ht + wid * wid / 4.0).sqrt() / wid).min(ht)
1602 };
1603 let point = tip - u * po;
1604 let h = ht - ltu / 2.0;
1605 let x = h - po;
1606 let v = if ht.abs() < 1e-12 {
1607 0.0
1608 } else {
1609 (wid / 2.0) * x / ht
1610 };
1611 let left = tip - u * h - perp * v;
1612 let right = tip - u * h + perp * v;
1613 let t = if x.abs() < 1e-12 { 1.0 } else { ht / x };
1614 let left_full = tip + (left - point) * t;
1615 let right_full = tip + (right - point) * t;
1616 Some((left_full, tip, right_full, point))
1617}
1618
1619fn prop(p1: Point, p2: Point, a: f64, b: f64, c: f64) -> Point {
1620 if c.abs() < 1e-12 {
1621 p2
1622 } else {
1623 (p1 * a + p2 * b) / c
1624 }
1625}
1626
1627fn midpoint(pts: &[Point]) -> Option<Point> {
1628 if pts.is_empty() {
1629 None
1630 } else {
1631 Some((pts[0] + pts[pts.len() - 1]) * 0.5)
1632 }
1633}
1634
1635fn path_text_point(pts: &[Point], closed: bool) -> Option<Point> {
1636 if !closed {
1637 return midpoint(pts);
1638 }
1639 let mut bb = Bbox::new();
1640 for p in pts {
1641 bb.add(*p);
1642 }
1643 Some((bb.min + bb.max) * 0.5)
1644}
1645
1646fn closed_shape_is_visible(style: &Style) -> bool {
1647 !style.invis || style.fill.is_some() || style.hatch.is_some() || style.gradient.is_some()
1648}
1649
1650fn fill_opacity_attr(style: &Style) -> String {
1651 if style.fill.is_none() && style.hatch.is_none() && style.gradient.is_none() {
1654 return String::new();
1655 }
1656 match style.fill_opacity {
1657 Some(opacity) => format!(" fill-opacity=\"{}\"", num(opacity)),
1658 None => String::new(),
1659 }
1660}
1661
1662fn open_fill_is_visible(style: &Style) -> bool {
1663 style.fill_open && (style.fill.is_some() || style.hatch.is_some() || style.gradient.is_some())
1664}
1665
1666fn classic_spline(q: &[Point]) -> String {
1671 let segs = q.len() - 1;
1672 let mut d = format!("M {} {} C", num(q[0].x), num(q[0].y));
1673 for i in 0..segs {
1674 let a = q[i];
1675 let b = q[i + 1];
1676 let mut add = |p: Point| d.push_str(&format!(" {} {}", num(p.x), num(p.y)));
1677 if i == 0 {
1678 add(prop(a, b, 5.0, 1.0, 6.0));
1679 add(prop(a, b, 2.0, 1.0, 3.0));
1680 add(prop(a, b, 1.0, 1.0, 2.0));
1681 add(prop(a, b, 1.0, 5.0, 6.0));
1682 } else if i < segs - 1 {
1683 add(prop(a, b, 5.0, 1.0, 6.0));
1684 add(prop(a, b, 1.0, 1.0, 2.0));
1685 add(prop(a, b, 1.0, 5.0, 6.0));
1686 } else {
1687 add(prop(a, b, 5.0, 1.0, 6.0));
1688 add(prop(a, b, 1.0, 1.0, 2.0));
1689 add(prop(a, b, 1.0, 2.0, 3.0));
1690 add(prop(a, b, 1.0, 5.0, 6.0));
1691 add(b);
1692 }
1693 }
1694 d
1695}
1696
1697fn tensioned_spline(q: &[Point], t: f64) -> String {
1702 let n = q.len();
1703 let mut knots = Vec::with_capacity(n);
1705 knots.push(q[0]);
1706 for i in 1..n - 2 {
1707 knots.push((q[i] + q[i + 1]) * 0.5);
1708 }
1709 knots.push(q[n - 1]);
1710 let mut d = format!("M {} {}", num(knots[0].x), num(knots[0].y));
1711 for j in 0..knots.len() - 1 {
1712 let a = knots[j];
1713 let b = knots[j + 1];
1714 let w = q[j + 1]; let c1 = a + (w - a) * t;
1716 let c2 = b + (w - b) * t;
1717 push_cubic(&mut d, c1, c2, b);
1718 }
1719 d
1720}
1721
1722fn push_cubic(d: &mut String, c1: Point, c2: Point, end: Point) {
1723 d.push_str(&format!(
1724 " C {} {} {} {} {} {}",
1725 num(c1.x),
1726 num(c1.y),
1727 num(c2.x),
1728 num(c2.y),
1729 num(end.x),
1730 num(end.y)
1731 ));
1732}
1733
1734fn num(x: f64) -> String {
1738 if !x.is_finite() {
1739 return "0".to_string();
1740 }
1741 let r = (x * 1_000_000.0).round() / 1_000_000.0;
1742 let r = if r == 0.0 { 0.0 } else { r }; let mut s = format!("{r:.6}");
1744 while s.contains('.') && (s.ends_with('0') || s.ends_with('.')) {
1745 s.pop();
1746 }
1747 s
1748}
1749
1750fn escape_text(s: &str) -> String {
1757 s.replace('&', "&")
1758 .replace('<', "<")
1759 .replace('>', ">")
1760}
1761
1762fn escape_attr(s: &str) -> String {
1767 escape_text(s).replace('"', """)
1768}
1769
1770fn split_type_units(s: &str, by_word: bool) -> String {
1777 let mut out = String::new();
1778 let wrap = |out: &mut String, unit: &str| {
1779 out.push_str("<tspan class=\"rpic-ch\">");
1780 out.push_str(&escape_text(unit));
1781 out.push_str("</tspan>");
1782 };
1783 if by_word {
1784 let mut chars = s.chars().peekable();
1785 while let Some(&c) = chars.peek() {
1786 let ws = c.is_whitespace();
1787 let mut run = String::new();
1788 while let Some(&c) = chars.peek() {
1789 if c.is_whitespace() != ws {
1790 break;
1791 }
1792 run.push(c);
1793 chars.next();
1794 }
1795 if ws {
1796 out.push_str(&escape_text(&run)); } else {
1798 wrap(&mut out, &run);
1799 }
1800 }
1801 } else {
1802 let mut buf = [0u8; 4];
1803 for c in s.chars() {
1804 wrap(&mut out, c.encode_utf8(&mut buf));
1805 }
1806 }
1807 out
1808}
1809
1810#[cfg(test)]
1811mod tests {
1812 use super::*;
1813 use crate::{eval::eval, parser::parse};
1814
1815 fn svg(src: &str) -> String {
1816 to_svg(&eval(&parse(src).unwrap()).unwrap())
1817 }
1818
1819 #[test]
1820 fn type_effect_splits_label_into_addressable_tspans() {
1821 let s = svg("box \"Hi!\"\nanimate last with \"type\"");
1824 assert!(
1825 s.contains(
1826 "<tspan class=\"rpic-ch\">H</tspan><tspan class=\"rpic-ch\">i</tspan><tspan class=\"rpic-ch\">!</tspan>"
1827 ),
1828 "{s}"
1829 );
1830
1831 let s = svg("box \"one two\"\nanimate last with \"type\" by word");
1833 assert!(
1834 s.contains("<tspan class=\"rpic-ch\">one</tspan> <tspan class=\"rpic-ch\">two</tspan>"),
1835 "{s}"
1836 );
1837
1838 let s = svg("box \"Hi!\"");
1840 assert!(!s.contains("rpic-ch"), "{s}");
1841 assert!(s.contains(">Hi!</text>"), "{s}");
1842 }
1843
1844 fn text_y(svg: &str, text: &str) -> f64 {
1845 let needle = format!(">{text}</text>");
1846 let line = svg.lines().find(|line| line.contains(&needle)).unwrap();
1847 let y = line.split(" y=\"").nth(1).unwrap();
1848 y.split('"').next().unwrap().parse().unwrap()
1849 }
1850
1851 fn text_x(svg: &str, text: &str) -> f64 {
1852 let needle = format!(">{text}</text>");
1853 let line = svg.lines().find(|line| line.contains(&needle)).unwrap();
1854 let x = line.split(" x=\"").nth(1).unwrap();
1855 x.split('"').next().unwrap().parse().unwrap()
1856 }
1857
1858 fn attr_value<'a>(line: &'a str, name: &str) -> &'a str {
1859 let needle = format!("{name}=\"");
1860 line.split(&needle)
1861 .nth(1)
1862 .unwrap_or_else(|| panic!("missing attribute {name:?} in {line}"))
1863 .split('"')
1864 .next()
1865 .unwrap()
1866 }
1867
1868 fn attr_num(line: &str, name: &str) -> f64 {
1869 attr_value(line, name).parse().unwrap()
1870 }
1871
1872 fn root_viewbox_size(svg: &str) -> (f64, f64) {
1873 let root = svg.lines().next().unwrap();
1874 let nums: Vec<f64> = attr_value(root, "viewBox")
1875 .split_whitespace()
1876 .map(|n| n.parse().unwrap())
1877 .collect();
1878 assert_eq!(nums.len(), 4, "{root}");
1879 assert_eq!(nums[0], 0.0, "{root}");
1880 assert_eq!(nums[1], 0.0, "{root}");
1881 (nums[2], nums[3])
1882 }
1883
1884 fn assert_in_viewbox(x: f64, y: f64, w: f64, h: f64, svg: &str) {
1885 let eps = 1e-9;
1886 assert!(x >= -eps && x <= w + eps, "x={x} outside 0..{w}\n{svg}");
1887 assert!(y >= -eps && y <= h + eps, "y={y} outside 0..{h}\n{svg}");
1888 }
1889
1890 #[test]
1891 fn pipeline_svg_has_elements() {
1892 let s = svg(".PS\nellipse \"document\"\narrow\nbox \"PIC\"\n.PE");
1893 assert!(s.starts_with("<svg"));
1894 assert!(s.lines().next().unwrap().contains("fill=\"none\""));
1895 assert!(s.contains("<ellipse"));
1896 assert!(s.contains("<rect"));
1897 assert!(s.contains("<line"));
1898 assert!(s.contains("<polygon")); assert!(s.contains(">document<"));
1900 assert!(s.contains(">document</text>"));
1901 assert!(s.contains("</svg>"));
1902 }
1903
1904 #[test]
1905 fn brace_svg_uses_cubic_path_and_label() {
1906 let s = svg("brace from (0,0) to (2,0) up \"n\" wid .25");
1907 assert!(s.contains("<path d=\"M "));
1908 assert!(s.contains(" C "));
1909 assert!(s.contains(">n</text>"));
1910 assert!(text_y(&s, "n") > 0.0);
1911 }
1912
1913 #[test]
1914 fn multipoint_path_gets_polyline() {
1915 let s = svg("line right then up");
1916 assert!(s.contains("<polyline"));
1917 }
1918
1919 #[test]
1920 fn closed_path_can_be_filled() {
1921 let s = svg("line fill 0.5 right then up then left then down");
1922 assert!(s.contains("fill=\"rgb(128,128,128)\" stroke-width=\"0\""));
1923 }
1924
1925 #[test]
1926 fn close_line_renders_polygon_and_centers_text_on_bbox() {
1927 let s = svg("line right 1 then up 1 close shaded \"yellow\" outlined \"black\" \"closed\"");
1928 assert!(s.contains("<polygon"));
1929 assert!(s.contains("fill=\"yellow\""));
1930
1931 let x = text_x(&s, "closed");
1932 let y = text_y(&s, "closed");
1933 assert!(x > 40.0 && x < 70.0, "x = {x}\n{s}");
1934 assert!(y > 40.0 && y < 70.0, "y = {y}\n{s}");
1935 }
1936
1937 #[test]
1938 fn open_multipoint_path_can_be_filled() {
1939 let s = svg("line fill 0.5 right then up then left");
1940 assert!(s.contains("fill=\"rgb(128,128,128)\" stroke-width=\"0\""));
1941 }
1942
1943 #[test]
1944 fn spline_can_be_filled() {
1945 let s = svg("spline fill 0.5 right then up then left");
1946 assert!(s.contains("<path"));
1947 assert!(s.contains("fill=\"rgb(128,128,128)\" stroke-width=\"0\""));
1948 }
1949
1950 #[test]
1951 fn behind_renders_lower_layer_first_with_stable_ids() {
1952 let s = svg("A: box fill 0 at (0,0)\nB: box fill 1 behind A at (0,0)");
1953 let b = s.find("<g id=\"s1\">").unwrap();
1954 let a = s.find("<g id=\"s0\">").unwrap();
1955 assert!(b < a, "{s}");
1956 }
1957
1958 #[test]
1959 fn classic_spline_control_points_match_dpic() {
1960 let d = classic_spline(&[
1961 Point::new(0.0, 0.0),
1962 Point::new(6.0, 0.0),
1963 Point::new(6.0, 6.0),
1964 ]);
1965 assert_eq!(d, "M 0 0 C 1 0 2 0 3 0 5 0 6 1 6 3 6 4 6 5 6 6");
1966 }
1967
1968 #[test]
1969 fn filled_spline_arrow_stroke_is_trimmed_like_dpic() {
1970 let q0 = Point::new(4.0, 52.0);
1971 let q1 = Point::new(52.0, 4.0);
1972 let tip = Point::new(100.0, 52.0);
1973 let (_, _, _, stroke_end) = filled_arrowhead_points(tip, q1, &Style::default()).unwrap();
1974 assert!((stroke_end.x - 98.445_079).abs() < 1e-6);
1975 assert!((stroke_end.y - 50.445_079).abs() < 1e-6);
1976
1977 let d = spline_path_points(&[q0, q1, stroke_end], None);
1978 assert!(
1979 d.ends_with("98.445079 50.445079"),
1980 "spline path should end at the dpic-receded arrow point: {d}"
1981 );
1982 assert!(
1983 !d.ends_with("100 52"),
1984 "spline path still reaches the arrow tip: {d}"
1985 );
1986 }
1987
1988 #[test]
1989 fn svg_prelude_bounds_match_dpic_for_lines() {
1990 let s = svg("line right");
1991 assert!(
1992 s.contains("width=\"51.2\" height=\"3.2\" viewBox=\"0 0 51.2 3.2\""),
1993 "{s}"
1994 );
1995 assert!(
1996 s.contains("<line x1=\"1.066667\" y1=\"0.533333\" x2=\"49.066667\" y2=\"0.533333\""),
1997 "{s}"
1998 );
1999
2000 let s = svg("linethick = 0.4\nline right");
2001 assert!(
2002 s.contains("width=\"49.6\" height=\"1.6\" viewBox=\"0 0 49.6 1.6\""),
2003 "{s}"
2004 );
2005 assert!(
2006 s.contains("<line x1=\"0.533333\" y1=\"0.266667\" x2=\"48.533333\" y2=\"0.266667\""),
2007 "{s}"
2008 );
2009 assert!(s.contains("stroke-width=\"0.533333\""), "{s}");
2010 }
2011
2012 #[test]
2013 fn short_arrowhead_geometry_expands_svg_bounds() {
2014 let s = svg("arrow right .01");
2015 let (w, h) = root_viewbox_size(&s);
2016
2017 let mut saw_head = false;
2018 for line in s
2019 .lines()
2020 .filter(|line| line.contains("<polygon") && line.contains("points=\""))
2021 {
2022 saw_head = true;
2023 for pair in attr_value(line, "points").split_whitespace() {
2024 let (x, y) = pair.split_once(',').unwrap();
2025 assert_in_viewbox(x.parse().unwrap(), y.parse().unwrap(), w, h, &s);
2026 }
2027 }
2028 assert!(saw_head, "{s}");
2029
2030 let line = s.lines().find(|line| line.contains("<line ")).unwrap();
2031 for name in ["x1", "x2"] {
2032 let x = attr_num(line, name);
2033 assert!(
2034 x >= -1e-9 && x <= w + 1e-9,
2035 "{name}={x} outside 0..{w}\n{s}"
2036 );
2037 }
2038 for name in ["y1", "y2"] {
2039 let y = attr_num(line, name);
2040 assert!(
2041 y >= -1e-9 && y <= h + 1e-9,
2042 "{name}={y} outside 0..{h}\n{s}"
2043 );
2044 }
2045 }
2046
2047 #[test]
2048 fn ps_sizing_scales_svg_prelude_padding_like_dpic() {
2049 let s = svg(".PS 3.5\nlinethick = 0.375*72\ncircle rad 3\n.PE");
2050 assert!(
2051 s.contains(
2052 "width=\"375.529412\" height=\"375.529412\" viewBox=\"0 0 375.529412 375.529412\""
2053 ),
2054 "{s}"
2055 );
2056 assert!(
2057 s.contains("<circle cx=\"177.882353\" cy=\"168\" r=\"158.117647\""),
2058 "{s}"
2059 );
2060 assert!(s.contains("stroke-width=\"36\""), "{s}");
2061 }
2062
2063 #[test]
2064 fn font_attrs_emit_presentation_attributes() {
2065 let s = svg("box \"t\" bold italic mono fontsize 18");
2066 assert!(
2067 s.contains(
2068 "<text font-size=\"18pt\" font-family=\"monospace\" font-weight=\"bold\" font-style=\"italic\""
2069 ),
2070 "{s}"
2071 );
2072 let s = svg("box \"t\" font \"Fira Sans\"");
2073 assert!(s.contains("font-family=\"Fira Sans\""), "{s}");
2074 let s = svg("box \"t\"\n\"alone\"");
2076 for needle in ["font-weight", "font-style", "font-family=\"monospace"] {
2077 assert!(!s.contains(needle), "unexpected {needle} in {s}");
2078 }
2079 }
2080
2081 #[test]
2082 fn font_family_is_xml_attribute_escaped() {
2083 let s = svg("box \"hi\" font \"A\\\"B\"");
2087 assert!(
2088 s.contains("font-family=\"A\\"B\""),
2089 "font family quote not escaped: {s}"
2090 );
2091 assert!(
2094 !s.contains("font-family=\"A\\\"B\""),
2095 "raw quote broke the attribute: {s}"
2096 );
2097 }
2098
2099 #[test]
2100 fn rotated_text_emits_anchor_transform() {
2101 let s = svg("\"note\" rotated 30 at (0,0)");
2103 let t = s.lines().find(|l| l.contains("<text")).unwrap();
2104 assert!(t.contains("transform=\"rotate(-30 "), "{t}");
2105 let x = t.split("x=\"").nth(1).unwrap().split('"').next().unwrap();
2107 let y = t.split("y=\"").nth(1).unwrap().split('"').next().unwrap();
2108 assert!(t.contains(&format!("rotate(-30 {x} {y})")), "{t}");
2109 let s = svg("box \"t\"");
2111 assert!(
2112 !s.contains("<text") || !s.contains("transform=\"rotate"),
2113 "{s}"
2114 );
2115 }
2116
2117 #[test]
2118 fn rotated_justified_label_stays_inside_viewbox() {
2119 for just in ["ljust", "rjust", ""] {
2123 let s = svg(&format!("\"MMMMMMMMMMMM\" {just} rotated 90 at (1,1)"));
2124 let vb = s.split("viewBox=\"").nth(1).unwrap();
2125 let vb: Vec<f64> = vb
2126 .split('"')
2127 .next()
2128 .unwrap()
2129 .split_whitespace()
2130 .map(|n| n.parse().unwrap())
2131 .collect();
2132 let (w, h) = (vb[2], vb[3]);
2133 let t = s.lines().find(|l| l.contains("<text")).unwrap();
2134 let tx: f64 = t
2135 .split("x=\"")
2136 .nth(1)
2137 .unwrap()
2138 .split('"')
2139 .next()
2140 .unwrap()
2141 .parse()
2142 .unwrap();
2143 let ty: f64 = t
2144 .split("y=\"")
2145 .nth(1)
2146 .unwrap()
2147 .split('"')
2148 .next()
2149 .unwrap()
2150 .parse()
2151 .unwrap();
2152 assert!(
2154 (0.0..=w).contains(&tx) && (0.0..=h).contains(&ty),
2155 "anchor ({tx},{ty}) outside viewBox {w}x{h} for just='{just}'"
2156 );
2157 }
2158 }
2159
2160 #[test]
2161 fn canvas_stmt_pins_the_viewbox() {
2162 let a = svg("canvas from (0,0) to (4,3)\nbox at (1,1)");
2165 let b = svg("canvas from (0,0) to (4,3)\nbox at (2,2)");
2166 assert_eq!(a.lines().next(), b.lines().next());
2167 assert_ne!(
2168 a.lines().find(|l| l.contains("<rect")),
2169 b.lines().find(|l| l.contains("<rect"))
2170 );
2171 let head = a.lines().next().unwrap();
2173 assert!(head.contains("viewBox=\"0 0 387.2 291.2\""), "{head}");
2174 let c = svg("box at (1,1)");
2176 assert!(!c.contains("387.2"), "{c}");
2177 }
2178
2179 #[test]
2180 fn canvas_stmt_sizes_an_empty_page() {
2181 let s = svg("canvas from (0,0) to (2,1)");
2183 assert!(s.contains("viewBox=\"0 0 195.2 99.2\""), "{s}");
2184 }
2185
2186 #[test]
2187 fn canvas_margin_expands_svg_canvas_only_when_used() {
2188 let base = svg("line right");
2189 assert_eq!(svg("margin = 0; topmargin = 0; line right"), base);
2190
2191 let s = svg("margin = 0.25\nline right");
2192 assert!(
2193 s.contains("width=\"99.2\" height=\"51.2\" viewBox=\"0 0 99.2 51.2\""),
2194 "{s}"
2195 );
2196 assert!(
2197 s.contains("<line x1=\"25.066667\" y1=\"24.533333\" x2=\"73.066667\" y2=\"24.533333\""),
2198 "{s}"
2199 );
2200
2201 let s = svg("margin = 0.25\nleftmargin = -0.25\nline right");
2202 assert!(
2203 s.contains("width=\"75.2\" height=\"51.2\" viewBox=\"0 0 75.2 51.2\""),
2204 "{s}"
2205 );
2206 assert!(
2207 s.contains("<line x1=\"1.066667\" y1=\"24.533333\" x2=\"49.066667\" y2=\"24.533333\""),
2208 "{s}"
2209 );
2210 }
2211
2212 #[test]
2213 fn move_expands_svg_bounds_like_dpic() {
2214 let s =
2215 svg(".PS\nscale=0.25\nline from (0,0) to (1,0)\nmove left 0.4*scale from (0,0)\n.PE");
2216 assert!(
2217 s.contains("width=\"425.6\" height=\"3.2\" viewBox=\"0 0 425.6 3.2\""),
2218 "{s}"
2219 );
2220 assert!(
2221 s.contains("<line x1=\"39.466667\" y1=\"0.533333\" x2=\"423.466667\" y2=\"0.533333\""),
2222 "{s}"
2223 );
2224 }
2225
2226 #[test]
2227 fn attached_text_does_not_expand_svg_prelude_bounds() {
2228 let s = svg("box wid .2 \"longlonglong\"");
2229 assert!(
2230 s.contains("width=\"22.4\" height=\"51.2\" viewBox=\"0 0 22.4 51.2\""),
2231 "{s}"
2232 );
2233 assert!(
2234 s.contains("<rect x=\"1.066667\" y=\"0.533333\" width=\"19.2\" height=\"48\""),
2235 "{s}"
2236 );
2237 }
2238
2239 #[test]
2240 fn standalone_text_height_sets_svg_font_size_like_dpic() {
2241 let s = svg("\"\\includegraphics{diagA.eps}\" wid 172/72 ht 54/72");
2242 assert!(s.contains("font-size=\"81.818182pt\""), "{s}");
2243 assert!(
2244 s.contains("stroke-width=\"0.266667\" fill=\"black\" x=\"115.733333\" y=\"72.533333\""),
2245 "{s}"
2246 );
2247 assert!(!s.contains("dominant-baseline"), "{s}");
2248 }
2249
2250 #[test]
2251 fn standalone_text_below_offsets_svg_baseline_like_dpic() {
2252 let s = svg(".PS\nscale=0.25\nline up 0.05 from (0,0)\n\"0\" below at (0,0)\n.PE");
2253 assert!(
2257 s.contains("width=\"12\" height=\"34.746667\" viewBox=\"0 0 12 34.746667\""),
2258 "{s}"
2259 );
2260 assert!(
2261 s.contains("stroke-width=\"0.266667\" fill=\"black\" x=\"5.466667\" y=\"32.08\""),
2262 "{s}"
2263 );
2264 }
2265
2266 #[test]
2267 fn standalone_label_ink_is_bounded_not_clipped() {
2268 let s = svg(".PS\n\"wide label here\" rjust at (0,0)\n.PE");
2272 let vb = s
2273 .split("viewBox=\"")
2274 .nth(1)
2275 .and_then(|t| t.split('"').next())
2276 .unwrap();
2277 let w: f64 = vb.split_whitespace().nth(2).unwrap().parse().unwrap();
2278 assert!(w > 100.0, "viewBox too narrow, label clipped: {vb}");
2280 }
2281
2282 #[test]
2283 fn attached_text_uses_dpic_svg_baseline() {
2284 let s = svg("box wid .2 \"longlonglong\"");
2285 assert!(s.contains("font-size=\"11pt\""), "{s}");
2286 assert!(
2287 s.contains("stroke-width=\"0.2pt\" fill=\"black\" x=\"10.666667\" y=\"29.373333\""),
2288 "{s}"
2289 );
2290 assert!(!s.contains("dominant-baseline"), "{s}");
2291 }
2292
2293 #[test]
2294 fn arc_can_be_filled() {
2295 let s = svg("arc fill 0.5");
2296 assert!(s.contains("fill=\"rgb(128,128,128)\" stroke-width=\"0\""));
2297 }
2298
2299 #[test]
2300 fn open_color_changes_stroke_without_area_fill() {
2301 let s = svg("arc color \"red\"");
2302 assert!(s.contains("stroke=\"red\""));
2303 assert!(!s.contains("stroke-width=\"0\" stroke=\"black\""));
2304 }
2305
2306 #[test]
2307 fn root_fill_none_matches_dpic_invalid_color_fallback() {
2308 let s = svg("line right then up then left shaded \"NotAColor\"");
2311 assert!(s.lines().next().unwrap().contains("fill=\"none\""));
2312 assert!(s.contains("fill=\"NotAColor\""), "{s}");
2313 let s = svg("line right then up then left shaded \"Dandelion\"");
2315 assert!(s.contains("fill=\"#ffb529\""), "{s}");
2316 }
2317
2318 #[test]
2319 fn dashed_box_gets_dasharray() {
2320 let s = svg("box \"x\" dashed");
2321 assert!(s.contains("stroke-dasharray"));
2322 }
2323
2324 #[test]
2325 fn negative_box_dimensions_emit_positive_svg_rect() {
2326 let s = svg("box wid -0.5 ht -0.25");
2327 assert!(s.contains("<rect"));
2328 assert!(s.contains("width=\"48\""), "{s}");
2329 assert!(s.contains("height=\"24\""), "{s}");
2330 assert!(!s.contains("width=\"-"), "{s}");
2331 assert!(!s.contains("height=\"-"), "{s}");
2332 }
2333
2334 #[test]
2335 fn negative_circle_and_ellipse_dimensions_emit_positive_svg_radii() {
2336 let s = svg("circle rad -0.5");
2337 assert!(s.contains("<circle"), "{s}");
2338 assert!(s.contains(" r=\"48\""), "{s}");
2339 assert!(!s.contains(" r=\"-"), "{s}");
2340
2341 let s = svg("ellipse wid -1 ht -0.5");
2342 assert!(s.contains("<ellipse"), "{s}");
2343 assert!(s.contains(" rx=\"48\""), "{s}");
2344 assert!(s.contains(" ry=\"24\""), "{s}");
2345 assert!(!s.contains(" rx=\"-"), "{s}");
2346 assert!(!s.contains(" ry=\"-"), "{s}");
2347 }
2348
2349 #[test]
2350 fn filled_circle() {
2351 let s = svg("circle fill 0");
2352 assert!(s.contains("<circle"));
2353 assert!(s.contains("rgb(0,0,0)"));
2354 }
2355
2356 #[test]
2357 fn texlabels_embeds_math_fragment_at_the_baseline_anchor() {
2358 crate::math::set_math_renderer(|tex, font_pt| {
2359 let em = font_pt / 72.0;
2360 Ok(crate::math::MathSpan {
2361 svg: format!("<svg width=\"19.2\" height=\"14.08\"><!--{tex}--></svg>"),
2362 width: 1.0 * em,
2363 height: 0.7 * em,
2364 depth: 0.2 * em,
2365 })
2366 });
2367
2368 let s = svg("texlabels = 1\nbox \"$x$\" wid 1 ht 1");
2369 assert!(s.contains("<svg x=\""), "{s}");
2371 assert!(s.contains("<!--x-->"), "{s}");
2372 assert!(!s.contains(">$x$<"), "{s}");
2374
2375 let plain = svg("box \"$x$\" wid 1 ht 1");
2377 assert!(!plain.contains("<svg x=\""), "{plain}");
2378 assert!(plain.contains(">$x$<"), "{plain}");
2379 }
2380
2381 #[test]
2382 fn standalone_math_fragment_expands_svg_bounds() {
2383 let text = vec![TextLine {
2384 s: "$standalone$".into(),
2385 math: Some(crate::math::MathSpan {
2386 svg: "<svg width=\"24\" height=\"28.8\"><!--standalone-math--></svg>".into(),
2387 width: 0.25,
2388 height: 0.2,
2389 depth: 0.1,
2390 }),
2391 halign: 0,
2392 valign: 0,
2393 text_offset: 0.0,
2394 bold: false,
2395 italic: false,
2396 family: None,
2397 size_pt: None,
2398 rotate: None,
2399 aligned: false,
2400 }];
2401 let d = Drawing {
2402 shapes: vec![Shape::Text {
2403 at: Point::ZERO,
2404 text,
2405 bbox: Bbox::new(),
2406 w: 0.0,
2407 h: 0.0,
2408 standalone: true,
2409 }],
2410 shape_layers: vec![0],
2411 shape_classes: vec![None],
2412 shape_links: vec![None],
2413 shape_spans: vec![None],
2414 bbox: Bbox::new(),
2415 canvas: None,
2416 prelude_thick: 0.8,
2417 canvas_margin: CanvasMargin::default(),
2418 anims: Vec::new(),
2419 interactions: Vec::new(),
2420 anim_scroll: false,
2421 diagnostics: Vec::new(),
2422 warnings: Vec::new(),
2423 };
2424 let s = to_svg(&d);
2425 let (root_w, root_h) = root_viewbox_size(&s);
2426 let frag = s
2427 .lines()
2428 .find(|line| line.contains("standalone-math"))
2429 .unwrap();
2430 let x = attr_num(frag, "x");
2431 let y = attr_num(frag, "y");
2432 let w = attr_num(frag, "width");
2433 let h = attr_num(frag, "height");
2434
2435 assert!(x >= -1e-9, "x={x}\n{s}");
2436 assert!(y >= -1e-9, "y={y}\n{s}");
2437 assert!(x + w <= root_w + 1e-9, "x+w={} root={root_w}\n{s}", x + w);
2438 assert!(y + h <= root_h + 1e-9, "y+h={} root={root_h}\n{s}", y + h);
2439 }
2440
2441 #[test]
2442 fn gradient_fill_emits_linear_gradient_defs() {
2443 let s = svg("box gradient \"steelblue\" \"white\"");
2445 assert!(s.contains("<defs><linearGradient id=\"grad0\""), "{s}");
2446 assert!(s.contains("x1=\"0\" y1=\"0.5\" x2=\"1\" y2=\"0.5\""), "{s}");
2447 assert!(
2448 s.contains("<stop offset=\"0\" stop-color=\"steelblue\"/>"),
2449 "{s}"
2450 );
2451 assert!(
2452 s.contains("<stop offset=\"1\" stop-color=\"white\"/>"),
2453 "{s}"
2454 );
2455 assert!(s.contains("fill=\"url(#grad0)\""), "{s}");
2456
2457 let s = svg("box gradient \"gold\" \"red\" gradientangle 90");
2459 assert!(s.contains("x1=\"0.5\" y1=\"1\" x2=\"0.5\" y2=\"0\""), "{s}");
2460
2461 let plain = svg("box\ncircle fill 0.5");
2463 assert!(!plain.contains("linearGradient"), "{plain}");
2464
2465 let s = svg("box gradient \"gold\" \"red\" opacity 0.3");
2467 assert!(
2468 s.contains("fill=\"url(#grad0)\" fill-opacity=\"0.3\""),
2469 "{s}"
2470 );
2471 }
2472
2473 #[test]
2474 fn gradient_composes_with_hatch_and_opacity() {
2475 let s = svg("box gradient \"gold\" \"white\" hatch opacity 0.5");
2479 assert!(s.contains("<linearGradient id=\"grad0\""), "{s}");
2480 assert!(s.contains("<pattern id=\"hatch1\""), "{s}");
2481 let under = s.find("fill=\"url(#grad0)\"").expect("gradient underlay");
2482 let main = s.find("fill=\"url(#hatch1)\"").expect("hatch fill");
2483 assert!(under < main, "underlay must be painted before the hatch");
2484 let pattern = &s[s.find("<pattern").unwrap()..s.find("</pattern>").unwrap()];
2486 assert!(!pattern.contains("url(#grad"), "{s}");
2487 assert_eq!(s.matches("fill-opacity=\"0.5\"").count(), 2, "{s}");
2488 }
2489
2490 #[test]
2491 fn class_hook_lands_on_shape_group_and_keeps_ids() {
2492 let s = svg("box class \"critical hot\"\ncircle");
2493 assert!(s.contains("<g id=\"s0\" class=\"critical hot\">"), "{s}");
2494 assert!(s.contains("<g id=\"s1\">\n<circle"), "{s}");
2495
2496 let plain = svg("box\ncircle");
2498 assert!(!plain.contains("class="), "{plain}");
2499 }
2500
2501 #[test]
2502 fn class_follows_its_shape_through_behind_reordering() {
2503 let s = svg("A: box class \"front\"\nbox class \"back\" behind A at A");
2506 let back = s.find("<g id=\"s1\" class=\"back\">").unwrap();
2507 let front = s.find("<g id=\"s0\" class=\"front\">").unwrap();
2508 assert!(back < front, "{s}");
2509 }
2510
2511 #[test]
2512 fn link_wraps_shape_group_in_anchor_and_keeps_ids() {
2513 let s = svg("box link \"https://rpic.dev\"\ncircle");
2514 assert!(
2516 s.contains("<a href=\"https://rpic.dev\" class=\"rpic-link\">\n<g id=\"s0\">"),
2517 "{s}"
2518 );
2519 assert!(s.contains("</g>\n</a>\n"), "{s}");
2520 assert!(s.contains("<g id=\"s1\">\n<circle"), "{s}");
2522
2523 let s = svg("box link \"https://a.dev/?x=1&y=2\"");
2525 assert!(
2526 s.contains("<a href=\"https://a.dev/?x=1&y=2\" class=\"rpic-link\">"),
2527 "{s}"
2528 );
2529
2530 let s = svg("box class \"hot\" link \"https://rpic.dev\"");
2532 assert!(
2533 s.contains(
2534 "<a href=\"https://rpic.dev\" class=\"rpic-link\">\n<g id=\"s0\" class=\"hot\">"
2535 ),
2536 "{s}"
2537 );
2538
2539 let plain = svg("box\ncircle");
2541 assert!(!plain.contains("<a "), "{plain}");
2542 }
2543
2544 #[test]
2545 fn link_follows_its_shape_through_behind_reordering() {
2546 let s =
2547 svg("A: box link \"https://front.dev\"\nbox link \"https://back.dev\" behind A at A");
2548 let back = s
2549 .find("<a href=\"https://back.dev\" class=\"rpic-link\">\n<g id=\"s1\">")
2550 .unwrap();
2551 let front = s
2552 .find("<a href=\"https://front.dev\" class=\"rpic-link\">\n<g id=\"s0\">")
2553 .unwrap();
2554 assert!(back < front, "{s}");
2555 }
2556
2557 #[test]
2558 fn hatch_fill_emits_svg_pattern() {
2559 let s = svg("box fill 0.9 crosshatch hatchangle 30 hatchsep .05 hatchwid 1 hatchcolor red");
2560 assert!(s.contains("<defs><pattern id=\"hatch0\""), "{s}");
2561 assert!(s.contains("patternUnits=\"userSpaceOnUse\""), "{s}");
2562 assert!(s.contains("patternTransform=\"rotate(-30)\""), "{s}");
2563 assert!(s.contains("<rect x=\"0\" y=\"0\""), "{s}");
2564 assert!(s.contains("fill=\"rgb(230,230,230)\""), "{s}");
2565 assert!(s.contains("stroke=\"red\""), "{s}");
2566 assert!(s.contains("fill=\"url(#hatch0)\""), "{s}");
2567 }
2568
2569 #[test]
2570 fn opacity_emits_as_fill_opacity_only() {
2571 let s = svg("box \"label\" fill 0.8 opacity .4");
2572 assert!(s.contains("<g id=\"s0\">"), "{s}");
2573 assert!(s.contains("fill-opacity=\"0.4\""), "{s}");
2574 assert!(!s.contains(" opacity=\"0.4\""), "{s}");
2575 assert!(s.contains(">label</text>"), "{s}");
2576 }
2577
2578 #[test]
2579 fn opacity_applies_to_open_path_fill_not_stroke_or_text() {
2580 let s = svg("line right then up then left then down fill 0.8 opacity .5 \"area\"");
2581 assert!(s.contains("fill-opacity=\"0.5\""), "{s}");
2582 assert!(s.contains("fill=\"none\" stroke=\"black\""), "{s}");
2583 assert!(!s.contains("stroke-opacity"), "{s}");
2584 assert!(s.contains(">area</text>"), "{s}");
2585 }
2586
2587 #[test]
2588 fn invisible_hatched_open_path_still_sets_svg_bounds() {
2589 let s = svg("line hatch invis right then up then left then down");
2590 assert!(
2591 s.contains("width=\"51.2\" height=\"51.2\" viewBox=\"0 0 51.2 51.2\""),
2592 "{s}"
2593 );
2594 assert!(s.contains("fill=\"url(#hatch0)\""), "{s}");
2595 assert!(!s.contains("fill=\"none\" stroke=\"black\""), "{s}");
2596 }
2597
2598 #[test]
2599 fn invisible_filled_closed_shape_keeps_fill_only() {
2600 let s = svg("box invis fill 0.5");
2601 assert!(s.contains("<rect"));
2602 assert!(s.contains("fill=\"rgb(128,128,128)\""));
2603 assert!(s.contains("stroke=\"none\""));
2604 }
2605
2606 #[test]
2607 fn xml_is_escaped() {
2608 let s = svg("box \"a < b & c\"");
2609 assert!(s.contains("a < b & c"));
2610 }
2611
2612 #[test]
2613 fn text_justification_is_per_string() {
2614 let s = svg("\"LLLL\" ljust\n\"RRRR\" rjust");
2615 assert!(s.contains("text-anchor=\"start\""));
2616 assert!(s.contains(">LLLL</text>"));
2617 assert!(s.contains("text-anchor=\"end\""));
2618 assert!(s.contains(">RRRR</text>"));
2619
2620 let s = svg("box wid 1 ht .6 \"AAAA\" above \"BBBB\" below");
2621 let above = text_y(&s, "AAAA");
2622 let below = text_y(&s, "BBBB");
2623 assert!(above < below, "above={above} below={below}");
2624 assert!((below - above) < 40.0, "above/below offset too large: {s}");
2625 }
2626
2627 #[test]
2628 fn horizontal_text_justification_uses_textoffset() {
2629 let s = svg("textoffset = 0.1\n\"L\" ljust at (0,0)\n\"R\" rjust at (0,0)");
2630 let l = text_x(&s, "L");
2631 let r = text_x(&s, "R");
2632 assert!(
2633 (l - r - 19.2).abs() < 1e-9,
2634 "expected opposite 0.1in offsets in SVG px: {s}"
2635 );
2636 }
2637
2638 #[test]
2639 fn open_arrowhead_geometry_matches_dpic_default() {
2640 let style = Style {
2641 arrow_filled: false,
2642 ..Default::default()
2643 };
2644 let (left, point, right) = open_arrowhead_points(
2645 Point::new(97.066_667, 2.933_333),
2646 Point::new(1.066_667, 2.933_333),
2647 &style,
2648 )
2649 .unwrap();
2650 assert!((point.x - 94.867_677).abs() < 1e-6, "point={point:?}");
2651 assert!((point.y - 2.933_333).abs() < 1e-6, "point={point:?}");
2652 assert!((left.x - 87.333_333).abs() < 1e-6, "left={left:?}");
2653 assert!((left.y - 1.049_747).abs() < 1e-6, "left={left:?}");
2654 assert!((right.x - 87.333_333).abs() < 1e-6, "right={right:?}");
2655 assert!((right.y - 4.816_919).abs() < 1e-6, "right={right:?}");
2656 }
2657
2658 #[test]
2659 fn filled_arrowhead_geometry_matches_dpic_default() {
2660 let style = Style::default();
2661 let (left, point, right, stroke_end) = filled_arrowhead_points(
2662 Point::new(97.066_667, 2.933_333),
2663 Point::new(1.066_667, 2.933_333),
2664 &style,
2665 )
2666 .unwrap();
2667 assert!(
2668 (stroke_end.x - 94.867_677).abs() < 1e-6,
2669 "stroke_end={stroke_end:?}"
2670 );
2671 assert!(
2672 (stroke_end.y - 2.933_333).abs() < 1e-6,
2673 "stroke_end={stroke_end:?}"
2674 );
2675 assert!((point.x - 97.066_667).abs() < 1e-6, "point={point:?}");
2676 assert!((point.y - 2.933_333).abs() < 1e-6, "point={point:?}");
2677 assert!((left.x - 87.466_667).abs() < 1e-6, "left={left:?}");
2678 assert!((left.y - 0.533_333).abs() < 1e-6, "left={left:?}");
2679 assert!((right.x - 87.466_667).abs() < 1e-6, "right={right:?}");
2680 assert!((right.y - 5.333_333).abs() < 1e-6, "right={right:?}");
2681 }
2682
2683 #[test]
2684 fn open_arc_arrowhead_uses_curved_stroke_outline() {
2685 let s = svg("arrowhead = 0\nlinethick = 4\narc <-> wid .5 ht .5");
2686 assert!(s.contains("<path stroke-width=\"0\" stroke=\"black\" fill=\"black\""));
2687 assert!(s.contains(" A "), "{s}");
2688 assert!(!s.contains("<polyline"), "{s}");
2689 }
2690
2691 #[test]
2692 fn arc_with_explicit_center_uses_large_clockwise_svg_sweep() {
2693 let s = svg("arc cw rad 1 from (0,-1) to (1,0) with .c at (0,0)");
2694 assert!(s.contains(" A 96 96 0 1 1 "), "{s}");
2695 }
2696
2697 #[test]
2698 fn num_guards_non_finite() {
2699 assert_eq!(num(f64::NAN), "0");
2700 assert_eq!(num(f64::INFINITY), "0");
2701 assert_eq!(num(-0.0), "0");
2702 }
2703
2704 #[test]
2705 fn attr_escapes_quotes_and_markup() {
2706 assert_eq!(escape_attr("a\"<b&"), "a"<b&");
2707 assert_eq!(escape_text("a\"<b&"), "a\"<b&");
2710 }
2711
2712 #[test]
2713 fn user_text_in_every_attribute_kind_stays_well_formed_xml() {
2714 let hostile = "h\"/><g onload=evil";
2720 let sources = [
2721 "box color \"h\\\"/><g onload=evil\"",
2722 "box \"t\" font \"h\\\"/><g onload=evil\"",
2723 "box hatchcolor \"h\\\"/><g onload=evil\" hatch",
2724 ];
2725 for src in sources {
2726 let s = svg(src);
2727 assert!(
2728 !s.contains(hostile),
2729 "raw hostile text survived in `{src}`: {s}"
2730 );
2731 assert!(
2732 s.contains(""") && !s.contains("evil\">") && !s.contains("<g onload"),
2733 "attribute not neutralised in `{src}`: {s}"
2734 );
2735 }
2736 }
2737}