"""swp_generator.py — PDF SWP con espaciado compacto y traducción Ollama""" import os, json, urllib.request from io import BytesIO from reportlab.lib.pagesizes import letter from reportlab.lib import colors from reportlab.lib.units import inch from reportlab.lib.styles import ParagraphStyle from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, Image as RLImage, KeepTogether) from reportlab.platypus import Flowable from PIL import Image as PILImage NAVY = colors.HexColor('#0a1628') CYAN = colors.HexColor('#00b4d8') WARN = colors.HexColor('#f4a261') LIGHT = colors.HexColor('#eef2f7') GRAY = colors.HexColor('#8a9bb0') WHITE = colors.white RED = colors.HexColor('#e63946') GREEN = colors.HexColor('#2ec4b6') ORANGE= colors.HexColor('#e76f51') BLUE = colors.HexColor('#0077b6') PURPLE= colors.HexColor('#7b2d8b') CATEGORY_LABELS_ES = { 'electrical':'Trabajo Eléctrico','mechanical':'Mecánico / Motor', 'chemical':'Químicos / Pinturas','confined':'Espacio Confinado', 'height':'Trabajos en Altura','welding':'Soldadura / Calor', 'hull':'Casco / Buceo','other':'Otro', } CATEGORY_LABELS_EN = { 'electrical':'Electrical Work','mechanical':'Mechanical / Engine', 'chemical':'Chemicals / Paints','confined':'Confined Space', 'height':'Working at Height','welding':'Welding / Heat Work', 'hull':'Hull / Diving','other':'Other', } LABELS = { 'es': dict(title='PROCEDIMIENTO DE TRABAJO SEGURO',active='ACTIVO', category='Categoría',approved_by='Aprobado por',status='Estado', purpose='1. Propósito y Alcance',hazards='2. Riesgos Identificados', ppe='3. EPP Requerido',tools='4. Herramientas y Materiales', steps='5. Pasos del Procedimiento',emergency='6. Medidas de Emergencia', refs='7. Referencias y Normativa',version_ctrl='Control de Versiones', version='Versión',reason='Motivo del cambio',diff='Diferencias', created_by='Creado por',effective='Vigente desde', footer='Documento controlado — no válido si se imprime sin sello de aprobación', purpose_lbl='Propósito',scope_lbl='Alcance'), 'en': dict(title='SAFE WORK PROCEDURE',active='ACTIVE', category='Category',approved_by='Approved by',status='Status', purpose='1. Purpose and Scope',hazards='2. Identified Hazards', ppe='3. Required PPE',tools='4. Tools and Materials', steps='5. Procedure Steps',emergency='6. Emergency Measures', refs='7. References and Standards',version_ctrl='Version Control', version='Version',reason='Reason for change',diff='Differences', created_by='Created by',effective='Effective date', footer='Controlled document — not valid if printed without approval stamp', purpose_lbl='Purpose',scope_lbl='Scope'), } def mk(name, **kw): d = dict(fontName='Helvetica',fontSize=9,textColor=NAVY,leading=11,spaceAfter=0) d.update(kw); return ParagraphStyle(name, **d) class SHdr(Flowable): def __init__(self, text, color=NAVY): Flowable.__init__(self); self.text=text; self._c=color; self.height=16 def wrap(self,aw,ah): self._w=aw; return aw,self.height def draw(self): self.canv.setFillColor(self._c) self.canv.rect(0,0,self._w,self.height,fill=1,stroke=0) self.canv.setFillColor(WHITE) self.canv.setFont('Helvetica-Bold',8) self.canv.drawString(8,4,self.text.upper()) def num_table(items, W): if not items: return Paragraph('—', mk('b',textColor=GRAY)) rows=[[Paragraph(f'{i+1}',mk('n',fontSize=8,textColor=CYAN,alignment=TA_CENTER)), Paragraph(str(item),mk('it',fontSize=8,leading=10))] for i,item in enumerate(items)] t=Table(rows,colWidths=[0.28*inch,W-0.28*inch]) t.setStyle(TableStyle([ ('VALIGN',(0,0),(-1,-1),'TOP'), ('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2), ('LEFTPADDING',(0,0),(-1,-1),3),('RIGHTPADDING',(0,0),(-1,-1),3), ('ROWBACKGROUNDS',(0,0),(-1,-1),[WHITE,LIGHT]), ])); return t def tag_table(items, W): if not items: return Paragraph('—', mk('b',textColor=GRAY)) cells=[Paragraph(f'• {item}',mk('tg',fontSize=8,leading=10)) for item in items] rows=[cells[i:i+3] for i in range(0,len(cells),3)] while len(rows[-1])<3: rows[-1].append(Paragraph('',mk('tg'))) t=Table(rows,colWidths=[W/3]*3) t.setStyle(TableStyle([ ('BACKGROUND',(0,0),(-1,-1),LIGHT), ('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')), ('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3), ('LEFTPADDING',(0,0),(-1,-1),6),('VALIGN',(0,0),(-1,-1),'MIDDLE'), ])); return t def translate_text(text, target='en'): if not text or not text.strip(): return text try: direction = 'Spanish to English' if target=='en' else 'English to Spanish' prompt = (f"Translate from {direction}. Technical safety/marine terminology. " f"Return ONLY the translated text:\n\n{text}") payload = json.dumps({"model":"llama3.1:8b","prompt":prompt, "stream":False,"options":{"temperature":0.1}}).encode('utf-8') req = urllib.request.Request("http://localhost:11434/api/generate", data=payload,headers={"Content-Type":"application/json"},method="POST") with urllib.request.urlopen(req,timeout=25) as resp: data = json.loads(resp.read().decode('utf-8')) result = data.get('response',text).strip() for p in ['Here is the translated text:','Translation:','Translated text:']: if result.lower().startswith(p.lower()): result=result[len(p):].strip() return result except: return text def translate_list(lst, target='en'): return [translate_text(i,target) for i in lst] def resize_image(path,mw,mh): try: with PILImage.open(path) as img: iw,ih=img.size r=min(mw/iw,mh/ih); return iw*r,ih*r except: return mw,mh def section(hdr_flowable, content_flowable): """Compact section: header + 2pt gap + content + 5pt after""" return [KeepTogether([hdr_flowable, Spacer(1,2), content_flowable]), Spacer(1,5)] def generate_swp_pdf(swp, version, logo_path, json_module, lang='es'): L = LABELS.get(lang, LABELS['es']) cat_map = CATEGORY_LABELS_EN if lang=='en' else CATEGORY_LABELS_ES def parse(field): try: return json_module.loads(version.get(field) or '[]') except: return [] hazards = parse('hazards'); ppe=parse('ppe') tools = parse('tools'); steps=parse('steps') refs = parse('ref_standards') purpose = version.get('purpose') or '' scope = version.get('scope') or '' emergency = version.get('emergency') or '' if lang == 'en': purpose = translate_text(purpose,'en') scope = translate_text(scope,'en') emergency = translate_text(emergency,'en') hazards = translate_list(hazards,'en') ppe = translate_list(ppe,'en') tools = translate_list(tools,'en') steps = translate_list(steps,'en') buf = BytesIO() W = 7.5*inch doc = SimpleDocTemplate(buf, pagesize=letter, leftMargin=0.65*inch, rightMargin=0.65*inch, topMargin=0.6*inch, bottomMargin=0.6*inch) story = [] # ── HEADER ─────────────────────────────────────────────────────────────── story.append(HRFlowable(width=W,thickness=3,color=CYAN,spaceAfter=4)) if logo_path and os.path.exists(logo_path): try: lw,lh = resize_image(logo_path,1.4*inch,0.55*inch) logo_cell = RLImage(logo_path,width=lw,height=lh) except: logo_cell = Paragraph(f"{swp.get('company_name','')}",mk('co',fontSize=12,textColor=NAVY)) else: logo_cell = Paragraph(f"{swp.get('company_name','')}",mk('co',fontSize=12,textColor=NAVY)) right_txt = (f'{L["title"]}
' f'{swp["code"]}
' f'{version["version"]} · {L["effective"]}: {version.get("effective_date") or "—"}') hdr = Table([[logo_cell, Paragraph(right_txt,mk('rh',fontSize=8,alignment=TA_RIGHT,leading=13))]], colWidths=[W*0.55,W*0.45]) hdr.setStyle(TableStyle([('VALIGN',(0,0),(-1,-1),'TOP')])) story.append(hdr) if swp.get('company_info'): story.append(Paragraph(swp['company_info'],mk('ci',fontSize=7,textColor=GRAY))) story.append(HRFlowable(width=W,thickness=1,color=LIGHT,spaceAfter=4)) # ── META ───────────────────────────────────────────────────────────────── meta = Table([ [Paragraph(f'{swp.get("title","")}',mk('t',fontSize=10,fontName='Helvetica-Bold')), Paragraph(cat_map.get(swp.get('category','other'),''),mk('c',fontSize=8)), Paragraph(version.get('approved_by') or '—',mk('a',fontSize=8)), Paragraph(L['active'],mk('s',fontSize=8,textColor=GREEN,fontName='Helvetica-Bold'))], [Paragraph(swp.get('code',''),mk('lc',fontSize=7,textColor=GRAY)), Paragraph(L['category'],mk('lcat',fontSize=7,textColor=GRAY)), Paragraph(L['approved_by'],mk('lab',fontSize=7,textColor=GRAY)), Paragraph(L['status'],mk('lst',fontSize=7,textColor=GRAY))], ],colWidths=[W*0.38,W*0.25,W*0.25,W*0.12]) meta.setStyle(TableStyle([ ('BACKGROUND',(0,1),(-1,1),LIGHT), ('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')), ('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3), ('LEFTPADDING',(0,0),(-1,-1),6),('RIGHTPADDING',(0,0),(-1,-1),6), ])) story.append(meta) story.append(Spacer(1,4)) # ── PURPOSE & SCOPE ────────────────────────────────────────────────────── if purpose or scope: rows=[] if purpose: rows.append([Paragraph(f'{L["purpose_lbl"]}',mk('pl',fontSize=7,textColor=GRAY)), Paragraph(purpose,mk('pv',fontSize=8,leading=11))]) if scope: rows.append([Paragraph(f'{L["scope_lbl"]}',mk('sl',fontSize=7,textColor=GRAY)), Paragraph(scope,mk('sv',fontSize=8,leading=11))]) pt=Table(rows,colWidths=[0.85*inch,W-0.85*inch]) pt.setStyle(TableStyle([ ('BACKGROUND',(0,0),(0,-1),LIGHT), ('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')), ('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3), ('LEFTPADDING',(0,0),(-1,-1),6),('VALIGN',(0,0),(-1,-1),'TOP'), ])) story += section(SHdr(L['purpose']), pt) # ── HAZARDS ────────────────────────────────────────────────────────────── if hazards: story += section(SHdr(L['hazards'],RED), num_table(hazards,W)) # ── PPE ────────────────────────────────────────────────────────────────── if ppe: story += section(SHdr(L['ppe'],ORANGE), tag_table(ppe,W)) # ── TOOLS & MATERIALS ──────────────────────────────────────────────────── if tools: story += section(SHdr(L['tools'],PURPLE), tag_table(tools,W)) # ── STEPS ──────────────────────────────────────────────────────────────── if steps: story += section(SHdr(L['steps'],BLUE), num_table(steps,W)) # ── EMERGENCY ──────────────────────────────────────────────────────────── if emergency: et=Table([[Paragraph(emergency,mk('em',fontSize=8,leading=11))]],colWidths=[W]) et.setStyle(TableStyle([ ('BACKGROUND',(0,0),(-1,-1),colors.HexColor('#fff8e8')), ('BOX',(0,0),(-1,-1),1,WARN), ('TOPPADDING',(0,0),(-1,-1),4),('BOTTOMPADDING',(0,0),(-1,-1),4), ('LEFTPADDING',(0,0),(-1,-1),8),('RIGHTPADDING',(0,0),(-1,-1),8), ])) story += section(SHdr(L['emergency'],WARN), et) # ── REFERENCES ─────────────────────────────────────────────────────────── if refs: story += section(SHdr(L['refs']), tag_table(refs,W)) # ── VERSION CONTROL ────────────────────────────────────────────────────── story.append(HRFlowable(width=W,thickness=1,color=LIGHT,spaceAfter=3)) vt=Table([ [Paragraph(f'{L["version"]}',mk('vh',fontSize=7,textColor=GRAY)), Paragraph(f'{L["reason"]}',mk('vh',fontSize=7,textColor=GRAY)), Paragraph(f'{L["diff"]}',mk('vh',fontSize=7,textColor=GRAY)), Paragraph(f'{L["created_by"]}',mk('vh',fontSize=7,textColor=GRAY)), Paragraph(f'{L["effective"]}',mk('vh',fontSize=7,textColor=GRAY))], [Paragraph(f"{version['version']}",mk('vv',fontSize=8,textColor=CYAN,fontName='Helvetica-Bold')), Paragraph(version.get('change_reason') or '—',mk('vr',fontSize=8)), Paragraph(version.get('diff_summary') or '—',mk('vd',fontSize=8)), Paragraph(version.get('created_by') or '—',mk('vc',fontSize=8)), Paragraph(version.get('effective_date') or '—',mk('ve',fontSize=8))], ],colWidths=[W*0.1,W*0.22,W*0.28,W*0.22,W*0.18]) vt.setStyle(TableStyle([ ('BACKGROUND',(0,0),(-1,0),LIGHT), ('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')), ('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3), ('LEFTPADDING',(0,0),(-1,-1),6),('VALIGN',(0,0),(-1,-1),'TOP'), ])) story += section(SHdr(L['version_ctrl']), vt) # ── FOOTER ─────────────────────────────────────────────────────────────── story.append(Spacer(1,6)) story.append(HRFlowable(width=W,thickness=1,color=LIGHT,spaceAfter=2)) story.append(Paragraph( f"{swp.get('company_name','')} | {swp['code']} {version['version']} | {L['footer']}", mk('ft',fontSize=7,textColor=GRAY,alignment=TA_CENTER))) doc.build(story) buf.seek(0) return buf