pub mod compliance;
pub mod corrector;
pub mod extractor;
pub mod generator;
pub mod html;
pub mod llm;
pub mod pdf;
pub mod template;
pub mod validator;
use std::path::Path;
pub fn prune_empty_fields(v: serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match v {
Value::Object(map) => {
let pruned: serde_json::Map<_, _> = map
.into_iter()
.filter_map(|(k, v)| {
let pv = prune_empty_fields(v);
match &pv {
Value::Null => None,
Value::String(s) if s.trim().is_empty() => None,
Value::Array(a) if a.is_empty() => None,
Value::Object(o) if o.is_empty() => None,
_ => Some((k, pv)),
}
})
.collect();
Value::Object(pruned)
}
Value::Array(arr) => {
let pruned: Vec<_> = arr
.into_iter()
.map(prune_empty_fields)
.filter(|v| match v {
Value::Null => false,
Value::String(s) if s.trim().is_empty() => false,
Value::Array(a) if a.is_empty() => false,
Value::Object(o) if o.is_empty() => false,
_ => true,
})
.collect();
Value::Array(pruned)
}
other => other,
}
}
use serde::{Deserialize, Serialize};
use crate::country::SourceCountry;
use crate::error::SdsError;
use crate::language::Language;
use crate::schema::SdsRoot;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConversionReport {
pub source_language: String,
pub language_auto_detected: bool,
pub populated_sections: Vec<String>,
pub empty_sections: Vec<String>,
pub standardization_notes: Vec<String>,
pub warnings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub compliance_diff: Option<compliance::ComplianceDiffReport>,
}
impl ConversionReport {
pub fn from_sds(
sds: &SdsRoot,
source_language: Language,
language_auto_detected: bool,
warnings: Vec<String>,
) -> Self {
let mut populated = Vec::new();
let mut empty = Vec::new();
macro_rules! check_opt {
($key:literal, $field:expr) => {
if $field.is_some() {
populated.push($key.to_string());
} else {
empty.push($key.to_string());
}
};
}
macro_rules! check_vec {
($key:literal, $field:expr) => {
if $field.as_ref().map_or(false, |v: &Vec<_>| !v.is_empty()) {
populated.push($key.to_string());
} else {
empty.push($key.to_string());
}
};
}
check_opt!("Identification", sds.identification);
check_opt!("HazardIdentification", sds.hazard_identification);
check_opt!("Composition", sds.composition);
check_opt!("FirstAidMeasures", sds.first_aid_measures);
check_opt!("FireFightingMeasures", sds.fire_fighting_measures);
check_opt!("AccidentalReleaseMeasures", sds.accidental_release_measures);
check_opt!("HandlingAndStorage", sds.handling_and_storage);
check_opt!("ExposureControlPersonalProtection", sds.exposure_control_personal_protection);
check_opt!("PhysicalChemicalProperties", sds.physical_chemical_properties);
check_opt!("StabilityReactivity", sds.stability_reactivity);
check_vec!("ToxicologicalInformation", sds.toxicological_information);
check_vec!("EcologicalInformation", sds.ecological_information);
check_opt!("DisposalConsiderations", sds.disposal_considerations);
check_opt!("TransportInformation", sds.transport_information);
check_opt!("RegulatoryInformation", sds.regulatory_information);
check_opt!("OtherInformation", sds.other_information);
let standardization_notes = match source_language {
Language::Japanese => vec![
"Section headings follow JIS Z 7253:2019. \
Section 1 uses '化学品及び会社情報' (JIS Z 7253:2019); \
older source documents that use '製品及び会社情報' are normalised automatically."
.to_string(),
],
Language::English => vec![
"Section headings follow GHS Rev.10 / ISO 11014.".to_string(),
],
Language::ChineseSimplified => vec![
"Section headings follow GB/T 16483-2012.".to_string(),
],
Language::ChineseTraditional => vec![
"Section headings follow CNS 15030.".to_string(),
],
};
Self {
source_language: source_language.bcp47().to_string(),
language_auto_detected,
populated_sections: populated,
empty_sections: empty,
standardization_notes,
warnings,
compliance_diff: None, }
}
}
pub use compliance::{ComplianceDiffReport, ComplianceGap, generate_compliance_diff};
pub use corrector::{CorrectionConfig, CorrectionResult};
pub use extractor::InputFormat;
#[allow(deprecated)]
pub use generator::generate_docx;
pub use generator::render_docx;
#[allow(deprecated)]
pub use pdf::generate_pdf;
pub use pdf::render_pdf;
pub use llm::{
openai_compat_url, AnthropicBackend, AnyBackend, build_any_backend,
extract_sds_from_pdf_vision, LlmBackend, LlmConfig, OpenAiCompatBackend,
};
pub use template::fill_template;
#[derive(Debug, Clone)]
pub struct ConvertConfig {
pub source_language: Option<Language>,
pub source_country: Option<SourceCountry>,
pub output_language: Language,
pub max_chars: usize,
pub correction: Option<CorrectionConfig>,
}
impl Default for ConvertConfig {
fn default() -> Self {
Self {
source_language: None,
source_country: None,
output_language: Language::default(),
max_chars: 80_000,
correction: None,
}
}
}
pub async fn convert_to_json_with_report<B: LlmBackend + Sync>(
input_path: &Path,
backend: &B,
config: &ConvertConfig,
) -> Result<(SdsRoot, ConversionReport), SdsError> {
let text = extractor::extract_text_limited(input_path, config.max_chars).await?;
if text.trim().is_empty() {
return Err(SdsError::Extract(
"No text extracted — document may be image-only or empty".into(),
));
}
let language_auto_detected = config.source_language.is_none();
let effective_lang_for_country = config.source_language
.unwrap_or_else(|| crate::language::detect_language(&text));
let country = config.source_country
.or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
let (mut sds, mut warnings) =
llm::extract_sds_from_text(backend, &text, config.source_language, country).await?;
normalize_cas_full_text(&mut sds);
ensure_hazard_identification(&mut sds);
let mut corrected_cas_values: Vec<String> = Vec::new();
if let Some(correction_cfg) = &config.correction {
let findings = validator::collect_findings(&sds);
if !findings.is_empty() {
let result = corrector::apply_correction_pass(
sds, &text, &findings, backend, correction_cfg,
)
.await;
sds = result.sds;
warnings.extend(result.notes);
corrected_cas_values = result.corrected_cas_values;
}
}
let validation_warnings = validator::validate(&sds);
warnings.extend(validation_warnings);
if let Some(c) = country {
warnings.extend(validator::validate_country(&sds, c));
}
let source_warnings = validator::verify_against_source(&sds, &text, &corrected_cas_values);
warnings.extend(source_warnings);
let effective_lang = config.source_language.unwrap_or_else(|| {
crate::language::detect_language(&text)
});
let mut report = ConversionReport::from_sds(&sds, effective_lang, language_auto_detected, warnings);
if let Some(c) = country {
report.compliance_diff = Some(compliance::generate_compliance_diff(&sds, c));
}
Ok((sds, report))
}
pub async fn convert_to_json<B: LlmBackend + Sync>(
input_path: &Path,
backend: &B,
config: &ConvertConfig,
) -> Result<(SdsRoot, Vec<String>), SdsError> {
let (sds, report) = convert_to_json_with_report(input_path, backend, config).await?;
Ok((sds, report.warnings))
}
pub async fn convert_bytes_to_json_with_report<B: LlmBackend + Sync>(
data: &[u8],
filename: &str,
backend: &B,
config: &ConvertConfig,
) -> Result<(SdsRoot, ConversionReport), SdsError> {
let suffix = Path::new(filename)
.extension()
.and_then(|e| e.to_str())
.map(|e| format!(".{}", e.to_ascii_lowercase()))
.unwrap_or_default();
let data_owned = data.to_vec();
let tmp = tokio::task::spawn_blocking(move || {
use std::io::Write as _;
let mut f = tempfile::Builder::new()
.suffix(&suffix)
.tempfile()
.map_err(|e| SdsError::Extract(format!("tempfile create: {e}")))?;
f.write_all(&data_owned)
.map_err(|e| SdsError::Extract(format!("tempfile write: {e}")))?;
f.flush()
.map_err(|e| SdsError::Extract(format!("tempfile flush: {e}")))?;
Ok::<_, SdsError>(f.into_temp_path())
})
.await
.map_err(|e| SdsError::Extract(format!("spawn_blocking panicked: {e}")))??;
convert_to_json_with_report(tmp.as_ref(), backend, config).await
}
pub async fn convert_bytes_to_json<B: LlmBackend + Sync>(
data: &[u8],
filename: &str,
backend: &B,
config: &ConvertConfig,
) -> Result<(SdsRoot, Vec<String>), SdsError> {
let (sds, report) =
convert_bytes_to_json_with_report(data, filename, backend, config).await?;
Ok((sds, report.warnings))
}
fn normalize_cas_full_text(sds: &mut SdsRoot) {
let Some(comp) = &mut sds.composition else { return };
let Some(items) = &mut comp.composition_and_concentration else { return };
for item in items.iter_mut() {
let Some(ids) = &mut item.substance_identifiers else { continue };
let Some(identity) = &mut ids.substance_identity else { continue };
let Some(cas_node) = &mut identity.ca_sno else { continue };
let Some(texts) = &mut cas_node.full_text else { continue };
let needs_split = texts.iter().any(|s| {
s.contains('\n') || s.contains('\r') || s.contains(',') || s.contains(';')
});
if !needs_split {
continue;
}
let expanded: Vec<String> = texts
.drain(..)
.flat_map(|s| {
s.split(|c: char| c == '\n' || c == '\r' || c == ',' || c == ';')
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
})
.collect();
*texts = expanded;
}
}
fn ensure_hazard_identification(sds: &mut SdsRoot) {
if sds.hazard_identification.is_some() {
return;
}
use crate::schema::{
HazardIdentification, HazardIdentificationHazardLabelling,
};
sds.hazard_identification = Some(HazardIdentification {
hazard_labelling: Some(HazardIdentificationHazardLabelling {
signal_word: Some("N/A".to_string()),
hazard_statement: Some(vec![]),
..Default::default()
}),
..Default::default()
});
}
pub fn convert_from_json(
sds: &SdsRoot,
output_path: &Path,
config: &ConvertConfig,
) -> Result<(), SdsError> {
render_docx(sds, output_path, config.output_language)
}
pub async fn convert_url_to_json<B: LlmBackend + Sync>(
url: &str,
backend: &B,
config: &ConvertConfig,
) -> Result<(SdsRoot, Vec<String>), SdsError> {
let text = extractor::extract_text_from_url_limited(url, config.max_chars).await?;
if text.trim().is_empty() {
return Err(SdsError::Extract(
"No text extracted from URL — page may be empty or JavaScript-rendered".into(),
));
}
let effective_lang_for_country = config.source_language
.unwrap_or_else(|| crate::language::detect_language(&text));
let country = config.source_country
.or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
let (sds, mut warnings) =
llm::extract_sds_from_text(backend, &text, config.source_language, country).await?;
let validation_warnings = validator::validate(&sds);
warnings.extend(validation_warnings);
if let Some(c) = country {
warnings.extend(validator::validate_country(&sds, c));
}
Ok((sds, warnings))
}
pub async fn convert_pdf_to_json_vision(
input_path: &Path,
api_key: &str,
llm_config: &LlmConfig,
config: &ConvertConfig,
) -> Result<(SdsRoot, Vec<String>), SdsError> {
let path_owned = input_path.to_path_buf();
let bytes = tokio::task::spawn_blocking(move || std::fs::read(&path_owned))
.await
.map_err(|e| SdsError::Extract(format!("spawn_blocking panicked: {e}")))?
.map_err(|e| SdsError::Extract(format!("reading PDF: {e}")))?;
let effective_lang_for_country = config.source_language
.unwrap_or_else(|| crate::language::detect_language(
&String::from_utf8_lossy(&bytes)
));
let country = config.source_country
.or_else(|| SourceCountry::infer_from_language(effective_lang_for_country));
let (sds, mut warnings) =
llm::extract_sds_from_pdf_vision(api_key, llm_config, &bytes, config.source_language, country)
.await?;
let validation_warnings = validator::validate(&sds);
warnings.extend(validation_warnings);
if let Some(c) = country {
warnings.extend(validator::validate_country(&sds, c));
}
Ok((sds, warnings))
}
pub fn convert_from_template(
sds: &SdsRoot,
template_path: &Path,
output_path: &Path,
) -> Result<(), SdsError> {
fill_template(sds, template_path, output_path)
}