feature: vor und nachteile

This commit is contained in:
Hikyu
2026-05-31 13:54:31 +02:00
parent 552a947a1c
commit e3dad17446
23270 changed files with 3024794 additions and 200 deletions
+29
View File
@@ -0,0 +1,29 @@
import type { Options } from './types'
export function applyStyle<T extends HTMLElement>(
node: T,
options: Options,
): T {
const { style } = node
if (options.backgroundColor) {
style.backgroundColor = options.backgroundColor
}
if (options.width) {
style.width = `${options.width}px`
}
if (options.height) {
style.height = `${options.height}px`
}
const manual = options.style
if (manual != null) {
Object.keys(manual).forEach((key: any) => {
style[key] = manual[key] as string
})
}
return node
}
+265
View File
@@ -0,0 +1,265 @@
import type { Options } from './types'
import { clonePseudoElements } from './clone-pseudos'
import {
createImage,
toArray,
isInstanceOfElement,
getStyleProperties,
} from './util'
import { getMimeType } from './mimes'
import { resourceToDataURL } from './dataurl'
async function cloneCanvasElement(canvas: HTMLCanvasElement) {
const dataURL = canvas.toDataURL()
if (dataURL === 'data:,') {
return canvas.cloneNode(false) as HTMLCanvasElement
}
return createImage(dataURL)
}
async function cloneVideoElement(video: HTMLVideoElement, options: Options) {
if (video.currentSrc) {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = video.clientWidth
canvas.height = video.clientHeight
ctx?.drawImage(video, 0, 0, canvas.width, canvas.height)
const dataURL = canvas.toDataURL()
return createImage(dataURL)
}
const poster = video.poster
const contentType = getMimeType(poster)
const dataURL = await resourceToDataURL(poster, contentType, options)
return createImage(dataURL)
}
async function cloneIFrameElement(iframe: HTMLIFrameElement, options: Options) {
try {
if (iframe?.contentDocument?.body) {
return (await cloneNode(
iframe.contentDocument.body,
options,
true,
)) as HTMLBodyElement
}
} catch {
// Failed to clone iframe
}
return iframe.cloneNode(false) as HTMLIFrameElement
}
async function cloneSingleNode<T extends HTMLElement>(
node: T,
options: Options,
): Promise<HTMLElement> {
if (isInstanceOfElement(node, HTMLCanvasElement)) {
return cloneCanvasElement(node)
}
if (isInstanceOfElement(node, HTMLVideoElement)) {
return cloneVideoElement(node, options)
}
if (isInstanceOfElement(node, HTMLIFrameElement)) {
return cloneIFrameElement(node, options)
}
return node.cloneNode(isSVGElement(node)) as T
}
const isSlotElement = (node: HTMLElement): node is HTMLSlotElement =>
node.tagName != null && node.tagName.toUpperCase() === 'SLOT'
const isSVGElement = (node: HTMLElement): node is HTMLSlotElement =>
node.tagName != null && node.tagName.toUpperCase() === 'SVG'
async function cloneChildren<T extends HTMLElement>(
nativeNode: T,
clonedNode: T,
options: Options,
): Promise<T> {
if (isSVGElement(clonedNode)) {
return clonedNode
}
let children: T[] = []
if (isSlotElement(nativeNode) && nativeNode.assignedNodes) {
children = toArray<T>(nativeNode.assignedNodes())
} else if (
isInstanceOfElement(nativeNode, HTMLIFrameElement) &&
nativeNode.contentDocument?.body
) {
children = toArray<T>(nativeNode.contentDocument.body.childNodes)
} else {
children = toArray<T>((nativeNode.shadowRoot ?? nativeNode).childNodes)
}
if (
children.length === 0 ||
isInstanceOfElement(nativeNode, HTMLVideoElement)
) {
return clonedNode
}
await children.reduce(
(deferred, child) =>
deferred
.then(() => cloneNode(child, options))
.then((clonedChild: HTMLElement | null) => {
if (clonedChild) {
clonedNode.appendChild(clonedChild)
}
}),
Promise.resolve(),
)
return clonedNode
}
function cloneCSSStyle<T extends HTMLElement>(
nativeNode: T,
clonedNode: T,
options: Options,
) {
const targetStyle = clonedNode.style
if (!targetStyle) {
return
}
const sourceStyle = window.getComputedStyle(nativeNode)
if (sourceStyle.cssText) {
targetStyle.cssText = sourceStyle.cssText
targetStyle.transformOrigin = sourceStyle.transformOrigin
} else {
getStyleProperties(options).forEach((name) => {
let value = sourceStyle.getPropertyValue(name)
if (name === 'font-size' && value.endsWith('px')) {
const reducedFont =
Math.floor(parseFloat(value.substring(0, value.length - 2))) - 0.1
value = `${reducedFont}px`
}
if (
isInstanceOfElement(nativeNode, HTMLIFrameElement) &&
name === 'display' &&
value === 'inline'
) {
value = 'block'
}
if (name === 'd' && clonedNode.getAttribute('d')) {
value = `path(${clonedNode.getAttribute('d')})`
}
targetStyle.setProperty(
name,
value,
sourceStyle.getPropertyPriority(name),
)
})
}
}
function cloneInputValue<T extends HTMLElement>(nativeNode: T, clonedNode: T) {
if (isInstanceOfElement(nativeNode, HTMLTextAreaElement)) {
clonedNode.innerHTML = nativeNode.value
}
if (isInstanceOfElement(nativeNode, HTMLInputElement)) {
clonedNode.setAttribute('value', nativeNode.value)
}
}
function cloneSelectValue<T extends HTMLElement>(nativeNode: T, clonedNode: T) {
if (isInstanceOfElement(nativeNode, HTMLSelectElement)) {
const clonedSelect = clonedNode as any as HTMLSelectElement
const selectedOption = Array.from(clonedSelect.children).find(
(child) => nativeNode.value === child.getAttribute('value'),
)
if (selectedOption) {
selectedOption.setAttribute('selected', '')
}
}
}
function decorate<T extends HTMLElement>(
nativeNode: T,
clonedNode: T,
options: Options,
): T {
if (isInstanceOfElement(clonedNode, Element)) {
cloneCSSStyle(nativeNode, clonedNode, options)
clonePseudoElements(nativeNode, clonedNode, options)
cloneInputValue(nativeNode, clonedNode)
cloneSelectValue(nativeNode, clonedNode)
}
return clonedNode
}
async function ensureSVGSymbols<T extends HTMLElement>(
clone: T,
options: Options,
) {
const uses = clone.querySelectorAll ? clone.querySelectorAll('use') : []
if (uses.length === 0) {
return clone
}
const processedDefs: { [key: string]: HTMLElement } = {}
for (let i = 0; i < uses.length; i++) {
const use = uses[i]
const id = use.getAttribute('xlink:href')
if (id) {
const exist = clone.querySelector(id)
const definition = document.querySelector(id) as HTMLElement
if (!exist && definition && !processedDefs[id]) {
// eslint-disable-next-line no-await-in-loop
processedDefs[id] = (await cloneNode(definition, options, true))!
}
}
}
const nodes = Object.values(processedDefs)
if (nodes.length) {
const ns = 'http://www.w3.org/1999/xhtml'
const svg = document.createElementNS(ns, 'svg')
svg.setAttribute('xmlns', ns)
svg.style.position = 'absolute'
svg.style.width = '0'
svg.style.height = '0'
svg.style.overflow = 'hidden'
svg.style.display = 'none'
const defs = document.createElementNS(ns, 'defs')
svg.appendChild(defs)
for (let i = 0; i < nodes.length; i++) {
defs.appendChild(nodes[i])
}
clone.appendChild(svg)
}
return clone
}
export async function cloneNode<T extends HTMLElement>(
node: T,
options: Options,
isRoot?: boolean,
): Promise<T | null> {
if (!isRoot && options.filter && !options.filter(node)) {
return null
}
return Promise.resolve(node)
.then((clonedNode) => cloneSingleNode(clonedNode, options) as Promise<T>)
.then((clonedNode) => cloneChildren(node, clonedNode, options))
.then((clonedNode) => decorate(node, clonedNode, options))
.then((clonedNode) => ensureSVGSymbols(clonedNode, options))
}
+69
View File
@@ -0,0 +1,69 @@
import type { Options } from './types'
import { uuid, getStyleProperties } from './util'
type Pseudo = ':before' | ':after'
function formatCSSText(style: CSSStyleDeclaration) {
const content = style.getPropertyValue('content')
return `${style.cssText} content: '${content.replace(/'|"/g, '')}';`
}
function formatCSSProperties(style: CSSStyleDeclaration, options: Options) {
return getStyleProperties(options)
.map((name) => {
const value = style.getPropertyValue(name)
const priority = style.getPropertyPriority(name)
return `${name}: ${value}${priority ? ' !important' : ''};`
})
.join(' ')
}
function getPseudoElementStyle(
className: string,
pseudo: Pseudo,
style: CSSStyleDeclaration,
options: Options,
): Text {
const selector = `.${className}:${pseudo}`
const cssText = style.cssText
? formatCSSText(style)
: formatCSSProperties(style, options)
return document.createTextNode(`${selector}{${cssText}}`)
}
function clonePseudoElement<T extends HTMLElement>(
nativeNode: T,
clonedNode: T,
pseudo: Pseudo,
options: Options,
) {
const style = window.getComputedStyle(nativeNode, pseudo)
const content = style.getPropertyValue('content')
if (content === '' || content === 'none') {
return
}
const className = uuid()
try {
clonedNode.className = `${clonedNode.className} ${className}`
} catch (err) {
return
}
const styleElement = document.createElement('style')
styleElement.appendChild(
getPseudoElementStyle(className, pseudo, style, options),
)
clonedNode.appendChild(styleElement)
}
export function clonePseudoElements<T extends HTMLElement>(
nativeNode: T,
clonedNode: T,
options: Options,
) {
clonePseudoElement(nativeNode, clonedNode, ':before', options)
clonePseudoElement(nativeNode, clonedNode, ':after', options)
}
+111
View File
@@ -0,0 +1,111 @@
import { Options } from './types'
function getContentFromDataUrl(dataURL: string) {
return dataURL.split(/,/)[1]
}
export function isDataUrl(url: string) {
return url.search(/^(data:)/) !== -1
}
export function makeDataUrl(content: string, mimeType: string) {
return `data:${mimeType};base64,${content}`
}
export async function fetchAsDataURL<T>(
url: string,
init: RequestInit | undefined,
process: (data: { result: string; res: Response }) => T,
): Promise<T> {
const res = await fetch(url, init)
if (res.status === 404) {
throw new Error(`Resource "${res.url}" not found`)
}
const blob = await res.blob()
return new Promise<T>((resolve, reject) => {
const reader = new FileReader()
reader.onerror = reject
reader.onloadend = () => {
try {
resolve(process({ res, result: reader.result as string }))
} catch (error) {
reject(error)
}
}
reader.readAsDataURL(blob)
})
}
const cache: { [url: string]: string } = {}
function getCacheKey(
url: string,
contentType: string | undefined,
includeQueryParams: boolean | undefined,
) {
let key = url.replace(/\?.*/, '')
if (includeQueryParams) {
key = url
}
// font resource
if (/ttf|otf|eot|woff2?/i.test(key)) {
key = key.replace(/.*\//, '')
}
return contentType ? `[${contentType}]${key}` : key
}
export async function resourceToDataURL(
resourceUrl: string,
contentType: string | undefined,
options: Options,
) {
const cacheKey = getCacheKey(
resourceUrl,
contentType,
options.includeQueryParams,
)
if (cache[cacheKey] != null) {
return cache[cacheKey]
}
// ref: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
if (options.cacheBust) {
// eslint-disable-next-line no-param-reassign
resourceUrl += (/\?/.test(resourceUrl) ? '&' : '?') + new Date().getTime()
}
let dataURL: string
try {
const content = await fetchAsDataURL(
resourceUrl,
options.fetchRequestInit,
({ res, result }) => {
if (!contentType) {
// eslint-disable-next-line no-param-reassign
contentType = res.headers.get('Content-Type') || ''
}
return getContentFromDataUrl(result)
},
)
dataURL = makeDataUrl(content, contentType!)
} catch (error) {
dataURL = options.imagePlaceholder || ''
let msg = `Failed to fetch resource: ${resourceUrl}`
if (error) {
msg = typeof error === 'string' ? error : error.message
}
if (msg) {
console.warn(msg)
}
}
cache[cacheKey] = dataURL
return dataURL
}
+104
View File
@@ -0,0 +1,104 @@
import { Options } from './types'
import { embedResources } from './embed-resources'
import { toArray, isInstanceOfElement } from './util'
import { isDataUrl, resourceToDataURL } from './dataurl'
import { getMimeType } from './mimes'
async function embedProp(
propName: string,
node: HTMLElement,
options: Options,
) {
const propValue = node.style?.getPropertyValue(propName)
if (propValue) {
const cssString = await embedResources(propValue, null, options)
node.style.setProperty(
propName,
cssString,
node.style.getPropertyPriority(propName),
)
return true
}
return false
}
async function embedBackground<T extends HTMLElement>(
clonedNode: T,
options: Options,
) {
;(await embedProp('background', clonedNode, options)) ||
(await embedProp('background-image', clonedNode, options))
;(await embedProp('mask', clonedNode, options)) ||
(await embedProp('-webkit-mask', clonedNode, options)) ||
(await embedProp('mask-image', clonedNode, options)) ||
(await embedProp('-webkit-mask-image', clonedNode, options))
}
async function embedImageNode<T extends HTMLElement | SVGImageElement>(
clonedNode: T,
options: Options,
) {
const isImageElement = isInstanceOfElement(clonedNode, HTMLImageElement)
if (
!(isImageElement && !isDataUrl(clonedNode.src)) &&
!(
isInstanceOfElement(clonedNode, SVGImageElement) &&
!isDataUrl(clonedNode.href.baseVal)
)
) {
return
}
const url = isImageElement ? clonedNode.src : clonedNode.href.baseVal
const dataURL = await resourceToDataURL(url, getMimeType(url), options)
await new Promise((resolve, reject) => {
clonedNode.onload = resolve
clonedNode.onerror = options.onImageErrorHandler
? (...attributes) => {
try {
resolve(options.onImageErrorHandler!(...attributes))
} catch (error) {
reject(error)
}
}
: reject
const image = clonedNode as HTMLImageElement
if (image.decode) {
image.decode = resolve as any
}
if (image.loading === 'lazy') {
image.loading = 'eager'
}
if (isImageElement) {
clonedNode.srcset = ''
clonedNode.src = dataURL
} else {
clonedNode.href.baseVal = dataURL
}
})
}
async function embedChildren<T extends HTMLElement>(
clonedNode: T,
options: Options,
) {
const children = toArray<HTMLElement>(clonedNode.childNodes)
const deferreds = children.map((child) => embedImages(child, options))
await Promise.all(deferreds).then(() => clonedNode)
}
export async function embedImages<T extends HTMLElement>(
clonedNode: T,
options: Options,
) {
if (isInstanceOfElement(clonedNode, Element)) {
await embedBackground(clonedNode, options)
await embedImageNode(clonedNode, options)
await embedChildren(clonedNode, options)
}
}
+92
View File
@@ -0,0 +1,92 @@
import { Options } from './types'
import { resolveUrl } from './util'
import { getMimeType } from './mimes'
import { isDataUrl, makeDataUrl, resourceToDataURL } from './dataurl'
const URL_REGEX = /url\((['"]?)([^'"]+?)\1\)/g
const URL_WITH_FORMAT_REGEX = /url\([^)]+\)\s*format\((["']?)([^"']+)\1\)/g
const FONT_SRC_REGEX = /src:\s*(?:url\([^)]+\)\s*format\([^)]+\)[,;]\s*)+/g
function toRegex(url: string): RegExp {
// eslint-disable-next-line no-useless-escape
const escaped = url.replace(/([.*+?^${}()|\[\]\/\\])/g, '\\$1')
return new RegExp(`(url\\(['"]?)(${escaped})(['"]?\\))`, 'g')
}
export function parseURLs(cssText: string): string[] {
const urls: string[] = []
cssText.replace(URL_REGEX, (raw, quotation, url) => {
urls.push(url)
return raw
})
return urls.filter((url) => !isDataUrl(url))
}
export async function embed(
cssText: string,
resourceURL: string,
baseURL: string | null,
options: Options,
getContentFromUrl?: (url: string) => Promise<string>,
): Promise<string> {
try {
const resolvedURL = baseURL ? resolveUrl(resourceURL, baseURL) : resourceURL
const contentType = getMimeType(resourceURL)
let dataURL: string
if (getContentFromUrl) {
const content = await getContentFromUrl(resolvedURL)
dataURL = makeDataUrl(content, contentType)
} else {
dataURL = await resourceToDataURL(resolvedURL, contentType, options)
}
return cssText.replace(toRegex(resourceURL), `$1${dataURL}$3`)
} catch (error) {
// pass
}
return cssText
}
function filterPreferredFontFormat(
str: string,
{ preferredFontFormat }: Options,
): string {
return !preferredFontFormat
? str
: str.replace(FONT_SRC_REGEX, (match: string) => {
// eslint-disable-next-line no-constant-condition
while (true) {
const [src, , format] = URL_WITH_FORMAT_REGEX.exec(match) || []
if (!format) {
return ''
}
if (format === preferredFontFormat) {
return `src: ${src};`
}
}
})
}
export function shouldEmbed(url: string): boolean {
return url.search(URL_REGEX) !== -1
}
export async function embedResources(
cssText: string,
baseUrl: string | null,
options: Options,
): Promise<string> {
if (!shouldEmbed(cssText)) {
return cssText
}
const filteredCSSText = filterPreferredFontFormat(cssText, options)
const urls = parseURLs(filteredCSSText)
return urls.reduce(
(deferred, url) =>
deferred.then((css) => embed(css, url, baseUrl, options)),
Promise.resolve(filteredCSSText),
)
}
+273
View File
@@ -0,0 +1,273 @@
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)
}
}
}
+101
View File
@@ -0,0 +1,101 @@
import { Options } from './types'
import { cloneNode } from './clone-node'
import { embedImages } from './embed-images'
import { applyStyle } from './apply-style'
import { embedWebFonts, getWebFontCSS } from './embed-webfonts'
import {
getImageSize,
getPixelRatio,
createImage,
canvasToBlob,
nodeToDataURL,
checkCanvasDimensions,
} from './util'
export async function toSvg<T extends HTMLElement>(
node: T,
options: Options = {},
): Promise<string> {
const { width, height } = getImageSize(node, options)
const clonedNode = (await cloneNode(node, options, true)) as HTMLElement
await embedWebFonts(clonedNode, options)
await embedImages(clonedNode, options)
applyStyle(clonedNode, options)
const datauri = await nodeToDataURL(clonedNode, width, height)
return datauri
}
export async function toCanvas<T extends HTMLElement>(
node: T,
options: Options = {},
): Promise<HTMLCanvasElement> {
const { width, height } = getImageSize(node, options)
const svg = await toSvg(node, options)
const img = await createImage(svg)
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')!
const ratio = options.pixelRatio || getPixelRatio()
const canvasWidth = options.canvasWidth || width
const canvasHeight = options.canvasHeight || height
canvas.width = canvasWidth * ratio
canvas.height = canvasHeight * ratio
if (!options.skipAutoScale) {
checkCanvasDimensions(canvas)
}
canvas.style.width = `${canvasWidth}`
canvas.style.height = `${canvasHeight}`
if (options.backgroundColor) {
context.fillStyle = options.backgroundColor
context.fillRect(0, 0, canvas.width, canvas.height)
}
context.drawImage(img, 0, 0, canvas.width, canvas.height)
return canvas
}
export async function toPixelData<T extends HTMLElement>(
node: T,
options: Options = {},
): Promise<Uint8ClampedArray> {
const { width, height } = getImageSize(node, options)
const canvas = await toCanvas(node, options)
const ctx = canvas.getContext('2d')!
return ctx.getImageData(0, 0, width, height).data
}
export async function toPng<T extends HTMLElement>(
node: T,
options: Options = {},
): Promise<string> {
const canvas = await toCanvas(node, options)
return canvas.toDataURL()
}
export async function toJpeg<T extends HTMLElement>(
node: T,
options: Options = {},
): Promise<string> {
const canvas = await toCanvas(node, options)
return canvas.toDataURL('image/jpeg', options.quality || 1)
}
export async function toBlob<T extends HTMLElement>(
node: T,
options: Options = {},
): Promise<Blob | null> {
const canvas = await toCanvas(node, options)
const blob = await canvasToBlob(canvas)
return blob
}
export async function getFontEmbedCSS<T extends HTMLElement>(
node: T,
options: Options = {},
): Promise<string> {
return getWebFontCSS(node, options)
}
+25
View File
@@ -0,0 +1,25 @@
const WOFF = 'application/font-woff'
const JPEG = 'image/jpeg'
const mimes: { [key: string]: string } = {
woff: WOFF,
woff2: WOFF,
ttf: 'application/font-truetype',
eot: 'application/vnd.ms-fontobject',
png: 'image/png',
jpg: JPEG,
jpeg: JPEG,
gif: 'image/gif',
tiff: 'image/tiff',
svg: 'image/svg+xml',
webp: 'image/webp',
}
function getExtension(url: string): string {
const match = /\.([^./]*?)$/g.exec(url)
return match ? match[1] : ''
}
export function getMimeType(url: string): string {
const extension = getExtension(url).toLowerCase()
return mimes[extension] || ''
}
+104
View File
@@ -0,0 +1,104 @@
export interface Options {
/**
* Width in pixels to be applied to node before rendering.
*/
width?: number
/**
* Height in pixels to be applied to node before rendering.
*/
height?: number
/**
* A string value for the background color, any valid CSS color value.
*/
backgroundColor?: string
/**
* Width in pixels to be applied to canvas on export.
*/
canvasWidth?: number
/**
* Height in pixels to be applied to canvas on export.
*/
canvasHeight?: number
/**
* An object whose properties to be copied to node's style before rendering.
*/
style?: Partial<CSSStyleDeclaration>
/**
* An array of style properties to be copied to node's style before rendering.
* For performance-critical scenarios, users may want to specify only the
* required properties instead of all styles.
*/
includeStyleProperties?: string[]
/**
* A function taking DOM node as argument. Should return `true` if passed
* node should be included in the output. Excluding node means excluding
* it's children as well.
*/
filter?: (domNode: HTMLElement) => boolean
/**
* A number between `0` and `1` indicating image quality (e.g. 0.92 => 92%)
* of the JPEG image.
*/
quality?: number
/**
* Set to `true` to append the current time as a query string to URL
* requests to enable cache busting.
*/
cacheBust?: boolean
/**
* Set false to use all URL as cache key.
* Default: false | undefined - which strips away the query parameters
*/
includeQueryParams?: boolean
/**
* A data URL for a placeholder image that will be used when fetching
* an image fails. Defaults to an empty string and will render empty
* areas for failed images.
*/
imagePlaceholder?: string
/**
* The pixel ratio of captured image. Defalut is the actual pixel ratio of
* the device. Set 1 to use as initial-scale 1 for the image
*/
pixelRatio?: number
/**
* Option to skip the fonts download and embed.
*/
skipFonts?: boolean
/**
* The preferred font format. If specified all other font formats are ignored.
*/
preferredFontFormat?:
| 'woff'
| 'woff2'
| 'truetype'
| 'opentype'
| 'embedded-opentype'
| 'svg'
| string
/**
* A CSS string to specify for font embeds. If specified only this CSS will
* be present in the resulting image. Use with `getFontEmbedCSS()` to
* create embed CSS for use across multiple calls to library functions.
*/
fontEmbedCSS?: string
/**
* A boolean to turn off auto scaling for truly massive images..
*/
skipAutoScale?: boolean
/**
* A string indicating the image format. The default type is image/png; that type is also used if the given type isn't supported.
*/
type?: string
/**
*
*the second parameter of window.fetch (Promise<Response> fetch(input[, init]))
*
*/
fetchRequestInit?: RequestInit
/**
* An event handler for the error event when any image in html has problem with loading.
*/
onImageErrorHandler?: OnErrorEventHandler
}
+261
View File
@@ -0,0 +1,261 @@
import type { Options } from './types'
export function resolveUrl(url: string, baseUrl: string | null): string {
// url is absolute already
if (url.match(/^[a-z]+:\/\//i)) {
return url
}
// url is absolute already, without protocol
if (url.match(/^\/\//)) {
return window.location.protocol + url
}
// dataURI, mailto:, tel:, etc.
if (url.match(/^[a-z]+:/i)) {
return url
}
const doc = document.implementation.createHTMLDocument()
const base = doc.createElement('base')
const a = doc.createElement('a')
doc.head.appendChild(base)
doc.body.appendChild(a)
if (baseUrl) {
base.href = baseUrl
}
a.href = url
return a.href
}
export const uuid = (() => {
// generate uuid for className of pseudo elements.
// We should not use GUIDs, otherwise pseudo elements sometimes cannot be captured.
let counter = 0
// ref: http://stackoverflow.com/a/6248722/2519373
const random = () =>
// eslint-disable-next-line no-bitwise
`0000${((Math.random() * 36 ** 4) << 0).toString(36)}`.slice(-4)
return () => {
counter += 1
return `u${random()}${counter}`
}
})()
export function delay<T>(ms: number) {
return (args: T) =>
new Promise<T>((resolve) => {
setTimeout(() => resolve(args), ms)
})
}
export function toArray<T>(arrayLike: any): T[] {
const arr: T[] = []
for (let i = 0, l = arrayLike.length; i < l; i++) {
arr.push(arrayLike[i])
}
return arr
}
let styleProps: string[] | null = null
export function getStyleProperties(options: Options = {}): string[] {
if (styleProps) {
return styleProps
}
if (options.includeStyleProperties) {
styleProps = options.includeStyleProperties
return styleProps
}
styleProps = toArray(window.getComputedStyle(document.documentElement))
return styleProps
}
function px(node: HTMLElement, styleProperty: string) {
const win = node.ownerDocument.defaultView || window
const val = win.getComputedStyle(node).getPropertyValue(styleProperty)
return val ? parseFloat(val.replace('px', '')) : 0
}
function getNodeWidth(node: HTMLElement) {
const leftBorder = px(node, 'border-left-width')
const rightBorder = px(node, 'border-right-width')
return node.clientWidth + leftBorder + rightBorder
}
function getNodeHeight(node: HTMLElement) {
const topBorder = px(node, 'border-top-width')
const bottomBorder = px(node, 'border-bottom-width')
return node.clientHeight + topBorder + bottomBorder
}
export function getImageSize(targetNode: HTMLElement, options: Options = {}) {
const width = options.width || getNodeWidth(targetNode)
const height = options.height || getNodeHeight(targetNode)
return { width, height }
}
export function getPixelRatio() {
let ratio
let FINAL_PROCESS
try {
FINAL_PROCESS = process
} catch (e) {
// pass
}
const val =
FINAL_PROCESS && FINAL_PROCESS.env
? FINAL_PROCESS.env.devicePixelRatio
: null
if (val) {
ratio = parseInt(val, 10)
if (Number.isNaN(ratio)) {
ratio = 1
}
}
return ratio || window.devicePixelRatio || 1
}
// @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size
const canvasDimensionLimit = 16384
export function checkCanvasDimensions(canvas: HTMLCanvasElement) {
if (
canvas.width > canvasDimensionLimit ||
canvas.height > canvasDimensionLimit
) {
if (
canvas.width > canvasDimensionLimit &&
canvas.height > canvasDimensionLimit
) {
if (canvas.width > canvas.height) {
canvas.height *= canvasDimensionLimit / canvas.width
canvas.width = canvasDimensionLimit
} else {
canvas.width *= canvasDimensionLimit / canvas.height
canvas.height = canvasDimensionLimit
}
} else if (canvas.width > canvasDimensionLimit) {
canvas.height *= canvasDimensionLimit / canvas.width
canvas.width = canvasDimensionLimit
} else {
canvas.width *= canvasDimensionLimit / canvas.height
canvas.height = canvasDimensionLimit
}
}
}
export function canvasToBlob(
canvas: HTMLCanvasElement,
options: Options = {},
): Promise<Blob | null> {
if (canvas.toBlob) {
return new Promise((resolve) => {
canvas.toBlob(
resolve,
options.type ? options.type : 'image/png',
options.quality ? options.quality : 1,
)
})
}
return new Promise((resolve) => {
const binaryString = window.atob(
canvas
.toDataURL(
options.type ? options.type : undefined,
options.quality ? options.quality : undefined,
)
.split(',')[1],
)
const len = binaryString.length
const binaryArray = new Uint8Array(len)
for (let i = 0; i < len; i += 1) {
binaryArray[i] = binaryString.charCodeAt(i)
}
resolve(
new Blob([binaryArray], {
type: options.type ? options.type : 'image/png',
}),
)
})
}
export function createImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
img.decode().then(() => {
requestAnimationFrame(() => resolve(img))
})
}
img.onerror = reject
img.crossOrigin = 'anonymous'
img.decoding = 'async'
img.src = url
})
}
export async function svgToDataURL(svg: SVGElement): Promise<string> {
return Promise.resolve()
.then(() => new XMLSerializer().serializeToString(svg))
.then(encodeURIComponent)
.then((html) => `data:image/svg+xml;charset=utf-8,${html}`)
}
export async function nodeToDataURL(
node: HTMLElement,
width: number,
height: number,
): Promise<string> {
const xmlns = 'http://www.w3.org/2000/svg'
const svg = document.createElementNS(xmlns, 'svg')
const foreignObject = document.createElementNS(xmlns, 'foreignObject')
svg.setAttribute('width', `${width}`)
svg.setAttribute('height', `${height}`)
svg.setAttribute('viewBox', `0 0 ${width} ${height}`)
foreignObject.setAttribute('width', '100%')
foreignObject.setAttribute('height', '100%')
foreignObject.setAttribute('x', '0')
foreignObject.setAttribute('y', '0')
foreignObject.setAttribute('externalResourcesRequired', 'true')
svg.appendChild(foreignObject)
foreignObject.appendChild(node)
return svgToDataURL(svg)
}
export const isInstanceOfElement = <
T extends typeof Element | typeof HTMLElement | typeof SVGImageElement,
>(
node: Element | HTMLElement | SVGImageElement,
instance: T,
): node is T['prototype'] => {
if (node instanceof instance) return true
const nodePrototype = Object.getPrototypeOf(node)
if (nodePrototype === null) return false
return (
nodePrototype.constructor.name === instance.name ||
isInstanceOfElement(nodePrototype, instance)
)
}