DEV Community

Love-Ash
Love-Ash

Posted on

How PowerPoint actually picks a font for CJK text (I had to pixel-diff renders to find out)

I build a lot of .pptx files with LLM agents and python-pptx. The most damaging defect I kept shipping was invisible in code review: CJK text silently rendering in the wrong font. No error, no warning. The file opens fine. The text is just... not in the font anyone chose.

This post is about the day I stopped guessing how PowerPoint resolves fonts and measured it instead, and about the open-source linter that came out of it.

The question that has no documented answer

OOXML (ECMA-376) gives every text run three font slots: a:latin, a:ea (East Asian), and a:cs (complex script). On top of that sits an inheritance stack: paragraph default properties (defRPr), list styles on the shape, the layout placeholder, the master placeholder, the master's txStyles, the presentation's defaultTextStyle, and finally the theme's fontScheme.

The spec defines all of these slots and layers. What it does not define is precedence when they conflict. Concretely:

  • A run has only a:latin (a Latin-only face), and the theme's ea slot holds a CJK font. Which one draws the CJK glyphs?
  • python-pptx's font.name writes only a:latin. So font.name = "Malgun Gothic" sometimes works and sometimes does nothing. When, exactly?
  • A master placeholder's lstStyle carries an a:ea. Is that actually inherited at render time, or is the chain decorative?

Blog posts and Stack Overflow answers contradict each other on all three. If you build a linter on guesses, you get false positives and false negatives at the same time, which is worse than no linter.

Asking the renderer directly

The method is crude but decisive:

  1. For each property combination, build a probe deck. Same CJK sentence, but one candidate font is a serif (Batang) and the other is a sans-serif (Malgun Gothic).
  2. Render each slide to PNG through PowerPoint's COM automation on Windows.
  3. Align the ink regions and diff pixels. Serif and sans strokes differ enough that the winner is unambiguous.

Seven rounds of this, changing one variable at a time, produced the actual resolution order:

run a:ea
> paragraph pPr/defRPr ea
> lstStyle inheritance chain (shape > layout placeholder > master placeholder
                              > master txStyles > presentation defaultTextStyle)
> theme ea slot (majorFont for title placeholders, minorFont otherwise;
                 a NON-EMPTY theme ea beats the run's own a:latin)
> run a:latin (only reached when the theme ea slot is an empty string)
> OS fallback (Malgun Gothic on Windows)
Enter fullscreen mode Exit fullscreen mode

Three results genuinely surprised me:

A non-empty theme ea beats the run's own a:latin. This single line explains the python-pptx mystery. font.name = "Malgun Gothic" works when the theme's ea slot is empty (the default Office theme ships it empty), and silently loses when any theme sets it. Nobody chose the rendered font; the theme did.

The inheritance chain is real. An a:ea buried in a master placeholder's lstStyle is actually inherited and rendered. You cannot skip layers when resolving.

The renderer is the spec. As a side discovery: put an lstStyle next to spPr on a plain text box (schema-valid as far as I can tell) and PowerPoint refuses to open the file at all. Reading the spec tells you what CAN be written; only the renderer tells you what it MEANS.

The full measurement log, round by round, is in the repo's docs/CALIBRATION.md. Scope note: the target renderer is PowerPoint for Windows. Mac, web, and LibreOffice can resolve differently, and the linter says so instead of pretending otherwise.

From measurement to gate

That measured model became the E1 gate of archforge, an MIT-licensed preflight linter for built .pptx files. It reads the XML directly and resolves the chain above, so it needs no PowerPoint install and runs in CI or inside an agent loop. If a CJK run's effective font has no CJK glyphs, it exits 1 with a machine-readable finding that carries the shape id and absolute bbox, so an agent can patch the exact run instead of grepping for text.

archforge scanning a deck that PowerPoint opens without complaint

The same measure-first approach produced the other gates: tracking damage on CJK runs, effective size below 5pt after autofit and the full inheritance chain, text-frame collisions and off-canvas overflow from approximated glyph geometry, and (opt-in) the AI-generation tells like em-dash punctuation. Geometry thresholds were calibrated against renders of real decks, and the repo ships a public regression corpus where every expected finding is enforced in CI. The corpus doc is blunt about what small samples do and do not prove.

Since 0.8.1 there is also a deterministic fixer for the three defects that need zero layout judgment:

pip install archforge
archforge demo                          # builds a broken deck + its fixed twin, lints both
archforge deck.pptx --profile full --fail-incomplete --json
archforge fix deck.pptx -o fixed.pptx   # font slot, tracking, dash punctuation
archforge deck.pptx --html report.html  # per-slide wireframes with finding boxes
Enter fullscreen mode Exit fullscreen mode

On the demo deck, fix clears 3 of the 4 blockers. The fourth is 4pt text, which is a design decision, so it stays a human (or agent) problem. There is an Agent Skills pack in the wheel (archforge skill --install) that teaches the whole build-lint-fix loop to Claude Code and friends.

One more humbling detail: while writing geometry tests I found that a namespace mixup in my own code had made group transforms silent no-ops for weeks. The linter needed linting. That bug is now a permanent regression test, which is the general policy: confirmed false positives become fixtures that can never regress.

The takeaway

Whenever a renderer sits between a file format and reality, there is a gap between what the spec says and what happens on screen. The only way I found to close it: build probes, make the renderer draw them, and count pixels. It is tedious, and it is the only kind of rule I would let block a deploy.

If archforge flags something on your deck that renders fine, that false positive is the most useful thing you can send: github.com/Love-Ash/archforge.

Top comments (0)