1use crate::content_renderer::{ContentRenderer, RenderContext, RendererCapabilities};
11use shape_value::content::{ChartSpec, Color, ContentNode, ContentTable, NamedColor, Style};
12use std::fmt::Write;
13
14pub struct HtmlRenderer {
19 pub ctx: RenderContext,
20}
21
22impl HtmlRenderer {
23 pub fn new() -> Self {
24 Self {
25 ctx: RenderContext::html(),
26 }
27 }
28
29 pub fn with_context(ctx: RenderContext) -> Self {
30 Self { ctx }
31 }
32}
33
34impl Default for HtmlRenderer {
35 fn default() -> Self {
36 Self::new()
37 }
38}
39
40impl ContentRenderer for HtmlRenderer {
41 fn capabilities(&self) -> RendererCapabilities {
42 RendererCapabilities::html()
43 }
44
45 fn render(&self, content: &ContentNode) -> String {
46 render_node(content, self.ctx.interactive)
47 }
48}
49
50fn render_node(node: &ContentNode, interactive: bool) -> String {
51 match node {
52 ContentNode::Text(st) => {
53 let mut out = String::new();
54 for span in &st.spans {
55 let css = style_to_css(&span.style);
56 if css.is_empty() {
57 let _ = write!(out, "{}", html_escape(&span.text));
58 } else {
59 let _ = write!(
60 out,
61 "<span style=\"{}\">{}</span>",
62 css,
63 html_escape(&span.text)
64 );
65 }
66 }
67 out
68 }
69 ContentNode::Table(table) => render_table(table, interactive),
70 ContentNode::Code { language, source } => render_code(language.as_deref(), source),
71 ContentNode::Chart(spec) => render_chart(spec, interactive),
72 ContentNode::KeyValue(pairs) => render_key_value(pairs, interactive),
73 ContentNode::Fragment(parts) => parts.iter().map(|n| render_node(n, interactive)).collect(),
74 }
75}
76
77fn style_to_css(style: &Style) -> String {
78 let mut parts = Vec::new();
79 if style.bold {
80 parts.push("font-weight:bold".to_string());
81 }
82 if style.italic {
83 parts.push("font-style:italic".to_string());
84 }
85 if style.underline {
86 parts.push("text-decoration:underline".to_string());
87 }
88 if style.dim {
89 parts.push("opacity:0.6".to_string());
90 }
91 if let Some(ref color) = style.fg {
92 parts.push(format!("color:{}", color_to_css(color)));
93 }
94 if let Some(ref color) = style.bg {
95 parts.push(format!("background-color:{}", color_to_css(color)));
96 }
97 parts.join(";")
98}
99
100fn color_to_css(color: &Color) -> String {
101 match color {
102 Color::Named(named) => named_to_css(*named).to_string(),
103 Color::Rgb(r, g, b) => format!("rgb({},{},{})", r, g, b),
104 }
105}
106
107fn named_to_css(color: NamedColor) -> &'static str {
108 match color {
109 NamedColor::Red => "red",
110 NamedColor::Green => "green",
111 NamedColor::Blue => "blue",
112 NamedColor::Yellow => "yellow",
113 NamedColor::Magenta => "magenta",
114 NamedColor::Cyan => "cyan",
115 NamedColor::White => "white",
116 NamedColor::Default => "inherit",
117 }
118}
119
120fn html_escape(s: &str) -> String {
121 s.replace('&', "&")
122 .replace('<', "<")
123 .replace('>', ">")
124 .replace('"', """)
125}
126
127fn render_table(table: &ContentTable, interactive: bool) -> String {
128 let mut out = String::from("<table>\n");
129
130 if !table.headers.is_empty() {
132 out.push_str("<thead><tr>");
133 for header in &table.headers {
134 let _ = write!(out, "<th>{}</th>", html_escape(header));
135 }
136 out.push_str("</tr></thead>\n");
137 }
138
139 let limit = table.max_rows.unwrap_or(table.rows.len());
141 let display_rows = &table.rows[..limit.min(table.rows.len())];
142 let truncated = table.rows.len().saturating_sub(limit);
143
144 out.push_str("<tbody>\n");
145 for row in display_rows {
146 out.push_str("<tr>");
147 for cell in row {
148 let _ = write!(out, "<td>{}</td>", render_node(cell, interactive));
149 }
150 out.push_str("</tr>\n");
151 }
152 if truncated > 0 {
153 let _ = write!(
154 out,
155 "<tr><td colspan=\"{}\">... {} more rows</td></tr>\n",
156 table.headers.len(),
157 truncated
158 );
159 }
160 out.push_str("</tbody>\n</table>");
161 out
162}
163
164fn render_code(language: Option<&str>, source: &str) -> String {
165 let lang_attr = language
166 .map(|l| format!(" class=\"language-{}\"", html_escape(l)))
167 .unwrap_or_default();
168 format!(
169 "<pre><code{}>{}</code></pre>",
170 lang_attr,
171 html_escape(source)
172 )
173}
174
175fn render_chart(spec: &ChartSpec, interactive: bool) -> String {
176 let title = spec.title.as_deref().unwrap_or("untitled");
177 let type_name = chart_type_display_name(spec.chart_type);
178 let y_count = spec.channels_by_name("y").len();
179 if interactive {
180 let echarts_json = build_echarts_option(spec, type_name);
182 let escaped_json = html_escape(&echarts_json);
183 format!(
184 "<div class=\"chart\" data-echarts=\"true\" data-type=\"{}\" data-title=\"{}\" data-chart-options=\"{}\">[{} Chart: {}]</div>",
185 type_name.to_lowercase(),
186 html_escape(title),
187 escaped_json,
188 type_name,
189 html_escape(title)
190 )
191 } else {
192 format!(
193 "<div class=\"chart\" data-type=\"{}\" data-series=\"{}\">[{} Chart: {}]</div>",
194 type_name.to_lowercase(),
195 y_count,
196 type_name,
197 html_escape(title)
198 )
199 }
200}
201
202fn chart_type_display_name(ct: shape_value::content::ChartType) -> &'static str {
203 use shape_value::content::ChartType;
204 match ct {
205 ChartType::Line => "Line",
206 ChartType::Bar => "Bar",
207 ChartType::Scatter => "Scatter",
208 ChartType::Area => "Area",
209 ChartType::Candlestick => "Candlestick",
210 ChartType::Histogram => "Histogram",
211 ChartType::BoxPlot => "BoxPlot",
212 ChartType::Heatmap => "Heatmap",
213 ChartType::Bubble => "Bubble",
214 }
215}
216
217fn build_echarts_option(spec: &ChartSpec, type_name: &str) -> String {
219 if let Some(ref opts) = spec.echarts_options {
221 return serde_json::to_string(opts).unwrap_or_default();
222 }
223
224 let chart_type = type_name.to_lowercase();
225
226 let use_category = matches!(chart_type.as_str(), "bar" | "histogram");
228
229 let x_channel = spec.channel("x");
231 let y_channels = spec.channels_by_name("y");
232
233 let categories: Vec<serde_json::Value> = if use_category {
235 if let Some(ref cats) = spec.x_categories {
236 cats.iter().map(|c| serde_json::json!(c)).collect()
237 } else if let Some(xc) = x_channel {
238 xc.values
239 .iter()
240 .map(|x| {
241 if x.fract() == 0.0 {
242 serde_json::json!(*x as i64)
243 } else {
244 serde_json::json!(x)
245 }
246 })
247 .collect()
248 } else {
249 vec![]
250 }
251 } else {
252 vec![]
253 };
254
255 let series: Vec<serde_json::Value> = if y_channels.is_empty() {
257 vec![serde_json::json!({"type": chart_type, "data": []})]
258 } else {
259 y_channels
260 .iter()
261 .map(|yc| {
262 if use_category {
263 let data: Vec<serde_json::Value> =
264 yc.values.iter().map(|y| serde_json::json!(y)).collect();
265 serde_json::json!({
266 "name": yc.label,
267 "type": chart_type,
268 "data": data,
269 })
270 } else {
271 let x_vals = x_channel.map(|xc| &xc.values[..]).unwrap_or(&[]);
273 let data: Vec<serde_json::Value> = yc
274 .values
275 .iter()
276 .enumerate()
277 .map(|(i, y)| {
278 let x = x_vals.get(i).copied().unwrap_or(i as f64);
279 serde_json::json!([x, y])
280 })
281 .collect();
282 serde_json::json!({
283 "name": yc.label,
284 "type": chart_type,
285 "data": data,
286 "smooth": false,
287 })
288 }
289 })
290 .collect()
291 };
292
293 let mut option = serde_json::json!({
294 "tooltip": {"trigger": "axis"},
295 "series": series,
296 "backgroundColor": "transparent",
297 });
298
299 if let Some(ref t) = spec.title {
300 option["title"] =
301 serde_json::json!({"text": t, "textStyle": {"color": "#ccc", "fontSize": 14}});
302 }
303
304 let x_axis_type = if use_category { "category" } else { "value" };
306 let mut x_axis = serde_json::json!({
307 "type": x_axis_type,
308 "axisLabel": {"color": "#888"},
309 "axisLine": {"lineStyle": {"color": "#555"}},
310 });
311 if use_category && !categories.is_empty() {
312 x_axis["data"] = serde_json::json!(categories);
313 }
314 if let Some(ref xl) = spec.x_label {
315 x_axis["name"] = serde_json::json!(xl);
316 x_axis["nameTextStyle"] = serde_json::json!({"color": "#888"});
317 }
318 option["xAxis"] = x_axis;
319
320 let mut y_axis = serde_json::json!({
321 "type": "value",
322 "axisLabel": {"color": "#888"},
323 "splitLine": {"lineStyle": {"color": "#333"}},
324 });
325 if let Some(ref yl) = spec.y_label {
326 y_axis["name"] = serde_json::json!(yl);
327 y_axis["nameTextStyle"] = serde_json::json!({"color": "#888"});
328 }
329 option["yAxis"] = y_axis;
330
331 if y_channels.len() > 1 {
332 option["legend"] = serde_json::json!({"show": true, "textStyle": {"color": "#ccc"}});
333 }
334
335 option["grid"] =
336 serde_json::json!({"left": "10%", "right": "10%", "bottom": "10%", "top": "15%"});
337
338 serde_json::to_string(&option).unwrap_or_default()
339}
340
341fn render_key_value(pairs: &[(String, ContentNode)], interactive: bool) -> String {
342 let mut out = String::from("<dl>\n");
343 for (key, value) in pairs {
344 let _ = write!(
345 out,
346 "<dt>{}</dt><dd>{}</dd>\n",
347 html_escape(key),
348 render_node(value, interactive)
349 );
350 }
351 out.push_str("</dl>");
352 out
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use shape_value::content::{BorderStyle, ContentTable};
359
360 fn renderer() -> HtmlRenderer {
361 HtmlRenderer::new()
362 }
363
364 #[test]
365 fn test_plain_text_html() {
366 let node = ContentNode::plain("hello world");
367 let output = renderer().render(&node);
368 assert_eq!(output, "hello world");
369 }
370
371 #[test]
372 fn test_bold_text_html() {
373 let node = ContentNode::plain("bold").with_bold();
374 let output = renderer().render(&node);
375 assert!(output.contains("font-weight:bold"));
376 assert!(output.contains("<span"));
377 assert!(output.contains("bold"));
378 }
379
380 #[test]
381 fn test_fg_color_html() {
382 let node = ContentNode::plain("red").with_fg(Color::Named(NamedColor::Red));
383 let output = renderer().render(&node);
384 assert!(output.contains("color:red"));
385 }
386
387 #[test]
388 fn test_rgb_color_html() {
389 let node = ContentNode::plain("custom").with_fg(Color::Rgb(255, 128, 0));
390 let output = renderer().render(&node);
391 assert!(output.contains("color:rgb(255,128,0)"));
392 }
393
394 #[test]
395 fn test_html_table() {
396 let table = ContentNode::Table(ContentTable {
397 headers: vec!["Name".into(), "Age".into()],
398 rows: vec![vec![ContentNode::plain("Alice"), ContentNode::plain("30")]],
399 border: BorderStyle::default(),
400 max_rows: None,
401 column_types: None,
402 total_rows: None,
403 sortable: false,
404 });
405 let output = renderer().render(&table);
406 assert!(output.contains("<table>"));
407 assert!(output.contains("<th>Name</th>"));
408 assert!(output.contains("<td>Alice</td>"));
409 assert!(output.contains("</table>"));
410 }
411
412 #[test]
413 fn test_html_table_truncation() {
414 let table = ContentNode::Table(ContentTable {
415 headers: vec!["X".into()],
416 rows: vec![
417 vec![ContentNode::plain("1")],
418 vec![ContentNode::plain("2")],
419 vec![ContentNode::plain("3")],
420 ],
421 border: BorderStyle::default(),
422 max_rows: Some(1),
423 column_types: None,
424 total_rows: None,
425 sortable: false,
426 });
427 let output = renderer().render(&table);
428 assert!(output.contains("... 2 more rows"));
429 }
430
431 #[test]
432 fn test_html_code() {
433 let code = ContentNode::Code {
434 language: Some("rust".into()),
435 source: "fn main() {}".into(),
436 };
437 let output = renderer().render(&code);
438 assert!(output.contains("<pre><code class=\"language-rust\">"));
439 assert!(output.contains("fn main() {}"));
440 }
441
442 #[test]
443 fn test_html_escape() {
444 let node = ContentNode::plain("<script>alert('xss')</script>");
445 let output = renderer().render(&node);
446 assert!(!output.contains("<script>"));
447 assert!(output.contains("<script>"));
448 }
449
450 #[test]
451 fn test_html_kv() {
452 let kv = ContentNode::KeyValue(vec![("name".into(), ContentNode::plain("Alice"))]);
453 let output = renderer().render(&kv);
454 assert!(output.contains("<dl>"));
455 assert!(output.contains("<dt>name</dt>"));
456 assert!(output.contains("<dd>Alice</dd>"));
457 }
458
459 #[test]
460 fn test_html_fragment() {
461 let frag = ContentNode::Fragment(vec![
462 ContentNode::plain("hello "),
463 ContentNode::plain("world"),
464 ]);
465 let output = renderer().render(&frag);
466 assert_eq!(output, "hello world");
467 }
468
469 #[test]
470 fn test_html_chart() {
471 let chart = ContentNode::Chart(shape_value::content::ChartSpec {
472 chart_type: shape_value::content::ChartType::Bar,
473 channels: vec![],
474 x_categories: None,
475 title: Some("Sales".into()),
476 x_label: None,
477 y_label: None,
478 width: None,
479 height: None,
480 echarts_options: None,
481 interactive: true,
482 });
483 let output = renderer().render(&chart);
484 assert!(output.contains("data-type=\"bar\""));
485 assert!(output.contains("Sales"));
486 }
487}