Skip to content

Font Optimization: Before & After

Implementation log for font subsetting, format support, and readable font names. Branch: font-optimization across Creative-Composer, Creative-Engine, Application-Frontend.

Summary

Custom fonts uploaded to creatives are now automatically subset and embedded as WOFF2 data URIs at build time. This eliminates separate font CDN requests, reduces memory usage by up to 99%, and prevents ad-verifier blocking.

Measured Results (Ford Puma creative, 5 custom fonts)

Published Bundle

MetricBeforeAfterChange
Script (gzip)81.46 kB158.11 kB+76.65 kB
Script (decompressed)286.67 kB398.94 kB+112.27 kB
Font transfer292 kB (5 requests)0 (embedded)-292 kB
Font memory8.1 MB (decompressed)~112 kB (subset)-7.99 MB
Total transfer~373 kB~158 kB-58%
Total memory~8.4 MB~399 kB-95%
Font requests50-5
Build time3s3sno change

How to verify

  1. Publish a creative with custom fonts
  2. Check backend build log -- look for optimizing fonts and optimized N font(s)
  3. Open DevTools Page Weight -- no "font" category in Published Bundle
  4. In the creative iframe, inspect <head> -- @font-face rules should use data:font/woff2;base64,...
  5. Check Network tab in delivery/standalone preview -- no CDN font requests

Where to find build output

Backend stores builds at:

Application-Backend/tmp/creatives/assets/creatives/{id}/{buildHash}/
  creative.json    -- input creative JSON (unchanged, still has CDN URLs)
  log.txt          -- build log with optimization messages
  built/assets/
    creative-engine.js  -- contains embedded font data URIs
    stub.js             -- tag stub

Changes by Repo

Creative-Composer

src/font-optimizer/index.ts (rewritten)

Before: Had optimizable() and extractCharset() but was never called from the build pipeline. optimizable() returned false for any creative with Tag blocks or free-text inputs, which meant most creatives were skipped. Only extracted payload.text. Download used wget-improved which hung on large files.

After:

FunctionPurpose
canExtractPreciseCharset()Returns false for Tag/free-text, but we still optimize with safety net
extractCharset()Covers text, inputPlaceholder, validationText, consent, link.name, ad label, creative name
findFontUrls(creativeBlocks)Walks block tree, finds all unique custom font URLs, returns Map<url, blockPaths[]>
findHtmlBlockFontUrls(creativeBlocks)Scans HTML block html strings for font file URLs (.woff2/.woff/.ttf/.otf), returns Map<url, blockNames[]>
optimizeFont(url, charset)Downloads font, subsets to charset, returns base64 WOFF2
optimizeFonts(remappedJson)Orchestrator: finds fonts in both customFont properties AND HTML blocks, subsets each, replaces CDN URLs with data URIs
download(url, dest)Uses native fetch + Buffer instead of wget-improved

Key design decisions:

  • Data URI approach: Replaces CDN URLs directly in the remapped JSON before build. Engine loads data URIs via existing @font-face mechanism without changes.
  • Latin safety net: Always includes ASCII + Latin-1 Supplement + common typographic characters (~200 glyphs). Used as the full charset when precise extraction is not possible (Tag blocks, free-text inputs).
  • Per-font deduplication: Same font URL used on multiple blocks is downloaded and subset once, then the data URI is applied to all blocks. If the same font appears in both a customFont property and an HTML block, it's downloaded once.
  • HTML block coverage: Font URLs inside <style> tags in HTML blocks (e.g. @font-face rules with CDN url() references) are discovered and replaced with data URIs in the HTML string.
  • Graceful failure: If optimization fails for one font, it keeps the CDN URL and continues.

src/bin/commands/job.ts

Before:

remapping -> profile -> fileposer -> save JSON -> build

After:

remapping -> profile -> fileposer -> optimizeFonts() -> save JSON -> build

Added between fileposer and save:

typescript
log.info('optimizing fonts')
const fontsOptimized = await optimizeFonts(remappedJson)
if (fontsOptimized > 0) {
  log.info(`optimized ${fontsOptimized} font(s) -- subset to WOFF2 data URIs`)
}

src/bin/commands/job/fontopt.ts

Old single-font wrapper. Not used by the new pipeline but updated optimizable -> canExtractPreciseCharset reference so the project compiles.

Build step required

Composer runs from compiled dist/, not TypeScript source. After changing source files, run npm run build to compile. This was the cause of the initial "optimization not running" issue during testing.

Creative-Engine

src/styles/components/helpers.ts

generateCustomFontFamily(fontName)

Before (pseudo-hash):

typescript
// Produced unreadable names like "eoq_Poppins_Regular_woff"
// ~50 lines of character manipulation

After:

typescript
export const generateCustomFontFamily = (fontName: string): string => {
  return fontName.replace(/\.(woff2?|ttf|otf)$/i, '')
}
// "Poppins Regular.woff2" -> "Poppins Regular"

This makes custom fonts usable in HTML blocks via font-family: 'Poppins Regular' since the @font-face rule in the iframe head uses the readable name.

getFontFormat(url)

Before: Only matched .woff / .woff2 file extensions.

After: Also detects data URIs:

typescript
export const getFontFormat = (url: string): string | null => {
  if (url.startsWith('data:font/woff2')) {
    return 'woff2'
  }
  const match = url.match(/\.(woff2?)(?:\?|$)/i) || url.match(/woff2?$/)
  return match ? match[1] || match[0] : null
}

src/style-engine/helpers/FontHelper.ts (unchanged)

loadCustomFont() creates @font-face rules with url('${url}') format('${format}'). Data URIs work here without changes because:

  • Base64 contains no single quotes (no escaping issues)
  • getFontFormat() returns 'woff2' for data URIs
  • Browser natively supports data URIs in @font-face src

Application-Frontend

TypefaceSection.vue

One-liner: changed file upload accept filter.

Before: ['.woff', '.woff2'] After: ['.woff', '.woff2', '.ttf', '.otf']

Backend already accepts these formats. Composer converts to WOFF2 at build time regardless of input format.

Tests

Creative-Composer (ava) -- 23 tests

src/font-optimizer/index.spec.ts:

  • canExtractPreciseCharset: text-only, Tag blocks, free-text, numbers-only, null slots
  • extractCharset: text, inputPlaceholder, validationText, consent fields, link names, ad label, number literals, Latin safety net
  • findFontUrls: top-level blocks, sub-blocks, deduplication, non-custom ignored, empty map
  • findHtmlBlockFontUrls: @font-face URLs, multiple formats, cross-block dedup, non-HTML ignored, no font URLs, OTF support

Creative-Engine (vitest) -- 12 tests

tests/unit/fontHelpers.test.ts:

  • generateCustomFontFamily: .woff2, .woff, .ttf, .otf, spaces, case-insensitive, no extension
  • getFontFormat: woff2 URL, woff URL, query params, data URI, unknown format

Not yet implemented

Part C: Google Fonts expansion (CAV-29)

Replace the hardcoded 11-font dropdown with a searchable Google Fonts catalog. Separate feature, not part of this branch.

HTML block external stylesheets/scripts

HTML blocks can also load external stylesheets (<link>) and scripts (<script src>) that may themselves load fonts. The Ford Puma creative loads 29 scripts (2.9 MB) and 19 stylesheets (1.6 MB) this way. These are not covered by font optimization. Addressing this would require deeper HTML parsing or restricting what HTML blocks can load.

Architecture: How fonts flow through the system

Upload (AF)                    Build (Composer)                 Runtime (Engine)
-----------                    ----------------                 ----------------
User uploads .woff2/.ttf  -->  Download from CDN           -->  @font-face created
Stored in CDN                  Subset to used chars              with data URI
URL saved in creative JSON     Convert to WOFF2                  Font rendered
                               Encode as base64                  No CDN request
                               Replace URL with data URI
                               Bake into built script

The upload/storage side is unchanged. Optimization is purely a build-time step. This is compatible with any future asset library or font management system.

Internal documentation