Restauration de la mise en forme DOCX pour articles académiques
Quand l'utiliser
- Lorsque vous devez convertir un rapport Markdown ou un tableau en DOCX conforme aux normes de soumission(normes académiques chinoises)
- Lorsque vous devez appliquer en masse la mise en forme mixte chinois/occidental, les tableaux à trois lignes et l'espacement des titres à plusieurs niveaux
- Lorsque les API d'insertion paragraphe par paragraphe ne peuvent pas contrôler les bordures des tableaux à trois lignes, les polices au niveau du run, ou le fractionnement en italique
Conversion des tailles de police chinoises → points
| Taille | Points |
|---|---|
| 三号 | 16 |
| 小三 | 15 |
| 四号 | 14 |
| 小四 | 12 |
| 五号 | 10.5 |
Norme par défaut (peut être remplacée selon la revue ou l'école cible) : Titre de niveau 1 en 小三 SimHei, titre de niveau 2 en 四号 SimHei, corps du texte en 小四 SimSong avec interligne 1,5, texte occidental en Times New Roman, tableaux en 五号 SimSong.
Points clés de mise en œuvre (pièges rencontrés)
1. Bordures des tableaux à trois lignes
L'unité w:sz de w:tblBorders est 1/8 de point :
- Bordure supérieure/inférieure 1,5 pt →
sz=12 - Ligne du milieu (sous l'en-tête) 1,0 pt →
sz=8 - Les lignes verticales doivent être explicitement définies à
val="none", sinon Word dessine des lignes verticales par défaut
La ligne sous l'en-tête doit être définie via row.cells[i] en utilisant w:tcBorders/w:bottom sur chaque cellule. Le simple fait de définir insideH dans tblBorders n'est pas fiable avec certains rendus.
2. cell.text = '' laisse un run vide sans formatage
Pour vider une cellule, utilisez :
p = cell.paragraphs[0]
for r in list(p.runs):
r._element.getparent().remove(r._element)
Sinon, runs[0] sera un run vide sans rPr, ce qui entraînera une erreur lors de la vérification ultérieure des polices.
3. Le mélange chinois/occidental nécessite de définir les 4 emplacements de police
rFonts.set(qn('w:ascii'), 'Times New Roman')
rFonts.set(qn('w:hAnsi'), 'Times New Roman')
rFonts.set(qn('w:eastAsia'), '宋体') # Police chinoise
rFonts.set(qn('w:cs'), 'Times New Roman')
Omettre eastAsia → le texte chinois revient à la police par défaut.
4. L'italique doit être fractionné en runs séparés
Les statistiques (P<0,001, OR=2,17, CI, β, P) doivent être divisées en runs indépendants pour pouvoir définir l'italique séparément.
L'ordre des expressions régulières est important : P值 doit être capturé avec \bP(\s*值)\b ;
D'abord P\s*[<>=]\s*[\d.]+ puis le P isolé, sinon P=0,06 serait divisé en P + =0,06.
5. « Une ligne vide » = espacement de paragraphe, pas un paragraphe vide
N'utilisez JAMAIS de retour à la ligne (paragraphe vide) pour créer une « ligne vide ». Les paragraphes vides dans Word causent :
- Des lignes supplémentaires difficiles à supprimer proprement lors de l'édition ultérieure
- Des espaces blancs imprévisibles lors de l'impression ou de la conversion en PDF
- Un espacement incontrôlable lorsqu'il est combiné avec l'espacement avant/après
Solution correcte — utilisez space_before / space_after (en points) :
| Scénario | Implémentation |
|---|---|
| Titre de niveau 1 avec « une ligne vide avant et après » | space_before = Pt(taille 小四 × 1,5), space_after = même valeur |
| Titre de niveau 2 avec « une ligne vide avant » | space_before = Pt(taille 小四 × 1,5), space_after = Pt(0) |
| Titres de niveau 3 et inférieurs | Pas d'espacement |
| Corps du texte | space_before = Pt(0), space_after = Pt(0) |
| Une ligne vide entre une figure/tableau et le texte | Définir space_before/after sur le paragraphe contenant la figure/tableau ; ne pas insérer de paragraphe vide supplémentaire |
Valeurs de référence : 小四(12pt) ligne vide ≈ 18 pt ; 五号(10,5pt) ligne vide ≈ 15,75 pt.
Méthode de vérification : sum(1 for p in doc.paragraphs if not p.text.strip()) == 0
—— Un document normal ne doit contenir aucun paragraphe vide.
6. Interdiction de gras automatique
N'ajoutez JAMAIS de gras au texte, sauf demande explicite de l'utilisateur.
C'est un piège très courant : **texte** en Markdown est une marque d'emphase dans le fichier source, pas une instruction de mise en page.
Le rendre en gras dans Word revient à ajouter arbitrairement une emphase au texte original.
Solution correcte :
**texte**→ ne prendre que le contenu textuel, sans gras- Pour les sous-titres, utilisez un paragraphe indépendant avec la police/taille cible, ne comptez pas sur le gras pour créer une hiérarchie
- La hiérarchie ne peut provenir que de : police (SimHei/SimKai), taille, espacement avant/après le paragraphe, numérotation
Autres interdictions : italique (sauf pour les statistiques/symboles spécifiés) ; souligné, point d'insistance, texte coloré, échelle de caractères/espacement des lettres — ne modifiez rien que l'utilisateur n'a pas demandé.
Après la conversion, effectuez une auto-vérification :
# Il ne doit y avoir aucun gras non demandé par l'utilisateur
bold = [r.text for p in doc.paragraphs for r in p.runs if r.font.bold]
Voir si l'en-tête doit être en gras dépend de la demande de l'utilisateur ; par défaut, cette compétence ne met pas l'en-tête en gras (différenciation par la taille + tableau à trois lignes).
7. Pas de ligne vide pour les titres de niveau 3 et inférieurs
Seuls les titres de niveau 1 et 2 ont un espacement avant/après ; les titres de niveau 3 et inférieurs n'ont pas d'espacement ni de gras.
Scripts
| Script | Utilité |
|---|---|
scripts/tri_line_table.py | Bordures de tableau à trois lignes (inclut verify_table_borders() pour l'auto-vérification) |
scripts/fmt_lib.py | Bibliothèque de fonctions pour les polices, espacement, analyse des segments en gras, styles de cellule |
scripts/fix_italic.py | Fractionnement de l'italique pour les statistiques (modifie sur place, sans recréer les runs) |
scripts/fix_spacing2.py | Correction de l'espacement mixte chinois/anglais et des symboles (modifie sur place, sans recréer les runs) |
Dépendance : pip install python-docx
fmt_lib.parse_bold_segments(text, allow_bold=False) par défaut supprime mais ne rend pas les marques ** ;
Ne transmettez allow_bold=True que si l'utilisateur demande explicitement le gras.
Ordre d'exécution pour la correction de format d'un DOCX existant (ne pas inverser) :
python3 scripts/fix_italic.py cible.docx # d'abord fractionner l'italique
python3 scripts/fix_spacing2.py cible.docx # ensuite corriger l'espacement
L'inversion entraînera une recombinaison erronée des symboles fractionnés par les règles d'espacement.
Liste de vérification (à effectuer après la génération)
- Nombre de paragraphes vides == 0 — Priorité absolue,
sum(1 for p in doc.paragraphs if not p.text.strip()) - Nombre de runs en gras == 0 (sauf demande de l'utilisateur) —
[r.text for p in doc.paragraphs for r in p.runs if r.font.bold]doit être vide ; vérifiez aussi dans les tableaux - Titre de niveau 1 sz==15 et eastAsia==SimHei, space_before==space_after==18pt
- Titre de niveau 2 sz==14, space_before==18pt
- Corps du texte sz==12, eastAsia==SimSong, line_spacing==1.5
- Tous les runs ascii/hAnsi==Times New Roman
- Nombre de runs en italique >0 et contenu = statistiques (c'est le seul format spécial autorisé)
- Tableau à trois lignes : utilisez
verify_table_borders(table)pour chaque tableau, doit retourner(True, [])pour tous - Titre du tableau : centré en 五号 SimHei, pas de ponctuation en fin de phrase
- Tous les runs dans les tableaux : SimSong + 10,5pt
- Paragraphes du corps du texte : alignment == JUSTIFY, titres et titres de tableau inchangés
- Espace avant le signe pourcentage :
33 %et non33% - Pas d'espace entre le symbole et le caractère chinois :
30余次; espace entre le symbole et le chiffre :≥ 10 小时,P < 0,05
Script d'auto-vérification unique :
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, 'Des paragraphes vides existent'
assert not [r for x in ps for r in x.runs if r.font.bold], 'Du gras existe'
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], 'Du gras existe dans les tableaux'
for i, t in enumerate(d.tables, 1):
good, prob = verify_table_borders(t)
assert good, f'Tableau {i} non conforme au tableau à trois lignes : {prob}'
print('Tout est ok')
En l'absence d'environnement de vérification visuelle
Sans LibreOffice / Word pour convertir en PDF et vérifier visuellement les tableaux à trois lignes, utilisez une vérification au niveau XML (points 1 à 9 de la liste), qui couvre de manière fiable la taille, la police, les bordures et l'espacement.
Complément : Spécifications précises du tableau à trois lignes
Seulement 3 lignes :
| Emplacement | Points | w:sz |
|---|---|---|
| Ligne d'en-tête (bordure supérieure) | 1,5 | 12 |
| Ligne d'en-tête (bordure inférieure) | 1,0 | 8 |
| Dernière ligne (bordure inférieure) | 1,5 | 12 |
| Toutes les autres bordures (y compris toutes les lignes intermédiaires, lignes verticales) | Vide | val="none" |
Clé : Ne vous contentez pas de définir insideH dans tblBorders. Les bordures au niveau du tableau peuvent être écrasées par tcBorders au niveau de la cellule ; si une cellule a un bottom, c'est le niveau de la cellule qui prime. Vous devez définir explicitement chaque cellule :
- Définir les six éléments de
tblBordersdu tableau surnone - Définir d'abord tous les quatre côtés de
tcBordersde toutes les lignes surnone - Cellules de la ligne d'en-tête :
top=('single',12)+bottom=('single',8) - Cellules de la dernière ligne :
bottom=('single',12)
clear_row_borders() doit être appelé sur chaque ligne, pas seulement sur l'en-tête et la dernière ligne — sinon les lignes intermédiaires conserveront des traces de lignes.
Complément : Remplacement des guillemets chinois
Les " en Markdown sont des guillemets droits ; lors de la conversion en DOCX, ils doivent être remplacés par paires par “ / ” :
dq_index = 0 # Doit compter en continu à travers les runs, ne pas recommencer à 0 pour chaque run
for r in p.runs:
for ch in r.text:
if ch == '"':
out.append('“' if dq_index % 2 == 0 else '”')
dq_index += 1
Ne modifiez que r.text, pas rPr → la police/la taille/l'italique sont tous conservés.
Après le remplacement, vérifiez : Nombre total de " dans le document == 0, Nombre de “ == Nombre de ”, appariement par paragraphe (l'équilibre global ne signifie pas l'équilibre par paragraphe).
Complément : L'édition Word crée des runs fragmentés
Lorsque l'utilisateur édite manuellement dans Word, les paragraphes sont divisés en plusieurs runs, dont certains (le - extrait, un seul caractère) n'ont pas rFonts et reviennent à la police par défaut. Après avoir corrigé les guillemets/bordures, passez en revue :
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: # → doit être complété
Complétion : Prenez le premier run du paragraphe ayant déjà une police définie comme référence, héritez de ses rFonts/sz/b.
Rappel important sur le flux de travail
Si l'utilisateur a modifié manuellement le document dans Word, ne régénérez jamais l'intégralité du document — cela écraserait les modifications de l'utilisateur.
Solution correcte : Lire le DOCX existant → corriger le XML sur place → enregistrer.
Avant de modifier, faites une sauvegarde : cp xxx.docx /tmp/backup.docx.
Norme pour l'italique
Critère de décision unique : Ce caractère est-il une variable ou un symbole en mathématiques/statistiques ?
| Catégorie | Italique ? | Exemple |
|---|---|---|
| Signification statistique P | Italique | P = 0,04, P < 0,001 |
| Seuil α | Italique | α = 0,05 |
| Statistiques de test F, Z, H, χ², t, r | Italique | F = 4,52, χ² = 8,31 |
| Lettres spéciales (gènes, valeur β de méthylation, etc.) | Italique | β |
| OR, CI, RR, HR, SD, SE, AUC | Pas italique | OR = 3,14, 95 % CI |
| Abréviations multi-lettres (BMI, OGTT, FPG…) | Pas italique | BMI, OGTT |
| Caractères chinois | Pas italique | 值, 为 |
⚠️ Erreur la plus fréquente : « P值 » seul le P est en italique
✗ Italique 'P值' ← '值' suit en italique
✓ Italique 'P' + romain '值'
✓ Italique 'P' + romain '值为' + ...
P 值, P值, P 值为 0,07 — seul P est en italique, les caractères chinois qui le suivent sont tous en romain.
Trois règles absolues pour le fractionnement de l'italique (apprises à la dure)
- Ne reconstruisez jamais les runs. L'utilisation de « runs de flux de caractères » pour corriger le formatage entraîne la perte de tout rFonts/sz/b, la suppression des bordures du tableau à trois lignes et le décalage des marques d'italique en fragments. Ne modifiez que
run.text, ne supprimez/créez jamais d'éléments run. - Le fractionnement doit utiliser
copy.deepcopy(run._element)pour conserver tous les attributs de format ; Position d'insertionparent.insert(index+1, el)(juste à côté du run d'origine). L'utilisation d'appendle déplacera à la fin du paragraphe, provoquant un ordre incorrectPP. Texte de la copie =lead + rest + trail(sans stat), texte du run d'origine =lead + stat. run.text = xn'efface pas le nœud<w:t>existant, il s'ajoute à la copie deepcopy. Vous devez d'abordel.remove(child)supprimer tous les nœudsw:t/w:br/w:tab/w:cr; Et vous devez d'abord supprimerw:i/w:iCsavant de définir le texte ; l'ordre inverse entraînera également des erreurs.
Voir l'implémentation de référence dans scripts/fix_italic.py.
Mélange chinois/anglais et espacement des symboles
Règles de base
| Scénario | Règle | Exemple |
|---|---|---|
| Chinois ↔ chiffre/lettre/ponctuation | Pas d'espace | 在30余次未校正检验的背景下, 但Table 1显示 |
| Caractère chinois ↔ caractère chinois | Pas d'espace | 研究目的 |
| Entre les mots anglais | Espace | Table 1 |
| À l'intérieur d'une expression mathématique | Espace entre chiffre/lettre et opérateur | P = 0,04, BMI < 18,5 |
| Expression mathématique ↔ chinois | Espace | P < 0,05 为阈值 |
Espacement entre symbole et chiffre/lettre/chinois (facile à se tromper)
| Combinaison | Règle | Exemple |
|---|---|---|
| Symbole ↔ chiffre | Espace | 33 %, 87,3 %, P = 0,04 |
| Symbole ↔ mot/lettre anglais | Espace | Table 1 ; α = 0,05 |
| Symbole ↔ caractère chinois | Pas d'espace | 30余次, ≥10小时 (symbole collé au chinois) |
Piège : 30 余次 (faux, espace superflu entre symbole et chinois) vs 30余次 (correct).
Les symboles d'opérateur (≥ ≤ = < >) doivent être espacés des chiffres, mais pas du caractère chinois qui les suit immédiatement.
Signe pourcentage
33 % Espace entre le chiffre et %. C'est une exigence des normes des articles scientifiques ; n'écrivez pas 33%.
Voir l'implémentation de référence dans scripts/fix_spacing2.py (inclut fix_spacing_in_run).
Alignement des paragraphes du corps du texte
Les paragraphes du corps du texte doivent être justifiés — c'est-à-dire JUSTIFY dans Word (Cmd+J / Ctrl+J),
et non DISTRIBUTE.
from docx.enum.text import WD_ALIGN_PARAGRAPH
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
Ne l'appliquez qu'aux paragraphes du corps du texte, conservez l'alignement d'origine pour les éléments suivants, ne les modifiez pas :
- Titres de tous niveaux — généralement alignés à gauche
- Titres de tableau — centrés
- Cellules de tableau — selon le besoin (alignées à gauche ou centrées)
⚠️ Trois règles absolues (ordre d'exécution et vérification)
Ordre d'exécution
python3 scripts/fix_italic.py cible.docx # d'abord fractionner l'italique
python3 scripts/fix_spacing2.py cible.docx # ensuite corriger l'espacement
Vérification (obligatoire, aucun point ne peut être omis)
import re
norm = lambda s: re.sub(r'\s', '', s)
# ① Aucune perte de texte — éviter les caractères supprimés/répétés
assert all(norm(a.text) == norm(b.text)
for a, b in zip(documents_original.paragraphs, nouveau_document.paragraphs))
# ② Les runs en italique ne peuvent être que des symboles statistiques eux-mêmes (P/α/β/F/Z/H/χ/t/r), ne doivent PAS contenir :
# - Opérateurs ou chiffres : [=<>≤≥0-9]
# - Caractères chinois ('值' dans 'P值' doit être en romain)
# - Abréviations comme OR/CI/RR
for p in nouveau_document.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'Italique contient opérateur/chiffre/chinois : {t!r}'
assert t.split(' ')[0] not in ('OR','CI','RR','HR','SD','SE','AUC'), f'Abréviation ne doit pas être en italique : {t!r}'
# ③ Les paragraphes du corps du texte doivent être justifiés
from docx.enum.text import WD_ALIGN_PARAGRAPH
assert any(p.alignment == WD_ALIGN_PARAGRAPH.JUSTIFY for p in nouveau_document.paragraphs)
# ④ Guillemets chinois appariés
assert sum(p.text.count('“') for p in nouveau_document.paragraphs) == \
sum(p.text.count('”') for p in nouveau_document.paragraphs)
⚠️ Règle absolue de la plus haute priorité (enfreindre = refaire)
- « Une ligne vide » ne peut être créée qu'avec un espacement de paragraphe (
space_before/space_after), jamais avec un retour à la ligne/paragraphe vide. - N'ajoutez JAMAIS de gras au texte, sauf si l'utilisateur l'a explicitement demandé dans ses besoins.
Le
**texte**de Markdown est une marque d'emphase dans le fichier source, pas une instruction de mise en page — retirez la marque, n'ajoutez pas de gras. - Les formats non demandés par l'utilisateur (souligné, point d'insistance, texte coloré, espacement des lettres, échelle de caractères) ne sont jamais modifiés.
- Si l'utilisateur a modifié manuellement le document, interdiction de régénérer l'intégralité du document, lisez uniquement le fichier existant et modifiez le XML sur place.
La hiérarchie ne peut provenir que de la police (SimHei/SimKai), de la taille, de l'espacement avant/après le paragraphe et de la numérotation — pas du gras.
Précautions lors de la simplification du texte du corps/tableau
Lors de la simplification du contenu textuel d'un DOCX déjà modifié par l'utilisateur :
Règle absolue : fusionner l'écriture dans le premier run, vider les autres
def set_paragraph_text(p, text):
runs = p.runs
if not runs: return False
runs[0].text = text # Le format est conservé dans le premier run
for r in runs[1:]:
r.text = '' # ⚠️ Doit être vidé, sinon l'ancien texte persiste et cause des répétitions
return True
Respectez également le principe de « ne pas reconstruire les runs » — ne modifiez que run.text, ne supprimez/créez jamais <w:r>.
Localisation du tableau : par ordre de flux du document, pas avec l'index doc.paragraphs
Lorsque des tableaux sont intégrés dans des paragraphes, l'index doc.paragraphs[i] ne correspond pas à l'ordre des tableaux. Vous devez parcourir le flux du 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)))
Deux pièges lors de l'ajout/suppression de lignes
table.rowsanalyse le XML à chaque accès — après avoir supprimé une ligne, utiliserrows[ri]provoquera un débordement. Prenez d'abord un instantanérows = list(table.rows), supprimez en commençant par la fin.- La ligne clonée perd la bordure inférieure de la dernière ligne — après avoir cloné la dernière ligne pour la compléter, la ligne de base du tableau à trois lignes devient
nil. À la fin, vous devez rétablir la bordurebottom(sz=12) de la dernière ligne.
⚠️ Lors de la détection des bordures, ne vérifiez pas seulement none
Dans les propres tableaux à trois lignes de Word, les lignes intermédiaires utilisent w:val="nil" et non "none".
Pour déterminer « sans ligne », acceptez les deux :
assert tv in ('nil','none') and bv in ('nil','none')
Ne vérifier que none entraînera un faux positif « bordure endommagée », alors que la logique de détection est erronée.