Skip to content

Cross-Platform Font Rendering: Windows vs Mac

Problem

Custom fonts (especially SemiBold weights) render noticeably thicker and slightly blurry on Windows compared to Mac. This is a well-documented, fundamental difference between the two operating systems' font rendering engines.

Example case: Ciutadella Rounded SemiBold looked correct on Mac but too thick/blurry on Windows in a conversation creative (creative 95243).

Screenshots

  • Mac: Thin, crisp, clean text
  • Windows: Thicker strokes, slightly blurry, font appears bolder than intended

Root Cause

Mac (Core Text) and Windows (DirectWrite/ClearType) use fundamentally different font rasterizers at the OS level. No CSS property can override this.

Additionally, the Creative Engine applies -webkit-font-smoothing: antialiased globally (in globalReset.ts), which makes fonts render thinner on Mac. This property has no effect on Windows. So part of the perceived difference is that Mac renders unusually thin, not just that Windows renders too thick.

What We Tried (and what didn't work)

CSS-only approaches (no visible effect)

css
text-rendering: geometricPrecision !important;
font-synthesis: none !important;

These were tested on Windows with correct selectors and made no visible difference. text-rendering: geometricPrecision affects glyph hinting but does not override ClearType's stroke thickening. font-synthesis: none prevents synthetic bolding but wasn't the issue here (no faux bold was occurring).

Wrong CSS selectors (never applied)

css
/* WRONG: .windows class does not exist in the engine */
.windows .m5 { font-family: ... }

/* WRONG: .message/.choice are correct class names, but #creative-container
   is INSIDE <html>, not outside it */
#creative-container .is-windows .message { ... }

The Creative Engine does NOT add OS-detection classes (.windows, .mac, etc.) to any element. Only iOS detection exists (src/utils/isIos.ts), used internally for event handling, not styling.

Working Solution: OS-detected font swap

Use JavaScript to detect Windows and swap to a lighter font weight (e.g., Medium instead of SemiBold). Medium on Windows renders visually similar to SemiBold on Mac.

Step 1: Add OS detection script

In the first flow operator (as a script action), or in an HTML block:

js
(() => {
  if (navigator.userAgent.indexOf('Windows') > -1) {
    document.documentElement.classList.add('is-windows')
  }
})()

This adds is-windows to the <html> element inside the creative's iframe.

Step 2: CSS font swap in an HTML block

css
.is-windows .message,
.is-windows .choice {
  font-family: 'HASHED_MEDIUM_FONT_NAME' !important;
}

Step 3: Get the correct font-family hash

The engine generates obfuscated font-family names via generateCustomFontFamily() in src/styles/components/helpers.ts. To get the exact hash:

  1. Open the creative in the builder
  2. Temporarily set the Medium font as the custom font on any block (e.g., messageProperties)
  3. Click the copy button next to the font name in TypefaceSection -- this copies the full font-family: 'hashedName'; rule to clipboard
  4. Restore the original SemiBold font
  5. Use the copied hash in the CSS override

Selector gotcha

The is-windows class is on <html> (documentElement), which is an ancestor of #creative-container. So selectors must have .is-windows BEFORE #creative-container, or omit #creative-container entirely:

css
/* Correct */
.is-windows .message { ... }
.is-windows #creative-container .message { ... }

/* WRONG -- is-windows is not inside creative-container */
#creative-container .is-windows .message { ... }

Engine Architecture Notes

Font loading (FontHelper.ts)

The @font-face declaration does NOT include a font-weight descriptor:

css
@font-face {
  font-family: 'hashedName';
  src: url('...') format('woff');
  /* no font-weight -- defaults to 400/normal */
}

This means all custom fonts are registered at weight 400 regardless of the actual font file's weight. Element styles also use fontWeight: "normal" (400), so no faux bolding occurs -- the mismatch is purely visual due to OS rendering.

No OS detection in engine

The engine only detects iOS (src/utils/isIos.ts) for event handling. There is no Windows/Mac detection, and no OS-specific CSS classes are added to the DOM. This means OS-specific styling must be handled manually via customCSS or HTML blocks as described above.

Class name obfuscation

In published (built) creatives, class names may be obfuscated by obfuscator.ts. The abbreviated class names (m1, c1, l1, etc.) are generated by abbrevName() in StyleAndClassNameGenerationMixin.ts:

  • message -> m1, m2, etc.
  • choice -> c1, c2, etc.
  • response -> r1, r2, etc.
  • link -> l1, l2, etc.
  • conversation -> co1, etc.

Raw <style> tags in HTML blocks are NOT processed by the obfuscator, so selectors in HTML block styles must use the non-obfuscated names (.message, .choice) which may not match in obfuscated builds. The short names (.m5, .c1) seem to survive in practice.

Potential Engine Improvements

  1. Add OS detection classes -- Add .os-windows, .os-mac, .os-linux classes to the creative container, enabling users to write OS-specific CSS in customCSS without needing JavaScript hacks. Low effort, high value.

  2. Add font-weight to @font-face -- Set the correct font-weight descriptor based on the font file name (e.g., SemiBold = 600). This wouldn't fix the rendering difference but would be more semantically correct and could prevent faux bolding in edge cases.

  3. Add font-synthesis: none globally -- Safe to add in globalReset.ts. Prevents any accidental synthetic bolding/italics. No visual side effects.

Internal documentation