Academic Paper DOCX Format Restyler
When to Use
- When you need to convert a Markdown analysis report or table into a DOCX file that conforms to journal submission guidelines.
- When you need to batch-apply mixed Chinese-Western fonts, three-line tables, and multi-level heading font sizes and spacing.
- When paragraph-by-paragraph insertion APIs cannot control three-line table borders, run-level
rFonts, or italic splitting—use this skill instead.
Chinese Font Size → Point Value
| Font Size | Points |
|---|---|
| Sanhao (三号) | 16 |
| Xiaosan (小三) | 15 |
| Sihao (四号) | 14 |
| Xiaosi (小四) | 12 |
| Wuhao (五号) | 10.5 |
Default specification (overridable per target journal/school requirements):
- Level 1 heading: Xiaosan SimHei
- Level 2 heading: Sihao SimHei
- Body text: Xiaosi SongTi, 1.5 line spacing
- All Western text: Times New Roman
- Tables: Wuhao SongTi
Key Implementation Points (Lessons Learned)
1. Three-Line Table Borders
The w:sz unit for w:tblBorders is 1/8 of a point:
- Top/Bottom 1.5pt →
sz=12 - Middle line (below header) 1.0pt →
sz=8 - Vertical lines must be explicitly set to
val="none", otherwise Word draws default vertical lines.
The line below the table header must be applied per-cell via row.cells[i] with w:tcBorders/w:bottom; relying solely on insideH in tblBorders is unreliable in some renderers.
2. cell.text = '' Leaves an Empty Unformatted Run
To clear a cell, use:
p = cell.paragraphs[0]
for r in list(p.runs):
r._element.getparent().remove(r._element)
Otherwise runs[0] remains as an empty run without rPr, causing false positives in subsequent font checks.
3. Mixed Chinese-Western Text Requires Setting All 4 Font Slots
rFonts.set(qn('w:ascii'), 'Times New Roman')
rFonts.set(qn('w:hAnsi'), 'Times New Roman')
rFonts.set(qn('w:eastAsia'), 'SimSun') # Chinese font
rFonts.set(qn('w:cs'), 'Times New Roman')
Omitting eastAsia causes Chinese characters to fall back to the default font.
4. Italics Require Run Splitting
Statistics symbols (P<0.001, OR=2.17, CI, β, P-value) must be split into separate runs to apply italic formatting individually.
The regex order matters: match P-value with \bP(\s*值)\b as a whole;
first match P\s*[<>=]\s*[\d.]+, then match isolated P; otherwise P=0.06 would be split into P + =0.06.
5. "Blank Line" = Paragraph Spacing, Not an Empty Paragraph
Never use carriage returns (empty paragraphs) to create a "blank line." Empty paragraphs cause:
- Extra blank lines that are hard to clean up during subsequent editing.
- Unpredictable white space when printing or converting to PDF.
- Combined spacing from
space_before/space_aftervalues getting out of control.
Correct approach: use space_before / space_after (in points):
| Scenario | Implementation |
|---|---|
| Level 1 heading: "one blank line above and below" | space_before = Pt(Xiaosi font size * 1.5), space_after = same value |
| Level 2 heading: "one blank line above" | space_before = Pt(Xiaosi font size * 1.5), space_after = Pt(0) |
| Level 3+ headings | No spacing |
| Body text | space_before = Pt(0), space_after = Pt(0) |
| Blank line between figure/table and body text | Apply space_before/after on the paragraph containing the figure/table, do not insert an empty paragraph |
Reference values: Xiaosi (12pt) blank line ≈ 18pt; Wuhao (10.5pt) blank line ≈ 15.75pt.
Verification method:
sum(1 for p in doc.paragraphs if not p.text.strip()) == 0
— A well-formed document should have zero empty paragraphs.
6. Forbid Automatic Bold
Never apply bold to text unless the user explicitly requests it.
This is a common pitfall: Markdown's **text** is a source emphasis marker, not a formatting instruction.
Rendering it as bold in Word is equivalent to arbitrarily adding emphasis to the original text.
Correct approach:
**text**→ extract only the text content, do not bold.- To create sub-headings, use separate paragraphs with the target font/size, not bold for hierarchy.
- Visual hierarchy should only come from: font (SimHei/KaiTi), font size, paragraph spacing before/after, and numbering.
Also prohibited:
- Italics: do not use except for specified statistics/symbols.
- Underline, emphasis marks, colored text, character scaling/kerning: leave untouched unless requested by the user.
After conversion, self-check:
# Should have no bold runs unless user-requested
bold = [r.text for p in doc.paragraphs for r in p.runs if r.font.bold]
Whether table headers are bold depends on user requirements; this skill defaults to headers not bold (differentiated by font size + three-line table).
7. Level 3 and Below Headings: No Blank Lines
Only level 1 and 2 headings get paragraph spacing (before/after). Level 3 and below: no spacing, no bold.
Scripts
| Script | Purpose |
|---|---|
scripts/tri_line_table.py | Three-line table borders (includes verify_table_borders() self-check) |
scripts/fmt_lib.py | Font/spacing/bold segment parsing/cell style utility library |
scripts/fix_italic.py | Statistical italic splitting (in-place, does not rebuild runs) |
scripts/fix_spacing2.py | Mixed Chinese-English spacing and symbol spacing correction (in-place, does not rebuild runs) |
Dependency: pip install python-docx
fmt_lib.parse_bold_segments(text, allow_bold=False) defaults to discarding but not rendering ** markers;
only pass allow_bold=True when the user explicitly requests bold.
Execution order for formatting corrections on an existing DOCX (order is critical):
python3 scripts/fix_italic.py target.docx # First: split italics
python3 scripts/fix_spacing2.py target.docx # Second: fix spacing
Reversing the order causes spacing rules to incorrectly rejoin already-split symbols.
Verification Checklist (Mandatory After Generation)
- Number of empty paragraphs == 0 — highest priority:
sum(1 for p in doc.paragraphs if not p.text.strip()) - Number of bold runs == 0 (unless requested by user) —
[r.text for p in doc.paragraphs for r in p.runs if r.font.bold]must be empty; also check inside tables - Level 1 headings:
sz==15andeastAsia==SimHei,space_before==space_after==18pt - Level 2 headings:
sz==14,space_before==18pt - Body text:
sz==12,eastAsia==SimSun,line_spacing==1.5 - All runs:
ascii/hAnsi==Times New Roman - Number of italic runs > 0 and content is statistical symbols (this is the only allowed special formatting)
- Three-line tables: verify each table with
verify_table_borders(table); all should return(True, []) - Table title: Wuhao SimHei, centered, no punctuation at the end of the sentence.
- All runs inside tables: SongTi + 10.5pt
- Body paragraph alignment == JUSTIFY; headings and table titles remain unchanged.
- Space before percent sign:
33 %not33% - No space between symbol and Chinese character:
30余次; space between symbol and digit:≥ 10 小时;P < 0.05
One-shot self-check script:
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, 'Empty paragraph exists'
assert not [r for x in ps for r in x.runs if r.font.bold], 'Bold exists'
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], 'Bold exists in tables'
for i, t in enumerate(d.tables, 1):
good, prob = verify_table_borders(t)
assert good, f'Table {i} three-line border non-compliant: {prob}'
print('All checks passed')
When There's No Visual Verification Environment
When LibreOffice/Word is not available to visually verify three-line tables by converting to PDF, use XML-level checks instead (Checklist items 1–9); this reliably covers font size, font, borders, and spacing.
Supplementary: Precise Specification of Three-Line Tables
Only 3 lines:
| Position | Point Value | w:sz |
|---|---|---|
| Header row (top border) | 1.5 | 12 |
| Header row (bottom border) | 1.0 | 8 |
| Last row (bottom border) | 1.5 | 12 |
| All other borders (including all middle rows and vertical lines) | None | val="none" |
Critical: Do not rely solely on insideH in tblBorders. Table-level borders can be overridden by cell-level tcBorders.
Once a cell has a bottom value, the cell-level value takes precedence. Must set per-cell explicitly:
- Set all six items in table-level
tblBorderstonone. - Set all four sides of
tcBordersfor all rows tonone. - Header row cells:
top=('single',12)+bottom=('single',8) - Last row cells:
bottom=('single',12)
clear_row_borders() must be called on every row, not just the header and last row—otherwise middle rows retain residual lines.
Supplementary: Chinese Quotation Mark Replacement
Markdown's " is a straight quotation mark; after conversion to DOCX, it must be replaced in pairs with \u201c / \u201d:
dq_index = 0 # Must maintain a continuous count across runs, not reset per run
for r in p.runs:
for ch in r.text:
if ch == '"':
out.append('\u201c' if dq_index % 2 == 0 else '\u201d')
dq_index += 1
Only modify r.text; do not touch rPr → font/size/italic formatting is preserved.
After replacement, verify: total " count == 0, \u201c count == \u201d count, and paired per paragraph
(paragraph-by-paragraph balance is required, not just document-wide).
Supplementary: Word Editing Produces Fragmented Runs
When users manually edit in Word, paragraphs can be split into multiple runs, some of which (separated -, single characters)
lack rFonts and fall back to default fonts. After fixing quotation marks/borders, scan for this:
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: # → needs patching
Patch by referring to the first run in the paragraph that already has font settings, inheriting its rFonts/sz/b.
Important Workflow Reminder
If a user has manually edited the document in Word, never regenerate the entire document—this would overwrite their changes.
Correct approach: read the existing DOCX → perform targeted in-place XML modifications → save.
Backup before modifying: cp xxx.docx /tmp/backup.docx.
Italic Specification
Unique Criterion: Whether the character is a variable or symbol in mathematics/statistics
| Category | Italic? | Example |
|---|---|---|
| Statistical significance P | Italic | P = 0.04, P < 0.001 |
| Significance level α | Italic | α = 0.05 |
| Test statistics F, Z, H, χ², t, r | Italic | F = 4.52, χ² = 8.31 |
| Special letters (gene symbols, methylation β values, etc.) | Italic | β |
| OR, CI, RR, HR, SD, SE, AUC | Not italic | OR = 3.14, 95% CI |
| Multi-letter abbreviations (BMI, OGTT, FPG…) | Not italic | BMI, OGTT |
| Chinese characters | Not italic | 值, 为 |
⚠️ Most Error-Prone: In "P值", Only P Is Italic
✗ italic 'P值' ← "值" incorrectly italicized
✓ italic 'P' + roman '值'
✓ italic 'P' + roman '值为' + ...
P 值, P值, P 值为 0.07 — always only P is italic; the following Chinese characters are all roman.
Three Golden Rules of Italic Splitting (Learned the Hard Way)
- Never rebuild runs. Using "character stream reassembly" to fix formatting will lose all
rFonts/sz/b, clear three-line table borders, and misalign italic markers into fragments. Only modifyrun.text; never delete or create<w:r>elements. - Splitting must use
copy.deepcopy(run._element)to preserve all formatting attributes; insert position:parent.insert(index+1, el)(immediately adjacent to the original run). Usingappendmoves the copy to the end of the paragraph, causing incorrect ordering (e.g.,PP). Copy text =lead + rest + trail(without stat), original run text =lead + stat. run.text = xdoes not clear existing<w:t>nodes—on adeepcopyelement, it appends. Must firstel.remove(child)to delete allw:t/w:br/w:tab/w:cr; also, removew:i/w:iCsbefore setting text; doing it in reverse order also causes errors.
See example implementation in scripts/fix_italic.py.
Mixed Chinese-English Text and Symbol Spacing
Basic Rules
| Scenario | Rule | Example |
|---|---|---|
| Chinese ↔ digit/letter/punctuation | No space | 在30余次未校正检验的背景下, 但Table 1显示 |
| Chinese ↔ Chinese | No space | 研究目的 |
| Between English words | Add space | Table 1 |
| Inside mathematical expressions | Space between letter/digit and operator | P = 0.04, BMI < 18.5 |
| Mathematical expression ↔ Chinese | Add space | P < 0.05 为阈值 |
Spacing Between Symbols and Digits/Letters/Chinese (Error-Prone)
| Combination | Rule | Example |
|---|---|---|
| Symbol ↔ Digit | Space | 33 %, 87.3 %, P = 0.04 |
| Symbol ↔ English word/letter | Space | Table 1; α = 0.05 |
| Symbol ↔ Chinese character | No space | 30余次, ≥10小时 (symbol adjacent to Chinese character) |
Error-prone point: 30 余次 (wrong, extra space between symbol and Chinese) vs 30余次 (correct).
Operator symbols (≥ ≤ = < >) must be spaced from digits, but no space between the symbol and an immediately following Chinese character.
Percent Sign
33 %: space between digit and %. This is required by scientific paper formatting standards; do not write 33%.
See example implementation in scripts/fix_spacing2.py (includes fix_spacing_in_run).
Body Paragraph Alignment
Body paragraphs use justified alignment — Word's JUSTIFY (Cmd+J / Ctrl+J),
not DISTRIBUTE.
from docx.enum.text import WD_ALIGN_PARAGRAPH
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
Apply only to body paragraphs; keep the original alignment of the following, do not modify:
- All headings—usually left-aligned
- Table titles—centered
- Table cell content—as needed (left-aligned or centered)
⚠️ Three Iron Rules (Execution Order and Verification)
Execution Order
python3 scripts/fix_italic.py target.docx # First: split italics
python3 scripts/fix_spacing2.py target.docx # Second: fix spacing
Verification (Mandatory, Each Step Required)
import re
norm = lambda s: re.sub(r'\s', '', s)
# ① Zero text loss—prevent missing/duplicate characters
assert all(norm(a.text) == norm(b.text)
for a, b in zip(original_doc.paragraphs, new_doc.paragraphs))
# ② Italic runs must contain only statistical symbols themselves (P/α/β/F/Z/H/χ/t/r), must NOT include:
# - operators or digits: [=<>≤≥0-9]
# - Chinese characters ('值' in 'P值' must be roman)
# - abbreviations like OR/CI/RR
for p in new_doc.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'Italic contains operator/digit/Chinese: {t!r}'
assert t.split(' ')[0] not in ('OR','CI','RR','HR','SD','SE','AUC'), f'Abbreviation should not be italic: {t!r}'
# ③ Body paragraphs must be justified
from docx.enum.text import WD_ALIGN_PARAGRAPH
assert any(p.alignment == WD_ALIGN_PARAGRAPH.JUSTIFY for p in new_doc.paragraphs)
# ④ Chinese quotation marks matched in pairs
assert sum(p.text.count('\u201c') for p in new_doc.paragraphs) == \
sum(p.text.count('\u201d') for p in new_doc.paragraphs)
⚠️ Highest Priority Iron Rules (Violation = Rework)
- "Blank line" must only use paragraph spacing (
space_before/space_after), never carriage returns/empty paragraphs. - Never apply bold to text, unless the user explicitly requests bold in their requirements.
Markdown's
**text**is a source emphasis marker, not a formatting instruction—remove the marker only, do not bold. - Formatting not requested by the user (underline, emphasis marks, colored text, char spacing, character scaling) must not be touched.
- If the user has manually edited the document, do not regenerate the entire document; only read the existing file and modify XML in-place.
Visual hierarchy can only come from: font (SimHei/KaiTi), font size, paragraph spacing (before/after), and numbering—not bold.
Precautions When Trimming Text in Body/Tables
When trimming text content in a user-modified DOCX:
Iron Rule: Write Merged Content to the First Run, Clear the Rest
def set_paragraph_text(p, text):
runs = p.runs
if not runs: return False
runs[0].text = text # Formatting retained in the first run
for r in runs[1:]:
r.text = '' # ⚠️ Must clear, otherwise old text remains causing duplication
return True
Follows the "don't rebuild runs" principle—only modify run.text, never delete/create <w:r>.
Locating Tables: Follow Document Stream Order, Don't Use doc.paragraphs Index
When tables are embedded within paragraphs, doc.paragraphs[i] indices do not correspond to table positions. Must traverse the body stream:
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)))
Two Pitfalls When Adding/Deleting Rows
table.rowsre-parses XML on every access — after deleting a row, usingrows[ri]causes an index out-of-bounds error. Take a snapshot first:rows = list(table.rows), then delete from the end backward.- A cloned row loses the last row's bottom border — after cloning the last row to fill, the three-line table's bottom line becomes
nil. Must reapply thebottomborder (sz=12) to the last row at the end.
⚠️ When Detecting Borders, Don't Check Only for none
Word's own three-line tables use w:val="nil" for middle rows, not "none".
To determine "no line," accept both:
assert tv in ('nil','none') and bv in ('nil','none')
Checking only for none produces false positives for "border damage" when the detection logic itself is wrong.