Skip to content

Commit 3ccd544

Browse files
committed
feat: add HTML rendering
1 parent 94847b3 commit 3ccd544

File tree

5 files changed

+2582
-76
lines changed

5 files changed

+2582
-76
lines changed

crates/anstyle-svg/src/lib.rs

Lines changed: 209 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
//! Convert ANSI escape codes to SVG
1+
//! Convert ANSI escape codes to SVG and HTML
22
//!
33
//! See [`Term`]
44
//!
5-
//! # Example
5+
//! # SVG Example
66
//!
77
//! ```
88
//! # use anstyle_svg::Term;
@@ -11,6 +11,14 @@
1111
//! ```
1212
//!
1313
//! ![demo of supported styles](https://raw.githubusercontent.com/rust-cli/anstyle/main/crates/anstyle-svg/tests/rainbow.svg "Example output")
14+
//!
15+
//! # HTML Example
16+
//!
17+
//! ```
18+
//! # use anstyle_svg::Term;
19+
//! let vte = std::fs::read_to_string("tests/rainbow.vte").unwrap();
20+
//! let html = Term::new().render_html(&vte);
21+
//! ```
1422
1523
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
1624
#![warn(missing_docs)]
@@ -92,18 +100,8 @@ impl Term {
92100

93101
let mut styled = adapter::AnsiBytes::new();
94102
let mut elements = styled.extract_next(ansi.as_bytes()).collect::<Vec<_>>();
95-
let mut effects_in_use = anstyle::Effects::new();
96-
for element in &mut elements {
97-
let style = &mut element.style;
98-
// Pre-process INVERT to make fg/bg calculations easier
99-
if style.get_effects().contains(anstyle::Effects::INVERT) {
100-
*style = style
101-
.fg_color(Some(style.get_bg_color().unwrap_or(self.bg_color)))
102-
.bg_color(Some(style.get_fg_color().unwrap_or(self.fg_color)))
103-
.effects(style.get_effects().remove(anstyle::Effects::INVERT));
104-
}
105-
effects_in_use |= style.get_effects();
106-
}
103+
preprocess_invert_style(&mut elements, self.bg_color, self.fg_color);
104+
107105
let styled_lines = split_lines(&elements);
108106

109107
let fg_color = rgb_value(self.fg_color, self.palette);
@@ -152,60 +150,7 @@ impl Term {
152150
writeln!(&mut buffer, r#" padding: 0 10px;"#).unwrap();
153151
writeln!(&mut buffer, r#" line-height: {line_height}px;"#).unwrap();
154152
writeln!(&mut buffer, r#" }}"#).unwrap();
155-
if effects_in_use.contains(anstyle::Effects::BOLD) {
156-
writeln!(&mut buffer, r#" .bold {{ font-weight: bold; }}"#).unwrap();
157-
}
158-
if effects_in_use.contains(anstyle::Effects::ITALIC) {
159-
writeln!(&mut buffer, r#" .italic {{ font-style: italic; }}"#).unwrap();
160-
}
161-
if effects_in_use.contains(anstyle::Effects::UNDERLINE) {
162-
writeln!(
163-
&mut buffer,
164-
r#" .underline {{ text-decoration-line: underline; }}"#
165-
)
166-
.unwrap();
167-
}
168-
if effects_in_use.contains(anstyle::Effects::DOUBLE_UNDERLINE) {
169-
writeln!(
170-
&mut buffer,
171-
r#" .double-underline {{ text-decoration-line: underline; text-decoration-style: double; }}"#
172-
)
173-
.unwrap();
174-
}
175-
if effects_in_use.contains(anstyle::Effects::CURLY_UNDERLINE) {
176-
writeln!(
177-
&mut buffer,
178-
r#" .curly-underline {{ text-decoration-line: underline; text-decoration-style: wavy; }}"#
179-
)
180-
.unwrap();
181-
}
182-
if effects_in_use.contains(anstyle::Effects::DOTTED_UNDERLINE) {
183-
writeln!(
184-
&mut buffer,
185-
r#" .dotted-underline {{ text-decoration-line: underline; text-decoration-style: dotted; }}"#
186-
)
187-
.unwrap();
188-
}
189-
if effects_in_use.contains(anstyle::Effects::DASHED_UNDERLINE) {
190-
writeln!(
191-
&mut buffer,
192-
r#" .dashed-underline {{ text-decoration-line: underline; text-decoration-style: dashed; }}"#
193-
)
194-
.unwrap();
195-
}
196-
if effects_in_use.contains(anstyle::Effects::STRIKETHROUGH) {
197-
writeln!(
198-
&mut buffer,
199-
r#" .strikethrough {{ text-decoration-line: line-through; }}"#
200-
)
201-
.unwrap();
202-
}
203-
if effects_in_use.contains(anstyle::Effects::DIMMED) {
204-
writeln!(&mut buffer, r#" .dimmed {{ opacity: 0.7; }}"#).unwrap();
205-
}
206-
if effects_in_use.contains(anstyle::Effects::HIDDEN) {
207-
writeln!(&mut buffer, r#" .hidden {{ opacity: 0; }}"#).unwrap();
208-
}
153+
write_effects_in_use(&mut buffer, &elements);
209154
writeln!(&mut buffer, r#" tspan {{"#).unwrap();
210155
writeln!(&mut buffer, r#" font: 14px {font_family};"#).unwrap();
211156
writeln!(&mut buffer, r#" white-space: pre;"#).unwrap();
@@ -237,7 +182,7 @@ impl Term {
237182
if element.text.is_empty() {
238183
continue;
239184
}
240-
write_bg_span(&mut buffer, &element.style, &element.text);
185+
write_bg_span(&mut buffer, "tspan", &element.style, &element.text);
241186
}
242187
// HACK: must close tspan on newline to include them in copy/paste
243188
writeln!(&mut buffer).unwrap();
@@ -249,7 +194,7 @@ impl Term {
249194
if element.text.is_empty() {
250195
continue;
251196
}
252-
write_fg_span(&mut buffer, element, &element.text);
197+
write_fg_span(&mut buffer, "tspan", element, &element.text);
253198
}
254199
// HACK: must close tspan on newline to include them in copy/paste
255200
writeln!(&mut buffer).unwrap();
@@ -263,12 +208,183 @@ impl Term {
263208
writeln!(&mut buffer, r#"</svg>"#).unwrap();
264209
buffer
265210
}
211+
212+
/// Render the HTML with the terminal defined
213+
///
214+
/// **Note:** Lines are not wrapped. This is intentional as this attempts to convey the exact
215+
/// output with escape codes translated to HTML elements.
216+
pub fn render_html(&self, ansi: &str) -> String {
217+
use std::fmt::Write as _;
218+
219+
const FG: &str = "fg";
220+
const BG: &str = "bg";
221+
222+
let mut styled = adapter::AnsiBytes::new();
223+
let mut elements = styled.extract_next(ansi.as_bytes()).collect::<Vec<_>>();
224+
preprocess_invert_style(&mut elements, self.bg_color, self.fg_color);
225+
226+
let styled_lines = split_lines(&elements);
227+
228+
let fg_color = rgb_value(self.fg_color, self.palette);
229+
let bg_color = rgb_value(self.bg_color, self.palette);
230+
let font_family = self.font_family;
231+
232+
let line_height = 18;
233+
234+
let mut buffer = String::new();
235+
writeln!(&mut buffer, r#"<!DOCTYPE html>"#).unwrap();
236+
writeln!(&mut buffer, r#"<html>"#).unwrap();
237+
writeln!(&mut buffer, r#"<head>"#).unwrap();
238+
writeln!(&mut buffer, r#" <meta charset="UTF-8">"#).unwrap();
239+
writeln!(
240+
&mut buffer,
241+
r#" <meta name="viewport" content="width=device-width, initial-scale=1.0">"#
242+
)
243+
.unwrap();
244+
writeln!(
245+
&mut buffer,
246+
r#" <meta http-equiv="X-UA-Compatible" content="ie=edge">"#
247+
)
248+
.unwrap();
249+
writeln!(&mut buffer, r#" <style>"#).unwrap();
250+
writeln!(&mut buffer, r#" .{FG} {{ color: {fg_color} }}"#).unwrap();
251+
writeln!(&mut buffer, r#" .{BG} {{ background: {bg_color} }}"#).unwrap();
252+
for (name, rgb) in color_styles(&elements, self.palette) {
253+
if name.starts_with(FG_PREFIX) {
254+
writeln!(&mut buffer, r#" .{name} {{ color: {rgb} }}"#).unwrap();
255+
}
256+
if name.starts_with(BG_PREFIX) {
257+
writeln!(
258+
&mut buffer,
259+
r#" .{name} {{ background: {rgb}; user-select: none; }}"#
260+
)
261+
.unwrap();
262+
}
263+
if name.starts_with(UNDERLINE_PREFIX) {
264+
writeln!(
265+
&mut buffer,
266+
r#" .{name} {{ text-decoration-line: underline; text-decoration-color: {rgb} }}"#
267+
)
268+
.unwrap();
269+
}
270+
}
271+
writeln!(&mut buffer, r#" .container {{"#).unwrap();
272+
writeln!(&mut buffer, r#" line-height: {line_height}px;"#).unwrap();
273+
writeln!(&mut buffer, r#" }}"#).unwrap();
274+
write_effects_in_use(&mut buffer, &elements);
275+
writeln!(&mut buffer, r#" span {{"#).unwrap();
276+
writeln!(&mut buffer, r#" font: 14px {font_family};"#).unwrap();
277+
writeln!(&mut buffer, r#" white-space: pre;"#).unwrap();
278+
writeln!(&mut buffer, r#" line-height: {line_height}px;"#).unwrap();
279+
writeln!(&mut buffer, r#" }}"#).unwrap();
280+
writeln!(&mut buffer, r#" </style>"#).unwrap();
281+
writeln!(&mut buffer, r#"</head>"#).unwrap();
282+
writeln!(&mut buffer).unwrap();
283+
284+
if !self.background {
285+
writeln!(&mut buffer, r#"<body>"#).unwrap();
286+
} else {
287+
writeln!(&mut buffer, r#"<body class="{BG}">"#).unwrap();
288+
}
289+
writeln!(&mut buffer).unwrap();
290+
291+
writeln!(&mut buffer, r#" <div class="container {FG}">"#).unwrap();
292+
for line in &styled_lines {
293+
if line.iter().any(|e| e.style.get_bg_color().is_some()) {
294+
for element in line {
295+
if element.text.is_empty() {
296+
continue;
297+
}
298+
write_bg_span(&mut buffer, "span", &element.style, &element.text);
299+
}
300+
writeln!(&mut buffer, r#"<br />"#).unwrap();
301+
}
302+
303+
for element in line {
304+
if element.text.is_empty() {
305+
continue;
306+
}
307+
write_fg_span(&mut buffer, "span", element, &element.text);
308+
}
309+
writeln!(&mut buffer, r#"<br />"#).unwrap();
310+
}
311+
writeln!(&mut buffer, r#" </div>"#).unwrap();
312+
writeln!(&mut buffer).unwrap();
313+
314+
writeln!(&mut buffer, r#"</body>"#).unwrap();
315+
writeln!(&mut buffer, r#"</html>"#).unwrap();
316+
buffer
317+
}
266318
}
267319

268320
const FG_COLOR: anstyle::Color = anstyle::Color::Ansi(anstyle::AnsiColor::White);
269321
const BG_COLOR: anstyle::Color = anstyle::Color::Ansi(anstyle::AnsiColor::Black);
270322

271-
fn write_fg_span(buffer: &mut String, element: &adapter::Element, fragment: &str) {
323+
fn write_effects_in_use(buffer: &mut String, elements: &[adapter::Element]) {
324+
use std::fmt::Write as _;
325+
326+
let mut effects_in_use = anstyle::Effects::new();
327+
for element in elements {
328+
effects_in_use |= element.style.get_effects();
329+
}
330+
331+
if effects_in_use.contains(anstyle::Effects::BOLD) {
332+
writeln!(buffer, r#" .bold {{ font-weight: bold; }}"#).unwrap();
333+
}
334+
if effects_in_use.contains(anstyle::Effects::ITALIC) {
335+
writeln!(buffer, r#" .italic {{ font-style: italic; }}"#).unwrap();
336+
}
337+
if effects_in_use.contains(anstyle::Effects::UNDERLINE) {
338+
writeln!(
339+
buffer,
340+
r#" .underline {{ text-decoration-line: underline; }}"#
341+
)
342+
.unwrap();
343+
}
344+
if effects_in_use.contains(anstyle::Effects::DOUBLE_UNDERLINE) {
345+
writeln!(
346+
buffer,
347+
r#" .double-underline {{ text-decoration-line: underline; text-decoration-style: double; }}"#
348+
)
349+
.unwrap();
350+
}
351+
if effects_in_use.contains(anstyle::Effects::CURLY_UNDERLINE) {
352+
writeln!(
353+
buffer,
354+
r#" .curly-underline {{ text-decoration-line: underline; text-decoration-style: wavy; }}"#
355+
)
356+
.unwrap();
357+
}
358+
if effects_in_use.contains(anstyle::Effects::DOTTED_UNDERLINE) {
359+
writeln!(
360+
buffer,
361+
r#" .dotted-underline {{ text-decoration-line: underline; text-decoration-style: dotted; }}"#
362+
)
363+
.unwrap();
364+
}
365+
if effects_in_use.contains(anstyle::Effects::DASHED_UNDERLINE) {
366+
writeln!(
367+
buffer,
368+
r#" .dashed-underline {{ text-decoration-line: underline; text-decoration-style: dashed; }}"#
369+
)
370+
.unwrap();
371+
}
372+
if effects_in_use.contains(anstyle::Effects::STRIKETHROUGH) {
373+
writeln!(
374+
buffer,
375+
r#" .strikethrough {{ text-decoration-line: line-through; }}"#
376+
)
377+
.unwrap();
378+
}
379+
if effects_in_use.contains(anstyle::Effects::DIMMED) {
380+
writeln!(buffer, r#" .dimmed {{ opacity: 0.7; }}"#).unwrap();
381+
}
382+
if effects_in_use.contains(anstyle::Effects::HIDDEN) {
383+
writeln!(buffer, r#" .hidden {{ opacity: 0; }}"#).unwrap();
384+
}
385+
}
386+
387+
fn write_fg_span(buffer: &mut String, span: &str, element: &adapter::Element, fragment: &str) {
272388
use std::fmt::Write as _;
273389
let style = element.style;
274390
let fg_color = style.get_fg_color().map(|c| color_name(FG_PREFIX, c));
@@ -329,7 +445,7 @@ fn write_fg_span(buffer: &mut String, element: &adapter::Element, fragment: &str
329445

330446
let mut need_closing_a = false;
331447

332-
write!(buffer, r#"<tspan"#).unwrap();
448+
write!(buffer, r#"<{span}"#).unwrap();
333449
if !classes.is_empty() {
334450
let classes = classes.join(" ");
335451
write!(buffer, r#" class="{classes}""#).unwrap();
@@ -343,10 +459,10 @@ fn write_fg_span(buffer: &mut String, element: &adapter::Element, fragment: &str
343459
if need_closing_a {
344460
write!(buffer, r#"</a>"#).unwrap();
345461
}
346-
write!(buffer, r#"</tspan>"#).unwrap();
462+
write!(buffer, r#"</{span}>"#).unwrap();
347463
}
348464

349-
fn write_bg_span(buffer: &mut String, style: &anstyle::Style, fragment: &str) {
465+
fn write_bg_span(buffer: &mut String, span: &str, style: &anstyle::Style, fragment: &str) {
350466
use std::fmt::Write as _;
351467
use unicode_width::UnicodeWidthStr;
352468

@@ -361,14 +477,14 @@ fn write_bg_span(buffer: &mut String, style: &anstyle::Style, fragment: &str) {
361477
if let Some(class) = bg_color.as_deref() {
362478
classes.push(class);
363479
}
364-
write!(buffer, r#"<tspan"#).unwrap();
480+
write!(buffer, r#"<{span}"#).unwrap();
365481
if !classes.is_empty() {
366482
let classes = classes.join(" ");
367483
write!(buffer, r#" class="{classes}""#).unwrap();
368484
}
369485
write!(buffer, r#">"#).unwrap();
370486
write!(buffer, "{fragment}").unwrap();
371-
write!(buffer, r#"</tspan>"#).unwrap();
487+
write!(buffer, r#"</{span}>"#).unwrap();
372488
}
373489

374490
impl Default for Term {
@@ -449,6 +565,23 @@ fn color_styles(
449565
colors.into_iter()
450566
}
451567

568+
fn preprocess_invert_style(
569+
elements: &mut [adapter::Element],
570+
bg_color: anstyle::Color,
571+
fg_color: anstyle::Color,
572+
) {
573+
for element in elements {
574+
let style = &mut element.style;
575+
// Pre-process INVERT to make fg/bg calculations easier
576+
if style.get_effects().contains(anstyle::Effects::INVERT) {
577+
*style = style
578+
.fg_color(Some(style.get_bg_color().unwrap_or(bg_color)))
579+
.bg_color(Some(style.get_fg_color().unwrap_or(fg_color)))
580+
.effects(style.get_effects().remove(anstyle::Effects::INVERT));
581+
}
582+
}
583+
}
584+
452585
fn split_lines(styled: &[adapter::Element]) -> Vec<Vec<adapter::Element>> {
453586
let mut lines = Vec::new();
454587
let mut current_line = Vec::new();

0 commit comments

Comments
 (0)