Files
MarineMaintenance/swp_generator.py
T
alro65 67a0e674ca Initial commit — MarineMaintenance v1.0
Marine maintenance management: work orders with photos, ISM/SWP procedures,
MSDS, inventory, RFQ/purchases, vessel history, bilingual PDF reports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 01:54:20 -04:00

283 lines
15 KiB
Python

"""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'<b>{i+1}</b>',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"<b>{swp.get('company_name','')}</b>",mk('co',fontSize=12,textColor=NAVY))
else:
logo_cell = Paragraph(f"<b>{swp.get('company_name','')}</b>",mk('co',fontSize=12,textColor=NAVY))
right_txt = (f'<font size="7" color="#8a9bb0">{L["title"]}</font><br/>'
f'<font size="16" color="#00b4d8"><b>{swp["code"]}</b></font><br/>'
f'<font size="7" color="#8a9bb0">{version["version"]} · {L["effective"]}: {version.get("effective_date") or "—"}</font>')
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'<b>{swp.get("title","")}</b>',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'<b>{L["purpose_lbl"]}</b>',mk('pl',fontSize=7,textColor=GRAY)),
Paragraph(purpose,mk('pv',fontSize=8,leading=11))])
if scope: rows.append([Paragraph(f'<b>{L["scope_lbl"]}</b>',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'<b>{L["version"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
Paragraph(f'<b>{L["reason"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
Paragraph(f'<b>{L["diff"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
Paragraph(f'<b>{L["created_by"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
Paragraph(f'<b>{L["effective"]}</b>',mk('vh',fontSize=7,textColor=GRAY))],
[Paragraph(f"<b>{version['version']}</b>",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