1use crate::color::Color;
9use crate::segment::Segment;
10
11#[derive(Debug, Clone)]
17pub struct ExportTheme {
18 pub background: (u8, u8, u8),
19 pub foreground: (u8, u8, u8),
20 pub ansi_colors: [(u8, u8, u8); 16],
22}
23
24impl Default for ExportTheme {
25 fn default() -> Self {
26 ExportTheme {
27 background: (0, 0, 0),
28 foreground: (255, 255, 255),
29 ansi_colors: [
30 (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), ],
47 }
48 }
49}
50
51pub const EXPORT_THEME_MONOKAI: ExportTheme = ExportTheme {
53 background: (39, 40, 34),
54 foreground: (248, 248, 242),
55 ansi_colors: [
56 (39, 40, 34), (249, 38, 114), (166, 226, 46), (230, 219, 116),(102, 217, 239),(174, 129, 255),(161, 239, 228),(248, 248, 242),(117, 113, 94), (249, 38, 114), (166, 226, 46), (230, 219, 116),(102, 217, 239),(174, 129, 255),(161, 239, 228),(248, 248, 242),],
73};
74
75pub const EXPORT_THEME_DIMMED_MONOKAI: ExportTheme = ExportTheme {
77 background: (35, 35, 35),
78 foreground: (185, 188, 186),
79 ansi_colors: [
80 (35, 35, 35), (190, 63, 72), (135, 154, 59), (197, 166, 56), (79, 118, 161), (133, 92, 141), (87, 143, 164), (185, 188, 186),(83, 83, 83), (240, 80, 80), (148, 166, 73), (215, 180, 66), (108, 147, 177),(152, 117, 171),(101, 164, 179),(230, 235, 235),],
97};
98
99pub const EXPORT_THEME_NIGHT_OWLISH: ExportTheme = ExportTheme {
101 background: (1, 22, 39),
102 foreground: (214, 222, 235),
103 ansi_colors: [
104 (1, 22, 39), (255, 88, 116), (173, 219, 103),(255, 203, 107),(130, 170, 255),(199, 146, 234),(137, 221, 255),(214, 222, 235),(84, 94, 109), (255, 88, 116), (173, 219, 103),(255, 203, 107),(130, 170, 255),(199, 146, 234),(137, 221, 255),(255, 255, 255),],
121};
122
123pub const EXPORT_THEME_SVG: ExportTheme = ExportTheme {
125 background: (255, 255, 255),
126 foreground: (0, 0, 0),
127 ansi_colors: [
128 (0, 0, 0), (204, 0, 0), (0, 170, 0), (204, 102, 0), (0, 0, 204), (170, 0, 170), (0, 170, 170), (170, 170, 170), (102, 102, 102), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), ],
145};
146
147pub const CONSOLE_HTML_FORMAT: &str = r#"<!DOCTYPE html>
153<html lang="en">
154<head>
155<meta charset="UTF-8">
156<meta name="viewport" content="width=device-width, initial-scale=1.0">
157<title>rusty-rich</title>
158<style>
159 body {{
160 margin: 0;
161 padding: 0;
162 }}
163 pre.rich-html {{
164 font-family: {font_family};
165 font-size: {font_size}px;
166 line-height: {line_height};
167 color: {foreground};
168 background-color: {background};
169 margin: 0;
170 padding: 16px 24px;
171 white-space: pre-wrap;
172 word-wrap: break-word;
173 overflow-x: auto;
174 }}
175</style>
176</head>
177<body>
178<pre class="rich-html">
179{code}
180</pre>
181</body>
182</html>"#;
183
184#[derive(Debug, Clone)]
186pub struct ExportHtmlOptions {
187 pub font_family: String,
189 pub font_size: u32,
191 pub line_height: f64,
193 pub theme: ExportTheme,
195 pub code: String,
197}
198
199impl Default for ExportHtmlOptions {
200 fn default() -> Self {
201 Self {
202 font_family: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Source Code Pro', Menlo, Consolas, monospace".into(),
203 font_size: 14,
204 line_height: 1.45,
205 theme: ExportTheme::default(),
206 code: String::new(),
207 }
208 }
209}
210
211pub fn export_html(options: &ExportHtmlOptions) -> String {
225 let fg = options.theme.foreground;
226 let bg = options.theme.background;
227
228 CONSOLE_HTML_FORMAT
229 .replace("{font_family}", &options.font_family)
230 .replace("{font_size}", &options.font_size.to_string())
231 .replace("{line_height}", &options.line_height.to_string())
232 .replace("{foreground}", &format!("rgb({},{},{})", fg.0, fg.1, fg.2))
233 .replace("{background}", &format!("rgb({},{},{})", bg.0, bg.1, bg.2))
234 .replace("{code}", &escape_html(&options.code))
235}
236
237pub fn save_html(path: impl AsRef<std::path::Path>, options: &ExportHtmlOptions) -> std::io::Result<()> {
241 std::fs::write(path.as_ref(), export_html(options))
242}
243
244pub const CONSOLE_SVG_FORMAT: &str = r#"<svg class="rich-svg" xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
250<style>
251 text {{ font-family: {font_family}; font-size: {font_size}px; }}
252</style>
253<rect width="100%" height="100%" fill="{background}"/>
254<text x="0" y="{baseline}" xml:space="preserve">
255{code}
256</text>
257</svg>"#;
258
259#[derive(Debug, Clone)]
261pub struct ExportSvgOptions {
262 pub font_family: String,
264 pub font_size: u32,
266 pub theme: ExportTheme,
268 pub code: String,
270 pub width: u32,
272 pub height: u32,
274}
275
276impl Default for ExportSvgOptions {
277 fn default() -> Self {
278 Self {
279 font_family: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace".into(),
280 font_size: 14,
281 theme: EXPORT_THEME_SVG,
282 code: String::new(),
283 width: 800,
284 height: 600,
285 }
286 }
287}
288
289pub fn export_svg(options: &ExportSvgOptions) -> String {
303 let fg = options.theme.foreground;
304 let bg = options.theme.background;
305 let baseline = options.font_size as f64 * 1.2; CONSOLE_SVG_FORMAT
308 .replace("{font_family}", &options.font_family)
309 .replace("{font_size}", &options.font_size.to_string())
310 .replace("{width}", &options.width.to_string())
311 .replace("{height}", &options.height.to_string())
312 .replace("{background}", &format!("rgb({},{},{})", bg.0, bg.1, bg.2))
313 .replace("{baseline}", &format!("{:.0}", baseline))
314 .replace("{code}", &escape_xml(&options.code))
315 .replace("{foreground}", &format!("rgb({},{},{})", fg.0, fg.1, fg.2))
316}
317
318pub fn save_svg(path: impl AsRef<std::path::Path>, options: &ExportSvgOptions) -> std::io::Result<()> {
320 std::fs::write(path.as_ref(), export_svg(options))
321}
322
323#[derive(Debug, Clone)]
329pub struct ExportTextOptions {
330 pub text: String,
332 pub strip_ansi: bool,
334}
335
336impl Default for ExportTextOptions {
337 fn default() -> Self {
338 Self {
339 text: String::new(),
340 strip_ansi: true,
341 }
342 }
343}
344
345pub fn export_text(options: &ExportTextOptions) -> String {
347 if options.strip_ansi {
348 strip_ansi_escapes(&options.text)
349 } else {
350 options.text.clone()
351 }
352}
353
354pub fn save_text(
356 path: impl AsRef<std::path::Path>,
357 options: &ExportTextOptions,
358) -> std::io::Result<()> {
359 std::fs::write(path.as_ref(), export_text(options))
360}
361
362pub fn escape_html(text: &str) -> String {
368 text.replace('&', "&")
369 .replace('<', "<")
370 .replace('>', ">")
371 .replace('"', """)
372}
373
374pub fn escape_xml(text: &str) -> String {
376 text.replace('&', "&")
377 .replace('<', "<")
378 .replace('>', ">")
379 .replace('"', """)
380 .replace('\'', "'")
381}
382
383pub fn strip_ansi_escapes(text: &str) -> String {
385 let mut result = String::with_capacity(text.len());
386 let mut chars = text.chars().peekable();
387
388 while let Some(ch) = chars.next() {
389 if ch == '\x1b' {
390 if chars.peek() == Some(&'[') {
392 chars.next(); while let Some(&c) = chars.peek() {
395 if c.is_ascii_digit() || c == ';' || c == '?' || c == '!' {
396 chars.next();
397 } else {
398 break;
399 }
400 }
401 chars.next();
403 }
404 } else {
405 result.push(ch);
406 }
407 }
408
409 result
410}
411
412pub fn segments_to_html(
417 segments: &[Segment],
418 theme: &ExportTheme,
419) -> String {
420 let mut html = String::new();
421
422 for seg in segments {
423 let mut styles: Vec<String> = Vec::new();
424
425 if let Some(ref style) = seg.style {
426 if let Some(color) = &style.color {
428 let rgb = resolve_color(color, theme);
429 styles.push(format!("color:rgb({},{},{})", rgb.0, rgb.1, rgb.2));
430 } else {
431 let fg = theme.foreground;
433 styles.push(format!("color:rgb({},{},{})", fg.0, fg.1, fg.2));
434 }
435
436 if let Some(bgcolor) = &style.bgcolor {
438 let rgb = resolve_color(bgcolor, theme);
439 styles.push(format!("background-color:rgb({},{},{})", rgb.0, rgb.1, rgb.2));
440 }
441
442 let attrs = &style.attributes;
444 if attrs.get(crate::style::Attributes::BOLD) {
445 styles.push("font-weight:bold".into());
446 }
447 if attrs.get(crate::style::Attributes::ITALIC) {
448 styles.push("font-style:italic".into());
449 }
450 if attrs.get(crate::style::Attributes::UNDERLINE)
451 || attrs.get(crate::style::Attributes::UNDERLINE2)
452 {
453 styles.push("text-decoration:underline".into());
454 }
455 if attrs.get(crate::style::Attributes::STRIKE) {
456 styles.push("text-decoration:line-through".into());
457 }
458 if attrs.get(crate::style::Attributes::DIM) {
459 styles.push("opacity:0.7".into());
460 }
461 if attrs.get(crate::style::Attributes::CONCEAL) {
462 styles.push("visibility:hidden".into());
463 }
464
465 if let Some(ref link) = style.link {
467 let escaped_link = escape_html(link);
468 let style_attr = if styles.is_empty() {
469 String::new()
470 } else {
471 format!(" style=\"{}\"", styles.join("; "))
472 };
473 html.push_str(&format!(
474 "<a href=\"{}\"{}>{}</a>",
475 escaped_link,
476 style_attr,
477 escape_html(&seg.text)
478 ));
479 continue; }
481 } else {
482 let fg = theme.foreground;
484 styles.push(format!("color:rgb({},{},{})", fg.0, fg.1, fg.2));
485 }
486
487 if styles.is_empty() {
489 html.push_str(&escape_html(&seg.text));
490 } else {
491 let style_attr = styles.join("; ");
492 html.push_str(&format!(
493 "<span style=\"{}\">{}</span>",
494 style_attr,
495 escape_html(&seg.text)
496 ));
497 }
498 }
499
500 html
501}
502
503fn resolve_color(color: &Color, theme: &ExportTheme) -> (u8, u8, u8) {
505 match color.color_type {
506 crate::color::ColorType::Default => theme.foreground,
507 crate::color::ColorType::Standard => {
508 let idx = color.number.unwrap_or(7) as usize % 16;
509 theme.ansi_colors[idx]
510 }
511 crate::color::ColorType::EightBit => {
512 let idx = color.number.unwrap_or(0) as usize % 256;
513 rgb_for_8bit(idx)
514 }
515 crate::color::ColorType::TrueColor => {
516 if let Some(ref triplet) = color.triplet {
517 (triplet.0, triplet.1, triplet.2)
518 } else {
519 theme.foreground
520 }
521 }
522 }
523}
524
525fn rgb_for_8bit(index: usize) -> (u8, u8, u8) {
527 if index < 16 {
528 crate::color::STANDARD_PALETTE
530 .get(index)
531 .copied()
532 .unwrap_or((0, 0, 0))
533 } else if index < 232 {
534 let idx = index - 16;
536 let r = (idx / 36) as u8 * 51;
537 let g = ((idx / 6) % 6) as u8 * 51;
538 let b = (idx % 6) as u8 * 51;
539 (r, g, b)
540 } else {
541 let g = ((index - 232) * 10 + 8) as u8;
543 (g, g, g)
544 }
545}
546
547#[cfg(test)]
552mod tests {
553 use super::*;
554 use crate::style::Style;
555 use crate::color::Color;
556
557 #[test]
558 fn test_escape_html_basic() {
559 assert_eq!(escape_html("<hello>"), "<hello>");
560 assert_eq!(escape_html("\"a\" & 'b'"), ""a" & 'b'");
561 }
562
563 #[test]
564 fn test_strip_ansi_escapes() {
565 let input = "\x1b[31mred\x1b[0m normal";
566 assert_eq!(strip_ansi_escapes(input), "red normal");
567 }
568
569 #[test]
570 fn test_strip_ansi_complex() {
571 let input = "\x1b[1;31mBold Red\x1b[0m \x1b[4munderlined\x1b[0m";
572 assert_eq!(strip_ansi_escapes(input), "Bold Red underlined");
573 }
574
575 #[test]
576 fn test_strip_ansi_no_escapes() {
577 assert_eq!(strip_ansi_escapes("plain text"), "plain text");
578 }
579
580 #[test]
581 fn test_export_html_basic() {
582 let opts = ExportHtmlOptions {
583 code: "Hello World".into(),
584 ..Default::default()
585 };
586 let html = export_html(&opts);
587 assert!(html.contains("<!DOCTYPE html>"));
588 assert!(html.contains("Hello World"));
589 assert!(html.contains("rich-html"));
590 assert!(html.contains("font-family"));
591 }
592
593 #[test]
594 fn test_export_html_escapes_markup() {
595 let opts = ExportHtmlOptions {
596 code: "<script>alert('xss')</script>".into(),
597 ..Default::default()
598 };
599 let html = export_html(&opts);
600 assert!(!html.contains("<script>"));
601 assert!(html.contains("<script>"));
602 }
603
604 #[test]
605 fn test_export_svg_basic() {
606 let opts = ExportSvgOptions {
607 code: "SVG text".into(),
608 ..Default::default()
609 };
610 let svg = export_svg(&opts);
611 assert!(svg.contains("<svg"));
612 assert!(svg.contains("SVG text"));
613 assert!(svg.contains("rich-svg"));
614 }
615
616 #[test]
617 fn test_export_svg_theme() {
618 let opts = ExportSvgOptions {
619 code: "test".into(),
620 theme: EXPORT_THEME_SVG,
621 ..Default::default()
622 };
623 let svg = export_svg(&opts);
624 assert!(svg.contains("rgb(255,255,255)")); }
626
627 #[test]
628 fn test_export_text_strip() {
629 let opts = ExportTextOptions {
630 text: "\x1b[1;32mGreen Bold\x1b[0m".into(),
631 strip_ansi: true,
632 };
633 assert_eq!(export_text(&opts), "Green Bold");
634 }
635
636 #[test]
637 fn test_export_text_keep() {
638 let ansi = "\x1b[31mred\x1b[0m";
639 let opts = ExportTextOptions {
640 text: ansi.into(),
641 strip_ansi: false,
642 };
643 assert_eq!(export_text(&opts), ansi);
644 }
645
646 #[test]
647 fn test_rgb_for_8bit_standard() {
648 assert_eq!(rgb_for_8bit(0), (0, 0, 0)); assert_eq!(rgb_for_8bit(1), (128, 0, 0)); assert_eq!(rgb_for_8bit(15), (255, 255, 255)); }
652
653 #[test]
654 fn test_rgb_for_8bit_cube() {
655 assert_eq!(rgb_for_8bit(16), (0, 0, 0));
656 let idx = 16 + 1 * 36 + 2 * 6 + 3; assert_eq!(rgb_for_8bit(idx), (51, 102, 153));
658 }
659
660 #[test]
661 fn test_rgb_for_8bit_greyscale() {
662 assert_eq!(rgb_for_8bit(232), (8, 8, 8));
663 assert_eq!(rgb_for_8bit(255), (238, 238, 238));
664 }
665
666 #[test]
667 fn test_segments_to_html_styled() {
668 let seg = Segment::styled(
669 "hello",
670 Style::new()
671 .color(Color::parse("red").unwrap())
672 .bold(true),
673 );
674 let html = segments_to_html(&[seg], &ExportTheme::default());
675 assert!(html.contains("color:rgb(128,0,0)"));
676 assert!(html.contains("font-weight:bold"));
677 assert!(html.contains("hello"));
678 }
679
680 #[test]
681 fn test_segments_to_html_plain() {
682 let seg = Segment::new("plain");
683 let html = segments_to_html(&[seg], &ExportTheme::default());
684 assert!(html.contains("plain"));
685 assert!(html.contains("color:rgb(255,255,255)"));
686 }
687
688 #[test]
689 fn test_export_theme_defaults() {
690 let theme = ExportTheme::default();
691 assert_eq!(theme.background, (0, 0, 0));
692 assert_eq!(theme.foreground, (255, 255, 255));
693 }
694
695 #[test]
696 fn test_save_to_disk() {
697 let dir = std::env::temp_dir();
698 let path = dir.join("test_export.html");
699 let opts = ExportHtmlOptions {
700 code: "test".into(),
701 ..Default::default()
702 };
703 save_html(&path, &opts).unwrap();
704 let contents = std::fs::read_to_string(&path).unwrap();
705 assert!(contents.contains("test"));
706 std::fs::remove_file(&path).unwrap();
707 }
708}