1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use crate::frame::{Cell, Frame, Underline};
6
7#[derive(Clone, Debug)]
8pub struct Options {
9 pub cell_width: f32,
10 pub cell_height: f32,
11 pub font_size: f32,
12 pub padding: f32,
13 pub font_family: String,
14 pub show_cursor: bool,
15}
16
17impl Default for Options {
18 fn default() -> Self {
19 Self {
20 cell_width: 9.0,
21 cell_height: 18.0,
22 font_size: 14.0,
23 padding: 18.0,
24 font_family: "JetBrains Mono, SFMono-Regular, Menlo, monospace".to_owned(),
25 show_cursor: true,
26 }
27 }
28}
29
30pub fn svg(frame: &Frame, options: &Options) -> String {
31 let width = f32::from(frame.cols) * options.cell_width + options.padding * 2.0;
32 let height = f32::from(frame.rows) * options.cell_height + options.padding * 2.0;
33 let mut output = format!(
34 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}"><rect width="100%" height="100%" rx="10" fill="{}"/><g font-family="{}" font-size="{}" xml:space="preserve">"#,
35 frame.background.css(),
36 xml(&options.font_family),
37 options.font_size,
38 );
39 for cell in &frame.cells {
40 if cell.background != frame.background {
41 output.push_str(&format!(
42 r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}"/>"#,
43 options.padding + f32::from(cell.x) * options.cell_width,
44 options.padding + f32::from(cell.y) * options.cell_height,
45 f32::from(cell.width) * options.cell_width,
46 options.cell_height,
47 cell.background.css(),
48 ));
49 }
50 }
51 for cell in &frame.cells {
52 if !cell.text.is_empty() && !cell.attributes.invisible {
53 output.push_str(&graphic(cell, options).unwrap_or_else(|| text(cell, options)));
54 }
55 }
56 if options.show_cursor
57 && let Some(cursor) = &frame.cursor
58 {
59 let x = options.padding + f32::from(cursor.x) * options.cell_width;
60 let y = options.padding + f32::from(cursor.y) * options.cell_height;
61 output.push_str(&format!(
62 r#"<rect x="{x}" y="{y}" width="{}" height="{}" fill="{}" opacity="0.32"/>"#,
63 options.cell_width,
64 options.cell_height,
65 cursor.color.css(),
66 ));
67 }
68 output.push_str("</g></svg>");
69 output
70}
71
72pub fn png(svg: &str, path: &Path, pixel_ratio: f32) -> Result<()> {
73 PngRenderer::new().render(svg, path, pixel_ratio)
74}
75
76pub struct PngRenderer {
77 options: resvg::usvg::Options<'static>,
78}
79
80impl PngRenderer {
81 pub fn new() -> Self {
82 let mut options = resvg::usvg::Options::default();
83 options.fontdb_mut().load_system_fonts();
84 Self { options }
85 }
86
87 pub fn render(&self, svg: &str, path: &Path, pixel_ratio: f32) -> Result<()> {
88 let tree = resvg::usvg::Tree::from_data(svg.as_bytes(), &self.options)
89 .context("parse rendered SVG")?;
90 let size = tree.size().to_int_size();
91 let width = ((size.width() as f32) * pixel_ratio).ceil() as u32;
92 let height = ((size.height() as f32) * pixel_ratio).ceil() as u32;
93 let mut pixmap =
94 resvg::tiny_skia::Pixmap::new(width, height).context("allocate PNG canvas")?;
95 resvg::render(
96 &tree,
97 resvg::tiny_skia::Transform::from_scale(pixel_ratio, pixel_ratio),
98 &mut pixmap.as_mut(),
99 );
100 pixmap.save_png(path).context("write PNG artifact")?;
101 Ok(())
102 }
103}
104
105impl Default for PngRenderer {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111fn graphic(cell: &Cell, options: &Options) -> Option<String> {
112 let mut chars = cell.text.chars();
113 let char = chars.next()?;
114 if chars.next().is_some() {
115 return None;
116 }
117 let x = options.padding + f32::from(cell.x) * options.cell_width;
118 let y = options.padding + f32::from(cell.y) * options.cell_height;
119 let width = options.cell_width * f32::from(cell.width);
120 let height = options.cell_height;
121 let rect = |x: f32, y: f32, width: f32, height: f32, opacity: Option<f32>| {
122 format!(
123 r#"<rect x="{x}" y="{y}" width="{width}" height="{height}" fill="{}"{}/>"#,
124 cell.foreground.css(),
125 opacity.map_or_else(String::new, |value| format!(r#" opacity="{value}""#)),
126 )
127 };
128 let stroke_width = width.min(height) * 0.08;
129 let stroke_rect = |left: f32, top: f32, wide: f32, tall: f32| {
130 format!(
131 r#"<rect x="{}" y="{}" width="{}" height="{}" fill="none" stroke="{}" stroke-width="{stroke_width}"/>"#,
132 x + width * left,
133 y + height * top,
134 width * wide,
135 height * tall,
136 cell.foreground.css(),
137 )
138 };
139 let single = |left: f32, top: f32, wide: f32, tall: f32| {
140 rect(
141 x + width * left,
142 y + height * top,
143 width * wide,
144 height * tall,
145 None,
146 )
147 };
148 let circle = |center_x: f32, center_y: f32, radius: f32| {
149 format!(
150 r#"<circle cx="{}" cy="{}" r="{}" fill="{}"/>"#,
151 x + width * center_x,
152 y + height * center_y,
153 radius,
154 cell.foreground.css(),
155 )
156 };
157 let ring = |center_x: f32, center_y: f32, radius: f32| {
158 format!(
159 r#"<circle cx="{}" cy="{}" r="{}" fill="none" stroke="{}" stroke-width="{stroke_width}"/>"#,
160 x + width * center_x,
161 y + height * center_y,
162 radius,
163 cell.foreground.css(),
164 )
165 };
166 let diamond = |scale: f32, filled: bool| {
167 let center_x = x + width * 0.5;
168 let center_y = y + height * 0.5;
169 let half_width = width * 0.42 * scale;
170 let half_height = height * 0.36 * scale;
171 let points = format!(
172 "{center_x},{} {},{center_y} {center_x},{} {},{center_y}",
173 center_y - half_height,
174 center_x + half_width,
175 center_y + half_height,
176 center_x - half_width,
177 );
178 if filled {
179 return format!(
180 r#"<polygon points="{points}" fill="{}"/>"#,
181 cell.foreground.css()
182 );
183 }
184 format!(
185 r#"<polygon points="{points}" fill="none" stroke="{}" stroke-width="{stroke_width}"/>"#,
186 cell.foreground.css(),
187 )
188 };
189 let codepoint = char as u32;
190 if (0x2800..=0x28ff).contains(&codepoint) {
191 return Some(braille_dots(
192 codepoint - 0x2800,
193 &circle,
194 width.min(height) * 0.09,
195 ));
196 }
197 Some(match char {
198 '█' => single(0.0, 0.0, 1.0, 1.0),
199 '▀' => single(0.0, 0.0, 1.0, 0.5),
200 '▄' => single(0.0, 0.5, 1.0, 0.5),
201 '▌' => single(0.0, 0.0, 0.5, 1.0),
202 '▐' => single(0.5, 0.0, 0.5, 1.0),
203 '▁' => single(0.0, 7.0 / 8.0, 1.0, 1.0 / 8.0),
204 '▂' => single(0.0, 6.0 / 8.0, 1.0, 2.0 / 8.0),
205 '▃' => single(0.0, 5.0 / 8.0, 1.0, 3.0 / 8.0),
206 '▅' => single(0.0, 3.0 / 8.0, 1.0, 5.0 / 8.0),
207 '▆' => single(0.0, 2.0 / 8.0, 1.0, 6.0 / 8.0),
208 '▇' => single(0.0, 1.0 / 8.0, 1.0, 7.0 / 8.0),
209 '▏' => single(0.0, 0.0, 1.0 / 8.0, 1.0),
210 '▎' => single(0.0, 0.0, 2.0 / 8.0, 1.0),
211 '▍' => single(0.0, 0.0, 3.0 / 8.0, 1.0),
212 '▋' => single(0.0, 0.0, 5.0 / 8.0, 1.0),
213 '▊' => single(0.0, 0.0, 6.0 / 8.0, 1.0),
214 '▉' => single(0.0, 0.0, 7.0 / 8.0, 1.0),
215 '▔' => single(0.0, 0.0, 1.0, 1.0 / 8.0),
216 '▖' => single(0.0, 0.5, 0.5, 0.5),
217 '▗' => single(0.5, 0.5, 0.5, 0.5),
218 '▘' => single(0.0, 0.0, 0.5, 0.5),
219 '▝' => single(0.5, 0.0, 0.5, 0.5),
220 '▚' => single(0.0, 0.0, 0.5, 0.5) + &single(0.5, 0.5, 0.5, 0.5),
221 '▞' => single(0.5, 0.0, 0.5, 0.5) + &single(0.0, 0.5, 0.5, 0.5),
222 '▙' => single(0.0, 0.0, 0.5, 1.0) + &single(0.5, 0.5, 0.5, 0.5),
223 '▛' => single(0.0, 0.0, 0.5, 1.0) + &single(0.5, 0.0, 0.5, 0.5),
224 '▜' => single(0.5, 0.0, 0.5, 1.0) + &single(0.0, 0.0, 0.5, 0.5),
225 '▟' => single(0.5, 0.0, 0.5, 1.0) + &single(0.0, 0.5, 0.5, 0.5),
226 '▣' => stroke_rect(0.18, 0.18, 0.64, 0.64) + &single(0.38, 0.38, 0.24, 0.24),
227 '■' => single(0.1, 0.18, 0.8, 0.64),
228 '⬝' => single(0.32, 0.38, 0.36, 0.28),
229 '◆' => diamond(1.0, true),
230 '◇' => diamond(1.0, false),
231 '◈' => diamond(1.0, false) + &diamond(0.42, true),
232 '⬥' => diamond(0.82, true),
233 '⬩' | '⬪' | '⬖' => diamond(0.52, true),
234 '●' => circle(0.5, 0.52, width.min(height) * 0.32),
235 '○' => ring(0.5, 0.52, width.min(height) * 0.32),
236 '◉' | '◍' => {
237 ring(0.5, 0.52, width.min(height) * 0.32) + &circle(0.5, 0.52, width.min(height) * 0.15)
238 }
239 '◔' => ring(0.5, 0.52, width.min(height) * 0.32) + &single(0.5, 0.2, 0.32, 0.32),
240 _ => return None,
241 })
242}
243
244fn braille_dots(pattern: u32, circle: &impl Fn(f32, f32, f32) -> String, radius: f32) -> String {
245 [
246 (0x01, 0.34, 0.2),
247 (0x02, 0.34, 0.38),
248 (0x04, 0.34, 0.56),
249 (0x40, 0.34, 0.74),
250 (0x08, 0.66, 0.2),
251 (0x10, 0.66, 0.38),
252 (0x20, 0.66, 0.56),
253 (0x80, 0.66, 0.74),
254 ]
255 .into_iter()
256 .filter(|(bit, _, _)| pattern & bit != 0)
257 .map(|(_, x, y)| circle(x, y, radius))
258 .collect::<String>()
259}
260
261fn text(cell: &Cell, options: &Options) -> String {
262 let x = options.padding + f32::from(cell.x) * options.cell_width;
263 let y = options.padding + f32::from(cell.y) * options.cell_height + options.cell_height * 0.78;
264 let decorations = [
265 cell.attributes
266 .underline
267 .map(|Underline::Single| "underline"),
268 cell.attributes.strikethrough.then_some("line-through"),
269 cell.attributes.overline.then_some("overline"),
270 ]
271 .into_iter()
272 .flatten()
273 .collect::<Vec<_>>()
274 .join(" ");
275 format!(
276 r#"<text x="{x}" y="{y}" fill="{}"{}{}{}{}>{}</text>"#,
277 cell.foreground.css(),
278 if cell.attributes.bold {
279 " font-weight=\"700\""
280 } else {
281 ""
282 },
283 if cell.attributes.italic {
284 " font-style=\"italic\""
285 } else {
286 ""
287 },
288 if cell.attributes.faint {
289 " opacity=\"0.55\""
290 } else {
291 ""
292 },
293 if decorations.is_empty() {
294 String::new()
295 } else {
296 format!(" text-decoration=\"{decorations}\"")
297 },
298 xml(&cell.text),
299 )
300}
301
302fn xml(value: &str) -> String {
303 value
304 .replace('&', "&")
305 .replace('<', "<")
306 .replace('>', ">")
307 .replace('"', """)
308 .replace('\'', "'")
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::frame::{Attributes, Color, Frame, Underline};
315
316 #[test]
317 fn emits_background_and_text_styles_in_svg() {
318 let frame = Frame {
319 version: 1,
320 cols: 4,
321 rows: 1,
322 foreground: Color {
323 r: 255,
324 g: 255,
325 b: 255,
326 },
327 background: Color { r: 0, g: 0, b: 0 },
328 cursor: None,
329 cells: vec![crate::frame::Cell {
330 x: 0,
331 y: 0,
332 text: "Hi".to_owned(),
333 width: 2,
334 foreground: Color { r: 1, g: 2, b: 3 },
335 background: Color { r: 4, g: 5, b: 6 },
336 attributes: Attributes {
337 bold: true,
338 underline: Some(Underline::Single),
339 ..Attributes::default()
340 },
341 }],
342 };
343
344 let output = svg(&frame, &Options::default());
345
346 assert!(output.contains("#040506"));
347 assert!(output.contains("#010203"));
348 assert!(output.contains("font-weight=\"700\""));
349 assert!(output.contains("text-decoration=\"underline\""));
350 }
351
352 #[test]
353 fn renders_block_elements_as_geometry_instead_of_font_glyphs() {
354 let frame = Frame {
355 version: 1,
356 cols: 1,
357 rows: 1,
358 foreground: Color {
359 r: 255,
360 g: 255,
361 b: 255,
362 },
363 background: Color { r: 0, g: 0, b: 0 },
364 cursor: None,
365 cells: vec![crate::frame::Cell {
366 x: 0,
367 y: 0,
368 text: "▀".to_owned(),
369 width: 1,
370 foreground: Color {
371 r: 255,
372 g: 255,
373 b: 255,
374 },
375 background: Color { r: 0, g: 0, b: 0 },
376 attributes: Attributes::default(),
377 }],
378 };
379
380 let output = svg(&frame, &Options::default());
381
382 assert!(output.contains("height=\"9\""));
383 assert!(!output.contains(">▀</text>"));
384 }
385
386 #[test]
387 fn renders_opencode_status_glyphs_as_geometry() {
388 let frame = Frame {
389 version: 1,
390 cols: 16,
391 rows: 1,
392 foreground: Color {
393 r: 80,
394 g: 140,
395 b: 220,
396 },
397 background: Color { r: 0, g: 0, b: 0 },
398 cursor: None,
399 cells: [
400 "■", "⬝", "▣", "◆", "◇", "◈", "⬥", "⬩", "⬪", "⬖", "●", "○", "◉", "◍", "◔",
401 ]
402 .into_iter()
403 .enumerate()
404 .map(|(x, text)| crate::frame::Cell {
405 x: x as u16,
406 y: 0,
407 text: text.to_owned(),
408 width: 1,
409 foreground: Color {
410 r: 80,
411 g: 140,
412 b: 220,
413 },
414 background: Color { r: 0, g: 0, b: 0 },
415 attributes: Attributes::default(),
416 })
417 .collect(),
418 };
419
420 let output = svg(&frame, &Options::default());
421
422 for text in [
423 "■", "⬝", "▣", "◆", "◇", "◈", "⬥", "⬩", "⬪", "⬖", "●", "○", "◉", "◍", "◔",
424 ] {
425 assert!(!output.contains(&format!(">{text}</text>")));
426 }
427 }
428
429 #[test]
430 fn renders_braille_spinner_frames_as_geometry() {
431 let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
432 let frame = Frame {
433 version: 1,
434 cols: frames.len() as u16,
435 rows: 1,
436 foreground: Color {
437 r: 180,
438 g: 130,
439 b: 255,
440 },
441 background: Color { r: 0, g: 0, b: 0 },
442 cursor: None,
443 cells: frames
444 .into_iter()
445 .enumerate()
446 .map(|(x, text)| crate::frame::Cell {
447 x: x as u16,
448 y: 0,
449 text: text.to_owned(),
450 width: 1,
451 foreground: Color {
452 r: 180,
453 g: 130,
454 b: 255,
455 },
456 background: Color { r: 0, g: 0, b: 0 },
457 attributes: Attributes::default(),
458 })
459 .collect(),
460 };
461
462 let output = svg(&frame, &Options::default());
463
464 assert!(output.contains("<circle"));
465 for text in frames {
466 assert!(!output.contains(&format!(">{text}</text>")));
467 }
468 }
469
470 #[test]
471 fn renders_shade_characters_as_font_glyphs() {
472 let frame = Frame {
473 version: 1,
474 cols: 3,
475 rows: 1,
476 foreground: Color {
477 r: 255,
478 g: 255,
479 b: 255,
480 },
481 background: Color { r: 0, g: 0, b: 0 },
482 cursor: None,
483 cells: ["░", "▒", "▓"]
484 .into_iter()
485 .enumerate()
486 .map(|(x, text)| crate::frame::Cell {
487 x: x as u16,
488 y: 0,
489 text: text.to_owned(),
490 width: 1,
491 foreground: Color {
492 r: 255,
493 g: 255,
494 b: 255,
495 },
496 background: Color { r: 0, g: 0, b: 0 },
497 attributes: Attributes::default(),
498 })
499 .collect(),
500 };
501
502 let output = svg(&frame, &Options::default());
503
504 assert!(output.contains(">░</text>"));
505 assert!(output.contains(">▒</text>"));
506 assert!(output.contains(">▓</text>"));
507 assert!(!output.contains("opacity=\"0.25\""));
508 assert!(!output.contains("opacity=\"0.5\""));
509 assert!(!output.contains("opacity=\"0.75\""));
510 }
511}