Reformateo de docx para artículos académicos
Cuándo usarlo
- Cuando necesites convertir un informe o tabla de Markdown a un docx que cumpla con normas de publicación.
- Cuando necesites aplicar configuración masiva de fuentes mixtas chino-occidental, tablas de tres líneas y espaciado entre títulos de varios niveles.
- Cuando las API de inserción por párrafos no puedan controlar bordes de tablas de tres líneas, rFonts a nivel de run o división de itálicas, usa esta habilidad.
Tamaños de fuente china → puntos
| Tamaño | Puntos |
|---|---|
| Sanhao | 16 |
| Xiaosan | 15 |
| Sihao | 14 |
| Xiaosi | 12 |
| Wuhao | 10.5 |
Norma por defecto (se puede sobrescribir según la revista o universidad de destino): títulos de nivel 1 en Xiaosan SimHei, nivel 2 en Sihao SimHei, cuerpo en Xiaosi Song Ti con interlineado 1.5, todos los textos occidentales en Times New Roman, tablas en Wuhao Song Ti.
Puntos clave de implementación (problemas encontrados)
1. Bordes de tabla de tres líneas
La unidad de w:sz en w:tblBorders es 1/8 de punto:
- Superior/inferior 1.5 puntos →
sz=12 - Línea media (debajo del encabezado) 1.0 puntos →
sz=8 - Las líneas verticales deben establecerse explícitamente como
val="none", de lo contrario Word dibujará líneas verticales por defecto.
La línea debajo del encabezado se debe configurar con w:tcBorders/w:bottom en cada row.cells[i]; solo configurar insideH en tblBorders no es fiable en algunos renderizadores.
2. cell.text = '' dejará un run vacío sin formato
Para vaciar una celda, usa:
p = cell.paragraphs[0]
for r in list(p.runs):
r._element.getparent().remove(r._element)
De lo contrario, runs[0] será un run vacío sin rPr, que luego causará errores al verificar fuentes.
3. Para mezcla chino-occidental, se deben establecer 4 posiciones de fuente simultáneamente
rFonts.set(qn('w:ascii'), 'Times New Roman')
rFonts.set(qn('w:hAnsi'), 'Times New Roman')
rFonts.set(qn('w:eastAsia'), '宋体') # Fuente china
rFonts.set(qn('w:cs'), 'Times New Roman')
Si se omite eastAsia, el texto chino volverá a la fuente predeterminada.
4. Las itálicas deben dividir los runs
Los estadísticos (P < 0.001, OR = 2.17, CI, β, valor P) deben dividirse en runs independientes para poder establecer itálica por separado. El orden de las expresiones regulares es importante: P值 debe coincidir con \bP(\s*值)\b; primero coincide P\s*[<>=]\s*[\d.]+ y luego P aislado; de lo contrario, P=0.06 se dividirá en P + =0.06.
5. "Dejar una línea en blanco" = espaciado de párrafo, no párrafo vacío
Nunca uses saltos de línea (párrafos vacíos) para lograr "dejar una línea en blanco". Los párrafos vacíos en Word causan:
- Líneas en blanco extra al editar, difíciles de eliminar limpiamente
- Espacios en blanco impredecibles al imprimir/convertir a PDF
- Espaciado descontrolado al superponerse con espacios antes/después
Método correcto: usar space_before / space_after (unidad: puntos):
| Situación | Implementación |
|---|---|
| Título nivel 1 "dejar una línea arriba y abajo" | space_before = Pt(12×1.5), space_after = mismo valor |
| Título nivel 2 "dejar una línea arriba" | space_before = Pt(12×1.5), space_after = Pt(0) |
| Títulos nivel 3 e inferiores | Sin espaciado |
| Cuerpo | space_before = Pt(0), space_after = Pt(0) |
| Entre figura/tabla y texto | Establecer space_before/after en el párrafo de la figura/tabla, no insertar párrafos vacíos extra |
Valores de referencia: Xiaosi (12pt) una línea en blanco ≈ 18 puntos; Wuhao (10.5pt) ≈ 15.75 puntos.
Método de verificación: sum(1 for p in doc.paragraphs if not p.text.strip()) == 0——un documento normal no debe tener ningún párrafo vacío.
6. Prohibido negrita automática
Nunca pongas texto en negrita a menos que el usuario lo solicite explícitamente.
Este es un error fácil de cometer: **texto** en Markdown es una marca de énfasis del archivo fuente, no una instrucción de formato. Renderizarlo como negrita en Word equivale a añadir énfasis no solicitado al texto original.
Método correcto:
**texto**→ tomar solo el contenido del texto, sin negrita- Cuando necesites subtítulos, usa párrafos independientes + fuente/tamaño objetivo, no uses negrita para jerarquía
- La sensación de jerarquía solo debe venir de: fuente (SimHei/KaiTi), tamaño, espaciado antes/después de párrafo, numeración
Igualmente prohibido: itálica excepto para estadísticos/símbolos especificados; subrayado, puntos de énfasis, texto de color, escala de caracteres/espaciado entre caracteres——si el usuario no lo pide, no se toca.
Después de la conversión, se debe auto-verificar:
# No debe haber negrita no solicitada
negritas = [r.text for p in doc.paragraphs for r in p.runs if r.font.bold]
Si el encabezado debe ir en negrita depende de la solicitud del usuario; esta habilidad por defecto no pone negrita en el encabezado (se distingue por tamaño + tabla de tres líneas).
7. Títulos nivel 3 e inferiores: sin línea en blanco
Solo los títulos de nivel 1 y 2 llevan espaciado antes/después; nivel 3 e inferiores: sin espaciado, sin negrita.
Scripts
| Script | Propósito |
|---|---|
scripts/tri_line_table.py | Bordes de tabla de tres líneas (incluye verify_table_borders() auto-verificación) |
scripts/fmt_lib.py | Biblioteca de funciones útiles: fuentes, espaciado, análisis de segmentos en negrita, estilos de celda |
scripts/fix_italic.py | División de itálicas en estadísticos (modifica in situ, no recrea runs) |
scripts/fix_spacing2.py | Corrección de espaciado en mezcla chino-inglés y signos (modifica in situ, no recrea runs) |
Dependencias: pip install python-docx
fmt_lib.parse_bold_segments(text, allow_bold=False): por defecto descarta pero no renderiza las marcas **; solo cuando el usuario pide explícitamente negrita se pasa allow_bold=True.
Orden de ejecución para corregir formato en un docx existente (no invertir el orden):
python3 scripts/fix_italic.py objetivo.docx # Primero dividir itálicas
python3 scripts/fix_spacing2.py objetivo.docx # Luego corregir espaciado
Invertir el orden hará que las reglas de espaciado recombinen incorrectamente los símbolos ya separados.
Lista de verificación (obligatorio después de generar)
- Número de párrafos vacíos == 0 — Prioridad máxima,
sum(1 for p in doc.paragraphs if not p.text.strip()) - Runs en negrita == 0 (a menos que el usuario lo solicite) —
[r.text for p in doc.paragraphs for r in p.runs if r.font.bold]debe estar vacío; también dentro de tablas - Títulos nivel 1: sz==15 y eastAsia==SimHei, space_before==space_after==18pt
- Títulos nivel 2: sz==14, space_before==18pt
- Cuerpo: sz==12, eastAsia==Song Ti, line_spacing==1.5
- Todos los runs: ascii/hAnsi==Times New Roman
- Número de runs en itálica >0 y su contenido son estadísticos (este es el único formato especial permitido)
- Tabla de tres líneas: usar
verify_table_borders(table)para verificar cada tabla, todas deben devolver(True, []) - Título de tabla: Wuhao SimHei centrado, sin puntuación al final
- Todos los runs dentro de tabla: Song Ti + 10.5pt
- Párrafos de cuerpo: alineación == JUSTIFY, títulos y títulos de tabla mantienen su alineación original
- Espacio antes del signo de porcentaje:
33 %en lugar de33% - Sin espacio entre signo y carácter chino:
30余次; espacio entre signo y número:≥ 10 小时,P < 0.05
Script de auto-verificación único:
import sys; sys.path.insert(0, '<skill>/scripts')
from tri_line_table import verify_table_borders
from docx import Document
d = Document('out.docx'); ps = d.paragraphs
assert sum(1 for x in ps if not x.text.strip()) == 0, 'Existen párrafos vacíos'
assert not [r for x in ps for r in x.runs if r.font.bold], 'Existen negritas'
assert not [r for t in d.tables for row in t.rows for c in row.cells
for r in c.paragraphs[0].runs if r.font.bold], 'Existen negritas en tablas'
for i, t in enumerate(d.tables, 1):
good, prob = verify_table_borders(t)
assert good, f'Tabla {i} no cumple formato de tres líneas: {prob}'
print('Todo correcto')
Cuando no hay entorno de verificación visual
Si no tienes LibreOffice / Word para convertir a PDF y verificar visualmente las tablas de tres líneas, usa verificación a nivel XML (elementos 1–9 de la lista), que cubre de forma fiable tamaño de fuente, fuente, bordes y espaciado.
Suplemento: Especificación exacta de tabla de tres líneas
Solo 3 líneas:
| Posición | Puntos | w:sz |
|---|---|---|
| Fila de encabezado (borde superior) | 1.5 | 12 |
| Fila de encabezado (borde inferior) | 1.0 | 8 |
| Última fila (borde inferior) | 1.5 | 12 |
| Todas las demás líneas (incluidas filas intermedias, verticales) | Vacío | val="none" |
Clave: No basta con establecer insideH en tblBorders. Los bordes a nivel de tabla son anulados por tcBorders a nivel de celda; una vez que una celda tiene bottom, se toma el nivel de celda. Se debe establecer explícitamente celda por celda:
- Todos los seis elementos de
tblBordersennone - Todos los lados de
tcBordersde todas las filas primero ennone - Celdas de la fila de encabezado:
top=('single',12)+bottom=('single',8) - Celdas de la última fila:
bottom=('single',12)
clear_row_borders() debe llamarse para cada fila, no solo para el encabezado y la última fila; de lo contrario, las filas intermedias dejarán líneas residuales.
Suplemento: Reemplazo de comillas chinas
Las " en Markdown son comillas rectas; al convertir a docx deben reemplazarse por pares “ / ”:
dq_index = 0 # Debe contar continuamente a través de runs, no reiniciar por cada run
for r in p.runs:
for ch in r.text:
if ch == '"':
out.append('“' if dq_index % 2 == 0 else '”')
dq_index += 1
Solo modificar r.text, no tocar rPr → se conservan fuente, tamaño e itálica.
Después del reemplazo, verificar: recuento total de " == 0, recuento de “ == recuento de ”, y por párrafo el emparejamiento izquierda-derecha (el equilibrio global no garantiza el equilibrio por párrafo).
Suplemento: La edición en Word genera runs fragmentados
Después de que el usuario edite manualmente en Word, un párrafo se divide en varios runs, algunos de los cuales (como -, caracteres individuales extraídos) no llevan rFonts y vuelven a la fuente predeterminada. Después de corregir comillas/bordes, se debe revisar:
rf = r._element.rPr.rFonts if r._element.rPr is not None else None
if rf is None or rf.get(qn('w:eastAsia')) is None: # → necesita completarse
Forma de completar: tomar como referencia el primer run del párrafo que ya tenga fuente establecida, heredar su rFonts/sz/b.
Recordatorio importante del flujo de trabajo
Si el usuario ha modificado manualmente el documento en Word, nunca regeneres el documento completo; eso sobrescribiría los cambios del usuario. Método correcto: leer el docx existente → modificar XML in situ → guardar. Antes de modificar, haz una copia de seguridad: cp xxx.docx /tmp/backup.docx.
Norma de itálica
Único criterio de juicio: si el carácter es una variable o símbolo en matemáticas/estadística
| Categoría | ¿Itálica? | Ejemplo |
|---|---|---|
| Significancia estadística P | Itálica | P = 0.04, P < 0.001 |
| Nivel de significación α | Itálica | α = 0.05 |
| Estadísticos de prueba F, Z, H, χ², t, r | Itálica | F = 4.52, χ² = 8.31 |
| Letras especiales (símbolos de genes, valor β de metilación, etc.) | Itálica | β |
| OR, CI, RR, HR, SD, SE, AUC | No itálica | OR = 3.14, 95% CI |
| Abreviaturas de varias letras (BMI, OGTT, FPG…) | No itálica | BMI, OGTT |
| Texto chino | No itálica | 值, 为 |
⚠️ Error más común: en "P值" solo P va en itálica
✗ Itálica 'P值' ← "值" también va en itálica incorrectamente
✓ Itálica 'P' + redonda '值'
✓ Itálica 'P' + redonda '值为' + ...
P 值, P值, P 值为 0.07 — siempre solo la P va en itálica, los caracteres chinos que la siguen van en redonda.
Las tres reglas de oro de la división de itálicas (lecciones aprendidas)
- Nunca recrees runs. Usar "recomposición de runs por flujo de caracteres" para corregir formato hará que se pierdan rFonts/sz/b, se borren los bordes de tablas de tres líneas y las marcas de itálica se fragmenten. Solo se puede modificar
run.text, nunca eliminar/crear elementos run. - La división debe usar
copy.deepcopy(run._element)para conservar todos los atributos de formato; la posición de inserciónparent.insert(index+1, el)(justo al lado del run original). Usarappendlo colocará al final del párrafo, desordenando el orden, dando lugar aPP. Texto de la copia =lead + rest + trail(sin stat), texto del run original =lead + stat. run.text = xno vacía los nodos<w:t>existentes; en la copia deepcopy se añadirá. Primero se debeel.remove(child)eliminar todos losw:t/w:br/w:tab/w:cr; y antes de establecer el texto, se debe eliminarw:i/w:iCs; si el orden es incorrecto, también fallará.
Ver implementación de referencia en scripts/fix_italic.py.
Espaciado en mezcla chino-inglés y signos
Reglas básicas
| Situación | Regla | Ejemplo |
|---|---|---|
| Chino ↔ número/letra/puntuación | Sin espacio | 在30余次未校正检验的背景下, 但Table 1显示 |
| Carácter chino ↔ carácter chino | Sin espacio | 研究目的 |
| Entre palabras en inglés | Con espacio | Table 1 |
| Dentro de expresiones matemáticas | Espacio entre letra/número y operador | P = 0.04, BMI < 18.5 |
| Expresión matemática ↔ chino | Con espacio | P < 0.05 为阈值 |
Espaciado entre signos y números/letras/chino (fácil de equivocarse)
| Combinación | Regla | Ejemplo |
|---|---|---|
| Signo ↔ número | Con espacio | 33 %, 87.3 %, P = 0.04 |
| Signo ↔ palabra/letra en inglés | Con espacio | Table 1; α = 0.05 |
| Signo ↔ carácter chino | Sin espacio | 30余次, ≥10小时 (signo pegado al carácter chino) |
Punto fácil de equivocarse: 30 余次 (incorrecto, espacio extra entre signo y carácter chino) vs 30余次 (correcto).
Signos de operador (≥ ≤ = < >) deben tener espacio con los números, pero el signo y el carácter chino que le sigue inmediatamente no llevan espacio.
Signo de porcentaje
33 % con espacio entre el número y %. Esto es un requisito de las normas de publicaciones científicas; no escribas 33%.
Ver implementación de referencia en scripts/fix_spacing2.py (incluye fix_spacing_in_run).
Alineación de párrafos de cuerpo
Los párrafos de cuerpo deben tener alineación justificada — es decir, JUSTIFY en Word (Cmd+J / Ctrl+J), no DISTRIBUTE.
from docx.enum.text import WD_ALIGN_PARAGRAPH
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
Solo aplicar a párrafos de cuerpo; los siguientes mantienen su alineación original, no modificar:
- Todos los niveles de título — normalmente alineación izquierda
- Título de tabla — centrado
- Celdas dentro de tabla — según necesidad (izquierda o centrado)
⚠️ Tres reglas de oro (orden de ejecución y verificación)
Orden de ejecución
python3 scripts/fix_italic.py objetivo.docx # Primero dividir itálicas
python3 scripts/fix_spacing2.py objetivo.docx # Luego corregir espaciado
Verificación (obligatorio, no faltar ninguna)
import re
norm = lambda s: re.sub(r'\s', '', s)
# ① Cero pérdida de texto — evitar caracteres omitidos/duplicados
assert all(norm(a.text) == norm(b.text)
for a, b in zip(doc_original.paragraphs, doc_nuevo.paragraphs))
# ② Los runs en itálica solo pueden ser el símbolo estadístico en sí (P/α/β/F/Z/H/χ/t/r), no pueden contener:
# - operadores o números: [=<>≤≥0-9]
# - caracteres chinos ('值' en 'P值' debe ser redonda)
# - abreviaturas como OR/CI/RR
for p in doc_nuevo.paragraphs:
for r in p.runs:
if r.font.italic and r.text.strip():
t = r.text.strip()
assert not re.search(r'[=<>≤≥0-9\u4e00-\u9fff]', t), f'Itálica contiene operador/número/chino: {t!r}'
assert t.split(' ')[0] not in ('OR','CI','RR','HR','SD','SE','AUC'), f'Abreviatura no debe ir en itálica: {t!r}'
# ③ Párrafos de cuerpo deben estar justificados
from docx.enum.text import WD_ALIGN_PARAGRAPH
assert any(p.alignment == WD_ALIGN_PARAGRAPH.JUSTIFY for p in doc_nuevo.paragraphs)
# ④ Comillas chinas emparejadas
assert sum(p.text.count('“') for p in doc_nuevo.paragraphs) == \
sum(p.text.count('”') for p in doc_nuevo.paragraphs)
⚠️ Reglas de máxima prioridad (infringir = rehacer)
- "Dejar una línea en blanco" solo se puede hacer con espaciado de párrafo (
space_before/space_after), nunca con saltos de línea / párrafos vacíos. - Nunca pongas texto en negrita a menos que el usuario lo solicite explícitamente en el requerimiento.
El
**texto**de Markdown es una marca de énfasis del archivo fuente, no una instrucción de formato — solo elimina la marca, no pongas negrita. - Los formatos no solicitados por el usuario (subrayado, puntos de énfasis, texto de color, espaciado entre caracteres, escala de caracteres) nunca se tocan.
- Si el usuario ha modificado manualmente el documento, prohibido regenerar el documento completo; solo se puede leer el archivo existente y modificar XML in situ.
La sensación de jerarquía solo puede venir de la fuente (SimHei/KaiTi), el tamaño, el espaciado antes/después de párrafo y la numeración — no de la negrita.
Precauciones al reducir texto en cuerpo/tablas
Al reducir contenido textual en un docx modificado por el usuario:
Regla de oro: escribir fusionado en el primer run, vaciar el resto
def set_paragraph_text(p, text):
runs = p.runs
if not runs: return False
runs[0].text = text # El formato se conserva en el primer run
for r in runs[1:]:
r.text = '' # ⚠️ Debe vaciarse, de lo contrario queda texto antiguo causando duplicación
return True
Igualmente se sigue el principio de "no recrear runs" — solo modificar run.text, nunca eliminar/crear <w:r>.
Localizar tabla: por orden del flujo del documento, no se puede usar índice de doc.paragraphs
Cuando hay tablas incrustadas en párrafos, el índice doc.paragraphs[i] no se corresponde con la tabla. Se debe recorrer el flujo del body:
from docx.table import Table
from docx.text.paragraph import Paragraph
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
seq = []
for c in doc.element.body.iterchildren():
if c.tag == W+'p': seq.append(('p', Paragraph(c, doc)))
elif c.tag == W+'tbl': seq.append(('t', Table(c, doc)))
Dos problemas al añadir/eliminar filas
table.rowsse vuelve a analizar XML cada vez que se accede — después de eliminar una fila, usarrows[ri]dará error de índice. Primero tomar una instantánea conrows = list(table.rows), luego eliminar desde atrás hacia adelante.- Las filas clonadas perderán el borde inferior de la última fila — después de clonar la última fila para completar, la línea inferior de la tabla de tres líneas se convertirá en
nil. Al final, se debe volver a establecer el bordebottomde la última fila (sz=12).
⚠️ Al detectar bordes, no solo reconozcas none
En las tablas de tres líneas propias de Word, las filas intermedias escriben w:val="nil" en lugar de "none".
Para juzgar "sin línea", se deben aceptar ambos:
assert tv in ('nil','none') and bv in ('nil','none')
Solo reconocer none dará falsos positivos de "borde dañado", cuando en realidad es la lógica de detección la que está mal.