Set Fonts and Styles with python-docx
run.font.name = "Arial" runs without error, you save the file, open it in Word — and the font is still Calibri. Or you set a Chinese font name and the CJK characters still render in the default fallback face. Both symptoms share the same root cause: run-level formatting only wins when no style higher in the inheritance chain explicitly overrides it, and the w:eastAsia XML attribute that controls East-Asian character rendering is not exposed through the python-docx high-level API.
This guide explains the root cause, provides a minimal diagnostic to confirm it, then shows four targeted fixes: run-level font properties for Latin text, the w:eastAsia oxml workaround for CJK characters, defining a reusable named character style, and modifying the Normal paragraph style's font and spacing globally.
Root Cause
Word resolves the final rendered font through a precedence chain:
- Run direct formatting — properties set on a
Runobject (run.font.name,run.bold, etc.) - Character style — a named character style applied to the run (
run.style = ...) - Paragraph style — the style of the paragraph the run belongs to (
Normal,Body Text, etc.) - Document defaults — the document's default font and size in
word/settings.xml
Setting run.font.name = "Arial" writes w:ascii="Arial" and w:hAnsi="Arial" to the run's <w:rFonts> element. This correctly overrides the style chain for the Latin character range. However:
- If you set
run.font.namebut the paragraph style defines the same font explicitly, some renderers still show the style value (the run direct-formatting flag must be set, not just the value). - The
w:eastAsiaattribute — which Word uses for Han, Hiragana, Katakana, Hangul, and related ranges — is never written by the python-docx API when you assignrun.font.name. So CJK characters always fall through to the style or document default, regardless of what you put inrun.font.name.
Minimal Diagnostic
Run this to see exactly what XML python-docx produces for a run with font.name assigned:
# pip install python-docx lxml
from pathlib import Path
from docx import Document
from lxml import etree
OUTPUT = Path("output/diag_fonts.docx")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
doc = Document()
para = doc.add_paragraph()
run = para.add_run("Hello, 世界 — Latin and CJK mixed")
run.font.name = "Noto Sans SC" # intended CJK font
run.font.size = __import__("docx.shared", fromlist=["Pt"]).Pt(12)
doc.save(OUTPUT)
# Re-open and inspect the raw XML of the first run
doc2 = Document(OUTPUT)
first_r = doc2.paragraphs[0].runs[0]
print(etree.tostring(first_r._r, pretty_print=True).decode())
Expected (broken) output — notice w:eastAsia is absent:
<w:r xmlns:w="...">
<w:rPr>
<w:rFonts w:ascii="Noto Sans SC" w:hAnsi="Noto Sans SC"/>
<w:sz w:val="24"/>
<w:szCs w:val="24"/>
</w:rPr>
<w:t xml:space="preserve">Hello, 世界 — Latin and CJK mixed</w:t>
</w:r>
w:ascii and w:hAnsi are set, but w:eastAsia is missing. Word falls back to its default CJK face (SimSun on Windows, Heiti SC on macOS) for the CJK codepoints.
Fix 1 — Run-Level Font Properties (Latin Text)
For Latin scripts, setting run.font.name and run.font.size is sufficient as long as the paragraph's named style does not also set an explicit font. Add bold, italic, and color on the same object:
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.shared import Pt, RGBColor
OUTPUT = Path("output/word/styled_run.docx")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
doc = Document()
para = doc.add_paragraph()
run = para.add_run("Critical notice — Latin text only")
run.font.name = "Georgia" # Latin typeface
run.font.size = Pt(13) # explicit size in points
run.bold = True # shortcut for run.font.bold = True
run.font.italic = True
run.font.color.rgb = RGBColor(0xC0, 0x39, 0x2B) # dark red (hex RGB)
try:
doc.save(OUTPUT)
print(f"Saved: {OUTPUT}")
except OSError as exc:
print(f"Save failed: {exc}")
run.bold and run.font.bold are equivalent. Prefer run.font.bold for consistency with other font properties. Setting a value to None explicitly resets it to inherit from the style chain; setting False explicitly suppresses bold even if the style requests it.
Fix 2 — East-Asian Font via oxml (the w:eastAsia Workaround)
python-docx has no run.font.east_asian_name property. Set it by reaching into the underlying lxml element directly:
# pip install python-docx lxml
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Pt
OUTPUT = Path("output/word/cjk_font.docx")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
CJK_FONT = "Noto Sans SC"
LATIN_FONT = "Arial"
doc = Document()
para = doc.add_paragraph()
run = para.add_run("Hello, 世界 — mixed Latin and CJK text.")
# Set the Latin/ASCII font via the high-level API
run.font.name = LATIN_FONT
run.font.size = Pt(12)
# Add w:eastAsia to the rFonts element — the missing step
r_pr = run._r.get_or_add_rPr() # <w:rPr> element, created if absent
r_fonts = r_pr.get_or_add_rFonts() # <w:rFonts> element, created if absent
r_fonts.set(qn("w:eastAsia"), CJK_FONT) # write the attribute python-docx omits
try:
doc.save(OUTPUT)
print(f"Saved: {OUTPUT}")
except OSError as exc:
print(f"Save failed: {exc}")
After this fix, the XML for the run looks like:
<w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="Noto Sans SC"/>
Word now selects "Noto Sans SC" for the CJK codepoints (U+4E00–U+9FFF and related ranges) and "Arial" for the Latin characters. Both fonts must be installed on the machine that opens the document, or Word will substitute silently.
A reusable helper to apply this pattern to any run:
# pip install python-docx
from docx.oxml.ns import qn
def set_run_fonts(run, latin: str, cjk: str | None = None) -> None:
"""Set latin font (and optionally CJK font) on a run."""
run.font.name = latin
if cjk:
rpr = run._r.get_or_add_rPr()
rfonts = rpr.get_or_add_rFonts()
rfonts.set(qn("w:eastAsia"), cjk)
Fix 3 — Define a Named Character Style
Applying direct formatting run-by-run is verbose and fragile at scale. Define a named character style once on the document and apply it by name:
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Pt, RGBColor
from docx.enum.style import WD_STYLE_TYPE
OUTPUT = Path("output/word/named_style.docx")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
doc = Document()
# Create a character style
char_style = doc.styles.add_style("BrandHighlight", WD_STYLE_TYPE.CHARACTER)
char_style.font.name = "Trebuchet MS"
char_style.font.size = Pt(12)
char_style.font.bold = True
char_style.font.color.rgb = RGBColor(0x1D, 0x4E, 0xD8) # brand blue
# Add East-Asian coverage to the style via oxml
rpr = char_style.element.get_or_add_rPr()
rfonts = rpr.get_or_add_rFonts()
rfonts.set(qn("w:eastAsia"), "Noto Sans SC")
# Apply by name to any run
para = doc.add_paragraph("Total revenue: ")
highlight = para.add_run("$1.4 M")
highlight.style = doc.styles["BrandHighlight"] # apply the character style
para.add_run(" — up 12 % year-over-year.")
try:
doc.save(OUTPUT)
print(f"Saved: {OUTPUT}")
except OSError as exc:
print(f"Save failed: {exc}")
Named styles are stored in word/styles.xml and travel with the document. When a user opens the document in Word and modifies the BrandHighlight style definition, every run that references it updates automatically — a key advantage over run-level direct formatting.
Fix 4 — Modify the Normal Style and Paragraph Spacing
If every paragraph in the document should use a different font or line spacing baseline, override the Normal paragraph style globally. This avoids setting properties on every individual paragraph or run:
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.shared import Pt
from docx.oxml.ns import qn
OUTPUT = Path("output/word/normal_override.docx")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
doc = Document()
normal_style = doc.styles["Normal"]
# Change the default font for the whole document
normal_style.font.name = "Source Sans Pro"
normal_style.font.size = Pt(11)
# Apply East-Asian font to the Normal style
rpr = normal_style.element.get_or_add_rPr()
rfonts = rpr.get_or_add_rFonts()
rfonts.set(qn("w:eastAsia"), "Noto Sans SC")
# Remove default space-after so paragraphs don't balloon with extra whitespace
pf = normal_style.paragraph_format
pf.space_after = Pt(0)
pf.space_before = Pt(0)
pf.line_spacing = Pt(15) # 15pt leading for 11pt body text
doc.add_paragraph("This paragraph inherits the new Normal style baseline.")
doc.add_paragraph("So does this one — no per-run overrides needed.")
try:
doc.save(OUTPUT)
print(f"Saved: {OUTPUT}")
except OSError as exc:
print(f"Save failed: {exc}")
Modifying Normal cascades to every paragraph that inherits from it. Custom styles that explicitly define their own font (Heading 1, Body Text, etc.) are unaffected. To change Heading 1's font independently:
h1 = doc.styles["Heading 1"]
h1.font.name = "Montserrat"
h1.font.size = Pt(18)
h1.font.bold = True
Variant — Stripping Direct Formatting From an Existing Document
When a document acquired from an external source has stale run-level font overrides that resist style updates, clear them:
# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
PATH = Path("output/word/existing.docx")
try:
doc = Document(PATH)
except FileNotFoundError:
raise SystemExit(f"File not found: {PATH}")
for para in doc.paragraphs:
for run in para.runs:
rpr = run._r.find(qn("w:rPr"))
if rpr is not None:
# Remove direct font specification — runs will now inherit the style
for rfonts in rpr.findall(qn("w:rFonts")):
rpr.remove(rfonts)
for sz in rpr.findall(qn("w:sz")):
rpr.remove(sz)
doc.save(PATH)
print("Direct font overrides cleared.")
Use this when a style update at the document level is not flowing through to runs that were formatted individually.
Verification
Confirm the font attributes are written correctly by re-opening the file and printing the XML:
# pip install python-docx lxml
from pathlib import Path
from docx import Document
from lxml import etree
def check_run_fonts(path: Path) -> None:
"""Print the w:rFonts attributes for every run in the document."""
doc = Document(path)
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
for i, para in enumerate(doc.paragraphs):
for j, run in enumerate(para.runs):
rpr = run._r.find(f"{{{W}}}rPr")
if rpr is None:
continue
rf = rpr.find(f"{{{W}}}rFonts")
if rf is not None:
attrs = {k.split("}")[-1]: v for k, v in rf.attrib.items()}
print(f"Para {i}, Run {j}: {attrs}")
check_run_fonts(Path("output/word/cjk_font.docx"))
For Fix 2 the output should be:
Para 0, Run 0: {'ascii': 'Arial', 'hAnsi': 'Arial', 'eastAsia': 'Noto Sans SC'}
If eastAsia is missing, the r_fonts.set(qn("w:eastAsia"), ...) line did not execute. Add a print(etree.tostring(r_fonts, ...)) immediately after it to confirm the attribute was written before doc.save() is called.
Related
- Automating Word Document Creation — full guide to paragraphs, headings, tables, and page breaks with python-docx
- Dynamic Mail Merge with Python — Jinja2 template rendering for batch document generation where styles are set in the template file
- Inserting Images into Word Documents — embed images with correct DPI and sizing alongside styled text
Part of Automating Word Document Creation.