<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Love-Ash</title>
    <description>The latest articles on DEV Community by Love-Ash (@loveash).</description>
    <link>https://dev.to/loveash</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4025142%2Ff717ccff-ba1a-4e09-b474-9de144cbcdc2.png</url>
      <title>DEV Community: Love-Ash</title>
      <link>https://dev.to/loveash</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/loveash"/>
    <language>en</language>
    <item>
      <title>How PowerPoint actually picks a font for CJK text (I had to pixel-diff renders to find out)</title>
      <dc:creator>Love-Ash</dc:creator>
      <pubDate>Sat, 11 Jul 2026 12:07:37 +0000</pubDate>
      <link>https://dev.to/loveash/how-powerpoint-actually-picks-a-font-for-cjk-text-i-had-to-pixel-diff-renders-to-find-out-3ajj</link>
      <guid>https://dev.to/loveash/how-powerpoint-actually-picks-a-font-for-cjk-text-i-had-to-pixel-diff-renders-to-find-out-3ajj</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question that has no documented answer
&lt;/h2&gt;

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

&lt;p&gt;The spec defines all of these slots and layers. What it does not define is precedence when they conflict. Concretely:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Asking the renderer directly
&lt;/h2&gt;

&lt;p&gt;The method is crude but decisive:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;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).&lt;/li&gt;
&lt;li&gt;Render each slide to PNG through PowerPoint's COM automation on Windows.&lt;/li&gt;
&lt;li&gt;Align the ink regions and diff pixels. Serif and sans strokes differ enough that the winner is unambiguous.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Seven rounds of this, changing one variable at a time, produced the actual resolution order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run a:ea
&amp;gt; paragraph pPr/defRPr ea
&amp;gt; lstStyle inheritance chain (shape &amp;gt; layout placeholder &amp;gt; master placeholder
                              &amp;gt; master txStyles &amp;gt; presentation defaultTextStyle)
&amp;gt; theme ea slot (majorFont for title placeholders, minorFont otherwise;
                 a NON-EMPTY theme ea beats the run's own a:latin)
&amp;gt; run a:latin (only reached when the theme ea slot is an empty string)
&amp;gt; OS fallback (Malgun Gothic on Windows)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three results genuinely surprised me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A non-empty theme ea beats the run's own &lt;code&gt;a:latin&lt;/code&gt;.&lt;/strong&gt; This single line explains the python-pptx mystery. &lt;code&gt;font.name = "Malgun Gothic"&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inheritance chain is real.&lt;/strong&gt; An &lt;code&gt;a:ea&lt;/code&gt; buried in a master placeholder's &lt;code&gt;lstStyle&lt;/code&gt; is actually inherited and rendered. You cannot skip layers when resolving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The renderer is the spec.&lt;/strong&gt; As a side discovery: put an &lt;code&gt;lstStyle&lt;/code&gt; next to &lt;code&gt;spPr&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The full measurement log, round by round, is in the repo's &lt;a href="https://github.com/Love-Ash/archforge/blob/main/docs/CALIBRATION.md" rel="noopener noreferrer"&gt;docs/CALIBRATION.md&lt;/a&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  From measurement to gate
&lt;/h2&gt;

&lt;p&gt;That measured model became the E1 gate of &lt;a href="https://github.com/Love-Ash/archforge" rel="noopener noreferrer"&gt;archforge&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fznl3p3i26qesxqflyae8.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fznl3p3i26qesxqflyae8.gif" alt="archforge scanning a deck that PowerPoint opens without complaint" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Since 0.8.1 there is also a deterministic fixer for the three defects that need zero layout judgment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;archforge
archforge demo                          &lt;span class="c"&gt;# builds a broken deck + its fixed twin, lints both&lt;/span&gt;
archforge deck.pptx &lt;span class="nt"&gt;--profile&lt;/span&gt; full &lt;span class="nt"&gt;--fail-incomplete&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt;
archforge fix deck.pptx &lt;span class="nt"&gt;-o&lt;/span&gt; fixed.pptx   &lt;span class="c"&gt;# font slot, tracking, dash punctuation&lt;/span&gt;
archforge deck.pptx &lt;span class="nt"&gt;--html&lt;/span&gt; report.html  &lt;span class="c"&gt;# per-slide wireframes with finding boxes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the demo deck, &lt;code&gt;fix&lt;/code&gt; 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 (&lt;code&gt;archforge skill --install&lt;/code&gt;) that teaches the whole build-lint-fix loop to Claude Code and friends.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If archforge flags something on your deck that renders fine, that false positive is the most useful thing you can send: &lt;a href="https://github.com/Love-Ash/archforge" rel="noopener noreferrer"&gt;github.com/Love-Ash/archforge&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
