1pub mod compliance;
2pub mod corrector;
3pub mod extractor;
4pub mod generator;
5pub mod html;
6pub mod llm;
7pub mod pdf;
8pub mod template;
9pub mod validator;
10
11use std::path::Path;
12
13pub fn prune_empty_fields(v: serde_json::Value) -> serde_json::Value {
23 use serde_json::Value;
24 match v {
25 Value::Object(map) => {
26 let pruned: serde_json::Map<_, _> = map
27 .into_iter()
28 .filter_map(|(k, v)| {
29 let pv = prune_empty_fields(v);
30 match &pv {
31 Value::Null => None,
32 Value::String(s) if s.trim().is_empty() => None,
33 Value::Array(a) if a.is_empty() => None,
34 Value::Object(o) if o.is_empty() => None,
35 _ => Some((k, pv)),
36 }
37 })
38 .collect();
39 Value::Object(pruned)
40 }
41 Value::Array(arr) => {
42 let pruned: Vec<_> = arr
43 .into_iter()
44 .map(prune_empty_fields)
45 .filter(|v| match v {
46 Value::Null => false,
47 Value::String(s) if s.trim().is_empty() => false,
48 Value::Array(a) if a.is_empty() => false,
49 Value::Object(o) if o.is_empty() => false,
50 _ => true,
51 })
52 .collect();
53 Value::Array(pruned)
54 }
55 other => other,
56 }
57}
58
59use serde::{Deserialize, Serialize};
60
61use crate::country::SourceCountry;
62use crate::error::SdsError;
63use crate::language::Language;
64use crate::schema::SdsRoot;
65
66#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct ConversionReport {
79 pub source_language: String,
82 pub language_auto_detected: bool,
85 pub populated_sections: Vec<String>,
87 pub empty_sections: Vec<String>,
89 pub standardization_notes: Vec<String>,
94 pub warnings: Vec<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub compliance_diff: Option<compliance::ComplianceDiffReport>,
99}
100
101impl ConversionReport {
102 pub fn from_sds(
104 sds: &SdsRoot,
105 source_language: Language,
106 language_auto_detected: bool,
107 warnings: Vec<String>,
108 ) -> Self {
109 let mut populated = Vec::new();
110 let mut empty = Vec::new();
111
112 macro_rules! check_opt {
113 ($key:literal, $field:expr) => {
114 if $field.is_some() {
115 populated.push($key.to_string());
116 } else {
117 empty.push($key.to_string());
118 }
119 };
120 }
121 macro_rules! check_vec {
122 ($key:literal, $field:expr) => {
123 if $field.as_ref().map_or(false, |v: &Vec<_>| !v.is_empty()) {
124 populated.push($key.to_string());
125 } else {
126 empty.push($key.to_string());
127 }
128 };
129 }
130
131 check_opt!("Identification", sds.identification);
132 check_opt!("HazardIdentification", sds.hazard_identification);
133 check_opt!("Composition", sds.composition);
134 check_opt!("FirstAidMeasures", sds.first_aid_measures);
135 check_opt!("FireFightingMeasures", sds.fire_fighting_measures);
136 check_opt!("AccidentalReleaseMeasures", sds.accidental_release_measures);
137 check_opt!("HandlingAndStorage", sds.handling_and_storage);
138 check_opt!("ExposureControlPersonalProtection", sds.exposure_control_personal_protection);
139 check_opt!("PhysicalChemicalProperties", sds.physical_chemical_properties);
140 check_opt!("StabilityReactivity", sds.stability_reactivity);
141 check_vec!("ToxicologicalInformation", sds.toxicological_information);
142 check_vec!("EcologicalInformation", sds.ecological_information);
143 check_opt!("DisposalConsiderations", sds.disposal_considerations);
144 check_opt!("TransportInformation", sds.transport_information);
145 check_opt!("RegulatoryInformation", sds.regulatory_information);
146 check_opt!("OtherInformation", sds.other_information);
147
148 let standardization_notes = match source_language {
149 Language::Japanese => vec![
150 "Section headings follow JIS Z 7253:2019. \
151 Section 1 uses '化学品及び会社情報' (JIS Z 7253:2019); \
152 older source documents that use '製品及び会社情報' are normalised automatically."
153 .to_string(),
154 ],
155 Language::English => vec![
156 "Section headings follow GHS Rev.10 / ISO 11014.".to_string(),
157 ],
158 Language::ChineseSimplified => vec![
159 "Section headings follow GB/T 16483-2012.".to_string(),
160 ],
161 Language::ChineseTraditional => vec![
162 "Section headings follow CNS 15030.".to_string(),
163 ],
164 };
165
166 Self {
167 source_language: source_language.bcp47().to_string(),
168 language_auto_detected,
169 populated_sections: populated,
170 empty_sections: empty,
171 standardization_notes,
172 warnings,
173 compliance_diff: None, }
175 }
176}
177
178pub use compliance::{ComplianceDiffReport, ComplianceGap, generate_compliance_diff};
179pub use corrector::{CorrectionConfig, CorrectionResult};
180pub use extractor::InputFormat;
181#[allow(deprecated)]
182pub use generator::generate_docx;
183pub use generator::render_docx;
184#[allow(deprecated)]
185pub use pdf::generate_pdf;
186pub use pdf::render_pdf;
187pub use llm::{
188 openai_compat_url, AnthropicBackend, AnyBackend, build_any_backend,
189 extract_sds_from_pdf_vision, LlmBackend, LlmConfig, OpenAiCompatBackend,
190};
191pub use template::fill_template;
192
193#[derive(Debug, Clone)]
195pub struct ConvertConfig {
196 pub source_language: Option<Language>,
198 pub source_country: Option<SourceCountry>,
204 pub output_language: Language,
206 pub max_chars: usize,
210 pub correction: Option<CorrectionConfig>,
218}
219
220impl Default for ConvertConfig {
221 fn default() -> Self {
222 Self {
223 source_language: None,
224 source_country: None,
225 output_language: Language::default(),
226 max_chars: 80_000,
227 correction: None,
228 }
229 }
230}
231
232pub async fn convert_to_json_with_report<B: LlmBackend + Sync>(
237 input_path: &Path,
238 backend: &B,
239 config: &ConvertConfig,
240) -> Result<(SdsRoot, ConversionReport), SdsError> {
241 let text = extractor::extract_text_limited(input_path, config.max_chars).await?;
242 if text.trim().is_empty() {
243 return Err(SdsError::Extract(
244 "No text extracted — document may be image-only or empty".into(),
245 ));
246 }
247 let language_auto_detected = config.source_language.is_none();
248
249 let effective_lang_for_country = config.source_language
251 .unwrap_or_else(|| crate::language::detect_language(&text));
252 let country = config.source_country
253 .or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
254
255 let (mut sds, mut warnings) =
256 llm::extract_sds_from_text(backend, &text, config.source_language, country).await?;
257
258 normalize_cas_full_text(&mut sds);
263
264 ensure_hazard_identification(&mut sds);
268
269 let mut corrected_cas_values: Vec<String> = Vec::new();
271 if let Some(correction_cfg) = &config.correction {
272 let findings = validator::collect_findings(&sds);
273 if !findings.is_empty() {
274 let result = corrector::apply_correction_pass(
275 sds, &text, &findings, backend, correction_cfg,
276 )
277 .await;
278 sds = result.sds;
279 warnings.extend(result.notes);
280 corrected_cas_values = result.corrected_cas_values;
281 }
282 }
283
284 let validation_warnings = validator::validate(&sds);
285 warnings.extend(validation_warnings);
286
287 if let Some(c) = country {
289 warnings.extend(validator::validate_country(&sds, c));
290 }
291
292 let source_warnings = validator::verify_against_source(&sds, &text, &corrected_cas_values);
294 warnings.extend(source_warnings);
295
296 let effective_lang = config.source_language.unwrap_or_else(|| {
298 crate::language::detect_language(&text)
299 });
300 let mut report = ConversionReport::from_sds(&sds, effective_lang, language_auto_detected, warnings);
301
302 if let Some(c) = country {
304 report.compliance_diff = Some(compliance::generate_compliance_diff(&sds, c));
305 }
306
307 Ok((sds, report))
308}
309
310pub async fn convert_to_json<B: LlmBackend + Sync>(
318 input_path: &Path,
319 backend: &B,
320 config: &ConvertConfig,
321) -> Result<(SdsRoot, Vec<String>), SdsError> {
322 let (sds, report) = convert_to_json_with_report(input_path, backend, config).await?;
323 Ok((sds, report.warnings))
324}
325
326pub async fn convert_bytes_to_json_with_report<B: LlmBackend + Sync>(
331 data: &[u8],
332 filename: &str,
333 backend: &B,
334 config: &ConvertConfig,
335) -> Result<(SdsRoot, ConversionReport), SdsError> {
336 let suffix = Path::new(filename)
337 .extension()
338 .and_then(|e| e.to_str())
339 .map(|e| format!(".{}", e.to_ascii_lowercase()))
340 .unwrap_or_default();
341
342 let data_owned = data.to_vec();
343 let tmp = tokio::task::spawn_blocking(move || {
344 use std::io::Write as _;
345 let mut f = tempfile::Builder::new()
346 .suffix(&suffix)
347 .tempfile()
348 .map_err(|e| SdsError::Extract(format!("tempfile create: {e}")))?;
349 f.write_all(&data_owned)
350 .map_err(|e| SdsError::Extract(format!("tempfile write: {e}")))?;
351 f.flush()
352 .map_err(|e| SdsError::Extract(format!("tempfile flush: {e}")))?;
353 Ok::<_, SdsError>(f.into_temp_path())
354 })
355 .await
356 .map_err(|e| SdsError::Extract(format!("spawn_blocking panicked: {e}")))??;
357
358 convert_to_json_with_report(tmp.as_ref(), backend, config).await
359}
360
361pub async fn convert_bytes_to_json<B: LlmBackend + Sync>(
368 data: &[u8],
369 filename: &str,
370 backend: &B,
371 config: &ConvertConfig,
372) -> Result<(SdsRoot, Vec<String>), SdsError> {
373 let (sds, report) =
374 convert_bytes_to_json_with_report(data, filename, backend, config).await?;
375 Ok((sds, report.warnings))
376}
377
378fn normalize_cas_full_text(sds: &mut SdsRoot) {
383 let Some(comp) = &mut sds.composition else { return };
384 let Some(items) = &mut comp.composition_and_concentration else { return };
385 for item in items.iter_mut() {
386 let Some(ids) = &mut item.substance_identifiers else { continue };
387 let Some(identity) = &mut ids.substance_identity else { continue };
388 let Some(cas_node) = &mut identity.ca_sno else { continue };
389 let Some(texts) = &mut cas_node.full_text else { continue };
390
391 let needs_split = texts.iter().any(|s| {
394 s.contains('\n') || s.contains('\r') || s.contains(',') || s.contains(';')
395 });
396 if !needs_split {
397 continue;
398 }
399
400 let expanded: Vec<String> = texts
401 .drain(..)
402 .flat_map(|s| {
403 s.split(|c: char| c == '\n' || c == '\r' || c == ',' || c == ';')
404 .map(str::trim)
405 .filter(|t| !t.is_empty())
406 .map(str::to_string)
407 .collect::<Vec<_>>()
408 })
409 .collect();
410 *texts = expanded;
411 }
412}
413
414fn ensure_hazard_identification(sds: &mut SdsRoot) {
421 if sds.hazard_identification.is_some() {
422 return;
423 }
424 use crate::schema::{
425 HazardIdentification, HazardIdentificationHazardLabelling,
426 };
427 sds.hazard_identification = Some(HazardIdentification {
428 hazard_labelling: Some(HazardIdentificationHazardLabelling {
429 signal_word: Some("N/A".to_string()),
430 hazard_statement: Some(vec![]),
431 ..Default::default()
432 }),
433 ..Default::default()
434 });
435}
436
437pub fn convert_from_json(
439 sds: &SdsRoot,
440 output_path: &Path,
441 config: &ConvertConfig,
442) -> Result<(), SdsError> {
443 render_docx(sds, output_path, config.output_language)
444}
445
446pub async fn convert_url_to_json<B: LlmBackend + Sync>(
448 url: &str,
449 backend: &B,
450 config: &ConvertConfig,
451) -> Result<(SdsRoot, Vec<String>), SdsError> {
452 let text = extractor::extract_text_from_url_limited(url, config.max_chars).await?;
453 if text.trim().is_empty() {
454 return Err(SdsError::Extract(
455 "No text extracted from URL — page may be empty or JavaScript-rendered".into(),
456 ));
457 }
458 let effective_lang_for_country = config.source_language
459 .unwrap_or_else(|| crate::language::detect_language(&text));
460 let country = config.source_country
461 .or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
462 let (sds, mut warnings) =
463 llm::extract_sds_from_text(backend, &text, config.source_language, country).await?;
464 let validation_warnings = validator::validate(&sds);
465 warnings.extend(validation_warnings);
466 if let Some(c) = country {
467 warnings.extend(validator::validate_country(&sds, c));
468 }
469 Ok((sds, warnings))
470}
471
472pub async fn convert_pdf_to_json_vision(
481 input_path: &Path,
482 api_key: &str,
483 llm_config: &LlmConfig,
484 config: &ConvertConfig,
485) -> Result<(SdsRoot, Vec<String>), SdsError> {
486 let path_owned = input_path.to_path_buf();
487 let bytes = tokio::task::spawn_blocking(move || std::fs::read(&path_owned))
488 .await
489 .map_err(|e| SdsError::Extract(format!("spawn_blocking panicked: {e}")))?
490 .map_err(|e| SdsError::Extract(format!("reading PDF: {e}")))?;
491 let effective_lang_for_country = config.source_language
492 .unwrap_or_else(|| crate::language::detect_language(
493 &String::from_utf8_lossy(&bytes)
494 ));
495 let country = config.source_country
496 .or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
497 let (sds, mut warnings) =
498 llm::extract_sds_from_pdf_vision(api_key, llm_config, &bytes, config.source_language, country)
499 .await?;
500 let validation_warnings = validator::validate(&sds);
501 warnings.extend(validation_warnings);
502 if let Some(c) = country {
503 warnings.extend(validator::validate_country(&sds, c));
504 }
505 Ok((sds, warnings))
506}
507
508pub fn convert_from_template(
515 sds: &SdsRoot,
516 template_path: &Path,
517 output_path: &Path,
518) -> Result<(), SdsError> {
519 fill_template(sds, template_path, output_path)
520}