//! Terminal state formatters: plain text, VT (with styles) and HTML. //! //! Port of `ZIG/src/terminal/formatter.zig`. //! //! Writers are `&mut dyn std::io::Write`; use the `format_string` helpers (or //! write into a `Vec`) for in-memory output. Where Zig used a //! `Discarding` writer to count bytes (for pin maps), the Rust port renders //! into a small scratch buffer and measures it. #![allow(clippy::too_many_arguments)] use std::io::{self, Write}; use ghostty_vt::{ charsets::{Charset, Slots}, color::{Palette, Rgb}, modes::ENTRIES, }; use crate::{ highlight::pin_page_iterator, hyperlink::HyperlinkId, page::{Cell, ContentTag, Page, Wide}, page_list::{PageList, Pin}, point::{Coordinate, Tag}, screen::Screen, selection::Selection, size, style::{Color, Style}, terminal::Terminal, }; type Result = io::Result; /// Formats available. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Format { /// Plain text. Plain, /// Include VT sequences to preserve colors, styles, URLs, etc. /// This is predominantly SGR sequences but may contain others as needed. /// /// Note that for reference colors, like palette indices, this will /// vary based on the formatter and you should see the docs. For example, /// PageFormatter with VT will emit SGR sequences with palette indices, /// not the color itself. /// /// For VT, newlines will be emitted as `\r\n` so that the cursor properly /// moves back to the beginning prior emitting follow-up lines. Vt, /// HTML output. /// /// This will emit inline styles for as much styling as possible, /// in the interest of simplicity and ease of editing. This isn't meant /// to build the most beautiful or efficient HTML, but rather to be /// stylistically correct. /// /// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette /// indices use CSS variables (var(--vt-palette-N)). The palette colors are /// emitted by TerminalFormatter.Extra.palette as a "); } } writer.write_all(&buf)?; // If we have a pin_map, add the bytes we wrote to map. Map all // those bytes to the same pin. Use the top left to ensure the // node is always properly initialized. if self.opts.emit != Format::Plain && let Some(m) = self.pin_map.as_deref_mut() { let top = pages.get_top_left(Tag::Screen).expect("top left"); m.extend(std::iter::repeat_n(top, buf.len())); } } // Emit terminal modes that differ from defaults. We probably have // some modes we want to emit before and some after, but for now for // simplicity we just emit them all before. If we make this more complex // later we should add test cases for it. if self.opts.emit == Format::Vt && self.extra.modes { let mut buf: Vec = Vec::new(); for entry in ENTRIES { let current = self.terminal.modes.get(entry.mode); let default_val = self.terminal.modes.default.get(entry.mode); if current != default_val { let prefix = if entry.ansi { "" } else { "?" }; let suffix = if current { "h" } else { "l" }; write!(buf, "\x1b[{}{}{}", prefix, entry.value, suffix)?; } } writer.write_all(&buf)?; if let Some(m) = self.pin_map.as_deref_mut() { let top = pages.get_top_left(Tag::Screen).expect("top left"); m.extend(std::iter::repeat_n(top, buf.len())); } } let mut screen_formatter = ScreenFormatter::new(self.terminal.screens.active(), self.opts.clone()); screen_formatter.content = self.content; screen_formatter.extra = self.extra.screen; screen_formatter.pin_map = self.pin_map.as_deref_mut(); screen_formatter.format(writer)?; // Extra terminal state to emit after the screen contents so that // it doesn't impact the emitted contents. if self.opts.emit == Format::Vt { let mut buf: Vec = Vec::new(); // Emit scrolling region using DECSTBM and DECSLRM if self.extra.scrolling_region { let region = &self.terminal.scrolling_region; // DECSTBM: top and bottom margins (1-indexed) // Only emit if not the full screen if region.top != 0 || region.bottom != self.terminal.rows - 1 { write!(buf, "\x1b[{};{}r", region.top + 1, region.bottom + 1)?; } // DECSLRM: left and right margins (1-indexed) // Only emit if not the full width if region.left != 0 || region.right != self.terminal.cols - 1 { write!(buf, "\x1b[{};{}s", region.left + 1, region.right + 1)?; } } // Emit tabstop positions if self.extra.tabstops { // Clear all tabs (CSI 3 g) write!(buf, "\x1b[3g")?; // Set each configured tabstop by moving cursor and using HTS for col in 0..usize::from(self.terminal.cols) { if self.terminal.tabstops.get(col) { // Move cursor to the column (1-indexed) write!(buf, "\x1b[{}G", col + 1)?; // Set tab (HTS) write!(buf, "\x1bH")?; } } } // Emit keyboard modes such as ModifyOtherKeys if self.extra.keyboard { // Only emit if modify_other_keys_2 is true if self.terminal.flags.modify_other_keys_2 { write!(buf, "\x1b[>4;2m")?; } } // Emit present working directory using OSC 7 if self.extra.pwd { let pwd = &self.terminal.pwd; if !pwd.is_empty() { buf.extend_from_slice(b"\x1b]7;"); buf.extend_from_slice(pwd); buf.extend_from_slice(b"\x1b\\"); } } writer.write_all(&buf)?; // If we have a pin_map, add the bytes we wrote to map. if let Some(m) = self.pin_map.as_deref_mut() { map_pad_last_or_top(m, pages, buf.len()); } } Ok(()) } } /// Screen formatter formats a single terminal screen (e.g. primary vs alt). pub struct ScreenFormatter<'a> { /// The screen to format. pub screen: &'a Screen, /// The common options pub opts: Options, /// The content to include. pub content: Content, /// Extra stuff to emit, such as cursor, style, hyperlinks, etc. /// This information is ONLY emitted when the format is "vt". pub extra: ScreenExtra, /// If non-null, then `map` will contain the Pin of every byte /// byte written to the writer offset by the byte index. /// /// Note that some emitted bytes may not correspond to any Pin, such as /// the extra data around screen state. For these, we'll map it to the /// most previous pin so there is some continuity but its an arbitrary /// choice. /// /// Warning: there is a significant performance hit to track this pub pin_map: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Content { /// Emit no content, only terminal state such as modes, palette, etc. /// via extra. None, /// Emit the content specified by the selection. None for all. /// The selection is inclusive on both ends. Selection(Option), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ScreenExtra { /// Emit cursor position using CUP (CSI H). pub cursor: bool, /// Emit current SGR style state based on the cursor's active style_id. /// This reconstructs the SGR attributes (bold, italic, colors, etc.) at /// the cursor position. pub style: bool, /// Emit current hyperlink state using OSC 8 sequences. /// This sets the active hyperlink based on cursor.hyperlink_id. pub hyperlink: bool, /// Emit character protection mode using DECSCA. pub protection: bool, /// Emit Kitty keyboard protocol state using CSI > u and CSI = sequences. pub kitty_keyboard: bool, /// Emit character set designations and invocations. /// This includes G0-G3 designations (ESC ( ) * +) and GL/GR invocations. pub charsets: bool, } impl ScreenExtra { /// Emit nothing. pub const NONE: ScreenExtra = ScreenExtra { cursor: false, style: false, hyperlink: false, protection: false, kitty_keyboard: false, charsets: false, }; /// Emit style-relevant information only. pub const STYLES: ScreenExtra = ScreenExtra { cursor: false, style: true, hyperlink: true, protection: false, kitty_keyboard: false, charsets: false, }; /// Emit everything. This reconstructs the screen state as closely /// as possible. pub const ALL: ScreenExtra = ScreenExtra { cursor: true, style: true, hyperlink: true, protection: true, kitty_keyboard: true, charsets: true, }; fn is_set(self) -> bool { self != Self::NONE } } impl<'a> ScreenFormatter<'a> { pub fn new(screen: &'a Screen, opts: Options) -> Self { ScreenFormatter { screen, opts, content: Content::Selection(None), extra: ScreenExtra::NONE, pin_map: None, } } /// Format into a new `String`. pub fn format_string(self) -> Result { let mut buf = Vec::new(); self.format(&mut buf)?; String::from_utf8(buf).map_err(|_| invalid_cp()) } pub fn format(mut self, writer: &mut dyn Write) -> Result<()> { match self.content { Content::None => {} Content::Selection(selection) => { // Emit our pagelist contents according to our selection. let mut list_formatter = PageListFormatter::new(&self.screen.pages, self.opts.clone()); list_formatter.pin_map = self.pin_map.as_deref_mut(); if let Some(sel) = selection { list_formatter.top_left = Some(sel.top_left(&self.screen.pages)); list_formatter.bottom_right = Some(sel.bottom_right(&self.screen.pages)); list_formatter.rectangle = sel.rectangle; } list_formatter.format(writer)?; } } // Emit extra screen state after content if we care. The state has // to be emitted after since some state such as cursor position and // style are impacted by content rendering. match self.opts.emit { Format::Plain => return Ok(()), Format::Vt => { if !self.extra.is_set() { return Ok(()); } } // HTML doesn't preserve any screen state because it has // nothing to do with rendering. Format::Html => return Ok(()), } let mut buf: Vec = Vec::new(); // Emit current SGR style state if self.extra.style { let cursor = &self.screen.cursor; write!(buf, "{}", cursor.style.formatter_vt())?; } // Emit current hyperlink state using OSC 8 if self.extra.hyperlink && let Some(link) = &self.screen.cursor.hyperlink { // Start hyperlink with uri (and explicit id if present) match &link.id { HyperlinkId::Explicit(id) => { buf.extend_from_slice(b"\x1b]8;id="); buf.extend_from_slice(id); buf.push(b';'); buf.extend_from_slice(&link.uri); buf.extend_from_slice(b"\x1b\\"); } HyperlinkId::Implicit(_) => { buf.extend_from_slice(b"\x1b]8;;"); buf.extend_from_slice(&link.uri); buf.extend_from_slice(b"\x1b\\"); } } } // Emit character protection mode using DECSCA if self.extra.protection && self.screen.cursor.protected { // DEC protected mode write!(buf, "\x1b[1\"q")?; } // Emit Kitty keyboard protocol state using CSI = u if self.extra.kitty_keyboard { let current_flags = self.screen.kitty_keyboard.current(); if current_flags.int() != ghostty_vt::kitty::key::Flags::DISABLED.int() { write!(buf, "\x1b[={};1u", current_flags.int())?; } } // Emit character set designations and invocations if self.extra.charsets { let charset = &self.screen.charset; // Emit G0-G3 designations for slot in [Slots::G0, Slots::G1, Slots::G2, Slots::G3] { let cs = charset.charsets.get(slot); if cs != Charset::Utf8 { // Only emit non-default charsets let intermediate = match slot { Slots::G0 => '(', Slots::G1 => ')', Slots::G2 => '*', Slots::G3 => '+', }; let fin = match cs { Charset::Ascii => 'B', Charset::British => 'A', Charset::DecSpecial => '0', Charset::Utf8 => continue, }; write!(buf, "\x1b{intermediate}{fin}")?; } } // Emit GL invocation if not G0 if charset.gl != Slots::G0 { let seq: &[u8] = match charset.gl { Slots::G0 => unreachable!(), Slots::G1 => b"\x0e", // SO - Shift Out Slots::G2 => b"\x1bn", // LS2 Slots::G3 => b"\x1bo", // LS3 }; buf.extend_from_slice(seq); } // Emit GR invocation if not G2 if charset.gr != Slots::G2 { let seq: &[u8] = match charset.gr { Slots::G0 => unreachable!(), // GR can't be G0 Slots::G1 => b"\x1b~", // LS1R Slots::G2 => unreachable!(), Slots::G3 => b"\x1b|", // LS3R }; buf.extend_from_slice(seq); } } // Emit cursor position using CUP (CSI H) if self.extra.cursor { let cursor = &self.screen.cursor; // CUP is 1-indexed write!(buf, "\x1b[{};{}H", cursor.y + 1, cursor.x + 1)?; } writer.write_all(&buf)?; // If we have a pin_map, map all the extra bytes to the same pin. if let Some(m) = self.pin_map.as_deref_mut() { map_pad_last_or_top(m, &self.screen.pages, buf.len()); } Ok(()) } } /// PageList formatter formats multiple pages as represented by a PageList. pub struct PageListFormatter<'a> { /// The pagelist to format. pub list: &'a PageList, /// The common options pub opts: Options, /// The bounds of the PageList to format. The top left and bottom right /// MUST be ordered properly. pub top_left: Option, pub bottom_right: Option, /// If true, the boundaries define a rectangle selection where start_x /// and end_x apply to every row, not just the first and last. pub rectangle: bool, /// If non-null, then `map` will contain the Pin of every byte /// byte written to the writer offset by the byte index. /// /// Warning: there is a significant performance hit to track this pub pin_map: Option>, } impl<'a> PageListFormatter<'a> { pub fn new(list: &'a PageList, opts: Options) -> Self { PageListFormatter { list, opts, top_left: None, bottom_right: None, rectangle: false, pin_map: None, } } /// Format into a new `String`. pub fn format_string(self) -> Result { let mut buf = Vec::new(); self.format(&mut buf)?; String::from_utf8(buf).map_err(|_| invalid_cp()) } pub fn format(mut self, writer: &mut dyn Write) -> Result<()> { let tl = self .top_left .unwrap_or_else(|| self.list.get_top_left(Tag::Screen).expect("top left")); let br = self.bottom_right.unwrap_or_else(|| { self.list .get_bottom_right(Tag::Screen) .expect("bottom right") }); // If we keep track of pins, we'll need this. let mut point_map: Vec = Vec::new(); let mut page_state: Option = None; for chunk in pin_page_iterator(self.list, tl, br) { assert!(chunk.start < chunk.end); assert!(chunk.end > 0); let node = self.list.node(chunk.node).expect("chunk node"); let mut formatter = PageFormatter::new(&node.data, &self.opts); formatter.start_y = chunk.start; formatter.end_y = Some(chunk.end - 1); formatter.trailing_state = page_state; formatter.rectangle = self.rectangle; // For rectangle selection, apply start_x and end_x to all chunks if self.rectangle { formatter.start_x = tl.x; formatter.end_x = Some(br.x); } else { // Otherwise only on the first/last, respectively. if chunk.node == tl.node { formatter.start_x = tl.x; } if chunk.node == br.node { formatter.end_x = Some(br.x); } } // If we're tracking pins, then we setup a point map for the // page formatter (cause it can't track pins). And then we convert // this to pins later. let tracking = self.pin_map.is_some(); if tracking { point_map.clear(); formatter.point_map = Some(&mut point_map); } page_state = Some(formatter.format_with_state(writer)?); // If we're tracking pins then grab our points and write them // to our pin map. if let Some(m) = self.pin_map.as_deref_mut() { for coord in &point_map { m.push(Pin { node: chunk.node, x: coord.x, y: coord.y as size::CellCountInt, garbage: false, }); } } } Ok(()) } } /// Trailing state. This is used to ensure that rows wrapped across /// multiple pages are unwrapped properly, as well as other accounting /// we may do in the future. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct TrailingState { pub rows: usize, pub cells: usize, } impl TrailingState { pub const EMPTY: TrailingState = TrailingState { rows: 0, cells: 0 }; } /// Page formatter. /// /// For styled formatting such as VT, this will emit references for palette /// colors. If you want to capture the palette as-is at the type of formatting, /// you'll have to emit the sequences for setting up the palette prior to /// this formatting. (TODO: A function to do this) pub struct PageFormatter<'a> { /// The page to format. pub page: &'a Page, /// The common options pub opts: &'a Options, /// Start and end points within the page to format. If end x is not given /// then it will be the full width. If end y is not given then it will be /// the full height. /// /// The start and end are both inclusive, so equal values will still /// return a non-empty result (i.e. a single cell or row). /// /// The start x is considered the X in the first row and end X is /// X in the final row. This isn't a rectangle selection by default. /// /// If start X falls on the second column of a wide character, then /// the entire character will be included (as if you specified the /// previous column). pub start_x: size::CellCountInt, pub start_y: size::CellCountInt, pub end_x: Option, pub end_y: Option, /// If true, the start x/y and end x/y define a rectangle selection. /// In this case, the boundaries will apply to every row, not just /// the first and last. pub rectangle: bool, /// If non-null, then `map` will contain the x/y coordinate of every /// byte written to the writer offset by the byte index. /// /// The x/y coordinate will be the coordinates within the page. /// /// Warning: there is a significant performance hit to track this pub point_map: Option<&'a mut Vec>, /// The previous trailing state from the prior page. If you're iterating /// over multiple pages this helps ensure that unwrapping and other /// accounting works properly. pub trailing_state: Option, } impl<'a> PageFormatter<'a> { /// Initializes a page formatter. Other options can be set directly on the /// struct after initialization and before calling `format()`. pub fn new(page: &'a Page, opts: &'a Options) -> Self { PageFormatter { page, opts, start_x: 0, start_y: 0, end_x: None, end_y: None, rectangle: false, point_map: None, trailing_state: None, } } /// Format into a new `String`. pub fn format_string(self) -> Result { let mut buf = Vec::new(); self.format(&mut buf)?; String::from_utf8(buf).map_err(|_| invalid_cp()) } pub fn format(self, writer: &mut dyn Write) -> Result<()> { self.format_with_state(writer).map(|_| ()) } fn map_push_n(&mut self, coord: Coordinate, n: usize) { if let Some(m) = self.point_map.as_deref_mut() { m.extend(std::iter::repeat_n(coord, n)); } } pub fn format_with_state(mut self, writer: &mut dyn Write) -> Result { let page = self.page; let opts = self.opts; let mut blank_rows: usize = 0; let mut blank_cells: usize = 0; // Continue our prior trailing state if we have it, but only if we're // starting from the beginning (start_y and start_x are both 0). // If a non-zero start position is specified, ignore trailing state. if let Some(state) = self.trailing_state && self.start_y == 0 && self.start_x == 0 { blank_rows = state.rows; blank_cells = state.cells; } let ret = |rows: usize, cells: usize| TrailingState { rows, cells }; // Setup our starting column and perform some validation for overflows. // Note: start_x only applies to the first row, end_x only applies to the last row. let start_x = self.start_x; if start_x >= page.size.cols { return Ok(ret(blank_rows, blank_cells)); } let end_x_unclamped = self.end_x.unwrap_or(page.size.cols - 1); let mut end_x = end_x_unclamped.min(page.size.cols - 1); // Setup our starting row and perform some validation for overflows. let start_y = self.start_y; if start_y >= page.size.rows { return Ok(ret(blank_rows, blank_cells)); } let end_y_unclamped = self.end_y.unwrap_or(page.size.rows - 1); if start_y > end_y_unclamped { return Ok(ret(blank_rows, blank_cells)); } let mut end_y = end_y_unclamped.min(page.size.rows - 1); // Edge case: if our end x/y falls on a spacer head AND we're unwrapping, // then we move the x/y to the start of the next row (if available). if opts.unwrap && !self.rectangle { let cells = page.get_cells(usize::from(end_y)).expect("row cells"); if cells[usize::from(end_x)].wide() == Wide::SpacerHead { // Move to next row if available // // TODO: if unavailable, we should add to our trailing state // // so the pagelist formatter can be aware and maybe add // another page if end_y < page.size.rows - 1 { end_y += 1; end_x = 0; } } } // If we only have a single row, validate that start_x <= end_x if start_y == end_y && start_x > end_x { return Ok(ret(blank_rows, blank_cells)); } // Wrap HTML output in monospace font styling match opts.emit { Format::Plain => {} Format::Html => { let mut header: Vec = Vec::new(); // Monospace and whitespace preserving header.extend_from_slice(b"
"); writer.write_all(&header)?; self.map_push_n(Coordinate { x: 0, y: 0 }, header.len()); } Format::Vt => { // OSC 10 sets foreground color, OSC 11 sets background color let mut header: Vec = Vec::new(); if let Some(fg) = opts.foreground { write!( header, "\x1b]10;rgb:{:02x}/{:02x}/{:02x}\x1b\\", fg.r, fg.g, fg.b )?; } if let Some(bg) = opts.background { write!( header, "\x1b]11;rgb:{:02x}/{:02x}/{:02x}\x1b\\", bg.r, bg.g, bg.b )?; } writer.write_all(&header)?; self.map_push_n(Coordinate { x: 0, y: 0 }, header.len()); } } // Our style for non-plain formats let mut style = Style::default(); // Track hyperlink state for HTML output. We need to close tags // when the hyperlink changes or ends. let mut current_hyperlink_id: Option = None; let mut scratch: Vec = Vec::new(); 'rows: for y in start_y..=end_y { let row = *page.get_row(usize::from(y)).expect("row"); let cells: &[Cell] = page.get_cells(usize::from(y)).expect("cells"); // Determine the x range for this row // - First row: start_x to end of row (or end_x if single row) // - Last row: start of row to end_x // - Middle rows: full width let (cells_subset, row_start_x) = { // The end is always straightforward let row_end_x: size::CellCountInt = if self.rectangle || y == end_y { end_x + 1 } else { page.size.cols }; // The first we have to check if our start X falls on the // tail of a wide character. let row_start_x: size::CellCountInt = if start_x > 0 && (self.rectangle || y == start_y) { match cells[usize::from(start_x)].wide() { // Include the prior cell to get the full wide char Wide::SpacerTail => start_x - 1, // If we're a spacer head on our first row then we // skip this whole row. Wide::SpacerHead => continue 'rows, Wide::Narrow | Wide::Wide => start_x, } } else { 0 }; ( &cells[usize::from(row_start_x)..usize::from(row_end_x)], row_start_x, ) }; // If this row is blank, accumulate to avoid a bunch of extra // work later. If it isn't blank, make sure we dump all our // blanks. if !Cell::has_text_any(cells_subset) { blank_rows += 1; continue; } if blank_rows > 0 { // Reset style before emitting newlines to prevent background // colors from bleeding into the next line's leading cells. if !style.is_default() { self.format_style_close(writer)?; style = Style::default(); } let sequence: &[u8] = match opts.emit { // Plaintext just uses standard newlines because newlines // on their own usually move the cursor back in anywhere // you type plaintext. Format::Plain => b"\n", // VT uses \r\n because in a raw pty, \n alone doesn't // guarantee moving the cursor back to column 0. \r // makes it work for sure. Format::Vt => b"\r\n", // HTML uses just \n because HTML rendering will move // the cursor back. Format::Html => b"\n", }; for _ in 0..blank_rows { writer.write_all(sequence)?; } // \r and \n map to the row that ends with this newline. // If we're continuing (trailing state) then this will be // in a prior page, so we just map to the first row of this // page. if let Some(map) = self.point_map.as_deref_mut() { let start = map.last().copied().unwrap_or(Coordinate { x: 0, y: 0 }); // The first one inherits the x value. map.extend(std::iter::repeat_n( Coordinate { x: start.x, y: start.y, }, sequence.len(), )); // All others have x = 0 since they reference their prior // blank line. for y_offset in 1..blank_rows { map.extend(std::iter::repeat_n( Coordinate { x: 0, y: start.y + y_offset as u32, }, sequence.len(), )); } } blank_rows = 0; } // If we're not wrapped, we always add a newline so after // the row is printed we can add a newline. if !row.wrap() || !opts.unwrap { blank_rows += 1; } // If the row doesn't continue a wrap then we need to reset // our blank cell count. if !row.wrap_continuation() || !opts.unwrap { blank_cells = 0; } // Go through each cell and print it 'cells: for (i, cell) in cells_subset.iter().enumerate() { let x: size::CellCountInt = row_start_x + i as size::CellCountInt; // Skip spacers. These happen naturally when wide characters // are printed again on the screen (for well-behaved terminals!) match cell.wide() { Wide::Narrow | Wide::Wide => {} Wide::SpacerHead | Wide::SpacerTail => continue, } // If we have a zero value, then we accumulate a counter. We // only want to turn zero values into spaces if we have a non-zero // char sometime later. 'blank: { // If we're emitting styled output (not plaintext) and // the cell has some kind of styling or is not empty // then this isn't blank. if opts.emit.styled() && (!cell.is_empty() || cell.has_styling()) { break 'blank; } // Cells with no text are blank if !cell.has_text() { blank_cells += 1; continue 'cells; } // Trailing spaces are blank. We know it is trailing // because if we get a non-empty cell later we'll // fill the blanks. if cell.codepoint() == ' ' as u32 && opts.trim { blank_cells += 1; continue 'cells; } } // This cell is not blank. If we have accumulated blank cells // then we want to emit them now. if blank_cells > 0 { scratch.clear(); scratch.resize(blank_cells, b' '); writer.write_all(&scratch)?; if let Some(map) = self.point_map.as_deref_mut() { // Map each blank cell to its coordinate. Blank cells can span // multiple rows if they carry over from wrap continuation. let mut remaining_blanks = blank_cells; let mut blank_x = x; let mut blank_y = y; while remaining_blanks > 0 { if blank_x > 0 { // We have space in this row blank_x -= 1; } else if blank_y > 0 { // Wrap to previous row blank_y -= 1; blank_x = page.size.cols - 1; } else { // Can't go back further, just use (0, 0) blank_x = 0; blank_y = 0; } map.push(Coordinate { x: blank_x, y: u32::from(blank_y), }); remaining_blanks -= 1; } } blank_cells = 0; } 'style: { // If we aren't emitting styled output then we don't // have to worry about styles. if !opts.emit.styled() { break 'style; } // Get our cell style. let cell_style = self.cell_style(*cell); // If the style hasn't changed, don't bloat output. if cell_style.eql(style) { break 'style; } // If we had a previous style, we need to close it, // because we've confirmed we have some new style // (which is maybe default). if !style.is_default() { match opts.emit { Format::Html => self.format_style_close(writer)?, // For VT, we only close if we're switching to a default // style because any non-default style will emit // a \x1b[0m as the start of a VT coloring sequence. Format::Vt => { if cell_style.is_default() { self.format_style_close(writer)?; } } // Unreachable because of the styled() check at the // top of this block. Format::Plain => unreachable!(), } } // At this point, we can copy our style over style = cell_style; // If we're just the default style now, we're done. if cell_style.is_default() { break 'style; } // New style, emit it. scratch.clear(); self.format_style_open(&mut scratch, &style)?; writer.write_all(&scratch)?; // If we have a point map, we map the style to // this cell. self.map_push_n(Coordinate { x, y: u32::from(y) }, scratch.len()); } // Hyperlink state 'hyperlink: { // We currently only emit hyperlinks for HTML. In the // future we can support emitting OSC 8 hyperlinks for // VT output as well. if opts.emit != Format::Html { break 'hyperlink; } // Get the hyperlink ID. This ID is our internal ID, // not necessarily the OSC8 ID. let link_id_: Option = if cell.hyperlink() { page.cell_index(usize::from(x), usize::from(y)) .ok() .and_then(|i| page.lookup_hyperlink(i)) } else { None }; // If our hyperlink IDs match (even null) then we have // identical hyperlink state and we do nothing. if current_hyperlink_id == link_id_ { break 'hyperlink; } // If our prior hyperlink ID was non-null, we need to // close it because the ID has changed. if current_hyperlink_id.is_some() { self.format_hyperlink_close(writer)?; current_hyperlink_id = None; } // Set our current hyperlink ID let Some(link_id) = link_id_ else { break 'hyperlink; }; current_hyperlink_id = Some(link_id); // Emit the opening hyperlink tag let uri: Vec = page .hyperlink_set .get(link_id) .map(|l| l.uri.clone()) .unwrap_or_default(); scratch.clear(); self.format_hyperlink_open(&mut scratch, &uri)?; writer.write_all(&scratch)?; // If we have a point map, we map the hyperlink to // this cell. self.map_push_n(Coordinate { x, y: u32::from(y) }, scratch.len()); } match cell.content_tag() { // We combine codepoint and graphemes because both have // shared style handling. ContentTag::Codepoint | ContentTag::CodepointGrapheme => { scratch.clear(); self.write_cell(&mut scratch, *cell, x, y)?; writer.write_all(&scratch)?; // If we have a point map, all codepoints map to this // cell. self.map_push_n(Coordinate { x, y: u32::from(y) }, scratch.len()); } // Cells with only background color (no text). Emit a space // with the appropriate background color SGR sequence. ContentTag::BgColorPalette | ContentTag::BgColorRgb => { writer.write_all(b" ")?; self.map_push_n(Coordinate { x, y: u32::from(y) }, 1); } } } } // If the style is non-default, we need to close our style tag. if !style.is_default() { self.format_style_close(writer)?; } // Close any open hyperlink for HTML output if current_hyperlink_id.is_some() { self.format_hyperlink_close(writer)?; } // Close the monospace wrapper for HTML output if opts.emit == Format::Html { let closing = b"
"; writer.write_all(closing)?; if let Some(map) = self.point_map.as_deref_mut() { let last = map.last().copied().unwrap_or(Coordinate { x: 0, y: 0 }); map.extend(std::iter::repeat_n(last, closing.len())); } } Ok(ret(blank_rows, blank_cells)) } fn write_cell( &self, out: &mut Vec, cell: Cell, x: size::CellCountInt, y: size::CellCountInt, ) -> Result<()> { // Blank cells get an empty space that isn't replaced by anything // because it isn't really a space. We do this so that formatting // is preserved if we're emitting styles. if !cell.has_text() { out.push(b' '); return Ok(()); } self.write_codepoint_with_replacement(out, cell.codepoint())?; if cell.content_tag() == ContentTag::CodepointGrapheme { let idx = self .page .cell_index(usize::from(x), usize::from(y)) .map_err(|_| invalid_cp())?; for &cp in self.page.lookup_grapheme(idx).expect("grapheme") { self.write_codepoint_with_replacement(out, cp)?; } } Ok(()) } fn write_codepoint_with_replacement(&self, out: &mut Vec, codepoint: u32) -> Result<()> { // Search for our replacement let r: Option<&Replacement> = self.opts.codepoint_map.as_ref().and_then(|map| { map.iter() .rev() .find(|m| m.range[0] <= codepoint && codepoint <= m.range[1]) .map(|m| &m.replacement) }); // If no replacement, write it directly. let Some(r) = r else { return self.write_codepoint(out, codepoint); }; match r { Replacement::Codepoint(v) => self.write_codepoint(out, *v), Replacement::String(s) => { for c in s.chars() { self.write_codepoint(out, c as u32)?; } Ok(()) } } } fn write_codepoint(&self, out: &mut Vec, codepoint: u32) -> Result<()> { let push = |out: &mut Vec, cp: u32| -> Result<()> { let c = char::from_u32(cp).ok_or_else(invalid_cp)?; let mut b = [0u8; 4]; out.extend_from_slice(c.encode_utf8(&mut b).as_bytes()); Ok(()) }; match self.opts.emit { Format::Plain | Format::Vt => push(out, codepoint), Format::Html => match codepoint { 0x3C => { out.extend_from_slice(b"<"); Ok(()) } 0x3E => { out.extend_from_slice(b">"); Ok(()) } 0x26 => { out.extend_from_slice(b"&"); Ok(()) } 0x22 => { out.extend_from_slice(b"""); Ok(()) } 0x27 => { out.extend_from_slice(b"'"); Ok(()) } _ => { // For HTML, emit ASCII (< 0x80) directly, but encode // all non-ASCII as numeric entities to avoid encoding // detection issues (fixes #9426). We can't set the // meta tag because we emit partial HTML so this ensures // proper unicode handling. if codepoint < 0x80 { push(out, codepoint) } else { write!(out, "&#{codepoint};") } } }, } } /// Returns the style for the given cell. If there is no styling this /// will return the default style. fn cell_style(&self, cell: Cell) -> Style { match cell.content_tag() { ContentTag::Codepoint | ContentTag::CodepointGrapheme => { if cell.has_styling() { self.page.style(cell) } else { Style::default() } } ContentTag::BgColorPalette => Style { bg_color: Color::Palette(cell.color_palette()), ..Style::default() }, ContentTag::BgColorRgb => Style { bg_color: Color::Rgb(cell.color_rgb()), ..Style::default() }, } } fn format_style_open(&self, out: &mut Vec, style: &Style) -> Result<()> { match self.opts.emit { Format::Plain => unreachable!(), Format::Vt => { let mut formatter = style.formatter_vt(); formatter.palette = self.opts.palette.as_ref(); write!(out, "{formatter}") } // We use `display: inline` so that the div doesn't impact // layout since we're primarily using it as a CSS wrapper. Format::Html => { let mut formatter = style.formatter_html(); formatter.palette = self.opts.palette.as_ref(); write!(out, "
") } } } fn write_close_str(&mut self, writer: &mut dyn Write, s: &[u8]) -> Result<()> { writer.write_all(s)?; if let Some(m) = self.point_map.as_deref_mut() { debug_assert!(m.capacity() > 0); let last = m.last().copied().unwrap_or(Coordinate { x: 0, y: 0 }); m.extend(std::iter::repeat_n(last, s.len())); } Ok(()) } fn format_style_close(&mut self, writer: &mut dyn Write) -> Result<()> { let s: &[u8] = match self.opts.emit { Format::Plain => return Ok(()), Format::Vt => b"\x1b[0m", Format::Html => b"
", }; self.write_close_str(writer, s) } fn format_hyperlink_open(&self, out: &mut Vec, uri: &[u8]) -> Result<()> { match self.opts.emit { Format::Plain | Format::Vt => unreachable!(), Format::Html => { out.extend_from_slice(b""); Ok(()) } } } fn format_hyperlink_close(&mut self, writer: &mut dyn Write) -> Result<()> { let s: &[u8] = match self.opts.emit { Format::Html => b"", Format::Plain | Format::Vt => return Ok(()), }; self.write_close_str(writer, s) } } #[cfg(test)] mod tests;