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
231 .replace("{code}", &escape_html(&options.code))
232 .replace("{font_family}", &escape_html(&options.font_family))
233 .replace("{font_size}", &escape_html(&options.font_size.to_string()))
234 .replace(
235 "{line_height}",
236 &escape_html(&options.line_height.to_string()),
237 )
238 .replace(
239 "{foreground}",
240 &escape_html(&format!("rgb({},{},{})", fg.0, fg.1, fg.2)),
241 )
242 .replace(
243 "{background}",
244 &escape_html(&format!("rgb({},{},{})", bg.0, bg.1, bg.2)),
245 )
246}
247
248pub fn save_html(
252 path: impl AsRef<std::path::Path>,
253 options: &ExportHtmlOptions,
254) -> std::io::Result<()> {
255 std::fs::write(path.as_ref(), export_html(options))
256}
257
258pub 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}">
264<style>
265 text {{ font-family: {font_family}; font-size: {font_size}px; }}
266</style>
267<rect width="100%" height="100%" fill="{background}"/>
268<text x="0" y="{baseline}" xml:space="preserve">
269{code}
270</text>
271</svg>"#;
272
273#[derive(Debug, Clone)]
275pub struct ExportSvgOptions {
276 pub font_family: String,
278 pub font_size: u32,
280 pub theme: ExportTheme,
282 pub code: String,
284 pub width: u32,
286 pub height: u32,
288}
289
290impl Default for ExportSvgOptions {
291 fn default() -> Self {
292 Self {
293 font_family: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace".into(),
294 font_size: 14,
295 theme: EXPORT_THEME_SVG,
296 code: String::new(),
297 width: 800,
298 height: 600,
299 }
300 }
301}
302
303pub fn export_svg(options: &ExportSvgOptions) -> String {
317 let fg = options.theme.foreground;
318 let bg = options.theme.background;
319 let baseline = options.font_size as f64 * 1.2; CONSOLE_SVG_FORMAT
323 .replace("{code}", &escape_xml(&options.code))
324 .replace("{font_family}", &escape_xml(&options.font_family))
325 .replace("{font_size}", &escape_xml(&options.font_size.to_string()))
326 .replace("{width}", &escape_xml(&options.width.to_string()))
327 .replace("{height}", &escape_xml(&options.height.to_string()))
328 .replace(
329 "{background}",
330 &escape_xml(&format!("rgb({},{},{})", bg.0, bg.1, bg.2)),
331 )
332 .replace("{baseline}", &escape_xml(&format!("{:.0}", baseline)))
333 .replace("{foreground}", &format!("rgb({},{},{})", fg.0, fg.1, fg.2))
334}
335
336pub fn save_svg(
338 path: impl AsRef<std::path::Path>,
339 options: &ExportSvgOptions,
340) -> std::io::Result<()> {
341 std::fs::write(path.as_ref(), export_svg(options))
342}
343
344#[derive(Debug, Clone)]
350pub struct ExportTextOptions {
351 pub text: String,
353 pub strip_ansi: bool,
355}
356
357impl Default for ExportTextOptions {
358 fn default() -> Self {
359 Self {
360 text: String::new(),
361 strip_ansi: true,
362 }
363 }
364}
365
366pub fn export_text(options: &ExportTextOptions) -> String {
368 if options.strip_ansi {
369 strip_ansi_escapes(&options.text)
370 } else {
371 options.text.clone()
372 }
373}
374
375pub fn save_text(
377 path: impl AsRef<std::path::Path>,
378 options: &ExportTextOptions,
379) -> std::io::Result<()> {
380 std::fs::write(path.as_ref(), export_text(options))
381}
382
383pub fn escape_html(text: &str) -> String {
389 text.replace('&', "&")
390 .replace('<', "<")
391 .replace('>', ">")
392 .replace('"', """)
393}
394
395pub fn escape_xml(text: &str) -> String {
397 text.replace('&', "&")
398 .replace('<', "<")
399 .replace('>', ">")
400 .replace('"', """)
401 .replace('\'', "'")
402}
403
404pub fn strip_ansi_escapes(text: &str) -> String {
414 let mut result = String::with_capacity(text.len());
415 let mut chars = text.chars().peekable();
416
417 while let Some(ch) = chars.next() {
418 if ch == '\x1b' {
419 match chars.peek() {
420 Some(&'[') => {
421 chars.next(); while let Some(&c) = chars.peek() {
424 if c.is_ascii_digit() || c == ';' || c == '?' || c == '!' || c == '>' {
425 chars.next();
426 } else {
427 break;
428 }
429 }
430 while let Some(&c) = chars.peek() {
432 if (0x20..=0x2F).contains(&(c as u32)) {
433 chars.next();
434 } else {
435 break;
436 }
437 }
438 chars.next();
440 }
441 Some(&']') | Some(&'P') | Some(&'_') | Some(&'^') | Some(&'X') => {
443 chars.next(); while let Some(&c) = chars.peek() {
445 if c == '\x07' {
446 chars.next();
447 break;
448 } else if c == '\x1b' {
449 chars.next();
450 if chars.peek() == Some(&'\\') {
451 chars.next();
452 break;
453 }
454 } else {
455 chars.next();
456 }
457 }
458 }
459 _ => {}
461 }
462 } else {
463 result.push(ch);
464 }
465 }
466
467 result
468}
469
470pub fn segments_to_html(segments: &[Segment], theme: &ExportTheme) -> String {
475 let mut html = String::new();
476
477 for seg in segments {
478 let mut styles: Vec<String> = Vec::new();
479
480 if let Some(ref style) = seg.style {
481 if let Some(color) = &style.color {
483 let rgb = resolve_color(color, theme);
484 styles.push(format!("color:rgb({},{},{})", rgb.0, rgb.1, rgb.2));
485 } else {
486 let fg = theme.foreground;
488 styles.push(format!("color:rgb({},{},{})", fg.0, fg.1, fg.2));
489 }
490
491 if let Some(bgcolor) = &style.bgcolor {
493 let rgb = resolve_color(bgcolor, theme);
494 styles.push(format!(
495 "background-color:rgb({},{},{})",
496 rgb.0, rgb.1, rgb.2
497 ));
498 }
499
500 let attrs = &style.attributes;
502 if attrs.get(crate::style::Attributes::BOLD) {
503 styles.push("font-weight:bold".into());
504 }
505 if attrs.get(crate::style::Attributes::ITALIC) {
506 styles.push("font-style:italic".into());
507 }
508 if attrs.get(crate::style::Attributes::UNDERLINE)
509 || attrs.get(crate::style::Attributes::UNDERLINE2)
510 {
511 styles.push("text-decoration:underline".into());
512 }
513 if attrs.get(crate::style::Attributes::STRIKE) {
514 styles.push("text-decoration:line-through".into());
515 }
516 if attrs.get(crate::style::Attributes::DIM) {
517 styles.push("opacity:0.7".into());
518 }
519 if attrs.get(crate::style::Attributes::CONCEAL) {
520 styles.push("visibility:hidden".into());
521 }
522
523 if let Some(ref link) = style.link {
525 let escaped_link = escape_html(link);
526 let style_attr = if styles.is_empty() {
527 String::new()
528 } else {
529 format!(" style=\"{}\"", styles.join("; "))
530 };
531 html.push_str(&format!(
532 "<a href=\"{}\"{}>{}</a>",
533 escaped_link,
534 style_attr,
535 escape_html(&seg.text)
536 ));
537 continue; }
539 } else {
540 let fg = theme.foreground;
542 styles.push(format!("color:rgb({},{},{})", fg.0, fg.1, fg.2));
543 }
544
545 if styles.is_empty() {
547 html.push_str(&escape_html(&seg.text));
548 } else {
549 let style_attr = styles.join("; ");
550 html.push_str(&format!(
551 "<span style=\"{}\">{}</span>",
552 style_attr,
553 escape_html(&seg.text)
554 ));
555 }
556 }
557
558 html
559}
560
561pub fn segments_to_svg(segments: &[Segment], theme: &ExportTheme) -> String {
566 let mut svg = String::new();
567
568 for seg in segments {
569 let mut styles: Vec<String> = Vec::new();
570
571 if let Some(ref style) = seg.style {
572 if let Some(color) = &style.color {
574 let rgb = resolve_color(color, theme);
575 styles.push(format!("fill:rgb({},{},{})", rgb.0, rgb.1, rgb.2));
576 } else {
577 let fg = theme.foreground;
578 styles.push(format!("fill:rgb({},{},{})", fg.0, fg.1, fg.2));
579 }
580
581 let attrs = &style.attributes;
583 if attrs.get(crate::style::Attributes::BOLD) {
584 styles.push("font-weight:bold".into());
585 }
586 if attrs.get(crate::style::Attributes::ITALIC) {
587 styles.push("font-style:italic".into());
588 }
589 if attrs.get(crate::style::Attributes::UNDERLINE)
590 || attrs.get(crate::style::Attributes::UNDERLINE2)
591 {
592 styles.push("text-decoration:underline".into());
593 }
594 if attrs.get(crate::style::Attributes::STRIKE) {
595 styles.push("text-decoration:line-through".into());
596 }
597 if attrs.get(crate::style::Attributes::DIM) {
598 styles.push("opacity:0.7".into());
599 }
600 if attrs.get(crate::style::Attributes::CONCEAL) {
601 styles.push("visibility:hidden".into());
602 }
603 } else {
604 let fg = theme.foreground;
605 styles.push(format!("fill:rgb({},{},{})", fg.0, fg.1, fg.2));
606 }
607
608 if styles.is_empty() {
609 svg.push_str(&escape_xml(&seg.text));
610 } else {
611 let style_attr = styles.join("; ");
612 svg.push_str(&format!(
613 "<tspan style=\"{}\">{}</tspan>",
614 style_attr,
615 escape_xml(&seg.text)
616 ));
617 }
618 }
619
620 svg
621}
622
623fn resolve_color(color: &Color, theme: &ExportTheme) -> (u8, u8, u8) {
625 match color.color_type {
626 crate::color::ColorType::Default => theme.foreground,
627 crate::color::ColorType::Standard => {
628 let idx = color.number.unwrap_or(7) as usize % 16;
629 theme.ansi_colors[idx]
630 }
631 crate::color::ColorType::EightBit => {
632 let idx = color.number.unwrap_or(0) as usize % 256;
633 rgb_for_8bit(idx)
634 }
635 crate::color::ColorType::TrueColor => {
636 if let Some(ref triplet) = color.triplet {
637 (triplet.0, triplet.1, triplet.2)
638 } else {
639 theme.foreground
640 }
641 }
642 }
643}
644
645fn rgb_for_8bit(index: usize) -> (u8, u8, u8) {
647 if index < 16 {
648 crate::color::STANDARD_PALETTE
650 .get(index)
651 .copied()
652 .unwrap_or((0, 0, 0))
653 } else if index < 232 {
654 let idx = index - 16;
656 let r = (idx / 36) as u8 * 51;
657 let g = ((idx / 6) % 6) as u8 * 51;
658 let b = (idx % 6) as u8 * 51;
659 (r, g, b)
660 } else {
661 let g = ((index - 232) * 10 + 8) as u8;
663 (g, g, g)
664 }
665}
666
667#[cfg(test)]
672mod tests {
673 use super::*;
674 use crate::color::Color;
675 use crate::style::Style;
676
677 #[test]
678 fn test_escape_html_basic() {
679 assert_eq!(escape_html("<hello>"), "<hello>");
680 assert_eq!(escape_html("\"a\" & 'b'"), ""a" & 'b'");
681 }
682
683 #[test]
684 fn test_strip_ansi_escapes() {
685 let input = "\x1b[31mred\x1b[0m normal";
686 assert_eq!(strip_ansi_escapes(input), "red normal");
687 }
688
689 #[test]
690 fn test_strip_ansi_complex() {
691 let input = "\x1b[1;31mBold Red\x1b[0m \x1b[4munderlined\x1b[0m";
692 assert_eq!(strip_ansi_escapes(input), "Bold Red underlined");
693 }
694
695 #[test]
696 fn test_strip_ansi_no_escapes() {
697 assert_eq!(strip_ansi_escapes("plain text"), "plain text");
698 }
699
700 #[test]
701 fn test_export_html_basic() {
702 let opts = ExportHtmlOptions {
703 code: "Hello World".into(),
704 ..Default::default()
705 };
706 let html = export_html(&opts);
707 assert!(html.contains("<!DOCTYPE html>"));
708 assert!(html.contains("Hello World"));
709 assert!(html.contains("rich-html"));
710 assert!(html.contains("font-family"));
711 }
712
713 #[test]
714 fn test_export_html_escapes_markup() {
715 let opts = ExportHtmlOptions {
716 code: "<script>alert('xss')</script>".into(),
717 ..Default::default()
718 };
719 let html = export_html(&opts);
720 assert!(!html.contains("<script>"));
721 assert!(html.contains("<script>"));
722 }
723
724 #[test]
725 fn test_export_svg_basic() {
726 let opts = ExportSvgOptions {
727 code: "SVG text".into(),
728 ..Default::default()
729 };
730 let svg = export_svg(&opts);
731 assert!(svg.contains("<svg"));
732 assert!(svg.contains("SVG text"));
733 assert!(svg.contains("rich-svg"));
734 }
735
736 #[test]
737 fn test_export_svg_theme() {
738 let opts = ExportSvgOptions {
739 code: "test".into(),
740 theme: EXPORT_THEME_SVG,
741 ..Default::default()
742 };
743 let svg = export_svg(&opts);
744 assert!(svg.contains("rgb(255,255,255)")); }
746
747 #[test]
748 fn test_export_text_strip() {
749 let opts = ExportTextOptions {
750 text: "\x1b[1;32mGreen Bold\x1b[0m".into(),
751 strip_ansi: true,
752 };
753 assert_eq!(export_text(&opts), "Green Bold");
754 }
755
756 #[test]
757 fn test_export_text_keep() {
758 let ansi = "\x1b[31mred\x1b[0m";
759 let opts = ExportTextOptions {
760 text: ansi.into(),
761 strip_ansi: false,
762 };
763 assert_eq!(export_text(&opts), ansi);
764 }
765
766 #[test]
767 fn test_rgb_for_8bit_standard() {
768 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)); }
772
773 #[test]
774 fn test_rgb_for_8bit_cube() {
775 assert_eq!(rgb_for_8bit(16), (0, 0, 0));
776 let idx = 16 + 1 * 36 + 2 * 6 + 3; assert_eq!(rgb_for_8bit(idx), (51, 102, 153));
778 }
779
780 #[test]
781 fn test_rgb_for_8bit_greyscale() {
782 assert_eq!(rgb_for_8bit(232), (8, 8, 8));
783 assert_eq!(rgb_for_8bit(255), (238, 238, 238));
784 }
785
786 #[test]
787 fn test_segments_to_html_styled() {
788 let seg = Segment::styled(
789 "hello",
790 Style::new().color(Color::parse("red").unwrap()).bold(true),
791 );
792 let html = segments_to_html(&[seg], &ExportTheme::default());
793 assert!(html.contains("color:rgb(128,0,0)"));
794 assert!(html.contains("font-weight:bold"));
795 assert!(html.contains("hello"));
796 }
797
798 #[test]
799 fn test_segments_to_html_plain() {
800 let seg = Segment::new("plain");
801 let html = segments_to_html(&[seg], &ExportTheme::default());
802 assert!(html.contains("plain"));
803 assert!(html.contains("color:rgb(255,255,255)"));
804 }
805
806 #[test]
807 fn test_export_theme_defaults() {
808 let theme = ExportTheme::default();
809 assert_eq!(theme.background, (0, 0, 0));
810 assert_eq!(theme.foreground, (255, 255, 255));
811 }
812
813 #[test]
814 fn test_segments_to_svg_styled() {
815 let seg = Segment::styled(
816 "hello",
817 Style::new().color(Color::parse("red").unwrap()).bold(true),
818 );
819 let svg = segments_to_svg(&[seg], &ExportTheme::default());
820 assert!(svg.contains("fill:rgb(128,0,0)"));
821 assert!(svg.contains("font-weight:bold"));
822 assert!(svg.contains("hello"));
823 assert!(svg.contains("<tspan"));
824 }
825
826 #[test]
827 fn test_segments_to_svg_plain() {
828 let seg = Segment::new("plain");
829 let svg = segments_to_svg(&[seg], &ExportTheme::default());
830 assert!(svg.contains("plain"));
831 assert!(svg.contains("fill:rgb(255,255,255)"));
832 }
833
834 #[test]
835 fn test_save_to_disk() {
836 let dir = std::env::temp_dir();
837 let path = dir.join("test_export.html");
838 let opts = ExportHtmlOptions {
839 code: "test".into(),
840 ..Default::default()
841 };
842 save_html(&path, &opts).unwrap();
843 let contents = std::fs::read_to_string(&path).unwrap();
844 assert!(contents.contains("test"));
845 std::fs::remove_file(&path).unwrap();
846 }
847}