1use std::cell::{Cell, RefCell};
21
22use blockworx_doc::block_model::Asset;
23use blockworx_geom::{Align2, Angle, Pos2, Rect, Vec2, WorldPx, grid::GRID_SIZE, vec2};
24use blockworx_paint::text::{Layout, TextLayout, TextRow};
25use blockworx_paint::{Color, Font};
26use svg::Document;
27use svg::node::element::{Circle, Image, Line, Polygon, Polyline, Rectangle, Style};
28use svg::node::{Blob, Node};
29
30use blockworx_paint::{Palette, PaletteStroke, Renderer, Swatch, extent::Bounds};
31
32pub struct SvgRenderer<L: TextLayout> {
33 palette: Palette,
34 layout: L,
36 doc: RefCell<Document>,
38 bounds: Bounds,
40 wrote_text: Cell<bool>,
42}
43
44impl<L: TextLayout> SvgRenderer<L> {
45 pub fn new(palette: Palette, layout: L) -> Self {
46 Self {
47 palette,
48 layout,
49 doc: RefCell::new(Document::new()),
50 bounds: Bounds::default(),
51 wrote_text: Cell::new(false),
52 }
53 }
54
55 fn color(&self, swatch: impl Into<Swatch>) -> Color {
57 self.palette.resolve(swatch.into())
58 }
59
60 fn resolve_stroke(&self, stroke: impl Into<PaletteStroke>) -> Ink {
62 let s = stroke.into();
63 Ink {
64 width: s.width,
65 color: self.color(s.color),
66 }
67 }
68
69 pub fn finish(self) -> (String, Rect) {
74 let view = framed(self.bounds.get());
75 let mut doc = self
76 .doc
77 .into_inner()
78 .set(
79 "viewBox",
80 (view.min.x, view.min.y, view.width(), view.height()),
81 )
82 .set("width", view.width())
83 .set("height", view.height());
84 if self.wrote_text.get() {
85 let typeface = self.layout.typeface();
86 let family = crate::fonts::family(typeface);
87 doc = doc
88 .set("font-family", family.as_str())
89 .set("xml:space", "preserve")
90 .add(Style::new(font_face(&family, typeface.bytes())));
91 }
92 (doc.to_string(), view)
93 }
94
95 fn append(&self, node: impl Into<Box<dyn Node>>) {
96 self.doc.borrow_mut().append(node);
97 }
98
99 #[expect(clippy::too_many_arguments)]
106 fn draw_rows(
107 &self,
108 pos: Pos2,
109 anchor: Align2,
110 laid: &Layout,
111 font: &Font,
112 color: Color,
113 angle: Angle,
114 ) -> Rect {
115 let rows = laid.rows_at(pos, anchor, angle);
116 if !rows.is_empty() {
117 for row in &rows {
118 self.append(text_element(row, font, color, angle));
119 }
120 self.wrote_text.set(true);
121 self.bounds.rect(laid.bounds_at(pos, anchor, angle));
122 }
123 anchor.anchor_size(pos, laid.size)
124 }
125}
126
127impl<L: TextLayout> Renderer for SvgRenderer<L> {
128 fn rect(
129 &self,
130 rect: Rect,
131 rounding: WorldPx,
132 fill: impl Into<Swatch>,
133 stroke: impl Into<PaletteStroke>,
134 ) {
135 let stroke = stroke.into();
136 self.bounds.stroked(rect, &stroke);
137 let stroke = self.resolve_stroke(stroke);
138 let mut el = Rectangle::new()
139 .set("x", rect.min.x)
140 .set("y", rect.min.y)
141 .set("width", rect.width())
142 .set("height", rect.height())
143 .set("rx", rounding.get());
144 apply_fill(&mut el, self.color(fill));
145 apply_stroke(&mut el, stroke);
146 self.append(el);
147 }
148
149 fn line_segment(&self, points: [Pos2; 2], stroke: impl Into<PaletteStroke>) {
150 let stroke = self.resolve_stroke(stroke);
151 self.bounds.path(&points);
152 let mut el = Line::new()
153 .set("x1", points[0].x)
154 .set("y1", points[0].y)
155 .set("x2", points[1].x)
156 .set("y2", points[1].y)
157 .set("stroke-linecap", "round");
158 apply_stroke(&mut el, stroke);
159 self.append(el);
160 }
161
162 fn line(&self, points: Vec<Pos2>, stroke: impl Into<PaletteStroke>) {
163 let stroke = self.resolve_stroke(stroke);
164 self.bounds.path(&points);
165 let mut el = Polyline::new()
166 .set("points", point_list(&points))
167 .set("fill", "none")
168 .set("stroke-linecap", "round")
169 .set("stroke-linejoin", "round");
170 apply_stroke(&mut el, stroke);
171 self.append(el);
172 }
173
174 fn circle(
175 &self,
176 center: Pos2,
177 radius: WorldPx,
178 fill: impl Into<Swatch>,
179 stroke: impl Into<PaletteStroke>,
180 ) {
181 let stroke = stroke.into();
182 self.bounds.disc(center, radius, &stroke);
183 let stroke = self.resolve_stroke(stroke);
184 let mut el = Circle::new()
185 .set("cx", center.x)
186 .set("cy", center.y)
187 .set("r", radius.get());
188 apply_fill(&mut el, self.color(fill));
189 apply_stroke(&mut el, stroke);
190 self.append(el);
191 }
192
193 fn add_convex_polygon(
194 &self,
195 points: Vec<Pos2>,
196 fill: impl Into<Swatch>,
197 stroke: impl Into<PaletteStroke>,
198 ) {
199 let stroke = self.resolve_stroke(stroke);
200 self.bounds.path(&points);
201 let mut el = Polygon::new().set("points", point_list(&points));
202 apply_fill(&mut el, self.color(fill));
203 apply_stroke(&mut el, stroke);
204 self.append(el);
205 }
206
207 fn text(
208 &self,
209 pos: Pos2,
210 anchor: Align2,
211 text: impl ToString,
212 font: &Font,
213 color: impl Into<Swatch>,
214 ) -> Rect {
215 let color = self.color(color);
216 let laid = self
217 .layout
218 .layout(&text.to_string(), font, WorldPx::UNBOUNDED);
219 self.draw_rows(pos, anchor, &laid, font, color, Angle::ZERO)
220 }
221
222 fn rotated_text(
223 &self,
224 pos: Pos2,
225 anchor: Align2,
226 text: impl ToString,
227 font: &Font,
228 color: impl Into<Swatch>,
229 angle: Angle,
230 ) {
231 let color = self.color(color);
232 let laid = self
233 .layout
234 .layout(&text.to_string(), font, WorldPx::UNBOUNDED);
235 self.draw_rows(pos, anchor, &laid, font, color, angle);
236 }
237
238 fn text_wrapped(
239 &self,
240 pos: Pos2,
241 anchor: Align2,
242 text: impl ToString,
243 font: &Font,
244 color: impl Into<Swatch>,
245 max_width: WorldPx,
246 ) -> Rect {
247 let color = self.color(color);
248 let laid = self.layout.layout(&text.to_string(), font, max_width);
249 self.draw_rows(pos, anchor, &laid, font, color, Angle::ZERO)
250 }
251
252 fn text_size(&self, text: impl ToString, font: &Font) -> Vec2 {
253 self.layout
254 .layout(&text.to_string(), font, WorldPx::UNBOUNDED)
255 .size
256 }
257
258 fn text_size_wrapped(&self, text: impl ToString, font: &Font, max_width: WorldPx) -> Vec2 {
259 self.layout.layout(&text.to_string(), font, max_width).size
260 }
261
262 fn text_layout(&self, text: &str, font: &Font, max_width: WorldPx) -> Layout {
263 self.layout.layout(text, font, max_width)
264 }
265
266 fn draw_image(&self, rect: Rect, image: &Asset) {
267 use base64::Engine as _;
268 self.bounds.rect(rect);
269 let mime = match image {
274 Asset::Svg(_) => "image/svg+xml",
275 Asset::Png(_) => "image/png",
276 };
277 let href = format!(
278 "data:{mime};base64,{}",
279 base64::engine::general_purpose::STANDARD.encode(image.bytes())
280 );
281 let el = Image::new()
282 .set("x", rect.min.x)
283 .set("y", rect.min.y)
284 .set("width", rect.width())
285 .set("height", rect.height())
286 .set("preserveAspectRatio", "xMidYMid meet")
287 .set("href", href.as_str());
288 self.append(el);
289 }
290
291 fn image_intrinsic_size(&self, image: &Asset) -> Option<Vec2> {
292 blockworx_paint::image::image_intrinsic_size(image).ok()
293 }
294}
295
296pub(crate) fn framed(drawn: Option<Rect>) -> Rect {
299 drawn.map_or(Rect::from_min_size(Pos2::ZERO, vec2(1.0, 1.0)), |r| {
300 r.expand(GRID_SIZE)
301 })
302}
303
304fn font_face(family: &str, bytes: &[u8]) -> String {
307 use base64::Engine as _;
308 format!(
309 "@font-face {{ font-family: '{family}'; src: url(data:font/ttf;base64,{}); }}",
310 base64::engine::general_purpose::STANDARD.encode(bytes)
311 )
312}
313
314#[cfg(test)]
319pub(crate) fn text_rows(svg: &str) -> Vec<(String, Pos2)> {
320 let attribute = |tag: &str, name: &str| -> f32 {
321 let at = tag
322 .find(&format!(" {name}=\""))
323 .expect("the attribute is written")
324 + name.len()
325 + 3;
326 tag[at..tag[at..].find('"').map(|end| at + end).expect("closed")]
327 .parse()
328 .expect("a number")
329 };
330 svg.match_indices("<text ")
331 .map(|(at, _)| {
332 let rest = &svg[at..];
333 let open = &rest[..rest.find('>').expect("the tag is closed")];
334 let body = &rest[open.len() + 1..rest.find("</text>").expect("the element is closed")];
335 let text = body
336 .replace("<", "<")
337 .replace(">", ">")
338 .replace(""", "\"")
339 .replace("'", "'")
340 .replace("&", "&");
341 (text, Pos2::new(attribute(open, "x"), attribute(open, "y")))
342 })
343 .collect()
344}
345
346fn point_list(points: &[Pos2]) -> Vec<(f32, f32)> {
349 points.iter().map(|p| (p.x, p.y)).collect()
350}
351
352fn color_hex(c: Color) -> String {
353 let [r, g, b, _] = c.to_srgba_unmultiplied();
354 format!("#{r:02x}{g:02x}{b:02x}")
355}
356
357fn fill(c: Color) -> Vec<(&'static str, String)> {
359 let [.., a] = c.to_srgba_unmultiplied();
360 match a {
361 0 => vec![("fill", "none".to_owned())],
362 255 => vec![("fill", color_hex(c))],
363 _ => vec![
364 ("fill", color_hex(c)),
365 ("fill-opacity", (f32::from(a) / 255.0).to_string()),
366 ],
367 }
368}
369
370fn apply_fill(node: &mut impl Node, c: Color) {
371 for (name, value) in fill(c) {
372 node.assign(name, value);
373 }
374}
375
376fn text_element(row: &TextRow, font: &Font, color: Color, angle: Angle) -> Blob {
380 use std::fmt::Write as _;
381 let (x, y) = (row.pen.x, row.pen.y);
382 let mut tag = format!(r#"<text x="{x}" y="{y}" font-size="{}""#, font.size);
383 if angle != Angle::ZERO {
384 let _ = write!(tag, r#" transform="rotate({} {x} {y})""#, angle.degrees());
385 }
386 for (name, value) in fill(color) {
387 let _ = write!(tag, r#" {name}="{value}""#);
388 }
389 let text = row
390 .text
391 .replace('&', "&")
392 .replace('<', "<")
393 .replace('>', ">");
394 Blob::new(format!("{tag}>{text}</text>"))
395}
396
397#[derive(Clone, Copy)]
399struct Ink {
400 width: WorldPx,
401 color: Color,
402}
403
404fn apply_stroke(node: &mut impl Node, ink: Ink) {
406 let [.., a] = ink.color.to_srgba_unmultiplied();
407 if ink.width == WorldPx::ZERO || a == 0 {
408 return;
409 }
410 node.assign("stroke", color_hex(ink.color));
411 node.assign("stroke-width", ink.width.get());
412 if a != 255 {
413 node.assign("stroke-opacity", a as f32 / 255.0);
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use blockworx_paint::{Base, FontChoice};
421 use blockworx_text::Shaper;
422
423 fn px(v: f32) -> WorldPx {
424 WorldPx::new(v)
425 }
426
427 fn palette() -> Palette {
428 Palette::tokyo_night_moon()
429 }
430
431 fn renderer() -> SvgRenderer<Shaper> {
432 SvgRenderer::new(palette(), Shaper::new(FontChoice::Basic))
433 }
434
435 fn font() -> Font {
437 Font::canvas(12.0)
438 }
439
440 #[test]
444 fn a_run_is_written_as_text_in_the_embedded_face() {
445 let r = renderer();
446 let rect = r.text(Pos2::ZERO, Align2::LEFT_TOP, "Config", &font(), Base::B00);
447 let (svg, _) = r.finish();
448 let rows = text_rows(&svg);
449 assert_eq!(rows.len(), 1, "one row: {rows:?}");
450 assert_eq!(rows[0].0, "Config", "a ligature is the viewer's to shape");
451 assert!(
452 rows[0].1.y > 0.0 && rows[0].1.y < rect.height(),
453 "the pen is on the baseline, inside the run: {rows:?} in {rect:?}"
454 );
455 assert!(!svg.contains("<path"), "no glyph outlines:\n{svg}");
456 assert!(svg.contains(r#"font-family="Roboto""#), "{svg}");
457 assert!(svg.contains("@font-face { font-family: 'Roboto'"));
458 assert!(svg.contains(r##"fill="#222436""##), "resolved palette fill");
459 }
460
461 #[test]
462 fn a_wrapped_run_writes_one_text_per_row() {
463 let r = renderer();
464 let line = "the quick brown fox jumps over the lazy dog";
465 let max = r.text_size(line, &font()).x / 3.0;
466 r.text_wrapped(
467 Pos2::ZERO,
468 Align2::LEFT_TOP,
469 line,
470 &font(),
471 Base::B00,
472 px(max),
473 );
474 let (svg, _) = r.finish();
475 let rows = text_rows(&svg);
476 assert!(
477 rows.len() >= 3,
478 "a third of the width takes three rows: {rows:?}"
479 );
480 let joined: String = rows.iter().map(|(text, _)| text.as_str()).collect();
481 assert_eq!(
482 joined.split_whitespace().collect::<Vec<_>>().join(" "),
483 line
484 );
485 assert!(
486 rows.windows(2)
487 .all(|pair| pair[0].1.y < pair[1].1.y && pair[0].1.x == pair[1].1.x),
488 "rows stack down one left edge: {rows:?}"
489 );
490 }
491
492 #[test]
493 fn a_turned_run_turns_each_row_about_its_pen() {
494 let r = renderer();
495 r.rotated_text(
496 Pos2::new(50.0, 20.0),
497 Align2::LEFT_TOP,
498 "sig",
499 &font(),
500 Base::B00,
501 Angle::QUARTER_TURN,
502 );
503 let (svg, _) = r.finish();
504 let rows = text_rows(&svg);
505 assert_eq!(rows.len(), 1);
506 let (x, y) = (rows[0].1.x, rows[0].1.y);
507 assert!(
508 x < 50.0 && (y - 20.0).abs() < 1e-3,
509 "a quarter turn puts the baseline left of the anchor: {rows:?}"
510 );
511 assert!(svg.contains(&format!("rotate(90 {x} {y})")), "{svg}");
512 }
513
514 #[test]
515 fn markup_in_a_run_is_escaped() {
516 let r = renderer();
517 r.text(Pos2::ZERO, Align2::LEFT_TOP, "a<b & c", &font(), Base::B00);
518 let (svg, _) = r.finish();
519 assert!(svg.contains("a<b & c"), "{svg}");
520 assert_eq!(text_rows(&svg)[0].0, "a<b & c");
521 }
522
523 #[test]
524 fn multiline_text_stacks_lines() {
525 let r = renderer();
526 let font = font();
527 let one = r.text_size("aaaa", &font);
529 let two = r.text_size("aa\naa", &font);
530 assert!(
531 (two.y - 2.0 * one.y).abs() < 0.001,
532 "two lines should be twice as tall: {} vs {}",
533 two.y,
534 one.y
535 );
536 assert!(
537 two.x < one.x + 0.001,
538 "width is the widest line, not the sum: {} vs {}",
539 two.x,
540 one.x
541 );
542 assert!((r.text_size("aa\n", &font).y - two.y).abs() < 0.001);
544 }
545
546 #[test]
547 fn wrapped_text_breaks_long_lines_within_the_width() {
548 let r = renderer();
549 let font = font();
550 let line = "the quick brown fox jumps over the lazy dog";
551 let unwrapped = r.text_size(line, &font);
552 let max = unwrapped.x / 3.0;
555 let wrapped = r.text_size_wrapped(line, &font, px(max));
556 assert!(
557 wrapped.y > unwrapped.y,
558 "wrapping a long line adds rows: {} vs {}",
559 wrapped.y,
560 unwrapped.y
561 );
562 assert!(
563 wrapped.x <= max + 0.001,
564 "no wrapped row exceeds the wrap width: {} vs {}",
565 wrapped.x,
566 max
567 );
568 assert!(
570 (r.text_size_wrapped(line, &font, WorldPx::UNBOUNDED).x - unwrapped.x).abs() < 0.001
571 );
572 }
573
574 #[test]
575 fn primitives_and_viewbox() {
576 let r = renderer();
577 r.rect(
578 Rect::from_min_size(Pos2::new(10.0, 10.0), vec2(30.0, 20.0)),
579 px(3.0),
580 Base::B01,
581 (1.0, Base::B0D),
582 );
583 r.line(
584 vec![Pos2::new(0.0, 0.0), Pos2::new(40.0, 0.0)],
585 (1.7, Base::B0B),
586 );
587 let (svg, _) = r.finish();
588 assert!(svg.contains("<svg"));
589 assert!(svg.contains("viewBox="));
590 assert!(svg.contains("<rect"));
591 assert!(svg.contains("<polyline"));
592 assert!(svg.contains("</svg>"));
593 assert!(
594 !svg.contains("@font-face"),
595 "a picture with no text embeds no face"
596 );
597 }
598
599 #[test]
600 fn image_embeds_as_data_uri() {
601 let r = renderer();
602 r.draw_image(
603 Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(10.0, 10.0)),
604 &Asset::Svg(r#"<svg viewBox="0 0 1 1"/>"#.as_bytes().into()),
605 );
606 let (svg, _) = r.finish();
607 assert!(
608 svg.contains("<image"),
609 "image exports as an <image>:\n{svg}"
610 );
611 assert!(
612 svg.contains("data:image/svg+xml;base64,"),
613 "image href is a base64 SVG data URI"
614 );
615 assert!(svg.contains(r#"preserveAspectRatio="xMidYMid meet""#));
616 }
617
618 #[test]
619 fn png_image_embeds_as_png_data_uri() {
620 let r = renderer();
621 r.draw_image(
622 Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(10.0, 10.0)),
623 &Asset::Png(vec![0x89, b'P', b'N', b'G', 1, 2, 3].into()),
624 );
625 let (svg, _) = r.finish();
626 assert!(
627 svg.contains("data:image/png;base64,"),
628 "PNG image href is a base64 PNG data URI:\n{svg}"
629 );
630 }
631}