1use std::cell::{Cell, RefCell};
30use std::mem;
31use std::sync::Arc;
32
33use blockworx_doc::block_model::Asset;
34use blockworx_geom::{Align2, Pos2, Rect, Vec2, WorldPx, vec2};
35use blockworx_paint::{Color, Font};
36use egui::Stroke;
37use egui::epaint::text::Glyph;
38use egui::text::{Fonts, Galley};
39use svg::Document;
40use svg::node::Node;
41use svg::node::element::path::Data;
42use svg::node::element::{Circle, Group, Image, Line, Path, Polygon, Polyline, Rectangle};
43
44use blockworx_paint::{FontChoice, Palette, PaletteStroke, Renderer, Swatch, extent::Bounds};
45
46use crate::canvas::egui_compat::{IntoEgui as _, IntoGeom as _};
47const PIXELS_PER_POINT: f32 = 1.0;
49
50pub struct SvgRenderer {
51 palette: Palette,
52 face: ttf_parser::Face<'static>,
53 units_per_em: f32,
54 font_ref: skrifa::FontRef<'static>,
57 shaper_data: harfrust::ShaperData,
60 fonts: RefCell<Fonts>,
63 doc: RefCell<Document>,
65 bounds: Bounds,
67 warned: Cell<bool>,
69}
70
71impl SvgRenderer {
72 #[expect(clippy::expect_used)]
75 pub fn new(palette: Palette, font: FontChoice) -> Self {
76 let bytes = font.bytes();
77 let face = ttf_parser::Face::parse(bytes, 0).expect("embedded font is valid");
78 let units_per_em = face.units_per_em() as f32;
79 let font_ref = skrifa::FontRef::from_index(bytes, 0).expect("embedded font is valid");
80 let shaper_data = harfrust::ShaperData::new(&font_ref);
81 let fonts = Fonts::new(
82 egui::epaint::text::TextOptions::default(),
83 crate::font::build_fonts(font),
84 );
85 Self {
86 palette,
87 face,
88 units_per_em,
89 font_ref,
90 shaper_data,
91 fonts: RefCell::new(fonts),
92 doc: RefCell::new(Document::new()),
93 bounds: Bounds::default(),
94 warned: Cell::new(false),
95 }
96 }
97
98 fn color(&self, swatch: impl Into<Swatch>) -> Color {
100 self.palette.resolve(swatch.into())
101 }
102
103 fn resolve_stroke(&self, stroke: impl Into<PaletteStroke>) -> Stroke {
106 let s = stroke.into();
107 Stroke::new(s.width.get(), self.color(s.color).egui())
108 }
109
110 pub fn finish(self) -> (String, Rect) {
115 const MARGIN: f32 = crate::grid::GRID_SIZE;
116 let view = self
117 .bounds
118 .get()
119 .map_or(Rect::from_min_size(Pos2::ZERO, vec2(1.0, 1.0)), |r| {
120 r.expand(MARGIN)
121 });
122 let svg = self
123 .doc
124 .into_inner()
125 .set(
126 "viewBox",
127 (view.min.x, view.min.y, view.width(), view.height()),
128 )
129 .set("width", view.width())
130 .set("height", view.height())
131 .to_string();
132 (svg, view)
133 }
134
135 fn append(&self, node: impl Into<Box<dyn Node>>) {
136 self.doc.borrow_mut().append(node);
137 }
138
139 fn layout(&self, text: String, font: &Font, max_width: WorldPx) -> Arc<Galley> {
142 self.fonts
143 .borrow_mut()
144 .with_pixels_per_point(PIXELS_PER_POINT)
145 .layout(
146 text,
147 font.egui(),
148 Color::PLACEHOLDER.egui(),
149 max_width.get(),
150 )
151 }
152
153 #[expect(clippy::too_many_arguments)]
161 fn draw_galley(
162 &self,
163 pos: Pos2,
164 anchor: Align2,
165 galley: &Galley,
166 font: &Font,
167 color: Color,
168 angle: f32,
169 ) -> Rect {
170 let scale = font.size / self.units_per_em;
171 let rect = anchor.anchor_size(pos, galley.size().geom());
172
173 let mut glyphs: Vec<Path> = Vec::new();
174 for paragraph in galley.rows.split_inclusive(|row| row.ends_with_newline) {
177 let run: Vec<Placed<'_>> = paragraph
178 .iter()
179 .flat_map(|row| {
180 row.glyphs.iter().map(|glyph| Placed {
181 pen: rect.min + row.pos.to_vec2().geom() + glyph.pos.to_vec2().geom(),
182 glyph,
183 })
184 })
185 .collect();
186 for (placed, gid) in run.iter().zip(self.identify(&run)) {
187 let Some(gid) = gid else {
188 continue;
189 };
190 let mut builder = GlyphPath {
191 data: Data::new(),
192 ox: placed.pen.x,
193 baseline: placed.pen.y,
194 scale,
195 };
196 if self.face.outline_glyph(gid, &mut builder).is_some() && !builder.data.is_empty()
197 {
198 let mut path = Path::new().set("d", builder.data);
199 apply_fill(&mut path, color);
200 glyphs.push(path);
201 }
202 }
203 }
204
205 if !glyphs.is_empty() {
206 if angle == 0.0 {
207 for path in glyphs {
208 self.append(path);
209 }
210 } else {
211 let mut group = Group::new().set(
212 "transform",
213 format!("rotate({} {} {})", angle.to_degrees(), pos.x, pos.y),
214 );
215 for path in glyphs {
216 group = group.add(path);
217 }
218 self.append(group);
219 self.bounds.point(pos);
222 }
223 self.bounds.rect(rect);
224 }
225 rect
226 }
227
228 fn identify(&self, run: &[Placed<'_>]) -> Vec<Option<ttf_parser::GlyphId>> {
236 let text: String = run.iter().map(|p| p.glyph.chr).collect();
237 let clusters: Vec<Cluster> = run.iter().map(|p| Cluster::of(p.glyph)).collect();
238 match Identity::zip(&self.shape(&text), &clusters) {
239 Identity::Shaped(ids) => ids,
240 Identity::ByChar => {
241 if !self.warned.replace(true) {
242 tracing::warn!(
243 text,
244 "export: re-shaping disagreed with the layout; \
245 glyph identity falls back to the character map, \
246 which draws ligature clusters stacked"
247 );
248 }
249 run.iter()
250 .map(|p| self.face.glyph_index(p.glyph.chr))
251 .collect()
252 }
253 }
254 }
255
256 fn shape(&self, text: &str) -> Vec<ttf_parser::GlyphId> {
260 let shaper = self.shaper_data.shaper(&self.font_ref).build();
261 let mut buffer = harfrust::UnicodeBuffer::new();
262 buffer.set_flags(
263 harfrust::BufferFlags::BEGINNING_OF_TEXT | harfrust::BufferFlags::END_OF_TEXT,
264 );
265 buffer.push_str(text);
266 buffer.guess_segment_properties();
267 shaper
268 .shape(buffer, harfrust::ShapeOptions::new())
269 .glyph_infos()
270 .iter()
271 .map(|info| ttf_parser::GlyphId(info.glyph_id as u16))
273 .collect()
274 }
275}
276
277struct Placed<'a> {
279 pen: Pos2,
280 glyph: &'a Glyph,
281}
282
283#[derive(Clone, Copy, PartialEq, Eq, Debug)]
288enum Cluster {
289 Head,
291 Continuation,
293}
294
295impl Cluster {
296 fn of(glyph: &Glyph) -> Self {
297 if glyph.advance_width > 0.0 {
298 Self::Head
299 } else {
300 Self::Continuation
301 }
302 }
303}
304
305enum Identity {
307 Shaped(Vec<Option<ttf_parser::GlyphId>>),
311 ByChar,
315}
316
317impl Identity {
318 fn zip(shaped: &[ttf_parser::GlyphId], clusters: &[Cluster]) -> Self {
319 if shaped.len() != clusters.iter().filter(|c| **c == Cluster::Head).count() {
320 return Self::ByChar;
321 }
322 let mut shaped = shaped.iter();
323 Self::Shaped(
324 clusters
325 .iter()
326 .map(|cluster| match cluster {
327 Cluster::Head => shaped.next().copied(),
328 Cluster::Continuation => None,
329 })
330 .collect(),
331 )
332 }
333}
334
335impl Renderer for SvgRenderer {
336 fn rect(
337 &self,
338 rect: Rect,
339 rounding: WorldPx,
340 fill: impl Into<Swatch>,
341 stroke: impl Into<PaletteStroke>,
342 ) {
343 let stroke = stroke.into();
344 self.bounds.stroked(rect, &stroke);
345 let stroke = self.resolve_stroke(stroke);
346 let mut el = Rectangle::new()
347 .set("x", rect.min.x)
348 .set("y", rect.min.y)
349 .set("width", rect.width())
350 .set("height", rect.height())
351 .set("rx", rounding.get());
352 apply_fill(&mut el, self.color(fill));
353 apply_stroke(&mut el, stroke);
354 self.append(el);
355 }
356
357 fn line_segment(&self, points: [Pos2; 2], stroke: impl Into<PaletteStroke>) {
358 let stroke = self.resolve_stroke(stroke);
359 self.bounds.path(&points);
360 let mut el = Line::new()
361 .set("x1", points[0].x)
362 .set("y1", points[0].y)
363 .set("x2", points[1].x)
364 .set("y2", points[1].y)
365 .set("stroke-linecap", "round");
366 apply_stroke(&mut el, stroke);
367 self.append(el);
368 }
369
370 fn line(&self, points: Vec<Pos2>, stroke: impl Into<PaletteStroke>) {
371 let stroke = self.resolve_stroke(stroke);
372 self.bounds.path(&points);
373 let mut el = Polyline::new()
374 .set("points", point_list(&points))
375 .set("fill", "none")
376 .set("stroke-linecap", "round")
377 .set("stroke-linejoin", "round");
378 apply_stroke(&mut el, stroke);
379 self.append(el);
380 }
381
382 fn circle(
383 &self,
384 center: Pos2,
385 radius: WorldPx,
386 fill: impl Into<Swatch>,
387 stroke: impl Into<PaletteStroke>,
388 ) {
389 let stroke = stroke.into();
390 self.bounds.disc(center, radius, &stroke);
391 let stroke = self.resolve_stroke(stroke);
392 let mut el = Circle::new()
393 .set("cx", center.x)
394 .set("cy", center.y)
395 .set("r", radius.get());
396 apply_fill(&mut el, self.color(fill));
397 apply_stroke(&mut el, stroke);
398 self.append(el);
399 }
400
401 fn add_convex_polygon(
402 &self,
403 points: Vec<Pos2>,
404 fill: impl Into<Swatch>,
405 stroke: impl Into<PaletteStroke>,
406 ) {
407 let stroke = self.resolve_stroke(stroke);
408 self.bounds.path(&points);
409 let mut el = Polygon::new().set("points", point_list(&points));
410 apply_fill(&mut el, self.color(fill));
411 apply_stroke(&mut el, stroke);
412 self.append(el);
413 }
414
415 fn text(
416 &self,
417 pos: Pos2,
418 anchor: Align2,
419 text: impl ToString,
420 font: &Font,
421 color: impl Into<Swatch>,
422 ) -> Rect {
423 let color = self.color(color);
424 let galley = self.layout(text.to_string(), font, WorldPx::UNBOUNDED);
425 self.draw_galley(pos, anchor, &galley, font, color, 0.0)
426 }
427
428 fn rotated_text(
429 &self,
430 pos: Pos2,
431 anchor: Align2,
432 text: impl ToString,
433 font: &Font,
434 color: impl Into<Swatch>,
435 angle: f32,
436 ) {
437 let color = self.color(color);
438 let galley = self.layout(text.to_string(), font, WorldPx::UNBOUNDED);
439 self.draw_galley(pos, anchor, &galley, font, color, angle);
440 }
441
442 fn text_wrapped(
443 &self,
444 pos: Pos2,
445 anchor: Align2,
446 text: impl ToString,
447 font: &Font,
448 color: impl Into<Swatch>,
449 max_width: WorldPx,
450 ) -> Rect {
451 let color = self.color(color);
452 let galley = self.layout(text.to_string(), font, max_width);
453 self.draw_galley(pos, anchor, &galley, font, color, 0.0)
454 }
455
456 fn text_size(&self, text: impl ToString, font: &Font) -> Vec2 {
457 self.layout(text.to_string(), font, WorldPx::UNBOUNDED)
458 .size()
459 .geom()
460 }
461
462 fn text_size_wrapped(&self, text: impl ToString, font: &Font, max_width: WorldPx) -> Vec2 {
463 self.layout(text.to_string(), font, max_width).size().geom()
464 }
465
466 fn draw_image(&self, rect: Rect, image: &Asset) {
467 use base64::Engine as _;
468 self.bounds.rect(rect);
469 let mime = match image {
474 Asset::Svg(_) => "image/svg+xml",
475 Asset::Png(_) => "image/png",
476 };
477 let href = format!(
478 "data:{mime};base64,{}",
479 base64::engine::general_purpose::STANDARD.encode(image.bytes())
480 );
481 let el = Image::new()
482 .set("x", rect.min.x)
483 .set("y", rect.min.y)
484 .set("width", rect.width())
485 .set("height", rect.height())
486 .set("preserveAspectRatio", "xMidYMid meet")
487 .set("href", href.as_str());
488 self.append(el);
489 }
490
491 fn image_intrinsic_size(&self, image: &Asset) -> Option<Vec2> {
492 crate::canvas::image::image_intrinsic_size(image).ok()
493 }
494}
495
496#[cfg(test)]
501pub(crate) fn glyph_pen_xs(svg: &str) -> Vec<f32> {
502 svg.match_indices("d=\"")
503 .map(|(at, _)| {
504 let d = &svg[at + 3..];
505 let d = &d[..d.find('"').expect("a path's d attribute is closed")];
506 d.split([' ', ','])
507 .filter_map(|t| {
508 t.trim_start_matches(char::is_alphabetic)
509 .parse::<f32>()
510 .ok()
511 })
512 .step_by(2)
513 .fold(f32::INFINITY, f32::min)
514 })
515 .collect()
516}
517
518struct GlyphPath {
522 data: Data,
523 ox: f32,
524 baseline: f32,
525 scale: f32,
526}
527
528impl GlyphPath {
529 fn tx(&self, x: f32) -> f32 {
530 self.ox + x * self.scale
531 }
532 fn ty(&self, y: f32) -> f32 {
533 self.baseline - y * self.scale
534 }
535}
536
537impl ttf_parser::OutlineBuilder for GlyphPath {
538 fn move_to(&mut self, x: f32, y: f32) {
539 let p = (self.tx(x), self.ty(y));
540 self.data = mem::take(&mut self.data).move_to(p);
541 }
542 fn line_to(&mut self, x: f32, y: f32) {
543 let p = (self.tx(x), self.ty(y));
544 self.data = mem::take(&mut self.data).line_to(p);
545 }
546 fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
547 let p = (self.tx(x1), self.ty(y1), self.tx(x), self.ty(y));
548 self.data = mem::take(&mut self.data).quadratic_curve_to(p);
549 }
550 fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
551 let p = (
552 self.tx(x1),
553 self.ty(y1),
554 self.tx(x2),
555 self.ty(y2),
556 self.tx(x),
557 self.ty(y),
558 );
559 self.data = mem::take(&mut self.data).cubic_curve_to(p);
560 }
561 fn close(&mut self) {
562 self.data = mem::take(&mut self.data).close();
563 }
564}
565
566fn point_list(points: &[Pos2]) -> Vec<(f32, f32)> {
569 points.iter().map(|p| (p.x, p.y)).collect()
570}
571
572fn color_hex(c: Color) -> String {
573 let [r, g, b, _] = c.to_srgba_unmultiplied();
574 format!("#{r:02x}{g:02x}{b:02x}")
575}
576
577fn apply_fill(node: &mut impl Node, c: Color) {
579 let [.., a] = c.to_srgba_unmultiplied();
580 if a == 0 {
581 node.assign("fill", "none");
582 return;
583 }
584 node.assign("fill", color_hex(c));
585 if a != 255 {
586 node.assign("fill-opacity", a as f32 / 255.0);
587 }
588}
589
590fn apply_stroke(node: &mut impl Node, s: Stroke) {
592 let color = s.color.geom();
593 let [.., a] = color.to_srgba_unmultiplied();
594 if s.width <= 0.0 || a == 0 {
595 return;
596 }
597 node.assign("stroke", color_hex(color));
598 node.assign("stroke-width", s.width);
599 if a != 255 {
600 node.assign("stroke-opacity", a as f32 / 255.0);
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use blockworx_paint::Base;
608
609 fn px(v: f32) -> WorldPx {
610 WorldPx::new(v)
611 }
612
613 fn palette() -> Palette {
614 Palette::tokyo_night_moon()
615 }
616
617 fn renderer() -> SvgRenderer {
618 SvgRenderer::new(palette(), FontChoice::Sketchy)
619 }
620
621 fn draw(choice: FontChoice, text: &str) -> Vec<f32> {
622 let r = SvgRenderer::new(palette(), choice);
623 r.text(Pos2::ZERO, Align2::LEFT_TOP, text, &font(), Base::B00);
624 let (svg, _) = r.finish();
625 glyph_pen_xs(&svg)
626 }
627
628 fn assert_marches_right(xs: &[f32], what: &str) {
629 assert!(xs.len() > 1, "{what}: expected glyphs, got {xs:?}");
630 for pair in xs.windows(2) {
631 assert!(
632 pair[0] < pair[1],
633 "{what}: glyphs must march rightwards, got {xs:?}",
634 );
635 }
636 }
637
638 #[test]
639 fn a_ligature_draws_once_and_its_cluster_does_not_stack() {
640 let xs = draw(FontChoice::Basic, "Config");
645 assert_eq!(
646 xs.len(),
647 5,
648 "C o n <fi> g is five outlines, not six: {xs:?}",
649 );
650 assert_marches_right(&xs, "Config");
651 }
652
653 #[test]
654 fn ligature_rich_text_places_every_glyph_in_order() {
655 let xs = draw(FontChoice::Basic, "waffle офис fi fl ffi");
659 assert_eq!(xs.len(), 11, "one outline per shaped glyph: {xs:?}");
660 assert_marches_right(&xs, "liga");
661 }
662
663 #[test]
664 fn every_paragraph_is_shaped_on_its_own() {
665 let xs = draw(FontChoice::Basic, "Config\nConfig");
668 assert_eq!(xs.len(), 10, "five outlines per line: {xs:?}");
669 assert_eq!(xs[..5], xs[5..], "both lines start at the same left edge");
670 assert_marches_right(&xs[..5], "line 1");
671 }
672
673 #[test]
674 fn a_font_that_substitutes_nothing_is_unaffected() {
675 let xs = draw(FontChoice::Monospace, "a -> b");
679 assert_eq!(xs.len(), 4, "a - > b, spaces drawing nothing: {xs:?}");
680 assert_marches_right(&xs, "calt");
681 }
682
683 #[test]
684 fn a_count_disagreement_falls_back_to_the_character_map() {
685 let gid = ttf_parser::GlyphId;
686 let clusters = [Cluster::Head, Cluster::Continuation, Cluster::Head];
687 assert!(matches!(
688 Identity::zip(&[gid(7), gid(8), gid(9)], &clusters),
689 Identity::ByChar,
690 ));
691 let Identity::Shaped(ids) = Identity::zip(&[gid(7), gid(8)], &clusters) else {
692 panic!("two shaped glyphs match the run's two cluster heads");
693 };
694 assert_eq!(ids, vec![Some(gid(7)), None, Some(gid(8))]);
695 }
696
697 fn font() -> Font {
700 Font::canvas(12.0)
701 }
702
703 #[test]
704 fn text_becomes_glyph_paths() {
705 let r = renderer();
706 let rect = r.text(
707 Pos2::new(0.0, 0.0),
708 Align2::LEFT_TOP,
709 "Ab",
710 &font(),
711 Base::B00,
713 );
714 let (svg, _) = r.finish();
715 assert!(svg.contains("<path"), "expected a glyph path:\n{svg}");
717 assert!(
718 svg.contains("d=\"M"),
719 "path should start with a move command"
720 );
721 assert!(
722 svg.contains("fill=\"#222436\""),
723 "resolved palette fill expected"
724 );
725 assert!(rect.width() > 0.0, "measured width should be positive");
726 }
727
728 #[test]
729 fn multiline_text_stacks_lines() {
730 let r = renderer();
731 let font = font();
732 let one = r.text_size("aaaa", &font);
734 let two = r.text_size("aa\naa", &font);
735 assert!(
736 (two.y - 2.0 * one.y).abs() < 0.001,
737 "two lines should be twice as tall: {} vs {}",
738 two.y,
739 one.y
740 );
741 assert!(
742 two.x < one.x + 0.001,
743 "width is the widest line, not the sum: {} vs {}",
744 two.x,
745 one.x
746 );
747 assert!((r.text_size("aa\n", &font).y - two.y).abs() < 0.001);
749 }
750
751 #[test]
752 fn wrapped_text_breaks_long_lines_within_the_width() {
753 let r = renderer();
754 let font = font();
755 let line = "the quick brown fox jumps over the lazy dog";
756 let unwrapped = r.text_size(line, &font);
757 let max = unwrapped.x / 3.0;
760 let wrapped = r.text_size_wrapped(line, &font, px(max));
761 assert!(
762 wrapped.y > unwrapped.y,
763 "wrapping a long line adds rows: {} vs {}",
764 wrapped.y,
765 unwrapped.y
766 );
767 assert!(
768 wrapped.x <= max + 0.001,
769 "no wrapped row exceeds the wrap width: {} vs {}",
770 wrapped.x,
771 max
772 );
773 assert!(
775 (r.text_size_wrapped(line, &font, WorldPx::UNBOUNDED).x - unwrapped.x).abs() < 0.001
776 );
777 }
778
779 #[test]
780 fn primitives_and_viewbox() {
781 let r = renderer();
782 r.rect(
783 Rect::from_min_size(Pos2::new(10.0, 10.0), vec2(30.0, 20.0)),
784 px(3.0),
785 Base::B01,
786 (1.0, Base::B0D),
787 );
788 r.line(
789 vec![Pos2::new(0.0, 0.0), Pos2::new(40.0, 0.0)],
790 (1.7, Base::B0B),
791 );
792 let (svg, _) = r.finish();
793 assert!(svg.contains("<svg"));
794 assert!(svg.contains("viewBox="));
795 assert!(svg.contains("<rect"));
796 assert!(svg.contains("<polyline"));
797 assert!(svg.contains("</svg>"));
798 }
799
800 #[test]
801 fn image_embeds_as_data_uri() {
802 let r = renderer();
803 r.draw_image(
804 Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(10.0, 10.0)),
805 &Asset::Svg(r#"<svg viewBox="0 0 1 1"/>"#.as_bytes().into()),
806 );
807 let (svg, _) = r.finish();
808 assert!(
809 svg.contains("<image"),
810 "image exports as an <image>:\n{svg}"
811 );
812 assert!(
813 svg.contains("data:image/svg+xml;base64,"),
814 "image href is a base64 SVG data URI"
815 );
816 assert!(svg.contains(r#"preserveAspectRatio="xMidYMid meet""#));
817 }
818
819 #[test]
820 fn png_image_embeds_as_png_data_uri() {
821 let r = renderer();
822 r.draw_image(
823 Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(10.0, 10.0)),
824 &Asset::Png(vec![0x89, b'P', b'N', b'G', 1, 2, 3].into()),
825 );
826 let (svg, _) = r.finish();
827 assert!(
828 svg.contains("data:image/png;base64,"),
829 "PNG image href is a base64 PNG data URI:\n{svg}"
830 );
831 }
832}