Skip to main content

energyml_section/
svg.rs

1//! A layout written as SVG (ADR-0015).
2//!
3//! A pure writer: every number it prints was decided by [`crate::layout`], so
4//! the front end, the CLI and the export draw the same bytes (WYSIWYG by
5//! construction). Four rules:
6//!
7//! * **grouped and annotated** - one `<g>` per object, with `data-key` and
8//!   `data-kind`; a well has one `<g>` per interval, with its unit
9//!   (`data-unit`, `data-md-from`, `data-md-to`). That is how the front end knows
10//!   what is under the pointer without redoing any geometry;
11//! * **colours are attributes**, never classes (the front end's
12//!   `bakeComputedPaint` trap: a class is lost the moment the SVG leaves the
13//!   front end's stylesheet - an export, a standalone file);
14//! * **everything is escaped** - a package's title is someone else's text and
15//!   the front end inserts this string into its DOM. [`escape`] also drops the
16//!   characters XML 1.0 cannot carry at all;
17//! * **the metadata** - `<metadata>` holds the format, this crate's
18//!   version, the inputs and the view as JSON, so `--from-svg` can redraw.
19//!
20//! # `data-key` and the NUL
21//!
22//! The front end's key of an object is its store and its uri **joined by a NUL**
23//! (`model::object_key`), and a NUL cannot be written in XML 1.0 - not raw, not
24//! as `&#0;`; an HTML parser turns it into U+FFFD. So `data-key` carries the
25//! key with the NUL spelled U+2400 (`␀`, *symbol for null*), and the two halves
26//! are also written whole as `data-store` and `data-uri`: the front end rebuilds its
27//! key from those, with no guess about a separator.
28
29use std::fmt::Write as _;
30
31use crate::layout::{
32    Anchor, Attr, LaidInterval, LaidObject, Layout, MARKERS_LAYER, Rect, STRATA_LAYER, Shape,
33};
34
35/// The script an SVG carries when `View::zoom_script` is on (it has to work
36/// outside a front end, when the user opens the SVG in a browser). Plain ES2015 in `zoom.js`; it reads the root's
37/// `data-frame`, `data-window` and `data-scale` and the `data-zoom*`
38/// attributes this writer puts on what it moves. Where scripts do not run
39/// (`<img>`, Inkscape, Office) the drawing is the same, static.
40const ZOOM_SCRIPT: &str = include_str!("zoom.js");
41
42/// What a piece of the drawing does when the script zooms it.
43#[derive(Clone, Copy, PartialEq, Eq)]
44enum Zoom {
45    /// No script: every byte as it was before the script existed.
46    Off,
47    /// Inside the plot group, which the script scales: strokes are
48    /// `non-scaling`, and dots and texts are counter-scaled around their anchor.
49    Plot,
50    /// The labels layer: outside the plot group (it reaches above the frame), so
51    /// the script moves each label to where its anchor went, at its own size.
52    Follow,
53    /// The legend and the like: never moves, but strokes stay as they were.
54    Still,
55}
56
57impl Zoom {
58    fn on(self) -> bool {
59        self != Zoom::Off
60    }
61}
62
63/// The font the labels are measured against (`energyml_sheet::text`'s table is
64/// Liberation Sans's, and the sheet writes the same stack).
65const FONT_FAMILY: &str = "Liberation Sans, Arial, Helvetica, sans-serif";
66
67/// The SVG of a layout, as a string.
68pub fn write(layout: &Layout) -> String {
69    let mut out = String::with_capacity(16 * 1024);
70    let [w, h] = layout.size;
71    // The map a front end's overlay reads a pointer through: the plot's
72    // frame in pixels, the window it shows in the drawing's units, and the
73    // pixels per unit - `x = frame.x + (s − window.x0)·scale.x`,
74    // `y = frame.y + frame.h − (z − window.y0)·scale.y`. Written in full, not
75    // rounded like the drawing's coordinates: a reading in metres must not
76    // inherit a hundredth of a pixel's error times the scale. `data-whole` is
77    // the window with none asked - the same value however the drawing was
78    // zoomed - so a zoom can be clamped to it from any drawing.
79    let (f, win, scale) = (layout.frame, layout.window, layout.scale);
80    // Inkscape's namespace, declared only when a group uses it: a drawing
81    // without the strata legend is the bytes it always was.
82    let has_strata = layout
83        .layers
84        .iter()
85        .any(|l| (l.name == STRATA_LAYER || l.name == MARKERS_LAYER) && !l.objects.is_empty());
86    let inkscape = if has_strata {
87        " xmlns:inkscape=\"http://www.inkscape.org/namespaces/inkscape\""
88    } else {
89        ""
90    };
91    let _ = writeln!(
92        out,
93        "<svg xmlns=\"http://www.w3.org/2000/svg\"{} width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\" \
94         font-family=\"{}\" font-size=\"11\" data-frame=\"{} {} {} {}\" data-window=\"{} {} {} {}\" data-whole=\"{} {} {} {}\" data-scale=\"{} {}\">",
95        inkscape,
96        fmt(w),
97        fmt(h),
98        fmt(w),
99        fmt(h),
100        FONT_FAMILY,
101        f.x,
102        f.y,
103        f.w,
104        f.h,
105        win.x[0],
106        win.x[1],
107        win.y[0],
108        win.y[1],
109        layout.whole.x[0],
110        layout.whole.x[1],
111        layout.whole.y[0],
112        layout.whole.y[1],
113        scale[0],
114        scale[1]
115    );
116    let _ = writeln!(out, "<title>{}</title>", escape(&layout.title));
117    write_metadata(&mut out, layout);
118    let f = layout.frame;
119    let _ = writeln!(
120        out,
121        "<defs><clipPath id=\"plot\"><rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"/></clipPath>{}</defs>",
122        fmt(f.x),
123        fmt(f.y),
124        fmt(f.w),
125        fmt(f.h),
126        hatch_pattern(layout)
127    );
128    let _ = writeln!(
129        out,
130        "<rect x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"{}\"/>",
131        fmt(w),
132        fmt(h),
133        escape(&layout.background)
134    );
135    let zoomable = layout.metadata.view.zoom_script;
136    write_axes(&mut out, layout, zoomable);
137
138    // With the script, the clip moves to a wrapper of its own: a `clip-path` on
139    // the group the script transforms would be scaled with it, and the plot
140    // would stop being clipped to its frame. One wrapper per run of clipped
141    // layers (they are contiguous today; `querySelectorAll` does not care).
142    let mut in_plot = false;
143    for layer in &layout.layers {
144        if layer.objects.is_empty() {
145            continue;
146        }
147        let zoom = match (zoomable, layer.clipped, layer.name) {
148            (false, ..) => Zoom::Off,
149            (true, true, _) => Zoom::Plot,
150            (true, false, "labels") => Zoom::Follow,
151            (true, false, _) => Zoom::Still,
152        };
153        if zoom == Zoom::Plot && !in_plot {
154            out.push_str("<g clip-path=\"url(#plot)\"><g data-zoom=\"plot\">\n");
155            in_plot = true;
156        } else if zoom != Zoom::Plot && in_plot {
157            out.push_str("</g></g>\n");
158            in_plot = false;
159        }
160        let clip = if layer.clipped && !zoomable {
161            " clip-path=\"url(#plot)\""
162        } else {
163            ""
164        };
165        if layer.name == STRATA_LAYER {
166            // One group, outside the zoomed plot, that an editor can move whole:
167            // an Inkscape layer with a name and an id of its own.
168            out.push_str("<g id=\"strata-legend\" data-legend=\"strata\" inkscape:groupmode=\"layer\" inkscape:label=\"Strata legend\">\n");
169        } else if layer.name == MARKERS_LAYER {
170            out.push_str("<g id=\"markers-legend\" data-legend=\"markers\" inkscape:groupmode=\"layer\" inkscape:label=\"Markers legend\">\n");
171        } else {
172            let _ = writeln!(out, "<g data-layer=\"{}\"{}>", layer.name, clip);
173        }
174        for object in &layer.objects {
175            write_object(&mut out, object, zoom);
176        }
177        out.push_str("</g>\n");
178    }
179    if in_plot {
180        out.push_str("</g></g>\n");
181    }
182
183    // What the drawing says about itself (and the warnings).
184    let _ = writeln!(
185        out,
186        "<text x=\"{}\" y=\"16\" font-size=\"14\" font-weight=\"bold\" fill=\"#222222\">{}</text>",
187        fmt(f.x),
188        escape(&layout.title)
189    );
190    let _ = writeln!(
191        out,
192        "<text data-role=\"exaggeration\" x=\"{}\" y=\"16\" text-anchor=\"end\" fill=\"#222222\">{}</text>",
193        fmt(f.x + f.w),
194        escape(&layout.exaggeration_text)
195    );
196    if !layout.origin_text.is_empty() {
197        // The layout measured the free space and chose where the text starts.
198        let _ = writeln!(
199            out,
200            "<text data-role=\"origin\" x=\"{}\" y=\"16\" fill=\"#444444\">{}</text>",
201            fmt(layout.origin_x),
202            escape(&layout.origin_text)
203        );
204    }
205    let base = f.y + f.h + 46.0;
206    for (i, line) in layout.written_warnings().iter().enumerate() {
207        let _ = writeln!(
208            out,
209            "<text data-role=\"warning\" x=\"{}\" y=\"{}\" fill=\"#b71c1c\">{}</text>",
210            fmt(f.x),
211            fmt(base + 14.0 * i as f64),
212            escape(line)
213        );
214    }
215    if zoomable {
216        // `]]>` would end the CDATA and `</script>` the element: the script has
217        // neither (`tests/zoom.rs`), and a CDATA needs no escaping otherwise.
218        let _ = writeln!(out, "<script><![CDATA[\n{ZOOM_SCRIPT}\n]]></script>");
219    }
220    out.push_str("</svg>\n");
221    out
222}
223
224fn write_metadata(out: &mut String, layout: &Layout) {
225    let json = serde_json::to_string(&layout.metadata).unwrap_or_else(|_| "null".to_string());
226    let _ = writeln!(
227        out,
228        "<metadata><section-view xmlns=\"urn:energyml:section-view\" format=\"{}\" version=\"{}\">{}</section-view></metadata>",
229        layout.metadata.format,
230        escape(&layout.metadata.version),
231        escape(&json)
232    );
233}
234
235/// Why an SVG's metadata could not be read back.
236#[derive(Debug, Clone, PartialEq, thiserror::Error)]
237pub enum MetadataError {
238    /// No `<section-view>` element: this SVG was not written by [`write()`], or
239    /// an editor dropped its `<metadata>`.
240    #[error(
241        "no section-view metadata in this SVG - it was not written by the section view, or its <metadata> was removed"
242    )]
243    Missing,
244    /// Written by another format of the metadata.
245    #[error("the metadata is format {found}, and this build reads format {known}")]
246    Format {
247        /// What the SVG says.
248        found: String,
249        /// [`crate::model::FORMAT`].
250        known: u32,
251    },
252    /// The element is there and its JSON does not read.
253    #[error("the section-view metadata does not read: {0}")]
254    Unreadable(String),
255}
256
257/// What [`write()`] put in `<metadata>` - the inputs and the view the drawing was
258/// made from - read back from the SVG's text.
259///
260/// The way back of `write_metadata`, and the only one: `--from-svg` and an
261/// operation that writes the trace an image was drawn along both read here, so
262/// that the trace written and the trace drawn are one fact.
263///
264/// The text is unescaped as XML 1.0 does: the five entities [`escape`] writes,
265/// and character references, which an editor that re-saved the file may have
266/// written instead.
267pub fn read_metadata(svg: &str) -> Result<crate::layout::Metadata, MetadataError> {
268    const OPEN: &str = "<section-view";
269    let start = svg.find(OPEN).ok_or(MetadataError::Missing)?;
270    let rest = &svg[start + OPEN.len()..];
271    let tag_end = rest.find('>').ok_or(MetadataError::Missing)?;
272    let attributes = &rest[..tag_end];
273    let body = &rest[tag_end + 1..];
274    let body = &body[..body.find("</section-view>").ok_or(MetadataError::Missing)?];
275
276    let format = attribute(attributes, "format").unwrap_or_default();
277    if format != crate::model::FORMAT.to_string() {
278        return Err(MetadataError::Format {
279            found: format,
280            known: crate::model::FORMAT,
281        });
282    }
283    let json = unescape(body).map_err(MetadataError::Unreadable)?;
284    serde_json::from_str(&json).map_err(|why| MetadataError::Unreadable(why.to_string()))
285}
286
287/// The value of `name="…"` in a start tag's attributes, unescaped.
288fn attribute(attributes: &str, name: &str) -> Option<String> {
289    let key = format!("{name}=\"");
290    let from = attributes.find(&key)? + key.len();
291    let to = attributes[from..].find('"')? + from;
292    unescape(&attributes[from..to]).ok()
293}
294
295/// XML 1.0 text, unescaped: the five predefined entities and `&#…;` / `&#x…;`.
296fn unescape(text: &str) -> Result<String, String> {
297    let mut out = String::with_capacity(text.len());
298    let mut rest = text;
299    while let Some(at) = rest.find('&') {
300        out.push_str(&rest[..at]);
301        let after = &rest[at + 1..];
302        let end = after
303            .find(';')
304            .ok_or_else(|| format!("an `&` with no `;` at `{}`", &rest[at..rest.len().min(at + 12)]))?;
305        let name = &after[..end];
306        let ch = match name {
307            "amp" => '&',
308            "lt" => '<',
309            "gt" => '>',
310            "quot" => '"',
311            "apos" => '\'',
312            _ => {
313                let code = if let Some(hex) = name.strip_prefix("#x").or_else(|| name.strip_prefix("#X")) {
314                    u32::from_str_radix(hex, 16).ok()
315                } else if let Some(decimal) = name.strip_prefix('#') {
316                    decimal.parse().ok()
317                } else {
318                    None
319                };
320                code.and_then(char::from_u32)
321                    .ok_or_else(|| format!("`&{name};` is not an entity XML 1.0 knows"))?
322            }
323        };
324        out.push(ch);
325        rest = &after[end + 1..];
326    }
327    out.push_str(rest);
328    Ok(out)
329}
330
331fn write_axes(out: &mut String, layout: &Layout, zoom: bool) {
332    let Rect { x, y, w, h } = layout.frame;
333    out.push_str("<g data-layer=\"axes\" fill=\"#444444\" stroke=\"none\">\n");
334    // The script rewrites the graduations from the window it shows. An axis
335    // whose ticks carry a second line (true coordinates) cannot be rewritten -
336    // that line is a function of the trace, not of the window - so its ticks
337    // are one group each, which the script moves and hides instead.
338    let moved = layout.x_axis.ticks.iter().any(|t| t.sublabel.is_some());
339    if zoom {
340        let _ = writeln!(
341            out,
342            "<g data-ticks=\"x\"{}>",
343            if moved { " data-ticks-mode=\"move\"" } else { "" }
344        );
345    }
346    for t in &layout.x_axis.ticks {
347        if t.at < x - 0.5 || t.at > x + w + 0.5 {
348            continue;
349        }
350        if zoom && moved {
351            let _ = writeln!(out, "<g data-tick-at=\"{}\">", fmt(t.at));
352        }
353        let _ = writeln!(
354            out,
355            "<line x1=\"{a}\" y1=\"{}\" x2=\"{a}\" y2=\"{}\" stroke=\"#e0e0e0\" stroke-width=\"1\"/>",
356            fmt(y),
357            fmt(y + h),
358            a = fmt(t.at)
359        );
360        let _ = writeln!(
361            out,
362            "<text x=\"{}\" y=\"{}\" text-anchor=\"middle\">{}</text>",
363            fmt(t.at),
364            fmt(y + h + 14.0),
365            escape(&t.label)
366        );
367        if let Some(sub) = &t.sublabel {
368            let _ = writeln!(
369                out,
370                "<text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-size=\"9\">{}</text>",
371                fmt(t.at),
372                fmt(y + h + 26.0),
373                escape(sub)
374            );
375        }
376        if zoom && moved {
377            out.push_str(
378                "</g>
379",
380            );
381        }
382    }
383    if zoom {
384        out.push_str(
385            "</g>
386<g data-ticks=\"y\">
387",
388        );
389    }
390    for t in &layout.y_axis.ticks {
391        if t.at < y - 0.5 || t.at > y + h + 0.5 {
392            continue;
393        }
394        let _ = writeln!(
395            out,
396            "<line x1=\"{}\" y1=\"{b}\" x2=\"{}\" y2=\"{b}\" stroke=\"#e0e0e0\" stroke-width=\"1\"/>",
397            fmt(x),
398            fmt(x + w),
399            b = fmt(t.at)
400        );
401        let _ = writeln!(
402            out,
403            "<text x=\"{}\" y=\"{}\" text-anchor=\"end\">{}</text>",
404            fmt(x - 5.0),
405            fmt(t.at + 4.0),
406            escape(&t.label)
407        );
408    }
409    if zoom {
410        out.push_str(
411            "</g>
412",
413        );
414    }
415    let _ = writeln!(
416        out,
417        "<text data-role=\"x-title\" x=\"{}\" y=\"{}\" text-anchor=\"middle\">{}</text>",
418        fmt(x + w / 2.0),
419        fmt(y
420            + h
421            + if layout.x_axis.ticks.iter().any(|t| t.sublabel.is_some()) {
422                38.0
423            } else {
424                30.0
425            }),
426        escape(&layout.x_axis.title)
427    );
428    let _ = writeln!(
429        out,
430        "<text data-role=\"y-title\" x=\"{a}\" y=\"{}\" text-anchor=\"middle\" transform=\"rotate(-90 {a} {})\">{}</text>",
431        fmt(y + h / 2.0),
432        fmt(y + h / 2.0),
433        escape(&layout.y_axis.title),
434        a = fmt(12.0 + layout.left_shift)
435    );
436    let _ = writeln!(
437        out,
438        "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"none\" stroke=\"#444444\" stroke-width=\"1\"/>",
439        fmt(x),
440        fmt(y),
441        fmt(w),
442        fmt(h)
443    );
444    out.push_str("</g>\n");
445}
446
447/// The `<pattern>` the hatched style is stroked with - written only when a shape
448/// refers to it, so a drawing without the style is the bytes it always was. Stripes
449/// of the style's colour on nothing, `spacing` apart and rotated by `angle`. Its
450/// units are the drawing's: the zoom script's transform magnifies it with the plot
451/// (a texture, not a stroke - only strokes keep their pixels).
452fn hatch_pattern(layout: &Layout) -> String {
453    let reference = crate::layout::hatch_ref();
454    let used = layout
455        .layers
456        .iter()
457        .flat_map(|l| &l.objects)
458        .flat_map(|o| &o.shapes)
459        .any(|s| match s {
460            Shape::Polyline { stroke, .. } => *stroke == reference,
461            Shape::Polygon { fill, .. } => *fill == reference,
462            _ => false,
463        });
464    let crate::model::UnstratifiedStyle::Hatched {
465        colour,
466        spacing,
467        angle,
468    } = layout.metadata.view.unstratified_style()
469    else {
470        return String::new();
471    };
472    if !used {
473        return String::new();
474    }
475    format!(
476        "<pattern id=\"{}\" patternUnits=\"userSpaceOnUse\" width=\"{}\" height=\"{}\" patternTransform=\"rotate({})\">         <line x1=\"{}\" y1=\"0\" x2=\"{}\" y2=\"{}\" stroke=\"{}\" stroke-width=\"{}\"/></pattern>",
477        crate::layout::HATCH_ID,
478        fmt(spacing),
479        fmt(spacing),
480        fmt(angle),
481        fmt(spacing / 2.0),
482        fmt(spacing / 2.0),
483        fmt(spacing),
484        escape(&colour),
485        fmt((spacing / 2.0).max(1.0))
486    )
487}
488
489fn write_object(out: &mut String, o: &LaidObject, zoom: Zoom) {
490    out.push_str("<g");
491    match &o.attr {
492        Attr::Key(key) => {
493            let _ = write!(out, " data-key=\"{}\"", escape(&key.replace('\0', "\u{2400}")));
494            if let Some((store, uri)) = key.split_once('\0') {
495                let _ = write!(
496                    out,
497                    " data-store=\"{}\" data-uri=\"{}\"",
498                    escape(store),
499                    escape(uri)
500                );
501            }
502        }
503        Attr::Section(s) => {
504            let _ = write!(out, " data-section=\"{}\"", escape(s));
505        }
506        Attr::FillOf(key) => {
507            let _ = write!(
508                out,
509                " data-fill-of=\"{}\"",
510                escape(&key.replace('\0', "\u{2400}"))
511            );
512        }
513        Attr::LabelOf(key) => {
514            let _ = write!(
515                out,
516                " data-label-of=\"{}\"",
517                escape(&key.replace('\0', "\u{2400}"))
518            );
519        }
520        Attr::Decor(name) => {
521            let _ = write!(out, " data-decor=\"{}\"", escape(name));
522        }
523        Attr::Marker { name, key } => {
524            let _ = write!(out, " data-marker=\"{}\"", escape(name));
525            if let Some(key) = key {
526                let _ = write!(out, " data-key=\"{}\"", escape(&key.replace('\0', "\u{2400}")));
527                if let Some((store, uri)) = key.split_once('\0') {
528                    let _ = write!(
529                        out,
530                        " data-store=\"{}\" data-uri=\"{}\"",
531                        escape(store),
532                        escape(uri)
533                    );
534                }
535            }
536        }
537        Attr::UnitEntry(name, uri, stores) => {
538            let _ = write!(
539                out,
540                " data-decor=\"{}\" data-unit-uri=\"{}\"",
541                escape(name),
542                escape(uri)
543            );
544            // The packages whose wells show the unit in this colour, by the
545            // front end's store ids - which never hold a space.
546            if !stores.is_empty() {
547                let _ = write!(out, " data-unit-stores=\"{}\"", escape(&stores.join(" ")));
548            }
549        }
550    }
551    let _ = write!(out, " data-kind=\"{}\"", escape(&o.kind));
552    if o.highlighted {
553        out.push_str(" data-highlight=\"true\"");
554    }
555    if zoom == Zoom::Follow {
556        // Where the label's first line sits: what the script moves it from.
557        if let Some(Shape::Text { at, .. }) = o.shapes.iter().find(|s| matches!(s, Shape::Text { .. })) {
558            let _ = write!(out, " data-zoom-anchor=\"{} {}\"", fmt(at[0]), fmt(at[1]));
559        }
560    }
561    out.push_str(">\n");
562    if !o.title.is_empty() {
563        let _ = writeln!(out, "<title>{}</title>", escape(&o.title));
564    }
565    for shape in &o.shapes {
566        write_shape(out, shape, zoom);
567    }
568    for iv in &o.intervals {
569        write_interval(out, iv, zoom);
570    }
571    // The boundaries between units, over every interval: a group of their own
572    // inside the well's, so that they are one thing to select or delete.
573    if o.intervals.iter().any(|iv| iv.tick.is_some()) {
574        out.push_str("<g data-part=\"strata-ticks\">\n");
575        for [a, b] in o.intervals.iter().filter_map(|iv| iv.tick) {
576            // Counter-scaled around its middle, which is on the well: zoomed in,
577            // the tick stays a few pixels across the well rather than growing
578            // with the drawing - as wide as the well, which keeps its own width.
579            let middle = [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0];
580            let _ = writeln!(
581                out,
582                "<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"#000000\" stroke-width=\"1\"{}{}/>",
583                fmt(a[0]),
584                fmt(a[1]),
585                fmt(b[0]),
586                fmt(b[1]),
587                non_scaling(zoom),
588                fixed(zoom, middle)
589            );
590        }
591        out.push_str("</g>\n");
592    }
593    out.push_str("</g>\n");
594}
595
596fn write_interval(out: &mut String, iv: &LaidInterval, zoom: Zoom) {
597    // The unit's full uri, when the interval names one: what the front end finds the
598    // unit by - its legend entry, its colour - and never its title.
599    let uri = iv
600        .unit_uri
601        .as_deref()
602        .map(|uri| format!(" data-unit-uri=\"{}\"", escape(uri)))
603        .unwrap_or_default();
604    let _ = writeln!(
605        out,
606        "<g data-unit=\"{}\"{} data-md-from=\"{}\" data-md-to=\"{}\"><polyline points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\"{}/></g>",
607        escape(&iv.unit),
608        uri,
609        fmt(iv.from),
610        fmt(iv.to),
611        points(&iv.points),
612        escape(&iv.colour),
613        fmt(iv.width),
614        non_scaling(zoom)
615    );
616}
617
618/// A stroke that keeps its width in pixels under the script's transform
619/// (`vector-effect`, which every browser knows): the line stays as thin as it
620/// was drawn however far the plot is magnified, so a zoom pulls objects apart
621/// instead of fattening them.
622fn non_scaling(zoom: Zoom) -> &'static str {
623    if zoom.on() {
624        " vector-effect=\"non-scaling-stroke\""
625    } else {
626        ""
627    }
628}
629
630/// What the script needs to keep a dot or a text at its pixel size: the point it
631/// is counter-scaled around, which is also where it is anchored. Only inside the
632/// plot group, the one that gets scaled. A dot stays a `<circle>` the script
633/// shrinks as the group grows: a zero-length round-capped line would draw the
634/// same dot, but the front end and its tests know circles.
635fn fixed(zoom: Zoom, at: [f64; 2]) -> String {
636    if zoom == Zoom::Plot {
637        format!(" data-zoom-fixed=\"{} {}\"", fmt(at[0]), fmt(at[1]))
638    } else {
639        String::new()
640    }
641}
642
643fn write_shape(out: &mut String, shape: &Shape, zoom: Zoom) {
644    match shape {
645        Shape::Polyline {
646            points: p,
647            stroke,
648            width,
649            dash,
650        } => {
651            if p.len() < 2 {
652                return;
653            }
654            let _ = writeln!(
655                out,
656                "<polyline points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\" stroke-linejoin=\"round\" stroke-linecap=\"round\"{}{}/>",
657                points(p),
658                escape(stroke),
659                fmt(*width),
660                non_scaling(zoom),
661                if *dash { " stroke-dasharray=\"4 3\"" } else { "" }
662            );
663        }
664        Shape::Dashed {
665            points: p,
666            stroke,
667            width,
668            dash,
669            gap,
670        } => {
671            if p.len() < 2 {
672                return;
673            }
674            let _ = writeln!(
675                out,
676                "<polyline points=\"{}\" fill=\"none\" stroke=\"{}\" stroke-width=\"{}\" stroke-linejoin=\"round\" stroke-dasharray=\"{} {}\"{}/>",
677                points(p),
678                escape(stroke),
679                fmt(*width),
680                fmt(*dash),
681                fmt(*gap),
682                non_scaling(zoom)
683            );
684        }
685        Shape::Polygon {
686            points: p,
687            fill,
688            opacity,
689            stroke,
690            dash,
691        } => {
692            if p.len() < 3 {
693                return;
694            }
695            let _ = write!(
696                out,
697                "<polygon points=\"{}\" fill=\"{}\" fill-opacity=\"{}\"",
698                points(p),
699                escape(fill),
700                fmt(*opacity)
701            );
702            if let Some(s) = stroke {
703                let _ = write!(
704                    out,
705                    " stroke=\"{}\" stroke-width=\"1\"{}",
706                    escape(s),
707                    non_scaling(zoom)
708                );
709                if *dash {
710                    out.push_str(" stroke-dasharray=\"4 3\"");
711                }
712            }
713            out.push_str("/>\n");
714        }
715        Shape::Circle { c, r, fill } => {
716            let _ = writeln!(
717                out,
718                "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{}\"{}/>",
719                fmt(c[0]),
720                fmt(c[1]),
721                fmt(*r),
722                escape(fill),
723                fixed(zoom, *c)
724            );
725        }
726        Shape::Symbol {
727            at,
728            kind,
729            size,
730            fill,
731            stroke,
732        } => write_symbol(out, *at, kind, *size, fill, stroke, zoom),
733        Shape::Text {
734            at,
735            text,
736            anchor,
737            size,
738            fill,
739        } => {
740            let anchor = match anchor {
741                Anchor::Start => "start",
742                Anchor::Middle => "middle",
743                Anchor::End => "end",
744            };
745            let _ = writeln!(
746                out,
747                "<text x=\"{}\" y=\"{}\" text-anchor=\"{}\" font-size=\"{}\" fill=\"{}\"{}>{}</text>",
748                fmt(at[0]),
749                fmt(at[1]),
750                anchor,
751                fmt(*size),
752                escape(fill),
753                fixed(zoom, *at),
754                escape(text)
755            );
756        }
757    }
758}
759
760/// A marker's symbol: one group, centred on `at`, that the zoom script keeps at
761/// its pixel size. Every shape is a path or a circle in pixels - nothing loaded,
762/// nothing `cleanSvg` could strip. 3D shapes are drawn as what they look like
763/// from the side: a cube a square, a sphere a disc, a pyramid and a tetrahedron
764/// a triangle (up, and down, so that the two stay apart).
765fn write_symbol(out: &mut String, at: [f64; 2], kind: &str, size: f64, fill: &str, stroke: &str, zoom: Zoom) {
766    let [x, y] = at;
767    let r = (size / 2.0).max(0.5);
768    let path = |pts: &[[f64; 2]], closed: bool| {
769        let mut d = String::new();
770        for (i, p) in pts.iter().enumerate() {
771            // A closed outline is one run of segments; an open one is pairs (a stroke each).
772            let start = if closed { i == 0 } else { i % 2 == 0 };
773            let _ = write!(
774                d,
775                "{}{},{}",
776                if start { "M" } else { "L" },
777                fmt(x + p[0] * r),
778                fmt(y + p[1] * r)
779            );
780        }
781        if closed {
782            d.push('Z');
783        }
784        format!("<path d=\"{d}\"")
785    };
786    let circle = |radius: f64| {
787        format!(
788            "<circle cx=\"{}\" cy=\"{}\" r=\"{}\"",
789            fmt(x),
790            fmt(y),
791            fmt(radius)
792        )
793    };
794    let body = match kind {
795        "circle" | "sphere" => circle(r),
796        "point" => circle((r / 2.0).max(1.5)),
797        "cube" => path(
798            &[[-0.85, -0.85], [0.85, -0.85], [0.85, 0.85], [-0.85, 0.85]],
799            true,
800        ),
801        "diamond" => path(&[[0.0, -1.0], [0.8, 0.0], [0.0, 1.0], [-0.8, 0.0]], true),
802        "pyramid" => path(&[[0.0, -1.0], [1.0, 0.85], [-1.0, 0.85]], true),
803        "tetrahedron" => path(&[[-1.0, -0.85], [1.0, -0.85], [0.0, 1.0]], true),
804        "cross" => path(&[[-0.8, -0.8], [0.8, 0.8], [-0.8, 0.8], [0.8, -0.8]], false),
805        "plus" => path(&[[-1.0, 0.0], [1.0, 0.0], [0.0, -1.0], [0.0, 1.0]], false),
806        "star" => {
807            // Five points: ten vertices, alternately on the outer and inner circle.
808            let pts: Vec<[f64; 2]> = (0..10)
809                .map(|i| {
810                    let angle = std::f64::consts::PI * f64::from(i) / 5.0 - std::f64::consts::FRAC_PI_2;
811                    let radius = if i % 2 == 0 { 1.0 } else { 0.42 };
812                    [radius * angle.cos(), radius * angle.sin()]
813                })
814                .collect();
815            path(&pts, true)
816        }
817        // The layout only gives shapes the drawer knows ([`crate::markers::is_drawn`]).
818        _ => return,
819    };
820    let width = if fill == "none" { 2.0 } else { 1.0 };
821    let _ = writeln!(
822        out,
823        "<g data-symbol=\"{}\"{}>{} fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g>",
824        escape(kind),
825        fixed(zoom, at),
826        body,
827        escape(fill),
828        escape(stroke),
829        fmt(width)
830    );
831}
832
833fn points(p: &[[f64; 2]]) -> String {
834    let mut s = String::with_capacity(p.len() * 12);
835    for (i, q) in p.iter().enumerate() {
836        if i > 0 {
837            s.push(' ');
838        }
839        s.push_str(&fmt(q[0]));
840        s.push(',');
841        s.push_str(&fmt(q[1]));
842    }
843    s
844}
845
846/// A number, two decimals at most, no trailing zeros, no "-0", never `NaN`.
847fn fmt(v: f64) -> String {
848    if !v.is_finite() {
849        return "0".to_string();
850    }
851    let t = format!("{v:.2}");
852    let t = t.trim_end_matches('0').trim_end_matches('.');
853    if t == "-0" || t.is_empty() {
854        "0".to_string()
855    } else {
856        t.to_string()
857    }
858}
859
860/// Text made safe for XML content and attribute values: the five characters
861/// that mean something, and the control characters XML 1.0 cannot carry at all
862/// are dropped (a title from a file may hold anything).
863pub fn escape(text: &str) -> String {
864    let mut out = String::with_capacity(text.len());
865    for ch in text.chars() {
866        match ch {
867            '&' => out.push_str("&amp;"),
868            '<' => out.push_str("&lt;"),
869            '>' => out.push_str("&gt;"),
870            '"' => out.push_str("&quot;"),
871            '\'' => out.push_str("&apos;"),
872            '\t' | '\n' | '\r' => out.push(ch),
873            c if (c as u32) < 0x20 || c == '\u{FFFE}' || c == '\u{FFFF}' => {}
874            c => out.push(c),
875        }
876    }
877    out
878}