2026-04-30 23:00:33 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
2026-06-01 01:37:31 +02:00
|
|
|
apply_template.py - Conversión de ENERGY REPORT a formato corporativo R360MX.
|
2026-05-05 01:42:09 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
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
|
2026-04-30 23:00:33 +02:00
|
|
|
"""
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
import sys
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import copy
|
|
|
|
|
import logging
|
|
|
|
|
import argparse
|
|
|
|
|
import zipfile
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from datetime import datetime
|
2026-04-30 23:00:33 +02:00
|
|
|
from lxml import etree
|
|
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
# 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')
|
2026-05-04 22:06:49 +02:00
|
|
|
|
|
|
|
|
# ======================================================================
|
2026-06-01 01:37:31 +02:00
|
|
|
# MAPEO DE ESTILOS: source -> template
|
2026-05-04 22:06:49 +02:00
|
|
|
# ======================================================================
|
2026-06-01 01:37:31 +02:00
|
|
|
DEFAULT_STYLE_MAP = {
|
2026-05-05 01:42:09 +02:00
|
|
|
'Title1': 'Título 1',
|
2026-06-01 01:37:31 +02:00
|
|
|
'Title2': 'Título 2',
|
|
|
|
|
'Title3': 'Título 3',
|
|
|
|
|
'Title2Index': 'Title2Index',
|
2026-05-05 01:42:09 +02:00
|
|
|
'TableContentEnd': 'TableContentEnd',
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
# ======================================================================
|
|
|
|
|
# UTILIDADES XML
|
|
|
|
|
# ======================================================================
|
|
|
|
|
|
|
|
|
|
def parse_xml(content: bytes) -> etree._Element:
|
2026-05-04 22:06:49 +02:00
|
|
|
return etree.fromstring(content)
|
2026-04-30 23:00:33 +02:00
|
|
|
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
def q(tag: str) -> str:
|
|
|
|
|
"""Convierte 'w:body' a la URL completa con namespace."""
|
|
|
|
|
prefix, local = tag.split(':')
|
|
|
|
|
return f'{{{NS[prefix]}}}{local}'
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-05-05 01:42:09 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DocxError(Exception):
|
|
|
|
|
"""Error relacionado con el procesamiento de documentos DOCX."""
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ======================================================================
|
|
|
|
|
# DETECCIÓN INTELIGENTE DE SECCIONES
|
|
|
|
|
# ======================================================================
|
|
|
|
|
|
|
|
|
|
class SectionDetector:
|
2026-05-04 22:06:49 +02:00
|
|
|
"""
|
2026-06-01 01:37:31 +02:00
|
|
|
Detecta las secciones clave en el template y el documento source
|
|
|
|
|
basándose en marcadores, estilos y contenido, sin números mágicos.
|
2026-05-04 22:06:49 +02:00
|
|
|
"""
|
2026-06-01 01:37:31 +02:00
|
|
|
|
|
|
|
|
MARKER_STYLES = {
|
|
|
|
|
'indice_fin': 'TableContentEnd',
|
|
|
|
|
'titulo_contenido': 'Título 1',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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() == '<<CONTENT_START>>':
|
|
|
|
|
log.debug(" Marker textual '<<CONTENT_START>>' 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 == '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 = 69 # fallback conservador
|
|
|
|
|
|
|
|
|
|
# 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', '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:
|
|
|
|
|
# Después de un salto de sección suele empezar el contenido
|
|
|
|
|
log.debug(" Salto de sección en source hijo %d", i)
|
|
|
|
|
return i + 1 if i + 1 < len(children) else i
|
|
|
|
|
|
|
|
|
|
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."""
|
2026-05-05 01:42:09 +02:00
|
|
|
changes = 0
|
2026-06-01 01:37:31 +02:00
|
|
|
for p in xml_root.iter(q('w:p')):
|
|
|
|
|
pPr = p.find(q('w:pPr'))
|
2026-05-05 01:42:09 +02:00
|
|
|
if pPr is None:
|
|
|
|
|
continue
|
2026-06-01 01:37:31 +02:00
|
|
|
pStyle = pPr.find(q('w:pStyle'))
|
2026-05-05 01:42:09 +02:00
|
|
|
if pStyle is None:
|
|
|
|
|
continue
|
2026-06-01 01:37:31 +02:00
|
|
|
old_val = pStyle.get(q('w:val'))
|
2026-05-05 01:42:09 +02:00
|
|
|
if old_val in style_map:
|
|
|
|
|
new_val = style_map[old_val]
|
|
|
|
|
if new_val:
|
2026-06-01 01:37:31 +02:00
|
|
|
pStyle.set(q('w:val'), new_val)
|
2026-05-05 01:42:09 +02:00
|
|
|
changes += 1
|
|
|
|
|
return changes
|
|
|
|
|
|
|
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
# ======================================================================
|
|
|
|
|
# MANEJO DE IMÁGENES
|
|
|
|
|
# ======================================================================
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
def get_image_number(filename: str) -> int:
|
|
|
|
|
m = re.search(r'image(\d+)\.', filename)
|
|
|
|
|
return int(m.group(1)) if m else 0
|
2026-05-04 22:06:49 +02:00
|
|
|
|
|
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
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'))
|
2026-05-04 22:06:49 +02:00
|
|
|
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
|
2026-06-01 01:37:31 +02:00
|
|
|
return src_rids, src_rel
|
|
|
|
|
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
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 = {}
|
2026-05-05 01:42:09 +02:00
|
|
|
generated = set()
|
2026-06-01 01:37:31 +02:00
|
|
|
|
|
|
|
|
# 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
|
2026-05-04 22:06:49 +02:00
|
|
|
src_items = []
|
|
|
|
|
for old_rid, rel_target in src_rids.items():
|
2026-06-01 01:37:31 +02:00
|
|
|
# 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))
|
|
|
|
|
|
2026-05-04 22:06:49 +02:00
|
|
|
src_items.sort()
|
|
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
# Asignar nuevos rIds disponibles
|
|
|
|
|
next_rid_num = find_next_available_rid(existing_rids)
|
|
|
|
|
|
2026-05-04 22:06:49 +02:00
|
|
|
for old_num, old_rid, old_abs in src_items:
|
|
|
|
|
ext = old_abs.rsplit('.', 1)[1]
|
2026-06-01 01:37:31 +02:00
|
|
|
candidate = next_rid_num
|
2026-05-05 01:42:09 +02:00
|
|
|
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}'
|
2026-06-01 01:37:31 +02:00
|
|
|
|
|
|
|
|
# Crear nuevo rId en formato rIdXX
|
|
|
|
|
new_rid = f'rId{candidate}'
|
2026-05-04 22:06:49 +02:00
|
|
|
image_rename_map[old_abs] = new_abs
|
2026-05-05 01:42:09 +02:00
|
|
|
generated.add(new_abs)
|
2026-06-01 01:37:31 +02:00
|
|
|
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
|
|
|
|
|
if style_id in ('Title1', 'Título 1') and text:
|
|
|
|
|
# Dividir título y subtítulo si están en el mismo párrafo
|
|
|
|
|
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,
|
|
|
|
|
) -> 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")
|
|
|
|
|
|
|
|
|
|
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 ----
|
|
|
|
|
tmpl_idx_end = SectionDetector.find_end_of_preface(body_tmpl)
|
|
|
|
|
tmpl_back = SectionDetector.find_back_cover_start(body_tmpl)
|
|
|
|
|
src_start = SectionDetector.find_content_start(body_src)
|
|
|
|
|
|
|
|
|
|
log.info(" Template: prefacio h. hijo %d, contraportada h. hijo %d", tmpl_idx_end, 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)
|
|
|
|
|
|
|
|
|
|
# Prefacio del template
|
|
|
|
|
for child in children_tmpl[:tmpl_idx_end + 1]:
|
2026-04-30 23:00:33 +02:00
|
|
|
body_tmpl.append(copy.deepcopy(child))
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
# Contenido del source (desde src_start, sin sectPr)
|
|
|
|
|
for child in children_src[src_start:]:
|
|
|
|
|
if child.tag != q('w:sectPr'):
|
|
|
|
|
body_tmpl.append(copy.deepcopy(child))
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
# Contraportada del template
|
|
|
|
|
for child in children_tmpl[tmpl_back:]:
|
|
|
|
|
body_tmpl.append(copy.deepcopy(child))
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
# ---- 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
|
|
|
|
|
rel_root = parse_xml(out_data.get('word/_rels/document.xml.rels', z_tmpl.read('word/_rels/document.xml.rels')))
|
|
|
|
|
|
|
|
|
|
# Eliminar relaciones existentes que podrían colisionar
|
|
|
|
|
existing_rids = set()
|
|
|
|
|
for rel in list(rel_root):
|
|
|
|
|
rid = rel.get('Id')
|
|
|
|
|
if rid:
|
|
|
|
|
existing_rids.add(rid)
|
|
|
|
|
|
|
|
|
|
# Añadir nuevas relaciones con rIds únicos
|
|
|
|
|
for old_rid, new_rid in rid_rename_map.items():
|
|
|
|
|
if new_rid in existing_rids:
|
|
|
|
|
log.debug(" rId %s ya existe en template, se omite", new_rid)
|
|
|
|
|
continue
|
|
|
|
|
for rel in src_rel:
|
|
|
|
|
if rel.get('Id') == old_rid:
|
|
|
|
|
target = rel.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)
|
|
|
|
|
# Asegurar que el Target sea relativo correctamente (solo media/imageN.ext)
|
|
|
|
|
new_target = new_target_abs.replace('word/', '') if new_target_abs.startswith('word/') else new_target_abs
|
|
|
|
|
new_rel = copy.deepcopy(rel)
|
|
|
|
|
new_rel.set('Id', new_rid)
|
|
|
|
|
new_rel.set('Target', new_target)
|
|
|
|
|
rel_root.append(new_rel)
|
|
|
|
|
existing_rids.add(new_rid)
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
out_data['word/_rels/document.xml.rels'] = etree.tostring(
|
|
|
|
|
rel_root, xml_declaration=True, encoding='UTF-8', standalone=True)
|
|
|
|
|
out_data['word/document.xml'] = etree.tostring(
|
|
|
|
|
tmpl_xml, xml_declaration=True, encoding='UTF-8', standalone=True)
|
|
|
|
|
|
|
|
|
|
# ---- 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
|
2026-05-04 22:06:49 +02:00
|
|
|
|
|
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
# ======================================================================
|
|
|
|
|
# CLI
|
|
|
|
|
# ======================================================================
|
2026-05-05 01:42:09 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
def setup_logging(verbose: bool = False):
|
|
|
|
|
"""Configura logging con formato limpio."""
|
|
|
|
|
level = logging.DEBUG if verbose else logging.INFO
|
|
|
|
|
handler = logging.StreamHandler(sys.stderr)
|
|
|
|
|
handler.setFormatter(logging.Formatter('%(message)s'))
|
|
|
|
|
log.addHandler(handler)
|
|
|
|
|
log.setLevel(level)
|
|
|
|
|
# Evitar duplicados
|
|
|
|
|
if log.handlers.count(handler) > 1:
|
|
|
|
|
log.removeHandler(handler)
|
2026-05-04 22:06:49 +02:00
|
|
|
|
|
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
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}")
|
2026-05-04 22:06:49 +02:00
|
|
|
|
|
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
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"
|
2026-04-30 23:00:33 +02:00
|
|
|
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
def run_single(args) -> int:
|
|
|
|
|
"""Procesa un solo documento."""
|
|
|
|
|
source = Path(args.documento)
|
|
|
|
|
template = Path(args.plantilla)
|
|
|
|
|
output = build_output_path(source, args.output)
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
log.info("📄 Template: %s", template)
|
|
|
|
|
log.info("📄 Documento: %s", source)
|
|
|
|
|
log.info("📄 Salida: %s", output)
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
validate_docx(source, "Documento")
|
|
|
|
|
validate_docx(template, "Plantilla")
|
|
|
|
|
|
|
|
|
|
if args.dry_run:
|
|
|
|
|
log.info(" 🏁 Dry-run: todo correcto, no se genera nada.")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
replace_content(template, source, output)
|
|
|
|
|
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
|
2026-05-04 22:06:49 +02:00
|
|
|
|
2026-06-01 01:37:31 +02:00
|
|
|
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)',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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())
|