UNPKG

html-to-image

Version:

Generates an image from a DOM node using HTML5 canvas and SVG.

274 lines (239 loc) 7.49 kB
import type { Options } from './types' import { toArray } from './util' import { fetchAsDataURL } from './dataurl' import { shouldEmbed, embedResources } from './embed-resources' interface Metadata { url: string cssText: string } const cssFetchCache: { [href: string]: Metadata } = {} async function fetchCSS(url: string) { let cache = cssFetchCache[url] if (cache != null) { return cache } const res = await fetch(url) const cssText = await res.text() cache = { url, cssText } cssFetchCache[url] = cache return cache } async function embedFonts(data: Metadata, options: Options): Promise<string> { let cssText = data.cssText const regexUrl = /url\(["']?([^"')]+)["']?\)/g const fontLocs = cssText.match(/url\([^)]+\)/g) || [] const loadFonts = fontLocs.map(async (loc: string) => { let url = loc.replace(regexUrl, '$1') if (!url.startsWith('https://')) { url = new URL(url, data.url).href } return fetchAsDataURL<[string, string]>( url, options.fetchRequestInit, ({ result }) => { cssText = cssText.replace(loc, `url(${result})`) return [loc, result] }, ) }) return Promise.all(loadFonts).then(() => cssText) } function parseCSS(source: string) { if (source == null) { return [] } const result: string[] = [] const commentsRegex = /(\/\*[\s\S]*?\*\/)/gi // strip out comments let cssText = source.replace(commentsRegex, '') // eslint-disable-next-line prefer-regex-literals const keyframesRegex = new RegExp( '((@.*?keyframes [\\s\\S]*?){([\\s\\S]*?}\\s*?)})', 'gi', ) // eslint-disable-next-line no-constant-condition while (true) { const matches = keyframesRegex.exec(cssText) if (matches === null) { break } result.push(matches[0]) } cssText = cssText.replace(keyframesRegex, '') const importRegex = /@import[\s\S]*?url\([^)]*\)[\s\S]*?;/gi // to match css & media queries together const combinedCSSRegex = '((\\s*?(?:\\/\\*[\\s\\S]*?\\*\\/)?\\s*?@media[\\s\\S]' + '*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})' // unified regex const unifiedRegex = new RegExp(combinedCSSRegex, 'gi') // eslint-disable-next-line no-constant-condition while (true) { let matches = importRegex.exec(cssText) if (matches === null) { matches = unifiedRegex.exec(cssText) if (matches === null) { break } else { importRegex.lastIndex = unifiedRegex.lastIndex } } else { unifiedRegex.lastIndex = importRegex.lastIndex } result.push(matches[0]) } return result } async function getCSSRules( styleSheets: CSSStyleSheet[], options: Options, ): Promise<CSSStyleRule[]> { const ret: CSSStyleRule[] = [] const deferreds: Promise<number | void>[] = [] // First loop inlines imports styleSheets.forEach((sheet) => { if ('cssRules' in sheet) { try { toArray<CSSRule>(sheet.cssRules || []).forEach((item, index) => { if (item.type === CSSRule.IMPORT_RULE) { let importIndex = index + 1 const url = (item as CSSImportRule).href const deferred = fetchCSS(url) .then((metadata) => embedFonts(metadata, options)) .then((cssText) => parseCSS(cssText).forEach((rule) => { try { sheet.insertRule( rule, rule.startsWith('@import') ? (importIndex += 1) : sheet.cssRules.length, ) } catch (error) { console.error('Error inserting rule from remote css', { rule, error, }) } }), ) .catch((e) => { console.error('Error loading remote css', e.toString()) }) deferreds.push(deferred) } }) } catch (e) { const inline = styleSheets.find((a) => a.href == null) || document.styleSheets[0] if (sheet.href != null) { deferreds.push( fetchCSS(sheet.href) .then((metadata) => embedFonts(metadata, options)) .then((cssText) => parseCSS(cssText).forEach((rule) => { inline.insertRule(rule, inline.cssRules.length) }), ) .catch((err: unknown) => { console.error('Error loading remote stylesheet', err) }), ) } console.error('Error inlining remote css file', e) } } }) return Promise.all(deferreds).then(() => { // Second loop parses rules styleSheets.forEach((sheet) => { if ('cssRules' in sheet) { try { toArray<CSSStyleRule>(sheet.cssRules || []).forEach((item) => { ret.push(item) }) } catch (e) { console.error(`Error while reading CSS rules from ${sheet.href}`, e) } } }) return ret }) } function getWebFontRules(cssRules: CSSStyleRule[]): CSSStyleRule[] { return cssRules .filter((rule) => rule.type === CSSRule.FONT_FACE_RULE) .filter((rule) => shouldEmbed(rule.style.getPropertyValue('src'))) } async function parseWebFontRules<T extends HTMLElement>( node: T, options: Options, ) { if (node.ownerDocument == null) { throw new Error('Provided element is not within a Document') } const styleSheets = toArray<CSSStyleSheet>(node.ownerDocument.styleSheets) const cssRules = await getCSSRules(styleSheets, options) return getWebFontRules(cssRules) } function normalizeFontFamily(font: string) { return font.trim().replace(/["']/g, '') } function getUsedFonts(node: HTMLElement) { const fonts = new Set<string>() function traverse(node: HTMLElement) { const fontFamily = node.style.fontFamily || getComputedStyle(node).fontFamily fontFamily.split(',').forEach((font) => { fonts.add(normalizeFontFamily(font)) }) Array.from(node.children).forEach((child) => { if (child instanceof HTMLElement) { traverse(child) } }) } traverse(node) return fonts } export async function getWebFontCSS<T extends HTMLElement>( node: T, options: Options, ): Promise<string> { const rules = await parseWebFontRules(node, options) const usedFonts = getUsedFonts(node) const cssTexts = await Promise.all( rules .filter((rule) => usedFonts.has(normalizeFontFamily(rule.style.fontFamily)), ) .map((rule) => { const baseUrl = rule.parentStyleSheet ? rule.parentStyleSheet.href : null return embedResources(rule.cssText, baseUrl, options) }), ) return cssTexts.join('\n') } export async function embedWebFonts<T extends HTMLElement>( clonedNode: T, options: Options, ) { const cssText = options.fontEmbedCSS != null ? options.fontEmbedCSS : options.skipFonts ? null : await getWebFontCSS(clonedNode, options) if (cssText) { const styleNode = document.createElement('style') const sytleContent = document.createTextNode(cssText) styleNode.appendChild(sytleContent) if (clonedNode.firstChild) { clonedNode.insertBefore(styleNode, clonedNode.firstChild) } else { clonedNode.appendChild(styleNode) } } }