1use std::collections::HashMap;
20use std::fmt::Write as _;
21
22use rich::Segment;
23use rich::export::{CONSOLE_HTML_FORMAT, ExportFormatError, format_template};
24use rich::protocol::RegionRole;
25use rich::svg::CONSOLE_SVG_FORMAT;
26use rich::terminal_theme::{DEFAULT_TERMINAL_THEME, SVG_EXPORT_THEME, TerminalTheme};
27
28use super::regions::{Region, role_name};
29use super::{Frame, NEWLINE, StyleId};
30
31#[derive(Clone, Debug)]
33pub struct HtmlOptions<'a> {
34 pub theme: &'a TerminalTheme,
37 pub inline_styles: bool,
40 pub regions: bool,
43}
44
45impl Default for HtmlOptions<'_> {
46 fn default() -> Self {
47 HtmlOptions {
48 theme: &DEFAULT_TERMINAL_THEME,
49 inline_styles: true,
50 regions: true,
51 }
52 }
53}
54
55#[derive(Clone, Debug)]
57pub struct SvgOptions<'a> {
58 pub theme: &'a TerminalTheme,
60 pub title: &'a str,
62 pub unique_id: &'a str,
64 pub width: Option<usize>,
66 pub window: bool,
69 pub cursor: Option<(usize, usize)>,
71 pub caption: Option<&'a str>,
73 pub font_aspect_ratio: f64,
75}
76
77impl<'a> SvgOptions<'a> {
78 #[must_use]
80 pub fn theme(mut self, theme: &'a TerminalTheme) -> Self {
81 self.theme = theme;
82 self
83 }
84
85 #[must_use]
87 pub fn title(mut self, title: &'a str) -> Self {
88 self.title = title;
89 self
90 }
91
92 #[must_use]
94 pub fn unique_id(mut self, unique_id: &'a str) -> Self {
95 self.unique_id = unique_id;
96 self
97 }
98
99 #[must_use]
101 pub fn width(mut self, width: usize) -> Self {
102 self.width = Some(width);
103 self
104 }
105
106 #[must_use]
108 pub fn window(mut self, on: bool) -> Self {
109 self.window = on;
110 self
111 }
112
113 #[must_use]
115 pub fn cursor(mut self, column: usize, row: usize) -> Self {
116 self.cursor = Some((column, row));
117 self
118 }
119
120 #[must_use]
122 pub fn caption(mut self, caption: &'a str) -> Self {
123 self.caption = Some(caption);
124 self
125 }
126
127 #[must_use]
129 pub fn font_aspect_ratio(mut self, ratio: f64) -> Self {
130 self.font_aspect_ratio = ratio;
131 self
132 }
133}
134
135impl Default for SvgOptions<'_> {
136 fn default() -> Self {
137 SvgOptions {
138 theme: &SVG_EXPORT_THEME,
139 title: "Rich",
140 unique_id: "terminal",
141 width: None,
142 window: true,
143 cursor: None,
144 caption: None,
145 font_aspect_ratio: 0.61,
146 }
147 }
148}
149
150fn escape(text: &str) -> String {
152 text.replace('&', "&")
153 .replace('<', "<")
154 .replace('>', ">")
155 .replace('"', """)
156 .replace('\'', "'")
157}
158
159struct Piece {
161 text: String,
162 style: StyleId,
163}
164
165#[derive(Default)]
169struct Classes {
170 order: Vec<(String, usize)>,
171 index: HashMap<String, usize>,
172}
173
174impl Classes {
175 fn number(&mut self, rule: &str) -> usize {
176 if let Some(n) = self.index.get(rule) {
177 return *n;
178 }
179 let n = self.order.len() + 1;
180 self.order.push((rule.to_string(), n));
181 self.index.insert(rule.to_string(), n);
182 n
183 }
184}
185
186impl Frame {
187 pub fn to_html(&self, options: &HtmlOptions<'_>) -> String {
190 self.to_html_with(options, CONSOLE_HTML_FORMAT)
191 .expect("the built-in template is valid")
192 }
193
194 pub fn to_html_with(
205 &self,
206 options: &HtmlOptions<'_>,
207 code_format: &str,
208 ) -> Result<String, ExportFormatError> {
209 let theme = options.theme;
210 let mut classes = Classes::default();
211 let mut code = String::new();
212 let regions = if options.regions && self.has_regions() {
213 self.regions()
214 } else {
215 Vec::new()
216 };
217 if regions.is_empty() {
218 for piece in self.pieces() {
219 code.push_str(&self.piece_html(&piece, options, &mut classes));
220 }
221 } else {
222 self.region_html(®ions, options, &mut classes, &mut code);
223 }
224 let stylesheet = classes
225 .order
226 .iter()
227 .filter(|(rule, _)| !rule.is_empty())
228 .map(|(rule, number)| format!(".r{number} {{{rule}}}"))
229 .collect::<Vec<_>>()
230 .join("\n");
231 format_template(
232 code_format,
233 &[
234 ("code", &code),
235 ("stylesheet", &stylesheet),
236 ("foreground", &theme.foreground.hex()),
237 ("background", &theme.background.hex()),
238 ],
239 )
240 }
241
242 fn pieces(&self) -> Vec<Piece> {
246 let mut pieces: Vec<Piece> = Vec::new();
247 let mut push = |text: &str, style: StyleId| match pieces.last_mut() {
248 Some(last) if last.style == style => last.text.push_str(text),
249 _ => pieces.push(Piece {
250 text: text.to_string(),
251 style,
252 }),
253 };
254 for row in 0..self.height() {
255 let runs = self.row(row);
256 for run in runs {
257 push(self.run_text(run), run.style);
258 if run.flags & NEWLINE != 0 {
259 push("\n", run.style);
260 }
261 }
262 let breaks = row + 1 < self.height() || self.ends_with_newline();
263 if breaks && runs.last().is_none_or(|run| run.flags & NEWLINE == 0) {
264 push("\n", StyleId::NONE);
265 }
266 }
267 pieces
268 }
269
270 fn piece_html(
273 &self,
274 piece: &Piece,
275 options: &HtmlOptions<'_>,
276 classes: &mut Classes,
277 ) -> String {
278 let mut text = escape(&piece.text);
279 let Some(style) = self.styles.get(piece.style).filter(|s| !s.is_null()) else {
280 return text;
281 };
282 let rule = style.get_html_style(options.theme);
283 let link = style.link().map(escape);
284 if options.inline_styles {
285 if let Some(link) = link {
286 text = format!("<a href=\"{link}\">{text}</a>");
287 }
288 if !rule.is_empty() {
289 text = format!("<span style=\"{rule}\">{text}</span>");
290 }
291 text
292 } else {
293 let number = classes.number(&rule);
294 match link {
295 Some(link) => format!("<a class=\"r{number}\" href=\"{link}\">{text}</a>"),
296 None => format!("<span class=\"r{number}\">{text}</span>"),
297 }
298 }
299 }
300
301 fn region_html(
305 &self,
306 regions: &[Region],
307 options: &HtmlOptions<'_>,
308 classes: &mut Classes,
309 code: &mut String,
310 ) {
311 let chain = |innermost: Option<usize>| {
312 let mut chain = Vec::new();
313 let mut at = innermost;
314 while let Some(index) = at {
315 chain.push(index);
316 at = regions[index].parent;
317 }
318 chain.reverse();
319 chain
320 };
321 let mut started = vec![false; regions.len()];
322 for row in 0..self.height() {
323 let mut open: Vec<usize> = Vec::new();
324 let range = self.row_range(row);
325 let mut index = range.start;
326 while index < range.end {
327 let run = self.runs[index];
328 let innermost = self.innermost(index);
329 let mut piece = Piece {
330 text: self.run_text(&run).to_string(),
331 style: run.style,
332 };
333 index += 1;
334 while index < range.end
335 && self.runs[index].style == run.style
336 && self.innermost(index) == innermost
337 {
338 piece.text.push_str(self.run_text(&self.runs[index]));
339 index += 1;
340 }
341 if piece.text.is_empty() {
342 continue;
343 }
344 let wanted = chain(innermost);
345 let keep = open.iter().zip(&wanted).take_while(|(a, b)| a == b).count();
346 for _ in keep..open.len() {
347 code.push_str("</span>");
348 }
349 open.truncate(keep);
350 for ®ion in &wanted[keep..] {
351 code.push_str(&open_tag(®ions[region], region, !started[region]));
352 started[region] = true;
353 open.push(region);
354 }
355 code.push_str(&self.piece_html(&piece, options, classes));
356 }
357 for _ in 0..open.len() {
358 code.push_str("</span>");
359 }
360 if row + 1 < self.height() || self.ends_with_newline() {
361 code.push('\n');
362 }
363 }
364 }
365
366 pub fn to_svg(&self, options: &SvgOptions<'_>) -> String {
369 let theme = options.theme;
370 let unique_id = options.unique_id;
371 let width = options.width.unwrap_or_else(|| self.width());
372 let char_height = CHAR_HEIGHT;
373 let char_width = char_height * options.font_aspect_ratio;
374 let line_height = char_height * 1.22;
375 let padding_top = if options.window {
376 PADDING_TOP
377 } else {
378 PADDING_SIDE
379 };
380 let caption_height = if options.caption.is_some() {
381 CAPTION
382 } else {
383 0
384 };
385 let padding_width = PADDING_SIDE + PADDING_SIDE;
386 let padding_height = padding_top + PADDING_SIDE + caption_height;
387
388 let mut classes = Classes::default();
389 let mut backgrounds = String::new();
390 let mut matrix = String::new();
391 let height = self.height().max(1);
392 for y in 0..self.height() {
393 let line: Vec<Segment> = self
394 .row(y)
395 .iter()
396 .filter(|run| run.len > 0)
397 .map(|run| Segment::new(self.run_text(run), self.styles.get(run.style).cloned()))
398 .collect();
399 let mut line = Segment::adjust_line_length(&line, width, None);
400 if y + 1 < self.height() || self.ends_with_newline() {
403 line.push(Segment::line());
404 }
405 let mut x = 0usize;
406 for segment in line {
407 let style = segment.style.clone().unwrap_or_default();
408 let class = classes.number(&style.get_svg_style(theme));
409 let (has_background, background) = if style.attr(6) == Some(true) {
410 let hex = style
411 .color()
412 .map_or(theme.foreground, |c| theme.resolve(c, true))
413 .hex();
414 (true, hex)
415 } else {
416 let has = style.bgcolor().is_some_and(|c| !c.is_default());
417 let hex = style
418 .bgcolor()
419 .map_or(theme.background, |c| theme.resolve(c, false))
420 .hex();
421 (has, hex)
422 };
423 let cells = rich::cells::cell_len(&segment.text);
424 if has_background {
425 let _ = write!(
426 backgrounds,
427 r#"<rect fill="{background}" x="{}" y="{}" width="{}" height="{}" shape-rendering="crispEdges"/>"#,
428 fmt_g(x as f64 * char_width),
429 fmt_g(y as f64 * line_height + 1.5),
430 fmt_g(char_width * cells as f64),
431 fmt_g(line_height + 0.25),
432 );
433 }
434 if !segment.text.chars().all(|c| c == ' ') {
435 let length = if segment.text == "\n" { 1 } else { cells };
438 let text = format!(
439 r#"<text class="{unique_id}-r{class}" x="{}" y="{}" textLength="{}" clip-path="url(#{unique_id}-line-{y})">{}</text>"#,
440 fmt_g(x as f64 * char_width),
441 fmt_g(y as f64 * line_height + char_height),
442 fmt_g(char_width * length as f64),
443 escape_text(&segment.text),
444 );
445 match style.link() {
446 Some(link) => {
447 let _ = write!(matrix, r#"<a href="{}">{text}</a>"#, escape_href(link));
448 }
449 None => matrix.push_str(&text),
450 }
451 }
452 x += cells;
453 }
454 }
455 if let Some((column, row)) = options.cursor {
456 let _ = write!(
457 matrix,
458 r#"<rect x="{}" y="{}" width="{}" height="{}" fill="{}" opacity="0.7"/>"#,
459 fmt_g(column as f64 * char_width),
460 fmt_g(row as f64 * line_height + 1.5),
461 fmt_g(char_width),
462 fmt_g(line_height + 0.25),
463 theme.foreground.hex()
464 );
465 }
466 let last_y = height - 1;
467 let lines = (0..last_y)
468 .map(|line_no| {
469 format!(
470 "<clipPath id=\"{unique_id}-line-{line_no}\">\n <rect x=\"0\" y=\"{}\" width=\"{}\" height=\"{}\"/>\n </clipPath>",
471 fmt_g(line_no as f64 * line_height + 1.5),
472 fmt_g(char_width * width as f64),
473 fmt_g(line_height + 0.25),
474 )
475 })
476 .collect::<Vec<_>>()
477 .join("\n");
478 let styles = classes
479 .order
480 .iter()
481 .map(|(css, n)| format!(".{unique_id}-r{n} {{ {css} }}"))
482 .collect::<Vec<_>>()
483 .join("\n");
484 let terminal_width = (width as f64 * char_width + padding_width as f64).ceil();
485 let terminal_height = (last_y as f64 + 1.0) * line_height + padding_height as f64;
486 let mut chrome = if options.window {
487 format!(
488 r#"<rect fill="{}" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="{MARGIN}" y="{MARGIN}" width="{}" height="{}" rx="8"/>"#,
489 theme.background.hex(),
490 terminal_width as i64,
491 fmt_g(terminal_height),
492 )
493 } else {
494 format!(
495 r#"<rect fill="{}" x="{MARGIN}" y="{MARGIN}" width="{}" height="{}"/>"#,
496 theme.background.hex(),
497 terminal_width as i64,
498 fmt_g(terminal_height),
499 )
500 };
501 if options.window {
502 if !options.title.is_empty() {
503 let _ = write!(
504 chrome,
505 r#"<text class="{unique_id}-title" fill="{}" text-anchor="middle" x="{}" y="{}">{}</text>"#,
506 theme.foreground.hex(),
507 (terminal_width / 2.0).floor() as i64,
508 MARGIN + CHAR_HEIGHT as i64 + 6,
509 escape_text(options.title),
510 );
511 }
512 chrome.push_str(
513 "\n <g transform=\"translate(26,22)\">\n <circle cx=\"0\" cy=\"0\" r=\"7\" fill=\"#ff5f57\"/>\n <circle cx=\"22\" cy=\"0\" r=\"7\" fill=\"#febc2e\"/>\n <circle cx=\"44\" cy=\"0\" r=\"7\" fill=\"#28c840\"/>\n </g>\n ",
514 );
515 }
516 if let Some(caption) = options.caption {
517 let _ = write!(
518 chrome,
519 r#"<text class="{unique_id}-title" fill="{}" text-anchor="middle" x="{}" y="{}" font-weight="normal">{}</text>"#,
520 theme.foreground.hex(),
521 (terminal_width / 2.0).floor() as i64,
522 fmt_g(MARGIN as f64 + terminal_height - PADDING_SIDE as f64 - 10.0),
523 escape_text(caption),
524 );
525 }
526 let int = |value: i64| value.to_string();
527 format_template(
528 CONSOLE_SVG_FORMAT,
529 &[
530 ("unique_id", unique_id),
531 ("char_width", &fmt_str(char_width)),
532 ("char_height", &int(CHAR_HEIGHT as i64)),
533 ("line_height", &fmt_str(line_height)),
534 ("terminal_width", &fmt_str(char_width * width as f64 - 1.0)),
535 (
536 "terminal_height",
537 &fmt_str((last_y as f64 + 1.0) * line_height - 1.0),
538 ),
539 ("width", &py_int_plus(terminal_width, MARGIN * 2)),
540 ("height", &fmt_str(terminal_height + (MARGIN * 2) as f64)),
541 ("terminal_x", &int(MARGIN + PADDING_SIDE)),
542 ("terminal_y", &int(MARGIN + padding_top)),
543 ("styles", &styles),
544 ("chrome", &chrome),
545 ("backgrounds", &backgrounds),
546 ("matrix", &matrix),
547 ("lines", &lines),
548 ],
549 )
550 .expect("the built-in template is valid")
551 }
552}
553
554fn open_tag(region: &Region, index: usize, first: bool) -> String {
557 let mut tag = format!(
558 "<span class=\"rich-region rich-{}\" data-region=\"{index}\"",
559 escape(&role_name(®ion.role))
560 );
561 if first {
562 let label = region.label.as_deref().map(escape);
563 let aria_label = |tag: &mut String| {
564 if let Some(label) = &label {
565 let _ = write!(tag, " aria-label=\"{label}\"");
566 }
567 };
568 match ®ion.role {
569 RegionRole::Panel => {
570 tag.push_str(" role=\"group\"");
571 aria_label(&mut tag);
572 }
573 RegionRole::Table => {
574 tag.push_str(" role=\"group\" aria-roledescription=\"table\"");
575 aria_label(&mut tag);
576 }
577 RegionRole::TableHeader { column } => {
578 let _ = write!(
579 tag,
580 " role=\"group\" aria-roledescription=\"column header\" data-column=\"{column}\""
581 );
582 }
583 RegionRole::TableFooter { column } => {
584 let _ = write!(
585 tag,
586 " role=\"group\" aria-roledescription=\"column footer\" data-column=\"{column}\""
587 );
588 }
589 RegionRole::TableCell { row, column } => {
590 let _ = write!(
591 tag,
592 " role=\"group\" aria-roledescription=\"cell\" data-row=\"{row}\" data-column=\"{column}\""
593 );
594 }
595 RegionRole::Rule => {
596 tag.push_str(" role=\"separator\"");
597 aria_label(&mut tag);
598 }
599 RegionRole::Heading { level } => {
600 let _ = write!(tag, " role=\"heading\" aria-level=\"{level}\"");
601 aria_label(&mut tag);
602 }
603 RegionRole::Code => {
604 tag.push_str(" role=\"code\"");
605 if let Some(label) = &label {
606 let _ = write!(tag, " data-language=\"{label}\"");
607 }
608 }
609 _ => aria_label(&mut tag),
610 }
611 }
612 tag.push('>');
613 tag
614}
615
616const CHAR_HEIGHT: f64 = 20.0;
617const MARGIN: i64 = 1;
618const PADDING_TOP: i64 = 40;
619const PADDING_SIDE: i64 = 8;
620const CAPTION: i64 = 32;
622
623fn escape_href(link: &str) -> String {
630 let mut href = String::with_capacity(link.len());
631 for c in link.chars() {
632 match c {
633 '\u{0}'..='\u{1f}' | '\u{7f}' | '\u{fffe}' | '\u{ffff}' => {
634 let mut bytes = [0; 4];
635 for byte in c.encode_utf8(&mut bytes).bytes() {
636 let _ = write!(href, "%{byte:02X}");
637 }
638 }
639 c => href.push(c),
640 }
641 }
642 escape(&href)
643}
644
645fn escape_text(text: &str) -> String {
646 let text: String = text
647 .chars()
648 .map(|c| match c {
649 '\t' | '\n' | '\r' => c,
650 '\u{0}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}' => '\u{fffd}',
651 c => c,
652 })
653 .collect();
654 escape(&text).replace(' ', " ")
655}
656
657fn fmt_g(v: f64) -> String {
664 if v == 0.0 {
665 return if v.is_sign_negative() { "-0" } else { "0" }.to_string();
666 }
667 if !v.is_finite() {
668 return py_non_finite(v);
669 }
670 let sci = format!("{v:.5e}");
671 let (mantissa, exp) = split_exp(&sci);
672 if (-4..6).contains(&exp) {
673 let decimals = (5 - exp) as usize;
674 strip_fraction_zeros(format!("{v:.decimals$}"))
675 } else {
676 py_exponent(&strip_fraction_zeros(mantissa.to_string()), exp)
677 }
678}
679
680fn fmt_str(v: f64) -> String {
683 if !v.is_finite() {
684 return py_non_finite(v);
685 }
686 let sci = format!("{v:e}");
687 let (mantissa, exp) = split_exp(&sci);
688 if v != 0.0 && !(-4..16).contains(&exp) {
689 return py_exponent(mantissa, exp);
690 }
691 let s = format!("{v}");
692 if s.contains('.') { s } else { format!("{s}.0") }
693}
694
695fn py_non_finite(v: f64) -> String {
696 if v.is_nan() {
697 "nan".to_string()
698 } else if v > 0.0 {
699 "inf".to_string()
700 } else {
701 "-inf".to_string()
702 }
703}
704
705fn split_exp(sci: &str) -> (&str, i32) {
706 let (mantissa, exp) = sci.split_once('e').unwrap_or((sci, "0"));
707 (mantissa, exp.parse().unwrap_or(0))
708}
709
710fn py_exponent(mantissa: &str, exp: i32) -> String {
711 let sign = if exp < 0 { '-' } else { '+' };
712 format!("{mantissa}e{sign}{:02}", exp.abs())
713}
714
715fn strip_fraction_zeros(s: String) -> String {
716 if s.contains('.') {
717 s.trim_end_matches('0').trim_end_matches('.').to_string()
718 } else {
719 s
720 }
721}
722
723fn py_int_plus(value: f64, add: i64) -> String {
725 (value as i128 + i128::from(add)).to_string()
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731 use crate::frame::render_frame;
732 #[cfg(feature = "markdown")]
733 use rich::markdown::Markdown;
734 use rich::{ColorSystem, Console, Panel, Renderable, Table, Text};
735
736 fn console(width: usize) -> Console {
737 Console::builder()
738 .width(width)
739 .force_terminal(true)
740 .color_system(Some(ColorSystem::Truecolor))
741 .no_color(false)
742 .build()
743 }
744
745 fn segments(console: &Console, renderable: &dyn Renderable) -> Vec<Segment> {
746 let mut segments = renderable.rich_render(console, &console.options());
747 segments.push(Segment::line());
748 segments
749 }
750
751 #[test]
752 fn html_matches_core_without_regions() {
753 let console = console(30);
754 let mut table = Table::new();
755 table.add_column("Name");
756 table.add_column("Size");
757 table.add_row(&["[bold red]a[/]", "1"]);
758 table.add_row(&["b", "[on blue]22[/]"]);
759 let panel = Panel::new(Box::new(Text::from_markup("[italic]hi[/] there").unwrap()));
760 for renderable in [&table as &dyn Renderable, &panel] {
761 let segments = segments(&console, renderable);
762 let frame = Frame::from_segments(&segments);
763 let theme = &DEFAULT_TERMINAL_THEME;
764 assert_eq!(
765 frame.to_html(&HtmlOptions::default()),
766 rich::export::export_html_inline(&segments, theme)
767 );
768 let classes = HtmlOptions {
769 inline_styles: false,
770 ..HtmlOptions::default()
771 };
772 assert_eq!(
773 frame.to_html(&classes),
774 rich::export::export_html_classes(&segments, theme)
775 );
776 }
777 }
778
779 #[test]
780 fn svg_matches_core_without_regions() {
781 let wide = console(24);
782 let mut table = Table::new().title("T");
783 table.add_column("Name");
784 table.add_row(&["[bold red on green]x[/] y"]);
785 let drawn = segments(&wide, &table);
786 let frame = Frame::from_segments(&drawn);
787 let options = SvgOptions {
788 title: "X",
789 unique_id: "test",
790 width: Some(24),
791 ..SvgOptions::default()
792 };
793 assert_eq!(
794 frame.to_svg(&options),
795 rich::svg::export_svg(&drawn, &SVG_EXPORT_THEME, "X", "test", 24)
796 );
797 let path = concat!(
800 env!("CARGO_MANIFEST_DIR"),
801 "/../rich/tests/golden/svg_export.svg"
802 );
803 let Ok(expected) = std::fs::read_to_string(path) else {
804 return;
805 };
806 let expected = expected.replace("\r\n", "\n");
807 let small = console(10);
808 let hi = segments(&small, &Text::from_markup("[bold red]Hi[/] ok").unwrap());
809 let frame = Frame::from_segments(&hi);
810 let options = SvgOptions {
811 title: "X",
812 unique_id: "test",
813 width: Some(10),
814 ..SvgOptions::default()
815 };
816 assert_eq!(frame.to_svg(&options), expected);
817 }
818
819 #[test]
820 fn links_survive_in_html_and_svg() {
821 let console = console(30);
822 let text = Text::from_markup("see [link=https://x.test/?a=1&b=2]docs[/link] now").unwrap();
823 let frame = Frame::from_segments(&segments(&console, &text));
824 let html = frame.to_html(&HtmlOptions::default());
825 assert!(
826 html.contains(r#"<a href="https://x.test/?a=1&b=2">docs</a>"#),
827 "{html}"
828 );
829 let svg = frame.to_svg(&SvgOptions::default());
830 assert!(
831 svg.contains(r#"<a href="https://x.test/?a=1&b=2"><text"#),
832 "{svg}"
833 );
834 let regions = frame.regions();
835 assert_eq!(regions.len(), 1);
836 assert_eq!(regions[0].role, RegionRole::Link);
837 assert_eq!(regions[0].spans[0].columns, 4..8);
838 }
839
840 #[test]
841 fn a_link_with_control_characters_keeps_the_svg_well_formed() {
842 let style = rich::Style::new().with_link("http://a/\u{1}b\u{1b}]8;;x\u{7f}");
843 let frame = Frame::from_segments(&[Segment::new("go", Some(style))]);
844 let svg = frame.to_svg(&SvgOptions::default());
845 assert!(
846 svg.contains(r#"<a href="http://a/%01b%1B]8;;x%7F">"#),
847 "{svg}"
848 );
849 assert!(!svg.chars().any(|c| c < ' ' && !"\t\n\r".contains(c)));
850 }
851
852 #[test]
853 fn many_distinct_styles_export_in_linear_time() {
854 let mut segments = Vec::new();
857 for i in 0..40_000u32 {
858 let colour = format!("#{:06x}", i * 97);
859 segments.push(Segment::new(
860 "▀",
861 Some(rich::Style::parse(&colour).unwrap()),
862 ));
863 if i % 200 == 199 {
864 segments.push(Segment::line());
865 }
866 }
867 let frame = Frame::from_segments(&segments);
868 let started = std::time::Instant::now();
869 let svg = frame.to_svg(&SvgOptions {
870 width: Some(200),
871 ..SvgOptions::default()
872 });
873 assert!(svg.contains("-r40000 {"), "every style has its class");
874 assert!(
875 started.elapsed() < std::time::Duration::from_secs(4),
877 "{:?}",
878 started.elapsed()
879 );
880 }
881
882 #[test]
883 fn regions_become_nested_wrappers() {
884 let console = console(40);
885 let mut table = Table::new().title("Crates");
886 table.add_column("Name");
887 table.add_row(&["rich"]);
888 let panel = Panel::new(Box::new(table)).title("Box");
889 let frame = render_frame(&console, &console.options(), &panel);
890 let plain = Frame::from_segments(&panel.rich_render(&console, &console.options()));
892 assert_eq!(frame.to_ansi(&console), plain.to_ansi(&console));
893 let off = HtmlOptions {
894 regions: false,
895 ..HtmlOptions::default()
896 };
897 assert_eq!(frame.to_html(&off), plain.to_html(&off));
898 let html = frame.to_html(&HtmlOptions::default());
899 assert!(
900 html.contains(r#"<span class="rich-region rich-panel" data-region="0" role="group" aria-label="Box">"#),
901 "{html}"
902 );
903 assert!(html.contains(r#"aria-roledescription="table" aria-label="Crates""#));
904 assert!(html.contains(r#"aria-roledescription="cell" data-row="0" data-column="0""#));
905 let code = html.split("<code").nth(1).unwrap();
907 for line in code.lines().take(frame.height()) {
908 assert_eq!(
909 line.matches("<span class=\"rich-region").count(),
910 line.matches("</span>").count() - line.matches("<span style").count(),
911 "{line}"
912 );
913 }
914 let text: String = frame.plain();
915 let stripped = strip_tags(code.split_once('>').unwrap().1);
916 assert!(stripped.starts_with(text.trim_end()), "{stripped}");
917 }
918
919 #[cfg(feature = "markdown")]
920 #[test]
921 fn headings_and_code_get_roles() {
922 let console = console(40);
923 let markdown = Markdown::new("## Install\n\n```sh\ncargo add rs-rich\n```\n");
924 let frame = render_frame(&console, &console.options(), &markdown);
925 let html = frame.to_html(&HtmlOptions::default());
926 assert!(
927 html.contains(r#"role="heading" aria-level="2" aria-label="Install""#),
928 "{html}"
929 );
930 assert!(html.contains(r#"role="code" data-language="sh""#), "{html}");
931 }
932
933 #[test]
934 fn svg_without_window_with_cursor_and_caption() {
935 let console = console(10);
936 let frame = Frame::from_segments(&segments(&console, &Text::new("ab")));
937 let options = SvgOptions {
938 window: false,
939 cursor: Some((2, 0)),
940 caption: Some("A <caption>"),
941 ..SvgOptions::default()
942 };
943 let svg = frame.to_svg(&options);
944 assert!(!svg.contains("#ff5f57"));
945 assert!(svg.contains("A <caption>"));
946 assert!(svg.contains(r#"opacity="0.7""#));
947 assert!(svg.contains("translate(9, 9)"));
948 }
949
950 fn strip_tags(html: &str) -> String {
951 let mut out = String::new();
952 let mut inside = false;
953 for c in html.chars() {
954 match c {
955 '<' => inside = true,
956 '>' => inside = false,
957 c if !inside => out.push(c),
958 _ => {}
959 }
960 }
961 out.replace(" ", " ")
962 }
963}