#!/usr/bin/env python3 """ apply_template.py - Conversión de ENERGY REPORT a formato corporativo R360MX. Aplica la plantilla oficial (portada + disclaimer + índice + contraportada) a uno o varios documentos ENERGY REPORT de RatedPower. Uso: # Simple python3 apply_template.py informe.docx plantilla.docx # Con opciones python3 apply_template.py informe.docx plantilla.docx -o salida.docx -v # Modo batch (procesa todo un directorio) python3 apply_template.py --batch ./informes/ plantilla.docx -v # Dry-run (solo muestra lo que haría) python3 apply_template.py informe.docx plantilla.docx --dry-run -v """ import sys import os import re import copy import logging import argparse import zipfile import json from pathlib import Path from datetime import datetime from lxml import etree # Namespaces OOXML NS = { 'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', 'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', 'wp': 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing', 'mc': 'http://schemas.openxmlformats.org/markup-compatibility/2006', 'ct': 'http://schemas.openxmlformats.org/package/2006/content-types', 'rel': 'http://schemas.openxmlformats.org/package/2006/relationships', } log = logging.getLogger('r360mx') # ====================================================================== # MAPEO DE ESTILOS: source -> template # ====================================================================== DEFAULT_STYLE_MAP = { # Estilos de título principales (RatedPower -> TEMPLATE R360MX) # IMPORTANTE: el template usa 'Ttulo1/2' SIN acento 'Title1': 'Ttulo1', 'Title2': 'Ttulo2', 'Title3': 'Ttulo3', 'Title1nfs': 'Title1nfs', # Índice / TOC 'CustomStyleLevelOne': 'TDC1', 'CustomStyleLevelTwo': 'TDC2', 'Title2Index': 'Title2Index', 'TableContentEnd': 'TableContentEnd', # Portada 'CoverSubtitle20': 'CoverSubtitle20', # Captions de figuras/tablas en el cuerpo 'NameTableImg': 'Descripcin', } # ====================================================================== # UTILIDADES XML # ====================================================================== def parse_xml(content: bytes) -> etree._Element: return etree.fromstring(content) def fill_sdt_fields(xml_root: etree._Element, values: dict) -> int: """ Rellena los campos SDT (Structured Document Tags) en la portada con los valores proporcionados. """ filled = 0 for sdt in xml_root.iter(q('w:sdt')): alias_el = sdt.find(q('w:alias')) if alias_el is None: continue alias = alias_el.get(q('w:val')) if alias and alias in values and values[alias]: sdt_content = sdt.find(q('w:sdtContent')) if sdt_content is not None: for run in sdt_content.iter(q('w:r')): for t in run.iter(q('w:t')): t.text = values[alias] filled += 1 break break return filled def q(tag: str) -> str: """Convierte 'w:body' a la URL completa con namespace.""" prefix, local = tag.split(':') return f'{{{NS[prefix]}}}{local}' def nsmap(*prefixes: str) -> dict: """Construye nsmap para tostring.""" return {p: NS[p] for p in prefixes} def get_style_id(p_element: etree._Element) -> str | None: """Devuelve el styleId de un párrafo, o None.""" pPr = p_element.find(q('w:pPr')) if pPr is None: return None pStyle = pPr.find(q('w:pStyle')) if pStyle is None: return None return pStyle.get(q('w:val')) def get_para_text(p_element: etree._Element) -> str: """Obtiene el texto plano de un párrafo.""" texts = p_element.findall(f'.//{q("w:t")}') return ''.join(t.text or '' for t in texts) def get_paras(body: etree._Element) -> list: """Devuelve todos los párrafos y tablas del body en orden.""" return [child for child in body if child.tag in (q('w:p'), q('w:tbl'))] def collect_image_refs(xml_root: etree._Element) -> list[tuple]: """Encuentra todos los a:blip con r:embed.""" blips = [] for blip in xml_root.iter(f'{{{NS["a"]}}}blip'): rid = blip.get(f'{{{NS["r"]}}}embed') if rid: blips.append((blip, rid)) return blips def _find_section_boundaries(body: etree._Element) -> list[int]: """ Encuentra los índices de todos los sectPr en el body. Cada sectPr marca el FINAL de una sección (el contenido de la sección está entre sectPr anteriores). Devuelve lista de índices de hijos donde hay sectPr. """ boundaries = [] for i, child in enumerate(body): if child.tag == q('w:sectPr'): boundaries.append(i) elif child.tag == q('w:p'): pPr = child.find(q('w:pPr')) if pPr is not None: sectPr = pPr.find(q('w:sectPr')) if sectPr is not None: boundaries.append(i) return boundaries class DocxError(Exception): """Error relacionado con el procesamiento de documentos DOCX.""" pass # ====================================================================== # DETECCIÓN INTELIGENTE DE SECCIONES # ====================================================================== class SectionDetector: """ Detecta las secciones clave en el template y el documento source basándose en marcadores, estilos y contenido, sin números mágicos. """ MARKER_STYLES = { 'indice_fin': 'TableContentEnd', 'titulo_contenido': 'Ttulo1', # El template usa Ttulo1 (sin acento) } @staticmethod def find_end_of_preface(body: etree._Element) -> int: """ Encuentra dónde termina el prefacio del template (portada + disclaimer + índice). Busca el marcador `TableContentEnd` o `ContentStart`. También busca un salto de sección después del índice. """ children = list(body) for i, child in enumerate(children): if child.tag == q('w:p'): style_id = get_style_id(child) if style_id == 'TableContentEnd': log.debug(" Marker 'TableContentEnd' encontrado en hijo %d", i) return i if style_id == 'ContentStart': log.debug(" Marker 'ContentStart' encontrado en hijo %d", i) return i text = get_para_text(child).strip() if text.upper() == '<>': log.debug(" Marker textual '<>' en hijo %d", i) return i # Fallback: buscar primer Título 1 que parezca contenido real for i, child in enumerate(children): if child.tag == q('w:p'): style_id = get_style_id(child) text = get_para_text(child).strip() if style_id in ('Ttulo1', 'Title1', 'Título 1') and text: # Si hay un salto de sección justo antes, ese es el límite for j in range(max(0, i - 3), i): prev_child = children[j] if prev_child.tag == q('w:p'): prev_pPr = prev_child.find(q('w:pPr')) if prev_pPr is not None: sectPr = prev_pPr.find(q('w:sectPr')) if sectPr is not None: log.debug(" Salto de sección antes de Título 1 en hijo %d", j) return j # Si no, devolver el índice del párrafo anterior al primer Título 1 return i - 1 if i > 0 else 0 return 0 @staticmethod def find_back_cover_start(body: etree._Element) -> int: """ Encuentra dónde empieza la contraportada en el template. Busca DESDE EL FINAL hacia el principio para encontrar la última ocurrencia de 'RENOVABLES 360' o el marcador BackCover. """ children = list(body) # Buscar desde el final hacia atrás for i in range(len(children) - 1, -1, -1): child = children[i] if child.tag == q('w:p'): style_id = get_style_id(child) if style_id == 'BackCover': log.debug(" Marker 'BackCover' en hijo %d (desde el final)", i) return i text = get_para_text(child).strip() if 'RENOVABLES 360' in text.upper() or 'RENEWABLE 360' in text.upper(): log.debug(" Texto 'RENOVABLES 360' en hijo %d (desde el final)", i) return i return len(children) - 1 # última página @staticmethod def find_content_start(body: etree._Element) -> int: """ Encuentra el primer elemento de contenido real en el documento source, detectando dónde acaba el índice de RatedPower. """ children = list(body) found_toc_marker = False best = None # 1. Buscar marcador TableContentEnd for i, child in enumerate(children): if child.tag == q('w:p'): style_id = get_style_id(child) if style_id == 'TableContentEnd': found_toc_marker = True best = i + 1 log.debug(" Marker TableContentEnd en source, hijo %d", i) break # 2. Si no hay marcador, buscar patrón típico del índice if not found_toc_marker: for i, child in enumerate(children): if child.tag == q('w:p'): style_id = get_style_id(child) text = get_para_text(child).strip() # El índice termina justo antes del primer título numerado (1., 2., etc.) if style_id in ('Title1', 'Ttulo1', 'Título 1') and text: # Verificar que parece un título de contenido (empieza con número) if re.match(r'^\d+\.?\s', text) or re.match(r'^[IVXLCDM]+\.\s', text): # Si está cerca del principio, ignorar (es el TOC) if i > 20: # suficientemente lejos para ser contenido real log.debug(" Primer título numerado en source hijo %d: '%s'", i, text[:50]) return i # 3. Buscar salto de sección como delimitador for i, child in enumerate(children): if child.tag == q('w:p'): pPr = child.find(q('w:pPr')) if pPr is not None: sectPr = pPr.find(q('w:sectPr')) if sectPr is not None: log.debug(" Salto de sección en source hijo %d", i) candidate = i + 1 if candidate < len(children): return candidate break # Fallback: si no se encontró nada, devolver la mitad del documento # (asumiendo que el índice ocupa ~la primera mitad) if best is None: best = max(len(children) // 2, 10) log.debug(" Fallback: contenido empieza en hijo %d (mitad del doc)", best) return best # ====================================================================== # REMAPEO DE ESTILOS # ====================================================================== def remap_styles(xml_root: etree._Element, style_map: dict) -> int: """Reasigna estilos del source a los equivalentes del template.""" changes = 0 for p in xml_root.iter(q('w:p')): pPr = p.find(q('w:pPr')) if pPr is None: continue pStyle = pPr.find(q('w:pStyle')) if pStyle is None: continue old_val = pStyle.get(q('w:val')) if old_val in style_map: new_val = style_map[old_val] if new_val: pStyle.set(q('w:val'), new_val) changes += 1 return changes # ====================================================================== # MANEJO DE IMÁGENES # ====================================================================== def get_image_number(filename: str) -> int: m = re.search(r'image(\d+)\.', filename) return int(m.group(1)) if m else 0 def find_all_rids_in_template(z_tmpl: zipfile.ZipFile) -> set: """Encuentra todos los rIds existentes en el template.""" existing_rids = set() try: tmpl_rel_content = z_tmpl.read('word/_rels/document.xml.rels') tmpl_rel = parse_xml(tmpl_rel_content) for rel in tmpl_rel: rid = rel.get('Id') if rid: existing_rids.add(rid) except KeyError: log.warning(" No se encontró word/_rels/document.xml.rels en el template") return existing_rids def find_next_available_rid(existing_rids: set) -> int: """Encuentra el siguiente rId disponible.""" # Extraer números de rIds existentes rid_numbers = set() for rid in existing_rids: if rid.startswith('rId'): try: rid_numbers.add(int(rid[4:])) # rId123 -> 123 except ValueError: pass # Encontrar el primer número disponible desde rId40 (para evitar colisiones) # El template usa rId1-rId39, empezamos desde 40 candidate = 40 while candidate in rid_numbers: candidate += 1 return candidate def collect_src_relations(z_src: zipfile.ZipFile) -> tuple[dict, etree._Element]: """Procesa las relaciones del source y devuelve (rid_info, rel_root).""" src_rel = parse_xml(z_src.read('word/_rels/document.xml.rels')) src_rids = {} for rel in src_rel: rid = rel.get('Id') target = rel.get('Target', '').replace('\\', '/') rel_type = rel.get('Type', '') if 'image' in rel_type: src_rids[rid] = target return src_rids, src_rel def rename_source_images( z_tmpl: zipfile.ZipFile, z_src: zipfile.ZipFile, src_rids: dict, src_start: int, body_src: etree._Element, ) -> tuple[dict, dict]: """ Renombra imágenes del source para evitar colisiones con las del template. Solo procesa imágenes de hijos >= src_start. Devuelve (image_rename_map, rid_rename_map). """ existing_tmpl_media = { name for name in z_tmpl.namelist() if name.startswith('word/media/') } # Encontrar todos los rIds existentes en el template existing_rids = find_all_rids_in_template(z_tmpl) image_rename_map = {} rid_rename_map = {} generated = set() # Crear mapeo de imágenes por hijo para identificar las que deben ser ignoradas children_src = list(body_src) src_images_by_child = {} # Primero, identificar qué imágenes están en cada hijo for i, child in enumerate(children_src): blips = collect_image_refs(child) if blips: src_images_by_child[i] = [rid for _, rid in blips] # Procesar solo imágenes de hijos >= src_start src_items = [] for old_rid, rel_target in src_rids.items(): # Verificar si este rId pertenece a un hijo que debe ser procesado should_process = False for child_index, rids in src_images_by_child.items(): if child_index >= src_start and old_rid in rids: should_process = True break if should_process: rel_path = rel_target.replace('../', '') old_abs = f'word/{rel_path}' if not rel_path.startswith('word/') else rel_path old_num = get_image_number(old_abs) src_items.append((old_num, old_rid, old_abs)) src_items.sort() # Asignar nuevos rIds disponibles next_rid_num = find_next_available_rid(existing_rids) for old_num, old_rid, old_abs in src_items: ext = old_abs.rsplit('.', 1)[1] candidate = next_rid_num new_abs = f'word/media/image{candidate}.{ext}' while new_abs in existing_tmpl_media or new_abs in generated: candidate += 1 new_abs = f'word/media/image{candidate}.{ext}' # Crear nuevo rId en formato rIdXX new_rid = f'rId{candidate}' image_rename_map[old_abs] = new_abs generated.add(new_abs) rid_rename_map[old_rid] = new_rid log.debug(" %s -> %s (rId: %s -> %s)", old_abs, new_abs, old_rid, new_rid) next_rid_num = candidate + 1 return image_rename_map, rid_rename_map def update_document_title(xml_root: etree._Element, source_title: str, source_subtitle: str = ""): """Actualiza el título y subtítulo en el documento.""" # Buscar el primer párrafo del template que contiene el título body = xml_root.find(q('w:body')) if body is not None: for child in body: if child.tag == q('w:p'): text = get_para_text(child) # Buscar párrafo que contiene elementos del título if "Cliente" in text and "Project Title" in text: # Actualizar texto en los elementos t for t_elem in child.findall(f'.//{q("w:t")}'): t_text = t_elem.text or "" if "Project Title" in t_text: # Reemplazar con el título real del proyecto new_text = t_text.replace("Project Title", source_title) if source_subtitle: new_text = new_text.replace("Subtitle", source_subtitle) else: new_text = new_text.replace("Subtitle", "") t_elem.text = new_text break def extract_source_title(source_xml: etree._Element) -> tuple[str, str]: """Extrae el título y subtítulo del documento source.""" body = source_xml.find(q('w:body')) if body is not None: children = list(body) for child in children: if child.tag == q('w:p'): style_id = get_style_id(child) text = get_para_text(child).strip() # Buscar primer título principal (no el del índice) if style_id in ('Title1', 'Ttulo1', 'Título 1') and text: # Saltarse títulos que parecen del índice (muy cortos o numéricos genéricos) if re.match(r'^\d+$', text) or text in ('Índice', 'Index', 'Contents', 'Tabla de contenido'): continue lines = text.split('\n') title = lines[0].strip() subtitle = lines[1].strip() if len(lines) > 1 else "" return title, subtitle return "Documento sin título", "" # ====================================================================== # FUSIÓN PRINCIPAL # ====================================================================== def replace_content( template_path: str | Path, source_docx_path: str | Path, output_path: str | Path, style_map: dict | None = None, doc_vars: dict | None = None, ) -> Path: """ Núcleo de la conversión: fusiona template + source en un solo documento. """ style_map = style_map or DEFAULT_STYLE_MAP template_path = Path(template_path) source_docx_path = Path(source_docx_path) output_path = Path(output_path) # ---- Validaciones ---- if not template_path.exists(): raise DocxError(f"Template no encontrado: {template_path}") if not source_docx_path.exists(): raise DocxError(f"Documento no encontrado: {source_docx_path}") if not zipfile.is_zipfile(template_path): raise DocxError(f"El template no es un DOCX válido: {template_path}") if not zipfile.is_zipfile(source_docx_path): raise DocxError(f"El documento fuente no es un DOCX válido: {source_docx_path}") z_tmpl = zipfile.ZipFile(str(template_path), 'r') z_src = zipfile.ZipFile(str(source_docx_path), 'r') try: # ---- Leer XML ---- tmpl_xml = parse_xml(z_tmpl.read('word/document.xml')) src_xml = parse_xml(z_src.read('word/document.xml')) tmpl_rel = parse_xml(z_tmpl.read('word/_rels/document.xml.rels')) src_rids, src_rel = collect_src_relations(z_src) body_tmpl = tmpl_xml.find(q('w:body')) body_src = src_xml.find(q('w:body')) if body_tmpl is None: raise DocxError("El template no tiene body") if body_src is None: raise DocxError("El documento fuente no tiene body") # ---- Rellenar campos SDT en la portada ---- if doc_vars: filled = fill_sdt_fields(tmpl_xml, doc_vars) log.info(" Campos SDT rellenados: %d", filled) children_tmpl = list(body_tmpl) children_src = list(body_src) # ---- Remapear estilos en source ---- changes = remap_styles(src_xml, style_map) log.info(" Estilos reasignados: %d", changes) # ---- Detectar límites por secciones ---- # El template tiene 5 secciones: portada, disclaimer, índice, CONTENIDO, contraportada # Localizamos los sectPr que marcan el final de cada sección tmpl_sections = _find_section_boundaries(body_tmpl) # La sección 4 (índice 3) es la del contenido a reemplazar if len(tmpl_sections) < 4: log.warning(" Template tiene %d secciones, se esperaban al menos 4. Usando detección por estilos.", len(tmpl_sections)+1) tmpl_idx_end = SectionDetector.find_end_of_preface(body_tmpl) tmpl_back = SectionDetector.find_back_cover_start(body_tmpl) else: log.info(" Template: %d secciones detectadas", len(tmpl_sections)+1) tmpl_idx_end = tmpl_sections[2] # sectPr de sección 3 -> contenido empieza en sección 4 tmpl_back = tmpl_sections[3] # sectPr de sección 4 -> contraportada empieza en sección 5 src_start = SectionDetector.find_content_start(body_src) log.info(" Template: sección contenido entre hijos %d y %d", tmpl_idx_end+1, tmpl_back) log.info(" Source: contenido real empieza en hijo %d", src_start) # ---- Extraer título del source ---- source_title, source_subtitle = extract_source_title(src_xml) log.info(" Título del source: %s", source_title) if source_subtitle: log.info(" Subtítulo del source: %s", source_subtitle) # ---- Actualizar título en template ---- update_document_title(tmpl_xml, source_title, source_subtitle) # ---- Renombrar imágenes del source ---- image_rename_map, rid_rename_map = rename_source_images( z_tmpl, z_src, src_rids, src_start, body_src ) log.info(" Imágenes renombradas: %d", len(image_rename_map)) # ---- Corregir campos TOC en el template para que coincidan con el idioma/contentido del source ---- # El template tiene campos TOC en inglés (\c "Figure", \c "Table") pero el contenido # del source usa "Figura" y "Tabla" en los estilos de caption. toc_fixes = { '\\c "Figure"': '\\c "Figura"', '\\c "Table"': '\\c "Tabla"', } for instr in tmpl_xml.iter(f'{{http://schemas.openxmlformats.org/wordprocessingml/2006/main}}instrText'): if instr.text: original = instr.text for old, new in toc_fixes.items(): if old in original: instr.text = original.replace(old, new) log.debug(" Campo TOC corregido: %s -> %s", old, new) break # ---- Fusionar bodies ---- for child in list(body_tmpl): body_tmpl.remove(child) if len(tmpl_sections) >= 4: # Método por secciones: reemplazar solo la sección 4 (contenido) # Secciones 1-3: portada + disclaimer + índice sec3_end = tmpl_sections[2] # sectPr de sección 3 sec4_end = tmpl_sections[3] # sectPr de sección 4 for child in children_tmpl[:sec3_end + 1]: body_tmpl.append(copy.deepcopy(child)) # Contenido del source (desde src_start, incluimos su sectPr si tiene) for child in children_src[src_start:]: # Incluir sectPr del source para mantener propiedades de página body_tmpl.append(copy.deepcopy(child)) # Sección 5: contraportada (después del sectPr de sección 4) for child in children_tmpl[sec4_end + 1:]: body_tmpl.append(copy.deepcopy(child)) else: # Fallback por estilos for child in children_tmpl[:tmpl_idx_end + 1]: body_tmpl.append(copy.deepcopy(child)) for child in children_src[src_start:]: if child.tag != q('w:sectPr'): body_tmpl.append(copy.deepcopy(child)) for child in children_tmpl[tmpl_back:]: body_tmpl.append(copy.deepcopy(child)) # ---- Actualizar rIds en document.xml ---- for blip, old_rid in collect_image_refs(tmpl_xml): if old_rid in rid_rename_map: blip.set(f'{{{NS["r"]}}}embed', rid_rename_map[old_rid]) # ---- Construir zip de salida ---- out_data = {} # 1. Partir del template (imágenes del template NUNCA se tocan) for item in z_tmpl.infolist(): out_data[item.filename] = z_tmpl.read(item.filename) # 2. Añadir imágenes del source renombradas for old_abs, new_abs in image_rename_map.items(): try: content = z_src.read(old_abs) out_data[new_abs] = content except KeyError: log.warning(" Imagen no encontrada en source: %s (ignorada)", old_abs) # 3. Añadir relaciones de imágenes del source (sin duplicados) rel_root = parse_xml(out_data.get('word/_rels/document.xml.rels', z_tmpl.read('word/_rels/document.xml.rels'))) # Relaciones existentes en el template existing_rids = set() for rel in list(rel_root): rid = rel.get('Id') if rid: existing_rids.add(rid) # Construir mapa old_rid -> src_rel element src_rel_by_rid = {} for rel in src_rel: rid = rel.get('Id') if rid: src_rel_by_rid[rid] = rel # Añadir cada nueva relación una sola vez for old_rid, new_rid in rid_rename_map.items(): if new_rid in existing_rids: continue src_rel_elem = src_rel_by_rid.get(old_rid) if src_rel_elem is None: continue target = src_rel_elem.get('Target', '') old_target_abs = target.replace('../', '') if not old_target_abs.startswith('word/'): old_target_abs = f'word/{old_target_abs}' new_target_abs = image_rename_map.get(old_target_abs, old_target_abs) new_target = new_target_abs.replace('word/', '') if new_target_abs.startswith('word/') else new_target_abs new_rel = copy.deepcopy(src_rel_elem) new_rel.set('Id', new_rid) new_rel.set('Target', new_target) rel_root.append(new_rel) existing_rids.add(new_rid) out_data['word/_rels/document.xml.rels'] = etree.tostring( rel_root, xml_declaration=True, encoding='UTF-8') out_data['word/document.xml'] = etree.tostring( tmpl_xml, xml_declaration=True, encoding='UTF-8') # ---- Escribir ---- with zipfile.ZipFile(str(output_path), 'w', zipfile.ZIP_DEFLATED) as zout: for fname, content in out_data.items(): zout.writestr(fname, content) finally: z_tmpl.close() z_src.close() log.info(" ✅ Convertido: %s", output_path) return output_path # ====================================================================== # CLI # ====================================================================== def setup_logging(verbose: bool = False): """Configura logging con formato limpio.""" level = logging.DEBUG if verbose else logging.INFO log.setLevel(level) # Solo añadir handler si no tiene ninguno aún if not log.handlers: handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter('%(message)s')) log.addHandler(handler) def validate_docx(path: Path, label: str) -> None: """Valida que un archivo sea un DOCX no corrupto.""" if not path.exists(): raise DocxError(f"{label} no encontrado: {path}") if not zipfile.is_zipfile(path): raise DocxError(f"{label} no es un DOCX válido: {path}") try: with zipfile.ZipFile(str(path), 'r') as z: if 'word/document.xml' not in z.namelist(): raise DocxError(f"{label} no contiene 'word/document.xml'") except (zipfile.BadZipFile, Exception) as e: raise DocxError(f"{label} corrupto: {e}") def find_docx_files(directory: Path) -> list[Path]: """Busca archivos .docx en un directorio (no recursivo).""" return sorted(directory.glob('*.docx')) def build_output_path(source: Path, output: str | None, suffix: str = '_r360mx') -> Path: """Construye la ruta de salida.""" if output: return Path(output) return source.parent / f"{source.stem}{suffix}.docx" def run_single(args) -> int: """Procesa un solo documento.""" source = Path(args.documento) template = Path(args.plantilla) output = build_output_path(source, args.output) log.info("📄 Template: %s", template) log.info("📄 Documento: %s", source) log.info("📄 Salida: %s", output) validate_docx(source, "Documento") validate_docx(template, "Plantilla") if args.dry_run: log.info(" 🏁 Dry-run: todo correcto, no se genera nada.") return 0 # Preparar variables para la portada doc_vars = { 'Cliente': args.cliente, 'Título': args.titulo, 'Asunto': args.asunto, 'Categoría': args.tipo, 'Palabras clave': args.codigo, } # 1. Cargar el XML del template z_tmpl = zipfile.ZipFile(str(template), 'r') tmpl_xml = parse_xml(z_tmpl.read('word/document.xml')) # 2. Rellenar campos SDT en la portada filled = fill_sdt_fields(tmpl_xml, doc_vars) log.info(" Campos SDT rellenados: %d", filled) # Guardar el XML modificado temporalmente para que replace_content lo use # (Sugerencia: pasar el XML ya modificado a replace_content o modificar la función) # Para evitar re-diseñar replace_content, vamos a inyectar el comportamiento # en la función principal. z_tmpl.close() # ... (dentro de run_single) ... replace_content(template, source, output, doc_vars=doc_vars) return 0 def run_batch(args) -> int: """Procesa múltiples documentos en lote.""" input_dir = Path(args.batch_dir) template = Path(args.plantilla) if not input_dir.is_dir(): log.error("El directorio no existe: %s", input_dir) return 1 validate_docx(template, "Plantilla") docx_files = find_docx_files(input_dir) if not docx_files: log.warning(" No se encontraron archivos .docx en %s", input_dir) return 0 total = len(docx_files) ok = 0 failed = 0 log.info("📦 Procesando %d documento(s) en lote...", total) log.info("📄 Template: %s", template) for idx, source in enumerate(docx_files, 1): output = build_output_path(source, None) log.info("[%d/%d] %s -> %s", idx, total, source.name, output.name) if args.dry_run: ok += 1 continue try: replace_content(template, source, output) ok += 1 except DocxError as e: log.error(" ❌ Error: %s", e) failed += 1 except Exception as e: log.error(" ❌ Error inesperado: %s", e) failed += 1 log.info("") log.info("═══════════════════════════════════") log.info(" Resumen: %d OK, %d fallos de %d", ok, failed, total) log.info("═══════════════════════════════════") return 1 if failed > 0 else 0 def run_dump_styles(args) -> int: """Dump de estilos de un documento para depuración.""" path = Path(args.documento) validate_docx(path, "Documento") with zipfile.ZipFile(str(path), 'r') as z: if 'word/styles.xml' in z.namelist(): styles_xml = parse_xml(z.read('word/styles.xml')) styles = styles_xml.findall(f'.//{q("w:style")}') log.info("Estilos en %s:", path) for style in styles: style_id = style.get(q('w:styleId')) style_type = style.get(q('w:type')) name_elem = style.find(q('w:name')) name = name_elem.get(q('w:val')) if name_elem is not None else '' log.info(" %-20s type=%-10s name=%s", style_id or '', style_type or '', name) else: log.warning(" No se encontró word/styles.xml") # Mostrar estructura del documento doc_xml = parse_xml(z.read('word/document.xml')) body = doc_xml.find(q('w:body')) if body is not None: paras = get_paras(body) log.info("\nEstructura del body (%d elementos):", len(paras)) for i, child in enumerate(paras[:100]): # primeros 100 style_id = get_style_id(child) if child.tag == q('w:p') else '[TABLE]' text = get_para_text(child)[:80] if child.tag == q('w:p') else '' log.info(" [%4d] %-20s %s", i, style_id or '', text) return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description='Convierte documentos ENERGY REPORT al formato corporativo R360MX.', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Ejemplos: %(prog)s informe.docx plantilla.docx %(prog)s informe.docx plantilla.docx -o salida.docx -v %(prog)s --batch ./informes/ plantilla.docx -v %(prog)s --dump-styles informe.docx %(prog)s informe.docx plantilla.docx --dry-run -v """, ) parser.add_argument( '-v', '--verbose', action='store_true', help='Modo verbose (debug)', ) # Subcomandos implícitos parser.add_argument( '--dry-run', action='store_true', help='Valida sin generar archivos', ) parser.add_argument( '--dump-styles', metavar='DOCUMENTO', help='Inspecciona los estilos y estructura de un DOCX', ) # Batch mode parser.add_argument( '--batch', metavar='DIRECTORIO', dest='batch_dir', help='Modo batch: procesa todos los .docx del directorio', ) # Posicionales parser.add_argument( 'documento', nargs='?', help='Documento ENERGY REPORT .docx', ) parser.add_argument( 'plantilla', nargs='?', help='Plantilla R360MX .docx', ) parser.add_argument( '-o', '--output', help='Archivo de salida (solo modo single)', ) # Variables de portada parser.add_argument('--cliente', default='', help='Nombre del cliente') parser.add_argument('--titulo', default='', help='Título del documento') parser.add_argument('--asunto', default='', help='Subtítulo / asunto') parser.add_argument('--tipo', default='', help='Tipo de documento') parser.add_argument('--codigo', default='', help='Código de documento') return parser def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) setup_logging(args.verbose) try: # Modo dump-styles if args.dump_styles: return run_dump_styles(args) # Modo batch if args.batch_dir: return run_batch(args) # Modo single if not args.documento or not args.plantilla: parser.print_help() return 1 return run_single(args) except DocxError as e: log.error("❌ %s", e) return 1 except KeyboardInterrupt: log.info("\nInterrumpido por el usuario.") return 130 except Exception as e: log.exception("❌ Error inesperado: %s", e) return 1 if __name__ == '__main__': sys.exit(main())