import type { ThemeTypography } from './typography'
/** Measure outside MapSVG, using the same theme body/content context on frontend and probe. */
export function readThemeTypography(doc: Document): ThemeTypography {
const win = doc.defaultView!
const content = doc.createElement('div')
content.className = 'entry-content'
content.style.cssText = 'position:absolute;left:-100000px;top:0;visibility:hidden;pointer-events:none'
content.innerHTML = '
Heading
Heading
Heading
Paragraph
Link'
doc.body.append(content)
const read = (element: Element, properties: string[]) => {
const style = win.getComputedStyle(element)
return Object.fromEntries(properties.map(key => [key, style[key as keyof CSSStyleDeclaration] as string]))
}
const text = ['fontFamily', 'fontSize', 'lineHeight']
const result = {
base: read(doc.body, [...text, 'color']), links: read(content.querySelector('a')!, ['color', 'textDecorationLine']),
hover: {}, h1: {}, h2: {}, h3: {}, p: {}, fonts: '',
} as ThemeTypography
for (const tag of ['h1', 'h2', 'h3', 'p'] as const) result[tag] = read(content.querySelector(tag)!, [...text, 'fontWeight', 'color', ...['marginTop', 'marginBottom']])
// CSSOM can resolve ordinary theme :hover rules without clicking/navigating a link.
const anchor = content.querySelector('a')!
anchor.style.transition = 'none'; anchor.style.animation = 'none'
const fontImports = new Set()
function rules(sheet: CSSStyleSheet | null): void {
if (!sheet) return
let entries: CSSRuleList
try { entries = sheet.cssRules } catch {
if (sheet.href && /^https?:/.test(sheet.href)) fontImports.add(`@import url(${JSON.stringify(sheet.href)}) layer(mapsvg-theme-fonts);`)
return
}
for (const rule of Array.from(entries)) {
if (rule.type === 3) rules((rule as CSSImportRule).styleSheet)
else if (rule.type === 5) result.fonts += rule.cssText.replace(/url\((['"]?)(.*?)\1\)/g, (_, _quote, url) => `url(${JSON.stringify(new URL(url, sheet.href || doc.baseURI).href)})`)
else if (rule.type === 1) {
const style = rule as CSSStyleRule
for (const selector of style.selectorText.split(',')) {
if (!selector.includes(':hover')) continue
try {
if (anchor.matches(selector.replace(/:hover/g, ''))) {
if (style.style.color) { anchor.style.color = style.style.color; result.hover.color = win.getComputedStyle(anchor).color }
if (style.style.textDecorationLine) { anchor.style.textDecorationLine = style.style.textDecorationLine; result.hover.textDecorationLine = win.getComputedStyle(anchor).textDecorationLine }
}
} catch { /* unsupported selectors do not block typography */ }
}
} else if ('cssRules' in rule) {
if (rule.type === 4 && win.matchMedia && !win.matchMedia((rule as CSSMediaRule).conditionText).matches) continue
if (rule.type === 12 && win.CSS?.supports && !win.CSS.supports((rule as CSSSupportsRule).conditionText)) continue
rules({ cssRules: (rule as CSSGroupingRule).cssRules, href: sheet.href } as CSSStyleSheet)
}
}
}
for (const sheet of Array.from(doc.styleSheets)) rules(sheet)
result.fonts = [...fontImports].join("\n") + "\n" + result.fonts
content.remove()
return result
}
const probes = new WeakMap>>()
/** The backend document has wp-admin styles; measure in a dedicated frontend shell. */
export function loadThemeTypography(doc: Document, url: string): Promise {
let cache = probes.get(doc)
if (!cache) { cache = new Map(); probes.set(doc, cache) }
const width = doc.defaultView!.innerWidth, height = doc.defaultView!.innerHeight
const viewport = `${width}:${height}`
if (cache.has(viewport)) return cache.get(viewport)!
const promise = new Promise((resolve, reject) => {
const win = doc.defaultView!
const target = new URL(url, doc.baseURI)
const frame = doc.createElement('iframe')
frame.title = 'WordPress theme typography'
frame.setAttribute('aria-hidden', 'true'); frame.tabIndex = -1
frame.style.cssText = `position:absolute;left:-100000px;top:0;width:${width}px;height:${height}px;opacity:0;pointer-events:none;border:0`
const finish = () => { win.clearTimeout(timer); win.removeEventListener('message', received); frame.remove() }
const timer = win.setTimeout(() => { finish(); reject(new Error('WordPress theme typography could not be loaded. Default typography is being used.')) }, 12000)
const received = (event: MessageEvent) => {
if (event.source !== frame.contentWindow || event.origin !== target.origin || event.data?.type !== 'mapsvg:theme-typography') return
const value = event.data.typography
if (!value?.base?.fontFamily || !value?.base?.fontSize || typeof value.fonts !== 'string') return
finish(); resolve(value)
}
win.addEventListener('message', received)
frame.src = target.href; doc.body.append(frame)
})
cache.set(viewport, promise)
if (cache.size > 8) cache.delete(cache.keys().next().value!)
promise.catch(() => { if (cache?.get(viewport) === promise) cache.delete(viewport) })
return promise
}