808 lines
35 KiB
Python
808 lines
35 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Compliance Audit Script for EU SDS PDFs (multi-language).
|
||
Checks PDFs against REACH Annex II and CLP requirements.
|
||
Usage:
|
||
python3 compliance-audit.py path/to/file.pdf
|
||
python3 compliance-audit.py path/to/directory/
|
||
python3 compliance-audit.py path/to/file.pdf --lang BG
|
||
python3 compliance-audit.py path/to/directory/ --lang auto
|
||
"""
|
||
|
||
import os, sys, re, json, glob
|
||
from datetime import datetime
|
||
|
||
try:
|
||
import pdfplumber
|
||
except ImportError:
|
||
print("❌ pdfplumber not found. Install: pip3 install pdfplumber --break-system-packages")
|
||
sys.exit(1)
|
||
|
||
# ---- Colour helpers ----
|
||
GREEN = "\033[92m"
|
||
RED = "\033[91m"
|
||
YELLOW = "\033[93m"
|
||
RESET = "\033[0m"
|
||
BOLD = "\033[1m"
|
||
|
||
def ok(t): return f"{GREEN}✅ {t}{RESET}"
|
||
def fail(t): return f"{RED}❌ {t}{RESET}"
|
||
def warn(t): return f"{YELLOW}🟡 {t}{RESET}"
|
||
|
||
# ---- CLP statement lookup ----
|
||
CLP_PATH = os.path.expanduser("~/msds-eu-translations/clp_statements_multilingual.json")
|
||
CLP_DATA = {}
|
||
if os.path.exists(CLP_PATH):
|
||
with open(CLP_PATH, encoding="utf-8") as f:
|
||
raw = json.load(f)
|
||
for stmt_type in ["h_statements", "euh_statements", "p_statements"]:
|
||
for code, translations in raw.get(stmt_type, {}).items():
|
||
CLP_DATA[code] = translations
|
||
|
||
# ============================================================
|
||
# LANGUAGE PROFILES
|
||
# ============================================================
|
||
# Each profile defines patterns for mandatory/optional checks
|
||
# that are specific to that language.
|
||
# Profiles can inherit from a base.
|
||
|
||
LANG_PROFILES = {
|
||
"EN": {
|
||
"name": "English",
|
||
"detect": [r"SECTION \d+", r"Safety Data Sheet", r"Regulation \(EC\)"],
|
||
"section_prefix": "SECTION",
|
||
"supplier_names": ["Synthesia"],
|
||
"section_checks": {
|
||
1: { # Identification
|
||
"mandatory": [
|
||
("Product name", r"(Ethyl acetate|Acetone|Methanol|Toluene|Butyl|Xylene|Chloroform|Glycerol|Formic acid|Sodium hydroxide)"),
|
||
("CAS number", r"\d{2,7}-\d{2}-\d"),
|
||
("EC number", r"\d{3}-\d{3}-\d"),
|
||
("REACH registration", r"01-\d{10,12}-\d{2}"),
|
||
("Supplier name present", r"Synthesia"),
|
||
("Supplier address", r"(Krivina|Todor Radunchev|Bulgaria)"),
|
||
("Emergency phone", r"112"),
|
||
],
|
||
"optional": [
|
||
("EC Index number", r"\d{3}-\d{3}-\d{2}-\d{2}"),
|
||
("Molecular formula", r"C\d+.*H\d+"),
|
||
]
|
||
},
|
||
2: {
|
||
"name": "Hazards",
|
||
"mandatory": [
|
||
("Classification table", r"(Hazard class|Category|H\d{3})"),
|
||
("Signal word", r"(DANGER|WARNING)"),
|
||
("Pictograms", r"GHS"),
|
||
("H-statements listed", r"H\d{3}"),
|
||
("PBT/vPvB statement", r"(PBT|vPvB|persistent)"),
|
||
("ED statement", r"(endocrine|ED)"),
|
||
],
|
||
"optional": [
|
||
("Precautionary statements", r"P\d{3}"),
|
||
("EUH-statements", r"EUH"),
|
||
("Explosive vapour warning", r"(explosive|vapour)"),
|
||
]
|
||
},
|
||
3: {
|
||
"name": "Composition",
|
||
"mandatory": [
|
||
("Component in table", r"(Ethyl acetate|Acetone|> \d+%)"),
|
||
("CAS in composition", r"\d{2,7}-\d{2}-\d"),
|
||
("Concentration", r">\s*\d+%"),
|
||
],
|
||
"optional": [
|
||
("CLP classification in table", r"(Flam\. Liq|Acute Tox|Skin Corr|Not classified)"),
|
||
]
|
||
},
|
||
4: {
|
||
"name": "First aid",
|
||
"mandatory": [
|
||
("Inhalation measures", r"(fresh air|inhalation)"),
|
||
("Skin contact measures", r"(skin|water|shower)"),
|
||
("Eye contact measures", r"(eye|rinse|contact lens)"),
|
||
("Ingestion measures", r"(ingestion|mouth|vomit)"),
|
||
],
|
||
"optional": [
|
||
("Symptoms table", r"(symptom|effect)"),
|
||
("Medical attention", r"(medical|physician|treat)"),
|
||
]
|
||
},
|
||
5: {
|
||
"name": "Firefighting",
|
||
"mandatory": [
|
||
("Extinguishing media", r"(water spray|dry powder|foam|CO₂|carbon dioxide)"),
|
||
("Fire hazard", r"(flammable|flammable liquid|not flammable)"),
|
||
],
|
||
"optional": [
|
||
("Decomposition products", r"(carbon oxides|decomposition)"),
|
||
("Firefighters equipment", r"(self-contained|SCBA)"),
|
||
]
|
||
},
|
||
6: {
|
||
"name": "Accidental release",
|
||
"mandatory": [
|
||
("Personal precautions", r"(PPE|protective|precaution|ventilat)"),
|
||
("Environmental precautions", r"(environment|avoid release|containment)"),
|
||
("Cleaning methods", r"(absorbent|clean|contain)"),
|
||
],
|
||
"optional": [
|
||
("Reference to other sections", r"(Section [78]|sections [78])"),
|
||
]
|
||
},
|
||
7: {
|
||
"name": "Handling & storage",
|
||
"mandatory": [
|
||
("Safe handling", r"(keep away|ground|bond|ventilation)"),
|
||
("Hygiene measures", r"(eat|drink|smoke|wash)"),
|
||
("Storage conditions", r"(store|well-ventilated|cool|tightly closed)"),
|
||
],
|
||
"optional": [
|
||
("Incompatible materials", r"(incompat|oxidiz)"),
|
||
]
|
||
},
|
||
8: {
|
||
"name": "Exposure controls",
|
||
"mandatory": [
|
||
("OEL/control parameter", r"(mg/m³|ppm|OEL|TWA|STEL)"),
|
||
("Hand protection", r"(glove|hand)"),
|
||
("Eye protection", r"(goggle|eye|face shield)"),
|
||
("Respiratory protection", r"(respirator|breathing|RPE)"),
|
||
],
|
||
"optional": [
|
||
("DNEL/DMEL table", r"DNEL"),
|
||
("PNEC table", r"PNEC"),
|
||
("Engineering controls", r"(ventilation|extract)"),
|
||
]
|
||
},
|
||
9: {
|
||
"name": "Physical/chemical",
|
||
"mandatory": [
|
||
("Physical state", r"(liquid|solid|gas|fluid)"),
|
||
("Boiling point", r"(boiling|°C|°F)"),
|
||
("Density", r"(g/cm³|density|g/ml)"),
|
||
("Flash point", r"(flash|°C)"),
|
||
],
|
||
"optional": [
|
||
("Colour/odour", r"(colour|odour|color|odor)"),
|
||
("Solubility", r"(solubility|water)"),
|
||
]
|
||
},
|
||
10: {
|
||
"name": "Stability/reactivity",
|
||
"mandatory": [
|
||
("Reactivity", r"(reactivity|reactive)"),
|
||
("Stability", r"(stable|stability)"),
|
||
],
|
||
"optional": [
|
||
("Conditions to avoid", r"(avoid|heat|flame)"),
|
||
("Incompatible materials", r"(incompatible|oxidiz)"),
|
||
("Decomposition products", r"(decomposition)"),
|
||
]
|
||
},
|
||
11: {
|
||
"name": "Toxicological",
|
||
"mandatory": [
|
||
("Acute toxicity info", r"(acute toxicity|toxicological)"),
|
||
("Classification", r"(classified|not classified)"),
|
||
],
|
||
"optional": [
|
||
("Skin corrosion/irritation", r"(skin|corrosion|irritation)"),
|
||
("Eye damage/irritation", r"(eye|irritation)"),
|
||
("STOT single exposure", r"STOT"),
|
||
("Carcinogenicity", r"(carcinogen|Carc\.)"),
|
||
("Reproductive toxicity", r"(reproductive|Repr\.)"),
|
||
("Mutagenicity", r"(mutagen|Germ cell)"),
|
||
("Aspiration hazard", r"(aspiration|Asp\.)"),
|
||
]
|
||
},
|
||
12: {
|
||
"name": "Ecological",
|
||
"mandatory": [
|
||
("Ecotoxicity info", r"(ecotox|ecological|aquatic)"),
|
||
("PBT/vPvB assessment", r"(PBT|vPvB)"),
|
||
],
|
||
"optional": [
|
||
("LC50/EC50 data", r"(LC50|EC50)"),
|
||
("Persistence/degradability", r"(biodegrad|persistence)"),
|
||
("Bioaccumulation", r"(bioaccumul|log Kow)"),
|
||
("Mobility in soil", r"(mobility|soil)"),
|
||
]
|
||
},
|
||
13: {
|
||
"name": "Disposal",
|
||
"mandatory": [
|
||
("Waste treatment", r"(dispose|waste|disposal)"),
|
||
],
|
||
"optional": [
|
||
("Packaging disposal", r"(packaging|container)"),
|
||
("EWC code", r"EWC|\d{2} \d{2} \d{2}"),
|
||
]
|
||
},
|
||
14: {
|
||
"name": "Transport",
|
||
"mandatory": [
|
||
("Transport info/ADR", r"(ADR|UN|transport|not classified as dangerous)"),
|
||
("UN number or N/A", r"(UN|not classified)"),
|
||
],
|
||
"optional": [
|
||
("Transport class", r"(Class|II|III|6\.1|8|9)"),
|
||
("Packing group", r"(II|III)"),
|
||
("Kemler number", r"(Kemler|33|30)"),
|
||
("Tunnel code", r"(tunnel|D/E)"),
|
||
("IMDG/IATA", r"(IMDG|IATA)"),
|
||
]
|
||
},
|
||
15: {
|
||
"name": "Regulatory",
|
||
"mandatory": [
|
||
("REACH Annex XVII", r"(Annex XVII|restriction)"),
|
||
("SVHC statement", r"SVHC"),
|
||
("WGK/national", r"(WGK|Water hazard|national regul)"),
|
||
("CSA statement", r"(chemical safety assessment|CSA)"),
|
||
],
|
||
"optional": [
|
||
("PIC regulation", r"PIC"),
|
||
("POP regulation", r"POP"),
|
||
]
|
||
},
|
||
16: {
|
||
"name": "Other info",
|
||
"mandatory": [
|
||
("H-statements full text", r"(Full text of H|H\d{3}.*—)"),
|
||
("Disclaimer", r"(Disclaimer|liability)"),
|
||
],
|
||
"optional": [
|
||
("Abbreviations", r"(abbreviat|acronym)"),
|
||
]
|
||
},
|
||
}
|
||
},
|
||
"BG": {
|
||
"name": "Български",
|
||
"detect": [r"РАЗДЕЛ \d+", r"ИНФОРМАЦИОНЕН ЛИСТ", r"Синтезия ООД"],
|
||
"section_prefix": "РАЗДЕЛ",
|
||
"supplier_names": ["Синтезия ООД"],
|
||
"section_checks": {
|
||
1: {"mandatory": [
|
||
("Product name (Cyrillic)", r"(Ацетон|Бутил|Етил|Метанол|Толуен|Хлороформ|Глицерин|Ксилен|Мравчена|Циклохексанон|Изобутанол|Натриева|Диетилен|Триетилен|Диоктил|Пропанол|Пропилен)"),
|
||
("CAS number", r"\d{2,7}-\d{2}-\d"),
|
||
("EC number", r"\d{3}-\d{3}-\d"),
|
||
("REACH registration", r"01-\d{10,12}-\d{2}"),
|
||
("Supplier: Синтезия ООД", r"Синтезия ООД"),
|
||
("Address (Кривина)", r"Кривина"),
|
||
("Emergency phone (112)", r"112"),
|
||
], "optional": [
|
||
("EC Index number", r"\d{3}-\d{3}-\d{2}-\d{2}"),
|
||
("Molecular formula", r"C\d+.*H\d+"),
|
||
]},
|
||
2: {"mandatory": [
|
||
("Classification table", r"(Клас на опасност|категория|H\d{3})"),
|
||
("Signal word", r"(ОПАСНОСТ|ВНИМАНИЕ)"),
|
||
("Pictograms", r"GHS"),
|
||
("H-statements", r"H\d{3}"),
|
||
("PBT/vPvB", r"(PBT|устойчив|биоакумулиращ)"),
|
||
("ED statement", r"(ендокринен|ED)"),
|
||
], "optional": [
|
||
("Precautionary statements", r"P\d{3}"),
|
||
("EUH-statements", r"EUH"),
|
||
("Explosive vapour", r"експлозивн"),
|
||
]},
|
||
3: {"mandatory": [
|
||
("Component in table", r"(Ацетон|Бутил|Етил|> 99%|Концентрация)"),
|
||
("CAS in composition", r"\d{2,7}-\d{2}-\d"),
|
||
("Concentration", r">\s*\d+%"),
|
||
], "optional": [
|
||
("Classification in comp", r"(Flam\. Liq|Acute Tox|Не е класифициран)"),
|
||
]},
|
||
4: {"mandatory": [
|
||
("Inhalation: fresh air", r"(чист въздух|вдишване)"),
|
||
("Skin: water", r"(кожата|вода)"),
|
||
("Eyes: rinse", r"(очите|промивайте|лещи)"),
|
||
("Ingestion: no vomiting", r"(повръщане|поглъщане)"),
|
||
], "optional": [
|
||
("Symptoms table", r"Симптоми"),
|
||
("Medical attention", r"медицинск"),
|
||
]},
|
||
5: {"mandatory": [
|
||
("Extinguishing media", r"(воден спрей|CO₂|пяна|сух прах)"),
|
||
("Fire hazard", r"(запалим|запалими|не е запалим)"),
|
||
], "optional": [
|
||
("Decomposition products", r"въглеродни оксиди"),
|
||
("Firefighters equip", r"автономен"),
|
||
]},
|
||
6: {"mandatory": [
|
||
("Personal precautions", r"(ЛПС|предпазни|спешни случаи)"),
|
||
("Environmental precautions", r"изпускане в околната среда"),
|
||
("Cleaning methods", r"(почистване|абсорбиращ)"),
|
||
], "optional": [
|
||
("Reference to other sections", r"7 и 8"),
|
||
]},
|
||
7: {"mandatory": [
|
||
("Safe handling", r"(пази|заземете|предпазни)"),
|
||
("Hygiene measures", r"(яде|пие|пуши)"),
|
||
("Storage conditions", r"(съхранява|проветриво)"),
|
||
], "optional": [
|
||
("Incompatible materials", r"несъвместим"),
|
||
]},
|
||
8: {"mandatory": [
|
||
("OEL/control parameter", r"(mg/m³|ppm|OEL)"),
|
||
("Hand protection (gloves)", r"ръкавици"),
|
||
("Eye protection (goggles)", r"очила"),
|
||
("Respiratory protection", r"дихателн"),
|
||
], "optional": [
|
||
("DNEL/DMEL tables", r"DNEL"),
|
||
("PNEC table", r"PNEC"),
|
||
("EN standards", r"EN \d+"),
|
||
("Engineering controls", r"вентилаци"),
|
||
]},
|
||
9: {"mandatory": [
|
||
("Physical state", r"(течност|твърд|газ)"),
|
||
("Boiling point", r"(°C|точка на кипене)"),
|
||
("Density", r"g/cm³"),
|
||
("Flash point", r"(°C|точка на запалване)"),
|
||
], "optional": [
|
||
("Colour/odour", r"(цвят|мирис|безцветен)"),
|
||
("Solubility", r"разтворимост"),
|
||
]},
|
||
10: {"mandatory": [
|
||
("Reactivity", r"реактивоспособност"),
|
||
("Stability", r"стабилен"),
|
||
], "optional": [
|
||
("Conditions to avoid", r"избягва"),
|
||
("Incompatible materials", r"несъвместим"),
|
||
("Decomposition", r"разпадане"),
|
||
]},
|
||
11: {"mandatory": [
|
||
("Acute toxicity", r"остра токсичност"),
|
||
("Classification", r"(класифицир|не се класифицир)"),
|
||
], "optional": [
|
||
("Skin corrosion/irritation", r"(дразнене|корози)"),
|
||
("Eye damage", r"очите"),
|
||
("STOT single", r"СТОО.*еднократн"),
|
||
("STOT repeated", r"СТОО.*многократн"),
|
||
("Carcinogenicity", r"Канцерогенност"),
|
||
("Reproductive toxicity", r"Репродуктивна токсичност"),
|
||
("Mutagenicity", r"Мутагенност"),
|
||
("Aspiration hazard", r"Аспирацион"),
|
||
("Sensitisation", r"Сенсибилизация"),
|
||
]},
|
||
12: {"mandatory": [
|
||
("Ecotoxicity info", r"Екологична информация"),
|
||
("PBT/vPvB conclusion", r"(PBT|vPvB)"),
|
||
], "optional": [
|
||
("LC50/EC50 data", r"(LC50|EC50)"),
|
||
("Persistence/degradability", r"биоразградим"),
|
||
("Bioaccumulation", r"биоакумул"),
|
||
("Mobility in soil", r"Мобилност в почвата"),
|
||
]},
|
||
13: {"mandatory": [
|
||
("Waste treatment", r"(изхвърлете|отпадъци|обезвреждане)"),
|
||
], "optional": [
|
||
("Packaging disposal", r"опаковк"),
|
||
("EWC code", r"EWC|07 01 04"),
|
||
]},
|
||
14: {"mandatory": [
|
||
("Transport info/ADR", r"(ADR|не е класифициран|транспорт)"),
|
||
("UN number or N/A", r"(UN|\d{4}|не е класифициран)"),
|
||
], "optional": [
|
||
("Transport class", r"(клас|II|III|6\.1|8|9)"),
|
||
("Packing group", r"(II|III)"),
|
||
("Kemler number", r"(Kemler|33|30)"),
|
||
("Tunnel code", r"(тунел|D/E)"),
|
||
("IMDG/IATA", r"(IMDG|IATA)"),
|
||
]},
|
||
15: {"mandatory": [
|
||
("REACH Annex XVII", r"Приложение XVII"),
|
||
("SVHC statement", r"SVHC"),
|
||
("WGK/national", r"WGK"),
|
||
("CSA statement", r"(оценка на химичната безопасност|CSA)"),
|
||
], "optional": [
|
||
("PIC regulation", r"PIC"),
|
||
("POP regulation", r"POP"),
|
||
]},
|
||
16: {"mandatory": [
|
||
("H-statements full text", r"(Пълен текст на H|H\d{3}.*—)"),
|
||
("Disclaimer", r"Отказ от отговорност"),
|
||
], "optional": [
|
||
("Abbreviations", r"Съкращени"),
|
||
]},
|
||
}
|
||
},
|
||
"RO": {
|
||
"name": "Română",
|
||
"detect": [r"SECȚIUNEA \d+", r"FIȘĂ CU DATE", r"Synthesia Grup"],
|
||
"section_prefix": "SECȚIUNEA",
|
||
"supplier_names": ["Synthesia Grup SRL"],
|
||
"section_checks": {
|
||
1: {"mandatory": [
|
||
("Product name", r"(Acetat de etil|Acetonă|Metanol|Toluen|Butanol|Xilen)"),
|
||
("CAS number", r"\d{2,7}-\d{2}-\d"),
|
||
("EC number", r"\d{3}-\d{3}-\d"),
|
||
("REACH registration", r"01-\d{10,12}-\d{2}"),
|
||
("Supplier: Synthesia Grup SRL", r"Synthesia Grup SRL"),
|
||
("Supplier address", r"(București|Dudesti|Pantelimon)"),
|
||
("Emergency phone", r"112"),
|
||
], "optional": [
|
||
("EC Index number", r"\d{3}-\d{3}-\d{2}-\d{2}"),
|
||
("Molecular formula", r"C\d+.*H\d+"),
|
||
]},
|
||
2: {"mandatory": [
|
||
("Classification table", r"(Clasa de pericol|Categoria|H\d{3})"),
|
||
("Signal word", r"(PERICOL|ATENȚIE)"),
|
||
("Pictograms", r"GHS"),
|
||
("H-statements", r"H\d{3}"),
|
||
("PBT/vPvB", r"(PBT|persisten|bioacumul)"),
|
||
("ED statement", r"(disruptor endocrin|ED)"),
|
||
], "optional": [
|
||
("Precautionary statements", r"P\d{3}"),
|
||
("EUH-statements", r"EUH"),
|
||
("Explosive vapour", r"exploziv"),
|
||
]},
|
||
3: {"mandatory": [
|
||
("Component in table", r"(Acetat|acetonă|> \d+%)"),
|
||
("CAS in composition", r"\d{2,7}-\d{2}-\d"),
|
||
("Concentration", r">\s*\d+%"),
|
||
], "optional": []},
|
||
4: {"mandatory": [
|
||
("Inhalation: fresh air", r"(aer liber|inhalare)"),
|
||
("Skin: water", r"(piele|apă)"),
|
||
("Eyes: rinse", r"(ochi|clăti|contact)"),
|
||
("Ingestion: no vomiting", r"(înghițire|gură|vărsă)"),
|
||
], "optional": [
|
||
("Symptoms table", r"Simptom"),
|
||
("Medical attention", r"medical|medic"),
|
||
]},
|
||
5: {"mandatory": [
|
||
("Extinguishing media", r"(apă pulverizat|pulbere uscat|spumă|CO₂)"),
|
||
("Fire hazard", r"(inflamabil|lichid.*vapori|nu este inflamabil)"),
|
||
], "optional": [
|
||
("Decomposition products", r"(oxizi de carbon|descompunere)"),
|
||
]},
|
||
6: {"mandatory": [
|
||
("Personal precautions", r"(protecție|echipament|ventilat)"),
|
||
("Environmental precautions", r"(evita|scurgeri|mediu)"),
|
||
("Cleaning methods", r"(absorbant|curățare|colecta)"),
|
||
], "optional": []},
|
||
7: {"mandatory": [
|
||
("Safe handling", r"(păstra|caldură|scântei|flacără)"),
|
||
("Hygiene measures", r"(mânca|bea|fuma|spăla)"),
|
||
("Storage conditions", r"(depozita|ventilat|răcoare)"),
|
||
], "optional": [
|
||
("Incompatible materials", r"incompatibil"),
|
||
]},
|
||
8: {"mandatory": [
|
||
("OEL/control parameter", r"(mg/m³|ppm|OEL)"),
|
||
("Hand protection", r"(mănuși|mâini)"),
|
||
("Eye protection", r"(ochelari|ochi)"),
|
||
("Respiratory protection", r"(respirator|respirație)"),
|
||
], "optional": [
|
||
("DNEL/DMEL tables", r"DNEL"),
|
||
("PNEC table", r"PNEC"),
|
||
]},
|
||
9: {"mandatory": [
|
||
("Physical state", r"(lichid|solid|gaz)"),
|
||
("Boiling point", r"(fierbere|°C)"),
|
||
("Density", r"(g/cm³|densitate)"),
|
||
("Flash point", r"(aprindere|°C)"),
|
||
], "optional": [
|
||
("Colour/odour", r"(culoare|miros|incolor)"),
|
||
("Solubility", r"solubilitate"),
|
||
]},
|
||
10: {"mandatory": [
|
||
("Reactivity", r"reactivitate"),
|
||
("Stability", r"stabil"),
|
||
], "optional": [
|
||
("Conditions to avoid", r"evit"),
|
||
("Incompatible materials", r"incompatibil"),
|
||
("Decomposition", r"descompunere"),
|
||
]},
|
||
11: {"mandatory": [
|
||
("Acute toxicity", r"toxicitate acută"),
|
||
("Classification", r"(clasificat|neclasificat)"),
|
||
], "optional": [
|
||
("Skin corrosion", r"(piele|corodare|iritare)"),
|
||
("Eye damage", r"ochilor"),
|
||
("STOT", r"STOT"),
|
||
("Carcinogenicity", r"cancerigenitate"),
|
||
("Reproductive toxicity", r"(reproducere|Repr\.)"),
|
||
("Aspiration hazard", r"aspirare"),
|
||
]},
|
||
12: {"mandatory": [
|
||
("Ecotoxicity info", r"ecologic"),
|
||
("PBT/vPvB assessment", r"(PBT|vPvB)"),
|
||
], "optional": [
|
||
("LC50/EC50 data", r"(LC50|EC50)"),
|
||
("Persistence/degradability", r"biodegradabil"),
|
||
("Bioaccumulation", r"bioacumulare"),
|
||
]},
|
||
13: {"mandatory": [
|
||
("Waste treatment", r"(deșeuri|elimin|arunca)"),
|
||
], "optional": []},
|
||
14: {"mandatory": [
|
||
("Transport info/ADR", r"(ADR|transport|clasificat ca periculos)"),
|
||
("UN number or N/A", r"(UN|clasificat ca periculos)"),
|
||
], "optional": [
|
||
("Transport class", r"(clasa|II|III|6\.1|8|9)"),
|
||
("Packing group", r"(II|III)"),
|
||
("IMDG/IATA", r"(IMDG|IATA)"),
|
||
]},
|
||
15: {"mandatory": [
|
||
("REACH Annex XVII", r"Anexa XVII"),
|
||
("SVHC statement", r"SVHC"),
|
||
("WGK/national", r"WGK"),
|
||
("CSA statement", r"(evaluare|siguranță chimică|CSA)"),
|
||
], "optional": []},
|
||
16: {"mandatory": [
|
||
("H-statements full text", r"(Textul integral|H\d{3}.*—)"),
|
||
("Disclaimer", r"(clauză de limitare|răspundere|declinare)"),
|
||
], "optional": [
|
||
("Abbreviations", r"abrevier"),
|
||
]},
|
||
}
|
||
}
|
||
}
|
||
|
||
def detect_language(pdf_text):
|
||
"""Auto-detect language from PDF text by looking for section markers."""
|
||
scores = {}
|
||
for lang_code, profile in LANG_PROFILES.items():
|
||
score = 0
|
||
for pattern in profile["detect"]:
|
||
if re.search(pattern, pdf_text):
|
||
score += 1
|
||
scores[lang_code] = score
|
||
|
||
if max(scores.values()) > 0:
|
||
return max(scores, key=scores.get)
|
||
return "EN" # default
|
||
|
||
|
||
def audit_pdf(pdf_path, lang_code=None):
|
||
"""Audit a single PDF against the spec for the detected/specified language."""
|
||
try:
|
||
with pdfplumber.open(pdf_path) as pdf:
|
||
full_text = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
||
pages = len(pdf.pages)
|
||
except Exception as e:
|
||
return {"error": str(e), "grade": "FAIL", "file": os.path.basename(pdf_path)}
|
||
|
||
# Detect language if not specified
|
||
if not lang_code or lang_code == "auto":
|
||
detected = detect_language(full_text)
|
||
lang_code = detected or "EN"
|
||
else:
|
||
detected = lang_code
|
||
|
||
profile = LANG_PROFILES.get(lang_code, LANG_PROFILES["EN"])
|
||
|
||
results = {}
|
||
total_score = 0
|
||
total_max = 0
|
||
|
||
for sec_num in sorted(profile["section_checks"].keys()):
|
||
spec = profile["section_checks"][sec_num]
|
||
sec_name = spec.get("name", f"Section {sec_num}")
|
||
|
||
mandatory_total = len(spec["mandatory"])
|
||
mandatory_pass = 0
|
||
mandatory_issues = []
|
||
|
||
for check_name, pattern in spec["mandatory"]:
|
||
if re.search(pattern, full_text, re.IGNORECASE):
|
||
mandatory_pass += 1
|
||
else:
|
||
mandatory_issues.append(f"❌ Missing: {check_name}")
|
||
|
||
optional_total = len(spec["optional"])
|
||
optional_pass = 0
|
||
optional_issues = []
|
||
|
||
for check_name, pattern in spec["optional"]:
|
||
if re.search(pattern, full_text, re.IGNORECASE):
|
||
optional_pass += 1
|
||
else:
|
||
optional_issues.append(f"🟡 Missing: {check_name}")
|
||
|
||
sec_score = mandatory_pass * 2 + optional_pass
|
||
sec_max = mandatory_total * 2 + optional_total
|
||
|
||
pct = sec_score / sec_max * 100 if sec_max > 0 else 100
|
||
|
||
if pct >= 80: status = "✅ PASS"
|
||
elif pct >= 50: status = "🟡 BARELY"
|
||
else: status = "❌ FAIL"
|
||
|
||
total_score += sec_score
|
||
total_max += sec_max
|
||
|
||
results[sec_num] = {
|
||
"name": sec_name,
|
||
"mandatory": f"{mandatory_pass}/{mandatory_total}",
|
||
"optional": f"{optional_pass}/{optional_total}",
|
||
"score": f"{sec_score}/{sec_max} ({pct:.0f}%)",
|
||
"status": status,
|
||
"issues": mandatory_issues + optional_issues
|
||
}
|
||
|
||
overall_pct = total_score / total_max * 100 if total_max > 0 else 0
|
||
if overall_pct >= 85: grade = "PASS"
|
||
elif overall_pct >= 60: grade = "BARELY PASS"
|
||
else: grade = "NO PASS"
|
||
|
||
return {
|
||
"lang": detected,
|
||
"grade": grade,
|
||
"score": f"{total_score}/{total_max} ({overall_pct:.0f}%)",
|
||
"overall_pct": overall_pct,
|
||
"pages": pages,
|
||
"file": os.path.basename(pdf_path),
|
||
"sections": results,
|
||
}
|
||
|
||
|
||
def generate_report(pdf_path, result, verbose=False):
|
||
"""Generate markdown report for one PDF."""
|
||
name = os.path.basename(pdf_path).replace(".pdf", "")
|
||
lang_name = LANG_PROFILES.get(result["lang"], {}).get("name", result["lang"])
|
||
|
||
report = []
|
||
report.append(f"# Compliance Audit Report: {name}")
|
||
report.append(f"\n**Language:** {lang_name} ({result['lang']})")
|
||
report.append(f"**Grade:** {result['grade']}")
|
||
report.append(f"**Score:** {result['score']}")
|
||
report.append(f"**Pages:** {result['pages']}")
|
||
report.append(f"**Date:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||
|
||
report.append(f"\n## Section Results\n")
|
||
report.append(f"| # | Section | Mandatory | Optional | Score | Status |")
|
||
report.append(f"|---|---------|-----------|----------|-------|--------|")
|
||
|
||
for sn in sorted(result["sections"].keys()):
|
||
s = result["sections"][sn]
|
||
report.append(f"| {sn} | {s['name']} | {s['mandatory']} | {s['optional']} | {s['score']} | {s['status']} |")
|
||
|
||
any_issues = False
|
||
report.append(f"\n## Issues\n")
|
||
for sn in sorted(result["sections"].keys()):
|
||
s = result["sections"][sn]
|
||
if s["issues"]:
|
||
any_issues = True
|
||
report.append(f"\n### Section {sn}: {s['name']}\n")
|
||
for iss in s["issues"]:
|
||
report.append(f"- {iss}")
|
||
|
||
if not any_issues:
|
||
report.append("No issues found.")
|
||
|
||
report.append(f"\n## ⚠️ Verification Required")
|
||
report.append(f"\nThis audit checks structural completeness. The following must be verified by a REACH expert:")
|
||
report.append(f"- REACH registration numbers — confirm against your REACH dossier")
|
||
report.append(f"- H-statements/classification — verify against CLP Annex VI")
|
||
report.append(f"- OEL values — confirm with national/local regulations")
|
||
report.append(f"- Transport data — verify against ADR")
|
||
report.append(f"- DNEL/PNEC values — verify against your CSR")
|
||
report.append(f"\n---\n*Compliance Auditor v2 | Hermes Agent*")
|
||
|
||
return "\n".join(report)
|
||
|
||
|
||
def main():
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(description="Compliance audit of SDS PDFs (multi-language)")
|
||
parser.add_argument("target", help="PDF file or directory to audit")
|
||
parser.add_argument("--lang", default="auto", choices=["auto", "EN", "BG", "RO"],
|
||
help="Language (default: auto-detect)")
|
||
parser.add_argument("--out", "-o", default=None, help="Output directory for reports")
|
||
args = parser.parse_args()
|
||
|
||
target = args.target
|
||
|
||
if os.path.isdir(target):
|
||
pdfs = sorted(glob.glob(os.path.join(target, "*.pdf")))
|
||
# Filter out non-SDS PDFs and old long-named duplicates
|
||
pdfs = [p for p in pdfs if "SDS" in os.path.basename(p) and not any(
|
||
x in os.path.basename(p) for x in ["DOTP.pdf", "DICHLOROMETHANE", "TETRAETHYL",
|
||
"MONOBUTYL", "2-BUTOXY", "AZEOTROPIC",
|
||
"DILUTE", "DIL.pdf", "_BUTANOL.pdf",
|
||
"PROPANOL.pdf", "ACETATE.pdf"])
|
||
and os.path.basename(p) not in [
|
||
"SDS_BG_N-BUTANOL.pdf", "SDS_BG_N-PROPANOL.pdf", "SDS_BG_N-PROPYL_ACETATE.pdf",
|
||
"SDS_BG_SODIUM_HYDROXIDE_SOLUTION_48%.pdf", "SDS_BG_SODIUM_HYDROXIDE_FLAKES.pdf"
|
||
]]
|
||
elif os.path.isfile(target):
|
||
pdfs = [target]
|
||
else:
|
||
print(f"Error: {target} not found")
|
||
sys.exit(1)
|
||
|
||
if not pdfs:
|
||
print("No PDF files found.")
|
||
sys.exit(1)
|
||
|
||
report_dir = args.out or os.path.join(os.path.dirname(pdfs[0]), "compliance-reports")
|
||
os.makedirs(report_dir, exist_ok=True)
|
||
|
||
# Version-tracked subdirectory
|
||
date_stamp = datetime.now().strftime("%Y-%m-%d")
|
||
versioned_dir = os.path.join(report_dir, date_stamp)
|
||
os.makedirs(versioned_dir, exist_ok=True)
|
||
|
||
print(f"\n{BOLD}Compliance Audit — EU Language SDS Checker{RESET}")
|
||
print(f"Files: {len(pdfs)} | Language: {args.lang}")
|
||
print("=" * 70)
|
||
|
||
all_results = []
|
||
grades_count = {"PASS": 0, "BARELY PASS": 0, "NO PASS": 0}
|
||
|
||
for pdf_path in pdfs:
|
||
result = audit_pdf(pdf_path, args.lang)
|
||
|
||
if "error" in result:
|
||
print(f" {fail('ERROR')}: {result['file']} — {result['error']}")
|
||
continue
|
||
|
||
icon = {"PASS": ok, "BARELY PASS": warn, "NO PASS": fail}[result["grade"]]
|
||
lang_tag = f"[{result['lang']}]"
|
||
print(f" {icon(result['grade']):15s} {lang_tag:5s} {result['file']:50s} ({result['pages']}p, {result['score']})")
|
||
|
||
grades_count[result["grade"]] = grades_count.get(result["grade"], 0) + 1
|
||
|
||
# Per-document report (save to both regular and versioned dirs)
|
||
report = generate_report(pdf_path, result)
|
||
rname = f"compliance-{os.path.basename(pdf_path).replace('.pdf','')}.md"
|
||
# Current
|
||
with open(os.path.join(report_dir, rname), 'w', encoding='utf-8') as f:
|
||
f.write(report)
|
||
# Versioned
|
||
with open(os.path.join(versioned_dir, rname), 'w', encoding='utf-8') as f:
|
||
f.write(report)
|
||
|
||
all_results.append(result)
|
||
|
||
# Summary
|
||
print(f"\n{'='*70}")
|
||
print(f"{BOLD}Summary{RESET}")
|
||
for g in ["PASS", "BARELY PASS", "NO PASS"]:
|
||
print(f" {g:15s}: {grades_count.get(g, 0)}")
|
||
print(f"{'Total':15s}: {sum(grades_count.values())}")
|
||
|
||
if all_results:
|
||
avg = sum(r["overall_pct"] for r in all_results if "overall_pct" in r) / len(all_results)
|
||
print(f"{'Average':15s}: {avg:.0f}%")
|
||
|
||
# Master report
|
||
master = [f"# Master Compliance Audit Report",
|
||
f"\n**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||
f"**PDFs audited:** {len(all_results)}",
|
||
f"\n## Summary\n",
|
||
f"| Grade | Count |",
|
||
f"|-------|-------|"]
|
||
for g in ["PASS", "BARELY PASS", "NO PASS"]:
|
||
master.append(f"| {g} | {grades_count.get(g, 0)} |")
|
||
|
||
master.append(f"\n## Per-Product\n")
|
||
master.append(f"| # | File | Lang | Grade | Pages | Score |")
|
||
master.append(f"|---|------|------|-------|-------|-------|")
|
||
for i, r in enumerate(all_results, 1):
|
||
if "error" in r:
|
||
master.append(f"| {i} | {r['file']} | — | ERROR | — | — |")
|
||
else:
|
||
master.append(f"| {i} | {r['file']} | {r['lang']} | {r['grade']} | {r['pages']} | {r['score']} |")
|
||
|
||
master_path = os.path.join(os.path.dirname(pdfs[0]), "master-compliance-audit-report.md")
|
||
with open(master_path, 'w', encoding='utf-8') as f:
|
||
f.write("\n".join(master))
|
||
# Versioned copy
|
||
master_ver = os.path.join(versioned_dir, "master-compliance-audit-report.md")
|
||
with open(master_ver, 'w', encoding='utf-8') as f:
|
||
f.write("\n".join(master))
|
||
|
||
print(f"\n📋 Reports: {report_dir}/")
|
||
print(f"📊 Master: {master_path}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|