Skip to main content

sdsforge_core/converter/
html.rs

1/// HTML generation for SDS data in JIS Z 7253 16-section layout.
2use crate::error::SdsError;
3use crate::language::Language;
4use crate::schema::SdsRoot;
5
6use super::generator::{lang_index, section_name, DOCUMENT_TITLE, SECTION_KEYS};
7
8/// Renamed to [`render_html`]; kept as a thin compat wrapper during the deprecation window.
9#[deprecated(note = "renamed to render_html — \"generate\" is now reserved for the CAS/composition SDS-authoring feature")]
10pub fn generate_html(sds: &SdsRoot, lang: Language) -> Result<String, SdsError> {
11    render_html(sds, lang)
12}
13
14/// Generate an HTML document from an [`SdsRoot`] in the given language.
15///
16/// The output is a self-contained UTF-8 HTML5 document with inline CSS
17/// including `@media print` styles suitable for printing to PDF.
18pub fn render_html(sds: &SdsRoot, lang: Language) -> Result<String, SdsError> {
19    let json = serde_json::to_value(sds)
20        .map_err(|e| SdsError::Extract(format!("SDS serialize error: {e}")))?;
21
22    let lang_idx = lang_index(lang);
23    let title = DOCUMENT_TITLE[lang_idx];
24
25    let product_name = json
26        .pointer("/Identification/TradeProductIdentity/TradeNameJP")
27        .or_else(|| json.pointer("/Identification/TradeProductIdentity/TradeNameEN"))
28        .and_then(|v| v.as_str())
29        .unwrap_or(title);
30
31    let mut html = String::with_capacity(65_536);
32    html.push_str(&format!(
33        r#"<!DOCTYPE html>
34<html lang="{lang_attr}">
35<head>
36<meta charset="UTF-8">
37<meta name="viewport" content="width=device-width, initial-scale=1.0">
38<title>{page_title}</title>
39<style>
40  body {{
41    font-family: "Noto Sans JP", "Hiragino Sans", "Meiryo", sans-serif;
42    font-size: 10pt;
43    color: #1a1a1a;
44    max-width: 1000px;
45    margin: 0 auto;
46    padding: 1em 2em;
47  }}
48  h1 {{
49    font-size: 16pt;
50    border-bottom: 2px solid #2c5f8a;
51    padding-bottom: 0.3em;
52    color: #2c5f8a;
53  }}
54  h2 {{
55    font-size: 12pt;
56    background: #2c5f8a;
57    color: white;
58    padding: 0.2em 0.6em;
59    margin-top: 1.2em;
60    page-break-after: avoid;
61  }}
62  table {{
63    border-collapse: collapse;
64    width: 100%;
65    margin: 0.4em 0;
66    font-size: 9pt;
67  }}
68  th, td {{
69    border: 1px solid #bbb;
70    padding: 0.3em 0.5em;
71    vertical-align: top;
72  }}
73  th {{
74    background: #e8eef5;
75    font-weight: bold;
76    white-space: nowrap;
77    width: 30%;
78  }}
79  .kv-key {{
80    background: #f4f6f9;
81    font-weight: bold;
82    width: 35%;
83  }}
84  ul {{
85    margin: 0.2em 0;
86    padding-left: 1.5em;
87  }}
88  @media print {{
89    body {{ font-size: 9pt; padding: 0; }}
90    h2 {{ page-break-after: avoid; }}
91    table, tr, td, th {{ page-break-inside: avoid; }}
92  }}
93</style>
94</head>
95<body>
96<h1>{title} — {product_name}</h1>
97"#,
98        lang_attr = lang_attr(lang),
99        page_title = html_escape(title),
100        title = html_escape(title),
101        product_name = html_escape(product_name),
102    ));
103
104    let obj = match &json {
105        serde_json::Value::Object(o) => o,
106        _ => return Err(SdsError::Extract("SDS JSON is not an object".into())),
107    };
108
109    for (i, key) in SECTION_KEYS.iter().enumerate() {
110        let section_label = section_name(i, lang);
111        html.push_str(&format!(
112            "<h2>{}. {}</h2>\n",
113            i + 1,
114            html_escape(section_label)
115        ));
116        if let Some(val) = obj.get(*key) {
117            html.push_str(&render_value_html(val));
118        } else {
119            html.push_str("<p><em>(not extracted)</em></p>\n");
120        }
121    }
122
123    html.push_str("</body>\n</html>\n");
124    Ok(html)
125}
126
127fn lang_attr(lang: Language) -> &'static str {
128    match lang {
129        Language::Japanese => "ja",
130        Language::English => "en",
131        Language::ChineseSimplified => "zh-Hans",
132        Language::ChineseTraditional => "zh-Hant",
133    }
134}
135
136fn html_escape(s: &str) -> String {
137    s.replace('&', "&amp;")
138        .replace('<', "&lt;")
139        .replace('>', "&gt;")
140        .replace('"', "&quot;")
141}
142
143fn render_value_html(val: &serde_json::Value) -> String {
144    match val {
145        serde_json::Value::Null => String::new(),
146        serde_json::Value::Bool(b) => format!("<span>{b}</span>\n"),
147        serde_json::Value::Number(n) => format!("<span>{n}</span>\n"),
148        serde_json::Value::String(s) if s.is_empty() => String::new(),
149        serde_json::Value::String(s) => format!("<span>{}</span>\n", html_escape(s)),
150
151        serde_json::Value::Array(arr) => {
152            if arr.is_empty() {
153                return String::new();
154            }
155            // If array contains objects, render as a multi-column table
156            if arr.iter().all(|v| v.is_object()) {
157                let keys: Vec<&str> = arr
158                    .first()
159                    .and_then(|v| v.as_object())
160                    .map(|o| o.keys().map(|k| k.as_str()).collect())
161                    .unwrap_or_default();
162                if keys.is_empty() {
163                    return String::new();
164                }
165                let mut out = String::from("<table>\n<thead><tr>");
166                for k in &keys {
167                    out.push_str(&format!("<th>{}</th>", html_escape(k)));
168                }
169                out.push_str("</tr></thead>\n<tbody>\n");
170                for item in arr {
171                    if let Some(obj) = item.as_object() {
172                        out.push_str("<tr>");
173                        for k in &keys {
174                            let cell = obj.get(*k).map(value_to_text).unwrap_or_default();
175                            out.push_str(&format!("<td>{}</td>", html_escape(&cell)));
176                        }
177                        out.push_str("</tr>\n");
178                    }
179                }
180                out.push_str("</tbody></table>\n");
181                return out;
182            }
183            // Scalar array → unordered list
184            let mut out = String::from("<ul>\n");
185            for item in arr {
186                let t = value_to_text(item);
187                if !t.is_empty() {
188                    out.push_str(&format!("<li>{}</li>\n", html_escape(&t)));
189                }
190            }
191            out.push_str("</ul>\n");
192            out
193        }
194
195        serde_json::Value::Object(obj) => {
196            if obj.is_empty() {
197                return String::new();
198            }
199            // All object depths rendered as a 2-column key/value table.
200            // This ensures harumi compatibility (no <dl>/<dt>/<dd>).
201            let mut out = String::from("<table>\n<tbody>\n");
202            for (k, v) in obj {
203                if v.is_null() {
204                    continue;
205                }
206                if let serde_json::Value::String(s) = v {
207                    if s.is_empty() {
208                        continue;
209                    }
210                }
211                let child_html = render_value_html(v);
212                if child_html.trim().is_empty() {
213                    continue;
214                }
215                out.push_str(&format!(
216                    "<tr><th class=\"kv-key\">{}</th><td>{}</td></tr>\n",
217                    html_escape(k),
218                    child_html.trim()
219                ));
220            }
221            out.push_str("</tbody></table>\n");
222            out
223        }
224    }
225}
226
227fn value_to_text(v: &serde_json::Value) -> String {
228    match v {
229        serde_json::Value::String(s) => s.clone(),
230        serde_json::Value::Number(n) => n.to_string(),
231        serde_json::Value::Bool(b) => b.to_string(),
232        serde_json::Value::Array(arr) => arr
233            .iter()
234            .map(value_to_text)
235            .filter(|s| !s.is_empty())
236            .collect::<Vec<_>>()
237            .join(", "),
238        _ => String::new(),
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::schema::SdsRoot;
246
247    #[test]
248    fn render_html_empty_sds_produces_valid_html() {
249        let sds = SdsRoot::default();
250        let html = render_html(&sds, Language::Japanese).unwrap();
251        assert!(html.starts_with("<!DOCTYPE html>"));
252        assert!(html.contains("安全データシート"));
253        assert!(html.contains("</html>"));
254    }
255
256    #[test]
257    fn render_html_english() {
258        let sds = SdsRoot::default();
259        let html = render_html(&sds, Language::English).unwrap();
260        assert!(html.contains("Safety Data Sheet"));
261        assert!(html.contains("lang=\"en\""));
262    }
263
264    /// `generate` is reserved for the future CAS/composition SDS-authoring
265    /// workflow; the deprecated re-export here must still only ever mean
266    /// "render an existing SdsRoot", never build one from scratch.
267    #[test]
268    #[allow(deprecated)]
269    fn deprecated_generate_html_delegates_to_render_html() {
270        let sds = SdsRoot::default();
271        assert_eq!(
272            generate_html(&sds, Language::Japanese).unwrap(),
273            render_html(&sds, Language::Japanese).unwrap(),
274        );
275    }
276
277    #[test]
278    fn html_escape_special_chars() {
279        assert_eq!(html_escape("a < b & c > d"), "a &lt; b &amp; c &gt; d");
280        assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
281    }
282}