Appearance
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
| Metric | Before | After | Change |
|---|---|---|---|
| Script (gzip) | 81.46 kB | 158.11 kB | +76.65 kB |
| Script (decompressed) | 286.67 kB | 398.94 kB | +112.27 kB |
| Font transfer | 292 kB (5 requests) | 0 (embedded) | -292 kB |
| Font memory | 8.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 requests | 5 | 0 | -5 |
| Build time | 3s | 3s | no change |
How to verify
- Publish a creative with custom fonts
- Check backend build log -- look for
optimizing fontsandoptimized N font(s) - Open DevTools Page Weight -- no "font" category in Published Bundle
- In the creative iframe, inspect
<head>--@font-facerules should usedata:font/woff2;base64,... - 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 stubChanges 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:
| Function | Purpose |
|---|---|
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-facerules with CDNurl()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 -> buildAfter:
remapping -> profile -> fileposer -> optimizeFonts() -> save JSON -> buildAdded 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 manipulationAfter:
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 slotsextractCharset: text, inputPlaceholder, validationText, consent fields, link names, ad label, number literals, Latin safety netfindFontUrls: top-level blocks, sub-blocks, deduplication, non-custom ignored, empty mapfindHtmlBlockFontUrls: @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 extensiongetFontFormat: 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 scriptThe upload/storage side is unchanged. Optimization is purely a build-time step. This is compatible with any future asset library or font management system.