diff --git a/__pycache__/apply_template.cpython-314.pyc b/__pycache__/apply_template.cpython-314.pyc new file mode 100644 index 0000000..c120f0f Binary files /dev/null and b/__pycache__/apply_template.cpython-314.pyc differ diff --git a/apply_template.py b/apply_template.py index 2f27cc7..48e20ca 100644 --- a/apply_template.py +++ b/apply_template.py @@ -48,11 +48,24 @@ log = logging.getLogger('r360mx') # MAPEO DE ESTILOS: source -> template # ====================================================================== DEFAULT_STYLE_MAP = { - 'Title1': 'Título 1', - 'Title2': 'Título 2', - 'Title3': 'Título 3', + # 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', } @@ -107,6 +120,26 @@ def collect_image_refs(xml_root: etree._Element) -> list[tuple]: 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 @@ -124,7 +157,7 @@ class SectionDetector: MARKER_STYLES = { 'indice_fin': 'TableContentEnd', - 'titulo_contenido': 'Título 1', + 'titulo_contenido': 'Ttulo1', # El template usa Ttulo1 (sin acento) } @staticmethod @@ -155,7 +188,7 @@ class SectionDetector: 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: + 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] @@ -200,7 +233,7 @@ class SectionDetector: """ children = list(body) found_toc_marker = False - best = 69 # fallback conservador + best = None # 1. Buscar marcador TableContentEnd for i, child in enumerate(children): @@ -220,7 +253,7 @@ class SectionDetector: 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: + 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) @@ -235,9 +268,17 @@ class SectionDetector: 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 + 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 @@ -428,9 +469,11 @@ def extract_source_title(source_xml: etree._Element) -> tuple[str, str]: 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 + # 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 "" @@ -491,12 +534,23 @@ def replace_content( 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) + # ---- 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: prefacio h. hijo %d, contraportada h. hijo %d", tmpl_idx_end, tmpl_back) + 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 ---- @@ -534,18 +588,32 @@ def replace_content( for child in list(body_tmpl): body_tmpl.remove(child) - # Prefacio del template - for child in children_tmpl[:tmpl_idx_end + 1]: - body_tmpl.append(copy.deepcopy(child)) - - # Contenido del source (desde src_start, sin sectPr) - for child in children_src[src_start:]: - if child.tag != q('w:sectPr'): + 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)) - - # Contraportada del template - 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): @@ -567,41 +635,48 @@ def replace_content( except KeyError: log.warning(" Imagen no encontrada en source: %s (ignorada)", old_abs) - # 3. Añadir relaciones de imágenes del source + # 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'))) - # Eliminar relaciones existentes que podrían colisionar + # Relaciones existentes en el template 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 + # 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: - 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 + 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', standalone=True) + rel_root, xml_declaration=True, encoding='UTF-8') out_data['word/document.xml'] = etree.tostring( - tmpl_xml, xml_declaration=True, encoding='UTF-8', standalone=True) + tmpl_xml, xml_declaration=True, encoding='UTF-8') # ---- Escribir ---- with zipfile.ZipFile(str(output_path), 'w', zipfile.ZIP_DEFLATED) as zout: @@ -623,13 +698,12 @@ def replace_content( 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) + # 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: diff --git a/run_batch.sh b/run_batch.sh index 47acebc..a7f64da 100755 --- a/run_batch.sh +++ b/run_batch.sh @@ -2,6 +2,8 @@ # Batch runner para apply_template.py # Procesa los documentos en /tmp/batch_t*.txt +set -euo pipefail + TEMPLATE="/mnt/c/Users/javie/Documents/R360MX/cloud/01. Info General/02. Standards/03. Templates/TPL01-Reports.docx" DIR="/home/javi/.openclaw/workspace/r360mx-docs-converter" LOGFILE="/tmp/r360mx_batch_$(date +%Y%m%d_%H%M%S).log" @@ -13,6 +15,18 @@ echo "" | tee -a "$LOGFILE" TOTAL=0 OK=0 FAIL=0 +TOTAL_BASE=0 + +# Primero contar docs totales +for TANDA in /tmp/batch_t1.txt /tmp/batch_t2.txt /tmp/batch_t3.txt /tmp/batch_t4.txt; do + if [ -f "$TANDA" ]; then + COUNT=$(wc -l < "$TANDA") + TOTAL_BASE=$((TOTAL_BASE + COUNT)) + fi +done + +echo "Total documentos: $TOTAL_BASE" | tee -a "$LOGFILE" +echo "" | tee -a "$LOGFILE" for TANDA in /tmp/batch_t1.txt /tmp/batch_t2.txt /tmp/batch_t3.txt /tmp/batch_t4.txt; do if [ ! -f "$TANDA" ]; then @@ -20,13 +34,14 @@ for TANDA in /tmp/batch_t1.txt /tmp/batch_t2.txt /tmp/batch_t3.txt /tmp/batch_t4 fi NUM=$(wc -l < "$TANDA") - echo "--- Tanda: $(basename $TANDA) ($NUM docs) ---" | tee -a "$LOGFILE" + echo "--- Tanda: $(basename "$TANDA") ($NUM docs) ---" | tee -a "$LOGFILE" while IFS= read -r DOC; do TOTAL=$((TOTAL + 1)) echo -n "[$TOTAL/$TOTAL_BASE] $(basename "$DOC")... " | tee -a "$LOGFILE" - if cd "$DIR" && python3 apply_template.py "$DOC" "$TEMPLATE" >> "$LOGFILE" 2>&1; then + # Ejecutar en subshell para no alterar el directorio actual + if ( cd "$DIR" && python3 apply_template.py "$DOC" "$TEMPLATE" ) >> "$LOGFILE" 2>&1; then echo "✅" | tee -a "$LOGFILE" OK=$((OK + 1)) else