Skip to main content

rpic_core/
svg.rs

1//! SVG 1.1 backend. Renders a [`Drawing`] to an SVG string.
2//!
3//! Reference semantics taken from dpic's `svg.c`: y-axis flipped (SVG is
4//! y-down), arrowheads emitted as filled polygons, splines as cubic Béziers,
5//! gray fills via pic's `0 = black … 1 = white` convention. Coordinates scale
6//! by 96 px/inch (dpic's `dpPPI`).
7
8use crate::geom::{Bbox, Point};
9use crate::ir::*;
10
11const PPI: f64 = 96.0;
12// Text metrics come from the shared source of truth in `ir` (#291):
13// `DP_TEXT_RATIO` arrives via the glob import above, and the label font size
14// derives from the same constant the evaluator's layout bbox uses.
15const FONT_PT: f64 = FONT_PT_CLASSIC;
16
17/// Render a drawing to an SVG document string.
18pub 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    /// Shapes that are `type`-effect animation targets → split by word (`true`)
32    /// or by char (`false`). Derived from the manifest, so a drawing with no
33    /// `type` animation emits byte-identical text.
34    type_targets: std::collections::HashMap<usize, bool>,
35    /// Split granularity for the shape currently being emitted, if it is a
36    /// `type` target (set per shape in the render loop).
37    cur_type: Option<bool>,
38}
39
40impl Svg {
41    fn new(d: &Drawing) -> Self {
42        // a fixed `canvas` pins the page frame; otherwise it hugs the content
43        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    /// Map a pic point to SVG pixel space (y flipped).
68    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            // A `link` extension wraps the group in `<a>` — outside it, so
103            // the stable `s<N>` id (the GSAP/animation target) is untouched.
104            // Raster/PDF backends flatten `<a>` to a group (usvg), so the
105            // picture is identical there, just without the link.
106            // `class="rpic-link"` opts the anchor out of the common
107            // `a:not([class])` host prose pattern (the picture styles itself)
108            // and doubles as a styling hook (#362).
109            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            // The stable `s<N>` id is the GSAP/animation target; a `class`
117            // extension hook rides alongside it without changing the id.
118            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    // ---- painting ----------------------------------------------------------
458
459    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    /// stroke + fill for closed shapes.
518    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    /// When hatch and gradient combine, the gradient must be painted by a
538    /// separate element *under* the hatch pattern: a pattern tile is stamped
539    /// once and replicated, so a gradient inside it restarts in every cell
540    /// instead of spanning the object (#273). Returns the underlay's fill.
541    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        // The angle is measured in pic coordinates (y-up): 0 = left to right,
552        // 90 = bottom to top. SVG bounding-box coordinates are y-down, so the
553        // direction vector flips its y component; center it in the unit box.
554        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        // A gradient background is painted by an underlay element (see
589        // `underlay_fill`), never inside the tile; a solid fill is uniform, so
590        // per-tile is indistinguishable from per-object and stays here.
591        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    /// dpic-compatible spline path. The control-point construction matches
770    /// `dpic`'s SVG backend (verified against `dpic -v` output); since both the
771    /// model→SVG transform and the constructions are affine, we build directly
772    /// in SVG space.
773    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            // rpic `texlabels` extension: a typeset math line is embedded as a
833            // nested self-contained <svg> fragment (glyph paths), aligned to
834            // the same baseline and anchor the plain <text> would use.
835            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                // Box-centering semantics, like LaTeX-typeset dpic labels:
843                // a centered formula's ink box centers on the point classic
844                // text visually centers on (baseline + xheight/2); an
845                // above/below formula keeps its whole ink box clear of the
846                // reference, its near edge sitting where the classic
847                // justified baseline would be.
848                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            // rpic font attributes: extra presentation attributes only when
874            // styled — unstyled lines stay byte-identical to classic output
875            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                // pic angles are CCW; SVG rotate() is CW in screen space
887                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
913// ---- helpers ---------------------------------------------------------------
914
915/// Per-shape geometry for the JSON `objects` export. `bbox` is in SVG user
916/// units — the same space as the root `viewBox` and the `<g id="sN">` groups —
917/// so editors can hit-test without touching the DOM. `None` when the shape
918/// draws nothing (e.g. `invis`), though its `sN` group still exists.
919pub struct ObjectGeometry {
920    /// Shape kind as a stable lowercase name (`box`, `circle`, …).
921    pub kind: &'static str,
922    /// `(x, y, w, h)` of the shape's bounds in SVG user units.
923    pub bbox: Option<(f64, f64, f64, f64)>,
924}
925
926/// Compute [`ObjectGeometry`] for every shape of a drawing, index-aligned
927/// with the emitted `<g id="sN">` groups.
928pub 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    // An explicit `wid` keeps the classic symmetric box; otherwise each line is
1085    // bounded by its actual glyph ink (like `attached_text_bounds`), so edge
1086    // labels no longer clip (#270).
1087    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            // rotate about the text anchor `(x, y)`, matching the emitter
1146            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            // rotate about the text anchor `(x, y)`, matching the emitter
1207            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    // Fewer than 3 control points: just a straight polyline.
1220    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    // style.thick is in points; default ~0.8pt.
1235    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 is in points; default ~0.8pt.
1241    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    // A gradient is a fill too (#285) — without it here, `box gradient … opacity`
1652    // rendered fully opaque while solid and hatch fills honoured the opacity.
1653    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
1666/// Classic pic spline (no tension), matching dpic's `svgsplinesegment`.
1667/// Dpic emits one SVG `C` command with multiple cubic segments. The control
1668/// points are fixed fractions along each original segment, not a raised
1669/// quadratic through midpoint knots.
1670fn 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
1697/// dpic tensioned spline: starts at the first control point, ends at the last,
1698/// passes through the midpoints of the *interior* segments, and bends toward
1699/// each control vertex by `t`. Control point = endpoint + t·(vertex − endpoint),
1700/// matching dpic's SVG backend exactly.
1701fn tensioned_spline(q: &[Point], t: f64) -> String {
1702    let n = q.len();
1703    // knots: V0, mid(V1,V2), …, mid(V_{n-3},V_{n-2}), V_{n-1}
1704    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]; // via vertex for this cubic
1715        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
1734/// Format a float compactly (up to 6 decimals, no trailing zeros). Non-finite
1735/// values (NaN/Inf, e.g. from a zero-length element) become `0` so the SVG stays
1736/// well-formed instead of emitting a literal `NaN`.
1737fn 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 }; // normalise -0
1743    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
1750/// Escape a string for use as SVG element **text content** (`&`, `<`, `>`). A
1751/// raw `"` is legal between tags and is left untouched. Do **not** use this for
1752/// an attribute value — a `"` there would close the attribute; use
1753/// [`escape_attr`], whose name says the context. Keeping the two escapers named
1754/// by context is what stops the #317 slip (a text escaper used for an attribute)
1755/// from silently recurring.
1756fn escape_text(s: &str) -> String {
1757    s.replace('&', "&amp;")
1758        .replace('<', "&lt;")
1759        .replace('>', "&gt;")
1760}
1761
1762/// Escape a string for embedding inside a double-quoted SVG **attribute** value
1763/// — `escape_text` plus `"` → `&quot;`. This is the only correct escaper for
1764/// anything placed inside `="…"`, including user-controlled colours, class
1765/// names, gradient stops, and font families.
1766fn escape_attr(s: &str) -> String {
1767    escape_text(s).replace('"', "&quot;")
1768}
1769
1770/// Wrap each unit of a `type`-effect label in a `<tspan class="rpic-ch">` the
1771/// browser player staggers into view. In `by_word` mode whole words are the
1772/// units and whitespace stays outside the tspans (so only words reveal); by
1773/// character every glyph, spaces included, gets its own tspan — a typewriter.
1774/// The tspans carry no positioning, so they flow exactly like the plain text
1775/// and the pre-animation render is visually identical.
1776fn 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)); // whitespace: plain, not staggered
1797            } 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        // #328: a `type` target's glyphs become `.rpic-ch` tspans the player
1822        // staggers; a label that isn't a `type` target is untouched.
1823        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        // by word: whole words are units, whitespace stays plain
1832        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        // no animation → no split
1839        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")); // arrowhead
1899        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        // unstyled output carries none of the extension attributes
2075        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        // A `"` in the family value must become &quot;, never break the
2084        // attribute — otherwise the SVG is malformed and an attacker can
2085        // inject stray attributes onto the <text> element (#317).
2086        let s = svg("box \"hi\" font \"A\\\"B\"");
2087        assert!(
2088            s.contains("font-family=\"A\\&quot;B\""),
2089            "font family quote not escaped: {s}"
2090        );
2091        // the escape()-only output (raw quote) must not survive — it would
2092        // close the attribute early and break XML well-formedness
2093        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        // pic CCW 30° = SVG rotate(-30) about the text anchor
2102        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        // the rotate pivot matches the emitted x/y anchor
2106        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        // unrotated output carries no transform on <text>
2110        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        // a justified label rotates about its anchor (left/right edge), not
2120        // the rect centre — the bbox must cover the ink there, or the whole
2121        // label lands outside the viewBox and vanishes (audit finding)
2122        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            // the anchor point must sit inside the frame for `just={just}`
2153            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        // same fixed canvas, object moved: identical <svg> header (stable
2163        // frame), different rect coordinates (only the object moved)
2164        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        // and the frame is the canvas, not the content: 4in × 3in + padding
2172        let head = a.lines().next().unwrap();
2173        assert!(head.contains("viewBox=\"0 0 387.2 291.2\""), "{head}");
2174        // content-hugging baseline still reflows
2175        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        // a canvas with no drawn content still yields a fixed-size page
2182        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        // Baseline (y) still matches dpic; the width now bounds the glyph ink
2254        // instead of dpic's zero-width standalone box (#270) — dpic would emit
2255        // width 3.2, clipping wide standalone labels at the page edge.
2256        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        // A wide standalone label used to leave the viewBox (dpic-faithful but
2269        // clipped at the page edge); now its glyph extent is inside the bounds.
2270        // rjust text extends left of its anchor — the bounds must reach there.
2271        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        // 15 chars at ~8.8px each ⇒ well over 100px, not the old ~8px stub
2279        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        // an unknown colour still passes through verbatim (warned, not blocked),
2309        // and the root <svg> keeps fill="none" so it can't paint the page
2310        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        // a dvips/xcolor name browsers can't render resolves to its RGB (#275)
2314        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        // the fragment is embedded as a nested <svg> with injected x/y
2370        assert!(s.contains("<svg x=\""), "{s}");
2371        assert!(s.contains("<!--x-->"), "{s}");
2372        // no literal `$x$` text element is emitted for the math line
2373        assert!(!s.contains(">$x$<"), "{s}");
2374
2375        // classic output is untouched: no nested svg without texlabels
2376        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        // angle 0: left to right in bounding-box coordinates
2444        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        // angle 90 in pic coordinates = bottom to top = SVG y from 1 to 0
2458        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        // classic output carries no gradient defs
2462        let plain = svg("box\ncircle fill 0.5");
2463        assert!(!plain.contains("linearGradient"), "{plain}");
2464
2465        // #285: a gradient-only fill honours opacity (like solid/hatch fills)
2466        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        // the gradient is painted by an underlay element spanning the object,
2476        // with the hatch pattern (transparent tile) on top — inside the tile
2477        // it would restart in every cell (#273); fill-opacity rides both
2478        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        // the pattern tile must NOT embed the gradient
2485        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        // classic output stays byte-identical: no class attribute anywhere
2497        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        // `behind` reorders group emission; the class must stay attached to
2504        // its own shape id, which is also the animation target contract.
2505        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        // class="rpic-link" opts out of `a:not([class])` host prose rules (#362)
2515        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        // the unlinked shape keeps its plain group
2521        assert!(s.contains("<g id=\"s1\">\n<circle"), "{s}");
2522
2523        // the URL goes through the attribute escaper
2524        let s = svg("box link \"https://a.dev/?x=1&y=2\"");
2525        assert!(
2526            s.contains("<a href=\"https://a.dev/?x=1&amp;y=2\" class=\"rpic-link\">"),
2527            "{s}"
2528        );
2529
2530        // class and link compose: anchor outside, class on the group
2531        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        // classic output stays byte-identical: no anchor anywhere
2540        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 &lt; b &amp; 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&quot;&lt;b&amp;");
2707        // text content leaves a bare `"` alone (legal between tags) but still
2708        // neutralises markup
2709        assert_eq!(escape_text("a\"<b&"), "a\"&lt;b&amp;");
2710    }
2711
2712    #[test]
2713    fn user_text_in_every_attribute_kind_stays_well_formed_xml() {
2714        // #324: any user-controlled attribute (colour, hatch colour, font
2715        // family) built from hostile text must escape `"` so it can never break
2716        // the attribute or inject markup. The bare quote (which would close the
2717        // attribute) must not survive; `<`/`>` are neutralised too. (`class` is
2718        // separately validated to a safe charset, so it can't reach here.)
2719        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("&quot;") && !s.contains("evil\">") && !s.contains("<g onload"),
2733                "attribute not neutralised in `{src}`: {s}"
2734            );
2735        }
2736    }
2737}