Skip to main content

rich_ext/frame/
export.rs

1//! HTML and SVG export from a frame (#226), with links and regions kept.
2//!
3//! Both follow core's exporters (`rich::export`, `rich::svg`, ports of
4//! upstream's `Console.export_html` and `export_svg`) and use their
5//! templates, so a frame without regions exports as core does, with these
6//! differences (docs/DIVERGENCES.md, "Frame export"):
7//!
8//! - links are kept: `<a href>` in HTML, as core writes them, and `<a>`
9//!   around the text in SVG, which core drops; the URL is escaped for the
10//!   attribute, where core writes it as it is. As in core, a link's scheme
11//!   is not filtered (`javascript:` stays), so drop the links of untrusted
12//!   text before exporting it;
13//! - an empty segment is not part of a frame, so it neither takes a class
14//!   number nor draws a zero-width background;
15//! - SVG text is stretched over the cells it covers, where core counts
16//!   characters (the two agree unless a character is wide);
17//! - SVG can leave out the window frame, and add a cursor and a caption.
18
19use 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/// Options for [`Frame::to_html`].
32#[derive(Clone, Debug)]
33pub struct HtmlOptions<'a> {
34    /// Resolves colours, as core's `export_html(theme=…)`. Default: core's
35    /// `DEFAULT_TERMINAL_THEME`, as `Console::export_html` uses.
36    pub theme: &'a TerminalTheme,
37    /// `style="…"` on each span (true, the default, as
38    /// `Console::export_html`), or classes and a stylesheet (false).
39    pub inline_styles: bool,
40    /// Wrap each region's cells in a `<span>` carrying its role (default
41    /// true). A frame without regions writes none either way.
42    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/// Options for [`Frame::to_svg`].
56#[derive(Clone, Debug)]
57pub struct SvgOptions<'a> {
58    /// Default: core's `SVG_EXPORT_THEME`, as `Console::export_svg` uses.
59    pub theme: &'a TerminalTheme,
60    /// The window title; empty for none.
61    pub title: &'a str,
62    /// Prefixes every id and class, as core's `unique_id`.
63    pub unique_id: &'a str,
64    /// The terminal width in cells; `None` is the frame's width.
65    pub width: Option<usize>,
66    /// Draw the window: rounded frame, title and buttons (default true).
67    /// Without it, only the terminal's background, with a small margin.
68    pub window: bool,
69    /// Draw a block cursor at (column, row).
70    pub cursor: Option<(usize, usize)>,
71    /// A line of text under the terminal.
72    pub caption: Option<&'a str>,
73    /// Cell width over height, as core's `font_aspect_ratio` (default 0.61).
74    pub font_aspect_ratio: f64,
75}
76
77impl<'a> SvgOptions<'a> {
78    /// The terminal colours.
79    #[must_use]
80    pub fn theme(mut self, theme: &'a TerminalTheme) -> Self {
81        self.theme = theme;
82        self
83    }
84
85    /// The window title; empty for none.
86    #[must_use]
87    pub fn title(mut self, title: &'a str) -> Self {
88        self.title = title;
89        self
90    }
91
92    /// The prefix of every id and class.
93    #[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    /// The terminal width in cells.
100    #[must_use]
101    pub fn width(mut self, width: usize) -> Self {
102        self.width = Some(width);
103        self
104    }
105
106    /// Whether to draw the window round the terminal.
107    #[must_use]
108    pub fn window(mut self, on: bool) -> Self {
109        self.window = on;
110        self
111    }
112
113    /// A block cursor at `column`, `row`.
114    #[must_use]
115    pub fn cursor(mut self, column: usize, row: usize) -> Self {
116        self.cursor = Some((column, row));
117        self
118    }
119
120    /// A line of text under the terminal.
121    #[must_use]
122    pub fn caption(mut self, caption: &'a str) -> Self {
123        self.caption = Some(caption);
124        self
125    }
126
127    /// A cell's width over its height.
128    #[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
150/// HTML-escape, as Python's `html.escape` (`quote=True`).
151fn escape(text: &str) -> String {
152    text.replace('&', "&amp;")
153        .replace('<', "&lt;")
154        .replace('>', "&gt;")
155        .replace('"', "&quot;")
156        .replace('\'', "&#x27;")
157}
158
159/// A stretch of text in one style and, in region mode, one region.
160struct Piece {
161    text: String,
162    style: StyleId,
163}
164
165/// Class numbers for style rules, in first-seen order (core's
166/// `styles.setdefault`). The index keeps a lookup constant-time: a screen
167/// with a colour per cell has tens of thousands of rules.
168#[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    /// A self-contained HTML document, with core's template
188    /// (`rich::export::CONSOLE_HTML_FORMAT`). See [`Frame::to_html_with`].
189    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    /// HTML in `code_format`, a Python format string with the fields
195    /// `{code}`, `{stylesheet}`, `{foreground}` and `{background}`, as core's
196    /// `export_html_with`. `"{code}"` alone gives the markup to embed in a
197    /// page of one's own (with inline styles, it needs no stylesheet).
198    ///
199    /// A linked run becomes `<a href>`. With regions (and
200    /// [`HtmlOptions::regions`]), each row's cells of a region are wrapped in
201    /// `<span class="rich-region rich-ROLE" data-region="N">`, nested as the
202    /// regions are, so the text keeps its place in the grid; the first
203    /// wrapper of a region carries its ARIA role and label.
204    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(&regions, 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    /// The frame as core's HTML export sees its stream after
243    /// `Segment::simplify`: pieces of one style merged, line breaks inside
244    /// the piece of the segment they belonged to.
245    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    /// One piece as core writes a segment: escaped, in a styled `<span>`
271    /// (or `<a>`) unless its style is null.
272    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    /// Rows with region wrappers: per row, runs of one style and region are
302    /// merged, wrappers open and close around them following the region
303    /// tree, and every wrapper closes before the line break.
304    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 &region in &wanted[keep..] {
351                    code.push_str(&open_tag(&regions[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    /// A self-contained SVG image of a terminal, with core's template
367    /// (`rich::svg::CONSOLE_SVG_FORMAT`). Linked text is wrapped in `<a>`.
368    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            // Core keeps each line's break as a segment of its own, and so
401            // writes it as text past the right edge (clipped away).
402            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                    // Stretched over its cells; a line break, as core has it,
436                    // over one.
437                    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
554/// A region wrapper's opening tag. The first wrapper of a region carries its
555/// ARIA role and label; later ones (its cells on other rows) only its number.
556fn 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(&region.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 &region.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;
620/// The height of the caption line under the terminal.
621const CAPTION: i64 = 32;
622
623/// HTML-escape, then spaces as `&#160;`: core's SVG `escape_text`. Unlike
624/// core, characters XML 1.0 forbids (controls other than tab, newline and
625/// carriage return, U+FFFE and U+FFFF) become U+FFFD, so the SVG stays
626/// well-formed whatever the text holds.
627/// A link target as an attribute value: characters XML forbids (controls,
628/// U+FFFE, U+FFFF) are percent-encoded, so the document stays well-formed.
629fn 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(' ', "&#160;")
655}
656
657// The number formatting below is core's (`rich::svg`, private there), so the
658// SVG's numbers print as upstream's Python does.
659
660/// Python `format(v, "g")`: 6 significant figures, trailing zeros (and a
661/// trailing `.`) stripped, scientific below `1e-4` or from `1e6`. Used for
662/// the coordinates inside SVG tags.
663fn 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
680/// Python `str(float)`: the shortest round-tripping form, keeping a `.0` for
681/// integer-valued floats, scientific below `1e-4` or from `1e16`.
682fn 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
723/// `int(value) + add`, for the integral, finite widths an SVG has.
724fn 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        // The fixture captured from upstream, through a frame. It lives in
798        // the core crate, so a packaged crate skips this.
799        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&amp;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&amp;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        // A colour per cell, as an image rendered in half blocks gives: the
855        // class lookup used to scan every earlier rule.
856        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            // About 0.5 s in a debug build; the old linear scan took 8 s.
876            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        // Without regions the bytes are the plain frame's.
891        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        // Wrappers open and close on each row, so the grid is intact.
906        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&#160;&lt;caption&gt;"));
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("&#160;", " ")
962    }
963}