1use std::fmt::Write as _;
30
31use crate::layout::{
32 Anchor, Attr, LaidInterval, LaidObject, Layout, MARKERS_LAYER, Rect, STRATA_LAYER, Shape,
33};
34
35const ZOOM_SCRIPT: &str = include_str!("zoom.js");
41
42#[derive(Clone, Copy, PartialEq, Eq)]
44enum Zoom {
45 Off,
47 Plot,
50 Follow,
53 Still,
55}
56
57impl Zoom {
58 fn on(self) -> bool {
59 self != Zoom::Off
60 }
61}
62
63const FONT_FAMILY: &str = "Liberation Sans, Arial, Helvetica, sans-serif";
66
67pub fn write(layout: &Layout) -> String {
69 let mut out = String::with_capacity(16 * 1024);
70 let [w, h] = layout.size;
71 let (f, win, scale) = (layout.frame, layout.window, layout.scale);
80 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq, thiserror::Error)]
237pub enum MetadataError {
238 #[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 #[error("the metadata is format {found}, and this build reads format {known}")]
246 Format {
247 found: String,
249 known: u32,
251 },
252 #[error("the section-view metadata does not read: {0}")]
254 Unreadable(String),
255}
256
257pub 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
287fn 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
295fn 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 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
447fn 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 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 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 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 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 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
618fn non_scaling(zoom: Zoom) -> &'static str {
623 if zoom.on() {
624 " vector-effect=\"non-scaling-stroke\""
625 } else {
626 ""
627 }
628}
629
630fn 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
760fn 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 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 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 _ => 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
846fn 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
860pub 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("&"),
868 '<' => out.push_str("<"),
869 '>' => out.push_str(">"),
870 '"' => out.push_str("""),
871 '\'' => out.push_str("'"),
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}