sdsforge_core/converter/
html.rs1use 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#[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
14pub 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('&', "&")
138 .replace('<', "<")
139 .replace('>', ">")
140 .replace('"', """)
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 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 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 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 #[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 < b & c > d");
280 assert_eq!(html_escape("\"quoted\""), ""quoted"");
281 }
282}