Skip to main content

dkit_core/format/
html.rs

1use std::io::Write;
2
3use crate::format::FormatWriter;
4use crate::value::Value;
5
6/// HTML 테이블 포맷 Writer (출력 전용)
7///
8/// - Array<Object> → 컬럼 헤더 + 데이터 행 테이블
9/// - Array<Primitive> → 단일 "value" 컬럼 테이블
10/// - Single Object → key | value 2-컬럼 테이블
11/// - Primitive → 단일 값 출력
12pub struct HtmlWriter {
13    /// 인라인 CSS 스타일 포함 여부
14    styled: bool,
15    /// 완전한 HTML 문서로 출력할지 여부
16    full_html: bool,
17}
18
19impl HtmlWriter {
20    pub fn new(styled: bool, full_html: bool) -> Self {
21        Self { styled, full_html }
22    }
23}
24
25impl FormatWriter for HtmlWriter {
26    fn write(&self, value: &Value) -> anyhow::Result<String> {
27        Ok(render_html(value, self.styled, self.full_html))
28    }
29
30    fn write_to_writer(&self, value: &Value, mut writer: impl Write) -> anyhow::Result<()> {
31        let output = render_html(value, self.styled, self.full_html);
32        writer.write_all(output.as_bytes())?;
33        Ok(())
34    }
35}
36
37fn render_html(value: &Value, styled: bool, full_html: bool) -> String {
38    let table = match value {
39        Value::Array(arr) if !arr.is_empty() && arr[0].as_object().is_some() => {
40            render_array_of_objects(arr, styled)
41        }
42        Value::Array(arr) => render_array_of_primitives(arr, styled),
43        Value::Object(_) => render_single_object(value, styled),
44        _ => return escape_html(&format_cell_value(value)),
45    };
46
47    if full_html {
48        wrap_full_html(&table, styled)
49    } else {
50        table
51    }
52}
53
54fn wrap_full_html(table: &str, styled: bool) -> String {
55    let style_block = if styled {
56        "\n    <style>\n      table { border-collapse: collapse; width: 100%; font-family: sans-serif; }\n      th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }\n      th { background-color: #4a4a4a; color: white; }\n      tr:nth-child(even) { background-color: #f9f9f9; }\n      tr:hover { background-color: #f1f1f1; }\n    </style>"
57    } else {
58        ""
59    };
60
61    format!(
62        "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\">{style_block}\n  </head>\n  <body>\n{table}\n  </body>\n</html>\n"
63    )
64}
65
66const TABLE_STYLE: &str =
67    " style=\"border-collapse: collapse; width: 100%; font-family: sans-serif;\"";
68const TH_STYLE: &str = " style=\"border: 1px solid #ddd; padding: 8px; text-align: left; background-color: #4a4a4a; color: white;\"";
69const TD_STYLE: &str = " style=\"border: 1px solid #ddd; padding: 8px; text-align: left;\"";
70
71/// Array<Object> → HTML 테이블
72fn render_array_of_objects(arr: &[Value], styled: bool) -> String {
73    let headers = collect_keys(arr);
74    if headers.is_empty() {
75        return String::new();
76    }
77
78    let table_attr = if styled { TABLE_STYLE } else { "" };
79    let th_attr = if styled { TH_STYLE } else { "" };
80    let td_attr = if styled { TD_STYLE } else { "" };
81
82    let mut lines = Vec::new();
83    lines.push(format!("    <table{table_attr}>"));
84    lines.push("      <thead>".to_string());
85    lines.push("        <tr>".to_string());
86    for h in &headers {
87        lines.push(format!("          <th{th_attr}>{}</th>", escape_html(h)));
88    }
89    lines.push("        </tr>".to_string());
90    lines.push("      </thead>".to_string());
91    lines.push("      <tbody>".to_string());
92
93    for item in arr {
94        if let Value::Object(obj) = item {
95            lines.push("        <tr>".to_string());
96            for key in &headers {
97                let cell = match obj.get(key) {
98                    Some(v) => escape_html(&format_cell_value(v)),
99                    None => String::new(),
100                };
101                lines.push(format!("          <td{td_attr}>{cell}</td>"));
102            }
103            lines.push("        </tr>".to_string());
104        }
105    }
106
107    lines.push("      </tbody>".to_string());
108    lines.push("    </table>".to_string());
109    lines.join("\n") + "\n"
110}
111
112/// Array<Primitive> → 단일 컬럼 HTML 테이블
113fn render_array_of_primitives(arr: &[Value], styled: bool) -> String {
114    let table_attr = if styled { TABLE_STYLE } else { "" };
115    let th_attr = if styled { TH_STYLE } else { "" };
116    let td_attr = if styled { TD_STYLE } else { "" };
117
118    let mut lines = Vec::new();
119    lines.push(format!("    <table{table_attr}>"));
120    lines.push("      <thead>".to_string());
121    lines.push(format!("        <tr><th{th_attr}>value</th></tr>"));
122    lines.push("      </thead>".to_string());
123    lines.push("      <tbody>".to_string());
124
125    for item in arr {
126        let cell = escape_html(&format_cell_value(item));
127        lines.push(format!("        <tr><td{td_attr}>{cell}</td></tr>"));
128    }
129
130    lines.push("      </tbody>".to_string());
131    lines.push("    </table>".to_string());
132    lines.join("\n") + "\n"
133}
134
135/// 단일 Object → key | value 테이블
136fn render_single_object(value: &Value, styled: bool) -> String {
137    let table_attr = if styled { TABLE_STYLE } else { "" };
138    let th_attr = if styled { TH_STYLE } else { "" };
139    let td_attr = if styled { TD_STYLE } else { "" };
140
141    let mut lines = Vec::new();
142    lines.push(format!("    <table{table_attr}>"));
143    lines.push("      <thead>".to_string());
144    lines.push(format!(
145        "        <tr><th{th_attr}>key</th><th{th_attr}>value</th></tr>"
146    ));
147    lines.push("      </thead>".to_string());
148    lines.push("      <tbody>".to_string());
149
150    if let Value::Object(obj) = value {
151        for (k, v) in obj {
152            lines.push(format!(
153                "        <tr><td{td_attr}>{}</td><td{td_attr}>{}</td></tr>",
154                escape_html(k),
155                escape_html(&format_cell_value(v))
156            ));
157        }
158    }
159
160    lines.push("      </tbody>".to_string());
161    lines.push("    </table>".to_string());
162    lines.join("\n") + "\n"
163}
164
165/// 모든 object에서 키를 순서 보존하며 수집
166fn collect_keys(arr: &[Value]) -> Vec<String> {
167    let mut keys: Vec<String> = Vec::new();
168    for item in arr {
169        if let Value::Object(obj) = item {
170            for k in obj.keys() {
171                if !keys.contains(k) {
172                    keys.push(k.clone());
173                }
174            }
175        }
176    }
177    keys
178}
179
180/// Value를 셀 표시용 문자열로 변환
181fn format_cell_value(v: &Value) -> String {
182    match v {
183        Value::Null => "null".to_string(),
184        Value::Bool(b) => b.to_string(),
185        Value::Integer(n) => n.to_string(),
186        Value::Float(f) => f.to_string(),
187        Value::String(s) => s.clone(),
188        Value::Array(_) | Value::Object(_) => {
189            serde_json::to_string(&value_to_json(v)).unwrap_or_else(|_| "{...}".to_string())
190        }
191    }
192}
193
194/// Value를 serde_json::Value로 변환
195fn value_to_json(v: &Value) -> serde_json::Value {
196    match v {
197        Value::Null => serde_json::Value::Null,
198        Value::Bool(b) => serde_json::Value::Bool(*b),
199        Value::Integer(n) => serde_json::json!(n),
200        Value::Float(f) => serde_json::json!(f),
201        Value::String(s) => serde_json::Value::String(s.clone()),
202        Value::Array(arr) => serde_json::Value::Array(arr.iter().map(value_to_json).collect()),
203        Value::Object(obj) => {
204            let map: serde_json::Map<String, serde_json::Value> = obj
205                .iter()
206                .map(|(k, v)| (k.clone(), value_to_json(v)))
207                .collect();
208            serde_json::Value::Object(map)
209        }
210    }
211}
212
213/// HTML 특수문자 이스케이프
214fn escape_html(s: &str) -> String {
215    s.replace('&', "&amp;")
216        .replace('<', "&lt;")
217        .replace('>', "&gt;")
218        .replace('"', "&quot;")
219        .replace('\'', "&#39;")
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::format::FormatWriter;
226    use indexmap::IndexMap;
227
228    fn make_user(name: &str, age: i64) -> Value {
229        let mut m = IndexMap::new();
230        m.insert("name".to_string(), Value::String(name.to_string()));
231        m.insert("age".to_string(), Value::Integer(age));
232        Value::Object(m)
233    }
234
235    #[test]
236    fn test_array_of_objects_basic() {
237        let data = Value::Array(vec![make_user("Alice", 30), make_user("Bob", 25)]);
238        let output = HtmlWriter::new(false, false).write(&data).unwrap();
239        assert!(output.contains("<table>"));
240        assert!(output.contains("<thead>"));
241        assert!(output.contains("<tbody>"));
242        assert!(output.contains("<th>name</th>"));
243        assert!(output.contains("<th>age</th>"));
244        assert!(output.contains("<td>Alice</td>"));
245        assert!(output.contains("<td>30</td>"));
246        assert!(output.contains("<td>Bob</td>"));
247        assert!(output.contains("<td>25</td>"));
248    }
249
250    #[test]
251    fn test_styled_output() {
252        let data = Value::Array(vec![make_user("Alice", 30)]);
253        let output = HtmlWriter::new(true, false).write(&data).unwrap();
254        assert!(output.contains("style="));
255        assert!(output.contains("border-collapse"));
256    }
257
258    #[test]
259    fn test_full_html_document() {
260        let data = Value::Array(vec![make_user("Alice", 30)]);
261        let output = HtmlWriter::new(false, true).write(&data).unwrap();
262        assert!(output.contains("<!DOCTYPE html>"));
263        assert!(output.contains("<html>"));
264        assert!(output.contains("<head>"));
265        assert!(output.contains("<meta charset=\"UTF-8\">"));
266        assert!(output.contains("<body>"));
267        assert!(output.contains("</html>"));
268        // No style block when not styled
269        assert!(!output.contains("<style>"));
270    }
271
272    #[test]
273    fn test_full_html_styled() {
274        let data = Value::Array(vec![make_user("Alice", 30)]);
275        let output = HtmlWriter::new(true, true).write(&data).unwrap();
276        assert!(output.contains("<!DOCTYPE html>"));
277        assert!(output.contains("<style>"));
278        assert!(output.contains("border-collapse"));
279    }
280
281    #[test]
282    fn test_html_entity_escape() {
283        let mut m = IndexMap::new();
284        m.insert(
285            "formula".to_string(),
286            Value::String("<script>alert('xss')</script>".to_string()),
287        );
288        let data = Value::Array(vec![Value::Object(m)]);
289        let output = HtmlWriter::new(false, false).write(&data).unwrap();
290        assert!(output.contains("&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"));
291        assert!(!output.contains("<script>"));
292    }
293
294    #[test]
295    fn test_ampersand_escape() {
296        let mut m = IndexMap::new();
297        m.insert("text".to_string(), Value::String("A & B".to_string()));
298        let data = Value::Array(vec![Value::Object(m)]);
299        let output = HtmlWriter::new(false, false).write(&data).unwrap();
300        assert!(output.contains("A &amp; B"));
301    }
302
303    #[test]
304    fn test_quote_escape() {
305        let mut m = IndexMap::new();
306        m.insert(
307            "attr".to_string(),
308            Value::String("say \"hello\"".to_string()),
309        );
310        let data = Value::Array(vec![Value::Object(m)]);
311        let output = HtmlWriter::new(false, false).write(&data).unwrap();
312        assert!(output.contains("say &quot;hello&quot;"));
313    }
314
315    #[test]
316    fn test_single_object() {
317        let mut m = IndexMap::new();
318        m.insert("host".to_string(), Value::String("localhost".to_string()));
319        m.insert("port".to_string(), Value::Integer(8080));
320        let data = Value::Object(m);
321        let output = HtmlWriter::new(false, false).write(&data).unwrap();
322        assert!(output.contains("<th>key</th>"));
323        assert!(output.contains("<th>value</th>"));
324        assert!(output.contains("<td>host</td>"));
325        assert!(output.contains("<td>localhost</td>"));
326        assert!(output.contains("<td>port</td>"));
327        assert!(output.contains("<td>8080</td>"));
328    }
329
330    #[test]
331    fn test_array_of_primitives() {
332        let data = Value::Array(vec![
333            Value::Integer(1),
334            Value::Integer(2),
335            Value::Integer(3),
336        ]);
337        let output = HtmlWriter::new(false, false).write(&data).unwrap();
338        assert!(output.contains("<th>value</th>"));
339        assert!(output.contains("<td>1</td>"));
340        assert!(output.contains("<td>2</td>"));
341        assert!(output.contains("<td>3</td>"));
342    }
343
344    #[test]
345    fn test_primitive_value() {
346        let data = Value::String("hello".to_string());
347        let output = HtmlWriter::new(false, false).write(&data).unwrap();
348        assert_eq!(output, "hello");
349    }
350
351    #[test]
352    fn test_null_value_in_cell() {
353        let mut m = IndexMap::new();
354        m.insert("name".to_string(), Value::String("Alice".to_string()));
355        m.insert("email".to_string(), Value::Null);
356        let data = Value::Array(vec![Value::Object(m)]);
357        let output = HtmlWriter::new(false, false).write(&data).unwrap();
358        assert!(output.contains("<td>null</td>"));
359    }
360
361    #[test]
362    fn test_nested_value_json_inline() {
363        let mut m = IndexMap::new();
364        m.insert(
365            "tags".to_string(),
366            Value::Array(vec![
367                Value::String("a".to_string()),
368                Value::String("b".to_string()),
369            ]),
370        );
371        let data = Value::Array(vec![Value::Object(m)]);
372        let output = HtmlWriter::new(false, false).write(&data).unwrap();
373        assert!(output.contains("[&quot;a&quot;,&quot;b&quot;]"));
374    }
375
376    #[test]
377    fn test_missing_fields() {
378        let mut m1 = IndexMap::new();
379        m1.insert("a".to_string(), Value::Integer(1));
380        m1.insert("b".to_string(), Value::Integer(2));
381        let mut m2 = IndexMap::new();
382        m2.insert("a".to_string(), Value::Integer(3));
383        let data = Value::Array(vec![Value::Object(m1), Value::Object(m2)]);
384        let output = HtmlWriter::new(false, false).write(&data).unwrap();
385        assert!(output.contains("<th>a</th>"));
386        assert!(output.contains("<th>b</th>"));
387        // m2 has no "b" field → empty cell
388        assert!(output.contains("<td></td>"));
389    }
390
391    #[test]
392    fn test_empty_array() {
393        let data = Value::Array(vec![]);
394        let output = HtmlWriter::new(false, false).write(&data).unwrap();
395        assert!(output.contains("<th>value</th>"));
396    }
397
398    #[test]
399    fn test_write_to_writer() {
400        let data = Value::Array(vec![make_user("Alice", 30)]);
401        let mut buf = Vec::new();
402        HtmlWriter::new(false, false)
403            .write_to_writer(&data, &mut buf)
404            .unwrap();
405        let output = String::from_utf8(buf).unwrap();
406        assert!(output.contains("<table>"));
407        assert!(output.contains("Alice"));
408    }
409}