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
13use serde::{Deserialize, Serialize};
14
15use crate::country::SourceCountry;
16use crate::error::SdsError;
17use crate::language::Language;
18use crate::schema::SdsRoot;
19
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct ConversionReport {
33 pub source_language: String,
36 pub language_auto_detected: bool,
39 pub populated_sections: Vec<String>,
41 pub empty_sections: Vec<String>,
43 pub standardization_notes: Vec<String>,
48 pub warnings: Vec<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub compliance_diff: Option<compliance::ComplianceDiffReport>,
53}
54
55impl ConversionReport {
56 pub fn from_sds(
58 sds: &SdsRoot,
59 source_language: Language,
60 language_auto_detected: bool,
61 warnings: Vec<String>,
62 ) -> Self {
63 let mut populated = Vec::new();
64 let mut empty = Vec::new();
65
66 macro_rules! check_opt {
67 ($key:literal, $field:expr) => {
68 if $field.is_some() {
69 populated.push($key.to_string());
70 } else {
71 empty.push($key.to_string());
72 }
73 };
74 }
75 macro_rules! check_vec {
76 ($key:literal, $field:expr) => {
77 if $field.as_ref().map_or(false, |v: &Vec<_>| !v.is_empty()) {
78 populated.push($key.to_string());
79 } else {
80 empty.push($key.to_string());
81 }
82 };
83 }
84
85 check_opt!("Identification", sds.identification);
86 check_opt!("HazardIdentification", sds.hazard_identification);
87 check_opt!("Composition", sds.composition);
88 check_opt!("FirstAidMeasures", sds.first_aid_measures);
89 check_opt!("FireFightingMeasures", sds.fire_fighting_measures);
90 check_opt!("AccidentalReleaseMeasures", sds.accidental_release_measures);
91 check_opt!("HandlingAndStorage", sds.handling_and_storage);
92 check_opt!("ExposureControlPersonalProtection", sds.exposure_control_personal_protection);
93 check_opt!("PhysicalChemicalProperties", sds.physical_chemical_properties);
94 check_opt!("StabilityReactivity", sds.stability_reactivity);
95 check_vec!("ToxicologicalInformation", sds.toxicological_information);
96 check_vec!("EcologicalInformation", sds.ecological_information);
97 check_opt!("DisposalConsiderations", sds.disposal_considerations);
98 check_opt!("TransportInformation", sds.transport_information);
99 check_opt!("RegulatoryInformation", sds.regulatory_information);
100 check_opt!("OtherInformation", sds.other_information);
101
102 let standardization_notes = match source_language {
103 Language::Japanese => vec![
104 "Section headings follow JIS Z 7253:2019. \
105 Section 1 uses '化学品及び会社情報' (JIS Z 7253:2019); \
106 older source documents that use '製品及び会社情報' are normalised automatically."
107 .to_string(),
108 ],
109 Language::English => vec![
110 "Section headings follow GHS Rev.10 / ISO 11014.".to_string(),
111 ],
112 Language::ChineseSimplified => vec![
113 "Section headings follow GB/T 16483-2012.".to_string(),
114 ],
115 Language::ChineseTraditional => vec![
116 "Section headings follow CNS 15030.".to_string(),
117 ],
118 };
119
120 Self {
121 source_language: source_language.bcp47().to_string(),
122 language_auto_detected,
123 populated_sections: populated,
124 empty_sections: empty,
125 standardization_notes,
126 warnings,
127 compliance_diff: None, }
129 }
130}
131
132pub use compliance::{ComplianceDiffReport, ComplianceGap, generate_compliance_diff};
133pub use corrector::{CorrectionConfig, CorrectionResult};
134pub use extractor::InputFormat;
135pub use generator::generate_docx;
136pub use pdf::generate_pdf;
137pub use llm::{
138 openai_compat_url, AnthropicBackend, AnyBackend, build_any_backend,
139 extract_sds_from_pdf_vision, LlmBackend, LlmConfig, OpenAiCompatBackend,
140};
141pub use template::fill_template;
142
143#[derive(Debug, Clone)]
145pub struct ConvertConfig {
146 pub source_language: Option<Language>,
148 pub source_country: Option<SourceCountry>,
154 pub output_language: Language,
156 pub max_chars: usize,
160 pub correction: Option<CorrectionConfig>,
168}
169
170impl Default for ConvertConfig {
171 fn default() -> Self {
172 Self {
173 source_language: None,
174 source_country: None,
175 output_language: Language::default(),
176 max_chars: 80_000,
177 correction: None,
178 }
179 }
180}
181
182pub async fn convert_to_json_with_report<B: LlmBackend + Sync>(
187 input_path: &Path,
188 backend: &B,
189 config: &ConvertConfig,
190) -> Result<(SdsRoot, ConversionReport), SdsError> {
191 let text = extractor::extract_text_limited(input_path, config.max_chars).await?;
192 if text.trim().is_empty() {
193 return Err(SdsError::Extract(
194 "No text extracted — document may be image-only or empty".into(),
195 ));
196 }
197 let language_auto_detected = config.source_language.is_none();
198
199 let effective_lang_for_country = config.source_language
201 .unwrap_or_else(|| crate::language::detect_language(&text));
202 let country = config.source_country
203 .or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
204
205 let (mut sds, mut warnings) =
206 llm::extract_sds_from_text(backend, &text, config.source_language, country).await?;
207
208 normalize_cas_full_text(&mut sds);
213
214 ensure_hazard_identification(&mut sds);
218
219 let mut corrected_cas_values: Vec<String> = Vec::new();
221 if let Some(correction_cfg) = &config.correction {
222 let findings = validator::collect_findings(&sds);
223 if !findings.is_empty() {
224 let result = corrector::apply_correction_pass(
225 sds, &text, &findings, backend, correction_cfg,
226 )
227 .await;
228 sds = result.sds;
229 warnings.extend(result.notes);
230 corrected_cas_values = result.corrected_cas_values;
231 }
232 }
233
234 let validation_warnings = validator::validate(&sds);
235 warnings.extend(validation_warnings);
236
237 if let Some(c) = country {
239 warnings.extend(validator::validate_country(&sds, c));
240 }
241
242 let source_warnings = validator::verify_against_source(&sds, &text, &corrected_cas_values);
244 warnings.extend(source_warnings);
245
246 let effective_lang = config.source_language.unwrap_or_else(|| {
248 crate::language::detect_language(&text)
249 });
250 let mut report = ConversionReport::from_sds(&sds, effective_lang, language_auto_detected, warnings);
251
252 if let Some(c) = country {
254 report.compliance_diff = Some(compliance::generate_compliance_diff(&sds, c));
255 }
256
257 Ok((sds, report))
258}
259
260pub async fn convert_to_json<B: LlmBackend + Sync>(
268 input_path: &Path,
269 backend: &B,
270 config: &ConvertConfig,
271) -> Result<(SdsRoot, Vec<String>), SdsError> {
272 let (sds, report) = convert_to_json_with_report(input_path, backend, config).await?;
273 Ok((sds, report.warnings))
274}
275
276pub async fn convert_bytes_to_json_with_report<B: LlmBackend + Sync>(
281 data: &[u8],
282 filename: &str,
283 backend: &B,
284 config: &ConvertConfig,
285) -> Result<(SdsRoot, ConversionReport), SdsError> {
286 let suffix = Path::new(filename)
287 .extension()
288 .and_then(|e| e.to_str())
289 .map(|e| format!(".{}", e.to_ascii_lowercase()))
290 .unwrap_or_default();
291
292 let data_owned = data.to_vec();
293 let tmp = tokio::task::spawn_blocking(move || {
294 use std::io::Write as _;
295 let mut f = tempfile::Builder::new()
296 .suffix(&suffix)
297 .tempfile()
298 .map_err(|e| SdsError::Extract(format!("tempfile create: {e}")))?;
299 f.write_all(&data_owned)
300 .map_err(|e| SdsError::Extract(format!("tempfile write: {e}")))?;
301 f.flush()
302 .map_err(|e| SdsError::Extract(format!("tempfile flush: {e}")))?;
303 Ok::<_, SdsError>(f.into_temp_path())
304 })
305 .await
306 .map_err(|e| SdsError::Extract(format!("spawn_blocking panicked: {e}")))??;
307
308 convert_to_json_with_report(tmp.as_ref(), backend, config).await
309}
310
311pub async fn convert_bytes_to_json<B: LlmBackend + Sync>(
318 data: &[u8],
319 filename: &str,
320 backend: &B,
321 config: &ConvertConfig,
322) -> Result<(SdsRoot, Vec<String>), SdsError> {
323 let (sds, report) =
324 convert_bytes_to_json_with_report(data, filename, backend, config).await?;
325 Ok((sds, report.warnings))
326}
327
328fn normalize_cas_full_text(sds: &mut SdsRoot) {
333 let Some(comp) = &mut sds.composition else { return };
334 let Some(items) = &mut comp.composition_and_concentration else { return };
335 for item in items.iter_mut() {
336 let Some(ids) = &mut item.substance_identifiers else { continue };
337 let Some(identity) = &mut ids.substance_identity else { continue };
338 let Some(cas_node) = &mut identity.ca_sno else { continue };
339 let Some(texts) = &mut cas_node.full_text else { continue };
340
341 let needs_split = texts.iter().any(|s| {
344 s.contains('\n') || s.contains('\r') || s.contains(',') || s.contains(';')
345 });
346 if !needs_split {
347 continue;
348 }
349
350 let expanded: Vec<String> = texts
351 .drain(..)
352 .flat_map(|s| {
353 s.split(|c: char| c == '\n' || c == '\r' || c == ',' || c == ';')
354 .map(str::trim)
355 .filter(|t| !t.is_empty())
356 .map(str::to_string)
357 .collect::<Vec<_>>()
358 })
359 .collect();
360 *texts = expanded;
361 }
362}
363
364fn ensure_hazard_identification(sds: &mut SdsRoot) {
371 if sds.hazard_identification.is_some() {
372 return;
373 }
374 use crate::schema::{
375 HazardIdentification, HazardIdentificationHazardLabelling,
376 };
377 sds.hazard_identification = Some(HazardIdentification {
378 hazard_labelling: Some(HazardIdentificationHazardLabelling {
379 signal_word: Some("N/A".to_string()),
380 hazard_statement: Some(vec![]),
381 ..Default::default()
382 }),
383 ..Default::default()
384 });
385}
386
387pub fn convert_from_json(
389 sds: &SdsRoot,
390 output_path: &Path,
391 config: &ConvertConfig,
392) -> Result<(), SdsError> {
393 generate_docx(sds, output_path, config.output_language)
394}
395
396pub async fn convert_url_to_json<B: LlmBackend + Sync>(
398 url: &str,
399 backend: &B,
400 config: &ConvertConfig,
401) -> Result<(SdsRoot, Vec<String>), SdsError> {
402 let text = extractor::extract_text_from_url_limited(url, config.max_chars).await?;
403 if text.trim().is_empty() {
404 return Err(SdsError::Extract(
405 "No text extracted from URL — page may be empty or JavaScript-rendered".into(),
406 ));
407 }
408 let effective_lang_for_country = config.source_language
409 .unwrap_or_else(|| crate::language::detect_language(&text));
410 let country = config.source_country
411 .or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
412 let (sds, mut warnings) =
413 llm::extract_sds_from_text(backend, &text, config.source_language, country).await?;
414 let validation_warnings = validator::validate(&sds);
415 warnings.extend(validation_warnings);
416 if let Some(c) = country {
417 warnings.extend(validator::validate_country(&sds, c));
418 }
419 Ok((sds, warnings))
420}
421
422pub async fn convert_pdf_to_json_vision(
431 input_path: &Path,
432 api_key: &str,
433 llm_config: &LlmConfig,
434 config: &ConvertConfig,
435) -> Result<(SdsRoot, Vec<String>), SdsError> {
436 let path_owned = input_path.to_path_buf();
437 let bytes = tokio::task::spawn_blocking(move || std::fs::read(&path_owned))
438 .await
439 .map_err(|e| SdsError::Extract(format!("spawn_blocking panicked: {e}")))?
440 .map_err(|e| SdsError::Extract(format!("reading PDF: {e}")))?;
441 let effective_lang_for_country = config.source_language
442 .unwrap_or_else(|| crate::language::detect_language(
443 &String::from_utf8_lossy(&bytes)
444 ));
445 let country = config.source_country
446 .or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
447 let (sds, mut warnings) =
448 llm::extract_sds_from_pdf_vision(api_key, llm_config, &bytes, config.source_language, country)
449 .await?;
450 let validation_warnings = validator::validate(&sds);
451 warnings.extend(validation_warnings);
452 if let Some(c) = country {
453 warnings.extend(validator::validate_country(&sds, c));
454 }
455 Ok((sds, warnings))
456}
457
458pub fn convert_from_template(
465 sds: &SdsRoot,
466 template_path: &Path,
467 output_path: &Path,
468) -> Result<(), SdsError> {
469 fill_template(sds, template_path, output_path)
470}