Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ reads that marker.
changed cells (#595). Painting checked each cell against the whole list of
changed cells, so a full-screen change on a large terminal took seconds
(8 s at 80,000 cells). The changed cells are now collected once.
- `Screen::to_svg` draws an underlined or struck-through run of blanks, such
as an empty form field, with its line (#586); it trimmed the run to nothing
and dropped it. The trailing blanks of any underlined or struck-through run
are now drawn with the line too, as in `to_html`. `to_svg` and `to_html`
also write a character XML 1.0 forbids, a C0 control other than tab, LF and
CR or U+FFFE/U+FFFF, as U+FFFD, so a window title holding `\x01` or a cell
holding U+FFFF no longer makes an SVG no parser opens.

## [0.11.6] - 2026-10-14

Expand Down
21 changes: 20 additions & 1 deletion crates/termlens/src/screen/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,23 @@ fn glyph(cell: &Cell) -> &str {
}
}

/// Escape text for an XML (SVG) or HTML text node or attribute value.
///
/// Characters outside the XML 1.0 `Char` production — C0 controls other
/// than tab, LF and CR, and the noncharacters U+FFFE and U+FFFF — cannot be
/// written at all, escaped or not: one of them makes the whole SVG
/// ill-formed, and no parser opens it (#586). They reach here from a cell
/// or a window title, so each becomes U+FFFD, one character for one, which
/// keeps a cell one column wide.
fn escape_xml(text: &str, out: &mut String) {
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\t' | '\n' | '\r' => out.push(c),
'\u{0}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}' => out.push(char::REPLACEMENT_CHARACTER),
other => out.push(other),
}
}
Expand Down Expand Up @@ -231,7 +241,16 @@ impl Screen {
// Trailing blanks draw nothing as text — the background run
// above is what paints them — so the element carries only
// the glyphs. Concealed text is blanks, as a terminal shows it.
let shown = if style.conceal { "" } else { text.trim_end() };
// An underline or strikethrough is drawn under blanks too, so
// a decorated run keeps them: an underlined empty form field
// is all blanks, and trimming it dropped the field (#586).
let shown = if style.conceal {
""
} else if style.underline || style.strikethrough {
text.as_str()
} else {
text.trim_end()
};
if shown.is_empty() {
continue;
}
Expand Down
87 changes: 87 additions & 0 deletions crates/termlens/tests/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,93 @@ fn every_style_attribute_reaches_every_rendering() -> termlens::Result<()> {
Ok(())
}

/// Every character of `doc` is one XML 1.0 allows (the `Char` production)
/// and its tags nest. Those are the two ways #586 broke the SVG; it is not
/// a full parse (entities and attribute syntax are not checked), which
/// keeps the tests free of an XML dependency.
fn assert_well_formed_xml(doc: &str) {
for (i, c) in doc.char_indices() {
let allowed = matches!(c, '\t' | '\n' | '\r')
|| ('\u{20}'..='\u{d7ff}').contains(&c)
|| ('\u{e000}'..='\u{fffd}').contains(&c)
|| c >= '\u{10000}';
assert!(allowed, "{c:?} at byte {i} is not an XML Char:\n{doc}");
}
let mut open = Vec::new();
let mut rest = doc;
while let Some(start) = rest.find('<') {
let end = rest[start..].find('>').expect("an unclosed tag") + start;
let tag = &rest[start + 1..end];
let name = |t: &str| t.split([' ', '\n', '/']).next().unwrap_or("").to_owned();
if let Some(closing) = tag.strip_prefix('/') {
assert_eq!(
open.pop().as_deref(),
Some(closing),
"mismatched </{closing}>:\n{doc}"
);
} else if !tag.ends_with('/') && !tag.starts_with('!') && !tag.starts_with('?') {
open.push(name(tag));
}
rest = &rest[end + 1..];
}
assert!(open.is_empty(), "unclosed {open:?}:\n{doc}");
}

/// An underlined or struck-through run of blanks is a form field: a
/// terminal draws the line under it, and the HTML did, but the SVG trimmed
/// the run to nothing and dropped it with its decoration (#586).
#[test]
fn a_decorated_run_of_blanks_is_drawn_in_the_svg() -> termlens::Result<()> {
let screen = Screen::parse(
"size: 12x1 cursor: hidden\nName: | |\n\nstyles:\n0: 5-7 underline\n0: 9-10 strikethrough",
)?;
let svg = screen.to_svg();
assert!(
svg.contains("text-decoration=\"underline\"> </text>"),
"the underlined field is drawn:\n{svg}"
);
assert!(
svg.contains("text-decoration=\"line-through\"> </text>"),
"the struck-through blanks are drawn:\n{svg}"
);
assert_well_formed_xml(&svg);
Ok(())
}

/// U+FFFF in a cell went into the SVG as itself, and XML forbids it: no
/// parser opened the file (#586). It is drawn as U+FFFD instead.
#[test]
fn a_character_xml_forbids_in_a_cell_is_replaced() -> termlens::Result<()> {
let screen = Screen::parse("size: 3x1 cursor: hidden\na\u{ffff}b\n\nstyles:\n0: 0-2 bold")?;
assert_eq!(screen.row_text(0), "a\u{ffff}b", "the cell holds it");
let svg = screen.to_svg();
assert!(svg.contains(">a\u{fffd}b</text>"), "{svg}");
assert_well_formed_xml(&svg);
let html = screen.to_html();
assert!(!html.contains('\u{ffff}'), "{html}");
Ok(())
}

/// A window title is the application's, and a C0 control in it went into
/// the SVG's `<title>` as itself (#586).
#[test]
#[cfg_attr(
windows,
ignore = "ConPTY rewrites the stream before termlens sees it, so the title under test is its rendering (#149)"
)]
fn a_control_character_in_the_title_is_replaced() -> termlens::Result<()> {
let mut t = emit(&["--raw", r"\e]0;a\x01b\aREADY", "--wait"])?;
t.wait_until(|s| s.contains("READY"))?;
let screen = t.screen();
assert_eq!(screen.title(), "a\u{1}b", "the title holds it");
let svg = screen.to_svg();
assert!(svg.contains(": a\u{fffd}b</title>"), "{svg}");
assert_well_formed_xml(&svg);
t.send(Key::Enter)?;
t.wait_exit()?;
Ok(())
}

#[cfg(feature = "serde")]
mod json {
use super::*;
Expand Down
Loading