apply_template: rewrite con renumeración de imágenes. Parte del template, copia imágenes source renombradas desde max_tmpl+1, evita colisiones

This commit is contained in:
Javier Braña
2026-05-04 22:06:49 +02:00
parent 55ca3e1625
commit 69e993e5cf
+269 -80
View File
@@ -1,20 +1,51 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Aplica plantilla portada.docx a un ENERGY REPORT. Aplica plantilla portada.docx a un ENERGY REPORT.
Estrategia: partir del DOCX ORIGINAL (que tiene todas sus imágenes y relaciones intactas) Estrategia:
y reemplazar solo los primeros hijos del body (portada+disclaimer+índice del original) 1. Partir del DOCX TEMPLATE como base.
por los del template. La contraportada del template se añade al final. 2. Copiar imágenes del DOCX ORIGINAL al template, renombrándolas
desde el último número de imagen del template +1.
Así las imágenes del contenido original mantienen sus relaciones intactas. 3. Actualizar las referencias a imágenes en document.xml y
document.xml.rels para que apunten a los nuevos nombres.
""" """
import sys, os, shutil, copy, zipfile, re
import sys, os, zipfile, re, copy
from lxml import etree from lxml import etree
# Espacios de nombres OOXML
w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main' w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
r = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
wp = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
a = 'http://schemas.openxmlformats.org/drawingml/2006/main'
pic = 'http://schemas.openxmlformats.org/drawingml/2006/picture'
rel_type_image = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'
def get_xml(path): # ======================================================================
with zipfile.ZipFile(path, 'r') as z: # Funciones auxiliares
return z.read('word/document.xml') # ======================================================================
def parse_xml(content):
return etree.fromstring(content)
def read_zip_entry(z, path):
return z.read(path)
def write_zip_entry(zout, name, content):
zout.writestr(name, content)
def get_image_number(filename):
"""Extrae el número de una imagen tipo 'word/media/image42.png'."""
m = re.search(r'image(\d+)\.', filename)
return int(m.group(1)) if m else 0
def find_max_image_id(z):
"""Encuentra el número de imagen más alto dentro del zip."""
max_id = 0
for name in z.namelist():
n = get_image_number(name)
if n > max_id:
max_id = n
return max_id
def find_content_start(children): def find_content_start(children):
"""Encuentra primer título de contenido real (después del índice).""" """Encuentra primer título de contenido real (después del índice)."""
@@ -25,21 +56,63 @@ def find_content_start(children):
sval = style.get(f'{{{w}}}val') if style is not None else '' sval = style.get(f'{{{w}}}val') if style is not None else ''
texts = child.findall(f'.//{{{w}}}t') texts = child.findall(f'.//{{{w}}}t')
text = ''.join(t.text or '' for t in texts) text = ''.join(t.text or '' for t in texts)
if sval == 'Title2Index': found_toc = True; continue if sval == 'Title2Index':
found_toc = True
continue
if found_toc and sval == 'Title1' and text and (text[0].isdigit() or text[0] in 'IVX'): if found_toc and sval == 'Title1' and text and (text[0].isdigit() or text[0] in 'IVX'):
if '. ' in text[:6] or text[-1].isdigit(): return i if '. ' in text[:6] or text[-1].isdigit():
return i
# Fallback
for i, child in enumerate(children): for i, child in enumerate(children):
if child.tag == f'{{{w}}}p': if child.tag == f'{{{w}}}p':
style = child.find(f'.//{{{w}}}pStyle') style = child.find(f'.//{{{w}}}pStyle')
sval = style.get(f'{{{w}}}val') if style is not None else '' sval = style.get(f'{{{w}}}val') if style is not None else ''
texts = child.findall(f'.//{{{w}}}t') texts = child.findall(f'.//{{{w}}}t')
text = ''.join(t.text or '' for t in texts) text = ''.join(t.text or '' for t in texts)
if sval == 'Title1' and text and text[0].isdigit() and '. ' in text[:6]: return i if sval == 'Title1' and text and text[0].isdigit() and '. ' in text[:6]:
return i
return 69 return 69
def collect_image_refs(xml_root):
"""
Escanea el XML y encuentra TODAS las referencias a imágenes:
- wp:extent (tamaño)
- a:blip r:embed (relación)
- pic:blipFill (relleno de imagen)
Devuelve lista de (elemento_blip, rId).
"""
blips = []
nsmap = {'a': a, 'r': r, 'pic': pic, 'wp': wp}
for blip in xml_root.iter(f'{{{a}}}blip'):
rid = blip.get(f'{{{r}}}embed')
if rid:
blips.append((blip, rid))
return blips
# ======================================================================
# Función principal
# ======================================================================
def replace_content(template_path, source_docx_path, output_path): def replace_content(template_path, source_docx_path, output_path):
tmpl_xml = etree.fromstring(get_xml(template_path)) """
src_xml = etree.fromstring(get_xml(source_docx_path)) Aplica el template al documento fuente y escribe el resultado.
1. Abre template y fuente como zips.
2. Detecta límites en ambos XML (portada, índice, contraportada).
3. Fusiona los bodies: portada+índice del template + contenido del source + contraportada del template.
4. Renombra imágenes del source y actualiza referencias.
5. Escribe el nuevo DOCX.
"""
z_tmpl = zipfile.ZipFile(template_path, 'r')
z_src = zipfile.ZipFile(source_docx_path, 'r')
# ---- Leer XMLs ----
tmpl_xml = parse_xml(read_zip_entry(z_tmpl, 'word/document.xml'))
src_xml = parse_xml(read_zip_entry(z_src, 'word/document.xml'))
tmpl_rel = parse_xml(read_zip_entry(z_tmpl, 'word/_rels/document.xml.rels'))
src_rel = parse_xml(read_zip_entry(z_src, 'word/_rels/document.xml.rels'))
body_tmpl = tmpl_xml.find(f'{{{w}}}body') body_tmpl = tmpl_xml.find(f'{{{w}}}body')
body_src = src_xml.find(f'{{{w}}}body') body_src = src_xml.find(f'{{{w}}}body')
@@ -47,117 +120,233 @@ def replace_content(template_path, source_docx_path, output_path):
children_tmpl = list(body_tmpl) children_tmpl = list(body_tmpl)
children_src = list(body_src) children_src = list(body_src)
# ===== DETECTAR LÍMITES ===== # ---- Detectar límites en el template ----
# Template # Índice
tmpl_idx_end = 36 tmpl_idx_end = 36
for i, child in enumerate(children_tmpl): for i, child in enumerate(children_tmpl):
if child.tag == f'{{{w}}}p': if child.tag == f'{{{w}}}p':
style = child.find(f'.//{{{w}}}pStyle') style = child.find(f'.//{{{w}}}pStyle')
sval = style.get(f'{{{w}}}val') if style is not None else '' sval = style.get(f'{{{w}}}val') if style is not None else ''
if sval == 'TableContentEnd': tmpl_idx_end = i if sval == 'TableContentEnd':
elif sval == 'Ttulo1' and i > tmpl_idx_end: break tmpl_idx_end = i
if tmpl_idx_end < 10: tmpl_idx_end = 36 elif sval == 'Ttulo1' and i > tmpl_idx_end:
break
if tmpl_idx_end < 10:
tmpl_idx_end = 36
# Contraportada
tmpl_back = 47 tmpl_back = 47
for i, child in enumerate(children_tmpl): for i, child in enumerate(children_tmpl):
if child.tag == f'{{{w}}}p': if child.tag == f'{{{w}}}p':
texts = child.findall(f'.//{{{w}}}t') texts = child.findall(f'.//{{{w}}}t')
if 'RENOVABLES 360' in ''.join(t.text or '' for t in texts): tmpl_back = i; break if 'RENOVABLES 360' in ''.join(t.text or '' for t in texts):
tmpl_back = i
break
# Original: dónde empieza el contenido real # ---- Detectar dónde empieza el contenido real en el source ----
src_start = find_content_start(children_src) src_start = find_content_start(children_src)
print(f" Template: índice h. {tmpl_idx_end}, contraportada h. {tmpl_back}") print(f" Template: índice termina en hijo {tmpl_idx_end}, contraportada en hijo {tmpl_back}")
print(f" Original: contenido real empieza en hijo {src_start}") print(f" Original: contenido real empieza en hijo {src_start}")
# ===== ESTRATEGIA: PARTIR DEL ORIGINAL, REEMPLAZAR PORTADA + AÑADIR CONTRAPORTADA ===== # ---- Renombrar imágenes del source ----
# Construir nuevo body: max_tmpl_img = find_max_image_id(z_tmpl)
# 1. Portada + Disclaimer + Índice del TEMPLATE print(f" Max imagen en template: {max_tmpl_img}")
# 2. Contenido real del ORIGINAL (desde src_start, sin sectPr)
# 3. Contraportada del TEMPLATE
for child in list(body_tmpl): body_tmpl.remove(child) # Mapa: old_rel_path -> new_rel_path (ej: media/image1.png -> media/image5.png)
image_rename_map = {}
# Mapa: old_rId -> new_rId (para actualizar relaciones)
rid_rename_map = {}
# 1. Construir un set de rIds que YA existen en el template
existing_tmpl_rids = set()
for rel in tmpl_rel:
rid = rel.get('Id')
if rid:
existing_tmpl_rids.add(rid)
# 2. Identificar rIds de imágenes en el source
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
print(f" Imágenes en source: {len(src_rids)}")
# 3. Generar nuevos nombres y rIds para las imágenes del source
# Debemos evitar colisiones: ni con imágenes del template ni entre imágenes
# del source después de renombrar.
new_rid_counter = 1
all_old_new_rids = {} # old_rId -> new_rId
# Pre-calcular: set de nombres de imagen que ya existen en el template
existing_tmpl_media = set()
for name in z_tmpl.namelist():
if name.startswith('word/media/') and get_image_number(name) > 0:
existing_tmpl_media.add(name)
# Pre-calcular: los nuevos nombres que vamos generando (para evitar colisiones internas)
generated_new_names = set()
# Ordenar por old_num ascendente para consistencia
src_items = []
for old_rid, rel_target in src_rids.items():
old_rel_path = rel_target
if old_rel_path.startswith('..'):
old_rel_path = old_rel_path.replace('../', '')
old_abs = f'word/{old_rel_path}' if not old_rel_path.startswith('word/') else old_rel_path
old_num = get_image_number(old_abs) if old_abs.startswith('word/media/') else 0
src_items.append((old_num, old_rid, old_abs))
src_items.sort()
for old_num, old_rid, old_abs in src_items:
if not old_abs.startswith('word/media/'):
continue
ext = old_abs.rsplit('.', 1)[1]
# Buscar el primer número disponible:
# empezar desde max_tmpl_img + old_num, pero si colisiona, incrementar
candidate_num = max_tmpl_img + old_num
candidate_abs = f'word/media/image{candidate_num}.{ext}'
while candidate_abs in existing_tmpl_media or candidate_abs in generated_new_names:
candidate_num += 1
candidate_abs = f'word/media/image{candidate_num}.{ext}'
new_abs = candidate_abs
new_num = candidate_num
image_rename_map[old_abs] = new_abs
generated_new_names.add(new_abs)
# Generar nuevo rId (evitar colisión con template)
new_rid = f'rIdImage{new_num}'
while new_rid in existing_tmpl_rids:
new_rid_counter += 1
new_rid = f'rIdImage{new_num}_{new_rid_counter}'
all_old_new_rids[old_rid] = new_rid
print(f" {old_abs} -> {new_abs} (rId: {old_rid} -> {new_rid})")
# ---- Fusionar bodies ----
# Vaciar body del template y reconstruirlo:
# [portada+disclaimer+índice del template]
# + [contenido del source (desde src_start)]
# + [contraportada del template]
for child in list(body_tmpl):
body_tmpl.remove(child)
# Portada + índice del template
for child in children_tmpl[:tmpl_idx_end + 1]: for child in children_tmpl[:tmpl_idx_end + 1]:
body_tmpl.append(copy.deepcopy(child)) body_tmpl.append(copy.deepcopy(child))
# Contenido del source (sin sectPr)
for child in children_src[src_start:]: for child in children_src[src_start:]:
if child.tag != f'{{{w}}}sectPr': if child.tag != f'{{{w}}}sectPr':
body_tmpl.append(copy.deepcopy(child)) body_tmpl.append(copy.deepcopy(child))
# Contraportada del template
for child in children_tmpl[tmpl_back:]: for child in children_tmpl[tmpl_back:]:
body_tmpl.append(copy.deepcopy(child)) body_tmpl.append(copy.deepcopy(child))
# ===== COPIAR ARCHIVOS ===== # ---- Actualizar referencias de imagen en document.xml ----
# Partir del DOCX ORIGINAL (imágenes y relaciones del contenido intactas) # Buscar todos los a:blip y actualizar rId
with zipfile.ZipFile(source_docx_path, 'r') as z: for blip, old_rid in collect_image_refs(tmpl_xml):
out_data = {item.filename: z.read(item.filename) for item in z.infolist()} if old_rid in all_old_new_rids:
blip.set(f'{{{r}}}embed', all_old_new_rids[old_rid])
# Añadir archivos del template que no están en el original # ---- Construir zip de salida ----
with zipfile.ZipFile(template_path, 'r') as z: out_data = {}
for item in z.infolist():
fname = item.filename
if fname not in out_data:
out_data[fname] = z.read(fname)
elif 'media/' in fname:
# Las imágenes del template se añaden con sufijo _tmpl para no colisionar
base, ext = fname.rsplit('.', 1)
new_fname = f"{base}_tmpl.{ext}"
if new_fname not in out_data:
out_data[new_fname] = z.read(fname)
# Reemplazar document.xml # 1. Copiar todo el template (base)
out_data['word/document.xml'] = etree.tostring(tmpl_xml, xml_declaration=True, encoding='UTF-8', standalone=True) for item in z_tmpl.infolist():
out_data[item.filename] = z_tmpl.read(item.filename)
# ===== ACTUALIZAR RELACIONES ===== # 2. Copiar imágenes renombradas del source
# Las imágenes del template ahora tienen _tmpl en el nombre for old_abs, new_abs in image_rename_map.items():
# Necesito actualizar las relaciones del template para que apunten a _tmpl if old_abs in out_data:
# La imagen del template tiene el mismo nombre -> la renombramos
out_data[new_abs] = z_src.read(old_abs)
else:
# No hay conflicto, copiamos directamente
out_data[new_abs] = z_src.read(old_abs)
rels_path = 'word/_rels/document.xml.rels' # 3. Actualizar document.xml.rels: añadir nuevas relaciones de imagen
if rels_path in out_data: rel_root = parse_xml(out_data['word/_rels/document.xml.rels'])
rels_xml = etree.fromstring(out_data[rels_path])
for rel in rels_xml: for old_rid, new_rid in all_old_new_rids.items():
# Buscar la relación original en el source
for rel in src_rel:
if rel.get('Id') == old_rid:
target = rel.get('Target', '') target = rel.get('Target', '')
# Las relaciones de imágenes del template que se sobrescribieron # Actualizar target al nuevo nombre de imagen
if target.startswith('media/') and not target.startswith('media/image'): old_target_abs = target.replace('../', '')
pass # no hay conflictos con nombres no-numéricos if not old_target_abs.startswith('word/'):
# Las imágenes numéricas del template están sobrescritas por las del original old_target_abs = f'word/{old_target_abs}'
# Pero nosotros las copiamos como _tmpl, así que hay que actualizar las relaciones new_target_abs = image_rename_map.get(old_target_abs, old_target_abs)
# SÓLO si la imagen original fue sobrescrita # Convertir a ruta relativa desde word/
m = re.match(r'media/(image\d+)\.(\w+)', target) if new_target_abs.startswith('word/'):
if m: new_target = new_target_abs[5:]
img_name = m.group(1) else:
ext = m.group(2) new_target = new_target_abs
# Verificar si esta imagen existe en el original # Crear nueva relación
orig_path = f'word/media/{img_name}.{ext}' new_rel = copy.deepcopy(rel)
if orig_path not in dict([(i.filename, None) for i in zipfile.ZipFile(source_docx_path, 'r').infolist() if not hasattr(i, 'filename')]): new_rel.set('Id', new_rid)
pass # No fue sobrescrita new_rel.set('Target', new_target)
# Es más fácil: simplemente cambiar todas las referencias a imágenes rel_root.append(new_rel)
# del template que colisionan a la versión _tmpl break
new_target = f'media/{img_name}_tmpl.{ext}'
# Solo cambiamos si existe la versión _tmpl
if f'word/{new_target}' in out_data:
rel.set('Target', new_target)
out_data[rels_path] = etree.tostring(rels_xml, xml_declaration=True, encoding='UTF-8', standalone=True) out_data['word/_rels/document.xml.rels'] = etree.tostring(
rel_root, xml_declaration=True, encoding='UTF-8', standalone=True)
# Escribir # 4. Actualizar document.xml
out_data['word/document.xml'] = etree.tostring(
tmpl_xml, xml_declaration=True, encoding='UTF-8', standalone=True)
# ---- Escribir ----
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout: with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zout:
for fname, content in out_data.items(): for fname, content in out_data.items():
zout.writestr(fname, content) zout.writestr(fname, content)
z_tmpl.close()
z_src.close()
print(f" ✅ Convertido: {output_path}")
return output_path return output_path
# ======================================================================
# Entry point
# ======================================================================
if __name__ == "__main__": if __name__ == "__main__":
if len(sys.argv) < 3: if len(sys.argv) < 3:
print("Uso: apply_template.py <documento.docx> <plantilla.docx>") print("Uso: apply_template.py <documento.docx> <plantilla.docx>")
print("")
print(" Salida: <documento>_r360mx.docx (en el mismo directorio)")
sys.exit(1) sys.exit(1)
docx_path = sys.argv[1]; template_path = sys.argv[2]
base_dir = os.path.dirname(docx_path) docx_path = sys.argv[1]
template_path = sys.argv[2]
if not os.path.exists(docx_path):
print(f"❌ No existe: {docx_path}")
sys.exit(1)
if not os.path.exists(template_path):
print(f"❌ No existe: {template_path}")
sys.exit(1)
base_dir = os.path.dirname(os.path.abspath(docx_path))
base_name = os.path.splitext(os.path.basename(docx_path))[0] base_name = os.path.splitext(os.path.basename(docx_path))[0]
output_path = os.path.join(base_dir, f"{base_name}_r360mx.docx") output_path = os.path.join(base_dir, f"{base_name}_r360mx.docx")
print(f"📄 Template: {template_path}") print(f"📄 Template: {template_path}")
print(f"📄 Documento: {docx_path}") print(f"📄 Documento: {docx_path}")
print(f"📄 Salida: {output_path}") print(f"📄 Salida: {output_path}")
print()
replace_content(template_path, docx_path, output_path) replace_content(template_path, docx_path, output_path)
print(f"✅ Convertido: {output_path}")
import subprocess
subprocess.Popen(['nextcloudcmd', '--non-interactive', '--user', 'JavierBrana', '--password', '%5qJuIrZ^eoq3rFYU$OpuV2aM', '/home/javi/Nextcloud', 'https://cloud.r360mx.com'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)