Skip to content

Commit 8f30e01

Browse files
authored
Merge pull request #442 from stefanofa/fix/embed-fonts-iframe-ownerdocument
fix(fonts): scan the capture element's ownerDocument so iframe fonts embed (#441)
2 parents 7366d5a + 2f1add3 commit 8f30e01

3 files changed

Lines changed: 118 additions & 17 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2+
import { embedCustomFonts } from '../src/modules/fonts.js'
3+
import { snapdom } from '../src/api/snapdom.js'
4+
import { cache } from '../src/core/cache.js'
5+
6+
// #441: when the capture root lives inside a same-origin iframe, embedFonts must
7+
// scan the iframe's document for @font-face sources — not the parent document.
8+
9+
const FAMILY = 'IframeProbeFont'
10+
// A data: src passes through embedCustomFonts untouched (no network fetch), so it
11+
// is a stable marker that the @font-face was actually found and inlined.
12+
const FONT_DATA_URL = 'data:font/woff2;base64,AAAA'
13+
14+
function makeRequired(family, weight = '400', style = 'normal', stretchPct = 100) {
15+
return new Set([`${family}__${weight}__${style}__${stretchPct}`])
16+
}
17+
18+
function makeUsedCodepoints(text = 'A') {
19+
const s = new Set()
20+
for (const ch of text) s.add(ch.codePointAt(0))
21+
return s
22+
}
23+
24+
function makeIframeWithFont() {
25+
const iframe = document.createElement('iframe')
26+
iframe.style.cssText = 'width:240px;height:120px;border:0'
27+
document.body.appendChild(iframe)
28+
const doc = iframe.contentDocument
29+
doc.open()
30+
doc.write(
31+
'<!doctype html><html><head><style>' +
32+
`@font-face{font-family:'${FAMILY}';font-style:normal;font-weight:400;` +
33+
`src:url(${FONT_DATA_URL}) format('woff2');}` +
34+
'</style></head><body style="margin:0">' +
35+
`<div id="root" style="font-family:'${FAMILY}',sans-serif;font-size:20px">Hello iframe fonts</div>` +
36+
'</body></html>'
37+
)
38+
doc.close()
39+
return { iframe, doc }
40+
}
41+
42+
describe('embedCustomFonts — same-origin iframe document (#441)', () => {
43+
let iframe
44+
45+
beforeEach(() => {
46+
// The fonts result cache is keyed by required/exclude/etc. (not by document),
47+
// so clear it between cases to keep the parent/iframe runs independent.
48+
cache.font?.clear?.()
49+
cache.resource?.clear?.()
50+
})
51+
52+
afterEach(() => {
53+
if (iframe?.parentNode) iframe.parentNode.removeChild(iframe)
54+
iframe = null
55+
})
56+
57+
it('embeds the iframe @font-face when doc is the iframe document', async () => {
58+
const made = makeIframeWithFont()
59+
iframe = made.iframe
60+
61+
const css = await embedCustomFonts({
62+
required: makeRequired(FAMILY),
63+
usedCodepoints: makeUsedCodepoints('Hello'),
64+
doc: made.doc,
65+
})
66+
67+
expect(css).toContain(FAMILY)
68+
expect(css).toMatch(/data:font\/woff2/)
69+
})
70+
71+
it('finds nothing when scanning the parent document (the pre-#441 behavior)', async () => {
72+
const made = makeIframeWithFont()
73+
iframe = made.iframe
74+
75+
const css = await embedCustomFonts({
76+
required: makeRequired(FAMILY),
77+
usedCodepoints: makeUsedCodepoints('Hello'),
78+
// no `doc` → defaults to the parent `document`, which has no such @font-face
79+
})
80+
81+
expect(css).not.toContain(FAMILY)
82+
})
83+
84+
it('snapdom(root, { embedFonts: true }) inlines the iframe font into the SVG', async () => {
85+
const made = makeIframeWithFont()
86+
iframe = made.iframe
87+
const root = made.doc.getElementById('root')
88+
89+
const url = await snapdom.toRaw(root, { embedFonts: true })
90+
const svg = decodeURIComponent(url.slice(url.indexOf(',') + 1))
91+
92+
expect(svg).toContain('@font-face')
93+
expect(svg).toContain(FAMILY)
94+
})
95+
})

src/core/capture.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,15 @@ export async function captureDOM(element, options) {
157157
if (options.embedFonts) {
158158
await new Promise((resolve) => {
159159
idle(async () => {
160+
// #441: read fonts from the element's own document (same-origin iframe support)
161+
const ownerDoc = state.element.ownerDocument || document
160162
const required = collectUsedFontVariants(state.element)
161163
const usedCodepoints = collectUsedCodepoints(state.element)
162164
if (isSafari()) {
163165
const families = new Set(
164166
Array.from(required).map((k) => String(k).split('__')[0]).filter(Boolean)
165167
)
166-
await ensureFontsReady(families, 1)
168+
await ensureFontsReady(families, 1, ownerDoc)
167169
}
168170
fontsCSS = await embedCustomFonts({
169171
required,
@@ -172,7 +174,8 @@ export async function captureDOM(element, options) {
172174
exclude: state.options.excludeFonts,
173175
localFonts: state.options.localFonts,
174176
useProxy: state.options.useProxy,
175-
fontStylesheetDomains: state.options.fontStylesheetDomains
177+
fontStylesheetDomains: state.options.fontStylesheetDomains,
178+
doc: ownerDoc
176179
})
177180
resolve()
178181
}, { fast })

src/modules/fonts.js

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,7 @@ async function collectFacesFromSheet(sheet, baseHref, emitFace, ctx) {
615615
* @param {Array<{family:string,src:string,weight?:string|number,style?:string,stretchPct?:number}>} [options.localFonts=[]]
616616
* @param {string} [options.useProxy=""]
617617
* @param {string[]} [options.fontStylesheetDomains=[]] // extra domains to fetch cross-origin CSS from (#309)
618+
* @param {Document} [options.doc=document] // document to scan for @font-face sources (the element's ownerDocument for iframe support, #441)
618619
* @returns {Promise<string>} inlined @font-face CSS
619620
*/
620621
export async function embedCustomFonts({
@@ -624,6 +625,7 @@ export async function embedCustomFonts({
624625
localFonts = [],
625626
useProxy = '',
626627
fontStylesheetDomains = [],
628+
doc = document,
627629
} = {}) {
628630
// ---------- Normalize inputs ----------
629631
if (!(required instanceof Set)) required = new Set()
@@ -733,12 +735,12 @@ function faceMatchesRequired(fam, styleSpec, weightSpec, stretchSpec) {
733735
const importUrls = []
734736
const IMPORT_ANY_RE_LOCAL = IMPORT_ANY_RE
735737

736-
for (const styleTag of document.querySelectorAll('style')) {
738+
for (const styleTag of doc.querySelectorAll('style')) {
737739
const cssText = styleTag.textContent || ''
738740
for (const m of cssText.matchAll(IMPORT_ANY_RE_LOCAL)) {
739741
const u = (m[2] || m[4] || '').trim()
740742
if (!u || isIconFont(u)) continue
741-
const hasLink = !!document.querySelector(`link[rel="stylesheet"][href="${u}"]`)
743+
const hasLink = !!doc.querySelector(`link[rel="stylesheet"][href="${u}"]`)
742744
if (!hasLink) importUrls.push(u)
743745
}
744746
}
@@ -747,14 +749,14 @@ function faceMatchesRequired(fam, styleSpec, weightSpec, stretchSpec) {
747749
const injectedLinks = []
748750
if (importUrls.length) {
749751
await Promise.all(importUrls.map((u) => new Promise((resolve) => {
750-
if (document.querySelector(`link[rel="stylesheet"][href="${u}"]`)) return resolve(null)
751-
const link = document.createElement('link')
752+
if (doc.querySelector(`link[rel="stylesheet"][href="${u}"]`)) return resolve(null)
753+
const link = doc.createElement('link')
752754
link.rel = 'stylesheet'
753755
link.href = u
754756
link.setAttribute('data-snapdom', 'injected-import')
755757
link.onload = () => resolve(link)
756758
link.onerror = () => resolve(null)
757-
document.head.appendChild(link)
759+
doc.head.appendChild(link)
758760
injectedLinks.push(link)
759761
})))
760762
}
@@ -764,7 +766,7 @@ function faceMatchesRequired(fam, styleSpec, weightSpec, stretchSpec) {
764766
// ---------- 1) External <link rel="stylesheet"> ----------
765767
// Snapshot BEFORE detaching the injected links so their @import'd font CSS is still
766768
// collected below, then remove them from <head> to keep the capture non-destructive.
767-
const linkNodes = Array.from(document.querySelectorAll('link[rel="stylesheet"]')).filter(l => !!l.href)
769+
const linkNodes = Array.from(doc.querySelectorAll('link[rel="stylesheet"]')).filter(l => !!l.href)
768770
for (const l of injectedLinks) { try { l.remove() } catch { /* ok */ } }
769771

770772
for (const link of linkNodes) {
@@ -781,7 +783,7 @@ function faceMatchesRequired(fam, styleSpec, weightSpec, stretchSpec) {
781783
}
782784

783785
if (sameOrigin) {
784-
const sheet = Array.from(document.styleSheets).find(s => s.href === link.href)
786+
const sheet = Array.from(doc.styleSheets).find(s => s.href === link.href)
785787
if (sheet) {
786788
try {
787789
const rules = sheet.cssRules || []
@@ -844,7 +846,7 @@ function faceMatchesRequired(fam, styleSpec, weightSpec, stretchSpec) {
844846
depth: 0
845847
}
846848

847-
for (const sheet of document.styleSheets) {
849+
for (const sheet of doc.styleSheets) {
848850
if (sheet.href && linkNodes.some(l => l.href === sheet.href)) continue
849851
try {
850852
const rootHref = sheet.href || (location.origin + '/')
@@ -862,7 +864,7 @@ function faceMatchesRequired(fam, styleSpec, weightSpec, stretchSpec) {
862864

863865
// ---------- 3) document.fonts with _snapdomSrc ----------
864866
try {
865-
for (const f of document.fonts || []) {
867+
for (const f of doc.fonts || []) {
866868
if (!f || !f.family || f.status !== 'loaded' || !f._snapdomSrc) continue
867869
const fam = String(f.family).replace(/^['"]+|['"]+$/g, '')
868870
if (isIconFont(fam)) continue
@@ -1028,20 +1030,21 @@ export function collectUsedCodepoints(root) {
10281030
*
10291031
* @param {Set<string>|string[]} families - Plain family names (e.g., "Mansalva", "Unbounded")
10301032
* @param {number} [warmupRepetitions=2] - How many times to warm-up each family
1033+
* @param {Document} [doc=document] - Document whose fonts to await and warm up (the element's ownerDocument for iframe support)
10311034
* @returns {Promise<void>}
10321035
*/
1033-
export async function ensureFontsReady(families, warmupRepetitions = 2) {
1034-
try { await document.fonts.ready } catch {}
1036+
export async function ensureFontsReady(families, warmupRepetitions = 2, doc = document) {
1037+
try { await doc.fonts.ready } catch {}
10351038

10361039
const fams = Array.from(families || []).filter(Boolean)
10371040
if (fams.length === 0) return
10381041

10391042
const warmupOnce = () => {
1040-
const container = document.createElement('div')
1043+
const container = doc.createElement('div')
10411044
container.style.cssText = 'position:absolute!important;left:-9999px!important;top:0!important;opacity:0!important;pointer-events:none!important;contain:layout size style;'
10421045

10431046
for (const fam of fams) {
1044-
const span = document.createElement('span')
1047+
const span = doc.createElement('span')
10451048
span.textContent = 'AaBbGg1234ÁÉÍÓÚçñ—∞'
10461049
span.style.fontFamily = `"${fam}"`
10471050
span.style.fontWeight = '700'
@@ -1054,10 +1057,10 @@ export async function ensureFontsReady(families, warmupRepetitions = 2) {
10541057
container.appendChild(span)
10551058
}
10561059

1057-
document.body.appendChild(container)
1060+
doc.body.appendChild(container)
10581061
// Force layout
10591062
container.offsetWidth
1060-
document.body.removeChild(container)
1063+
doc.body.removeChild(container)
10611064
}
10621065

10631066
for (let i = 0; i < Math.max(1, warmupRepetitions); i++) {

0 commit comments

Comments
 (0)