From 4b37fe01d17561fe72283590c8c6c9d1e0d179c6 Mon Sep 17 00:00:00 2001 From: hzsrc Date: Fri, 15 Nov 2024 19:32:47 +0800 Subject: [PATCH] =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=9B=BE=E5=83=8F=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E4=B8=8D=E5=AE=8C=E6=95=B4=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/embed-images.ts | 96 ---------------- src/embed-resources.ts | 92 ---------------- src/embed-webfonts.ts | 243 ----------------------------------------- src/util.ts | 91 ++++++++++++--- 4 files changed, 73 insertions(+), 449 deletions(-) delete mode 100644 src/embed-images.ts delete mode 100644 src/embed-resources.ts delete mode 100644 src/embed-webfonts.ts diff --git a/src/embed-images.ts b/src/embed-images.ts deleted file mode 100644 index aa552e8f..00000000 --- a/src/embed-images.ts +++ /dev/null @@ -1,96 +0,0 @@ -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( - clonedNode: T, - options: Options, -) { - if (!(await embedProp('background', clonedNode, options))) { - await embedProp('background-image', clonedNode, options) - } - if (!(await embedProp('mask', clonedNode, options))) { - await embedProp('mask-image', clonedNode, options) - } -} - -async function embedImageNode( - 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 = 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( - clonedNode: T, - options: Options, -) { - const children = toArray(clonedNode.childNodes) - const deferreds = children.map((child) => embedImages(child, options)) - await Promise.all(deferreds).then(() => clonedNode) -} - -export async function embedImages( - clonedNode: T, - options: Options, -) { - if (isInstanceOfElement(clonedNode, Element)) { - await embedBackground(clonedNode, options) - await embedImageNode(clonedNode, options) - await embedChildren(clonedNode, options) - } -} diff --git a/src/embed-resources.ts b/src/embed-resources.ts deleted file mode 100644 index bed8b373..00000000 --- a/src/embed-resources.ts +++ /dev/null @@ -1,92 +0,0 @@ -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, -): Promise { - 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 { - 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), - ) -} diff --git a/src/embed-webfonts.ts b/src/embed-webfonts.ts deleted file mode 100644 index 42c73da6..00000000 --- a/src/embed-webfonts.ts +++ /dev/null @@ -1,243 +0,0 @@ -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 { - 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 { - const ret: CSSStyleRule[] = [] - const deferreds: Promise[] = [] - - // First loop inlines imports - styleSheets.forEach((sheet) => { - if ('cssRules' in sheet) { - try { - toArray(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, sheet.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(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( - node: T, - options: Options, -) { - if (node.ownerDocument == null) { - throw new Error('Provided element is not within a Document') - } - - const styleSheets = toArray(node.ownerDocument.styleSheets) - const cssRules = await getCSSRules(styleSheets, options) - - return getWebFontRules(cssRules) -} - -export async function getWebFontCSS( - node: T, - options: Options, -): Promise { - const rules = await parseWebFontRules(node, options) - const cssTexts = await Promise.all( - rules.map((rule) => { - const baseUrl = rule.parentStyleSheet ? rule.parentStyleSheet.href : null - return embedResources(rule.cssText, baseUrl, options) - }), - ) - - return cssTexts.join('\n') -} - -export async function embedWebFonts( - 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) - } - } -} diff --git a/src/util.ts b/src/util.ts index 4458aee9..1ce5c84a 100644 --- a/src/util.ts +++ b/src/util.ts @@ -165,10 +165,7 @@ export function getMaxCanvasHeight(width: number): number { ] for (let i = 0; i < heights.length; i++) { try { - const canvas = document.createElement('canvas') - canvas.width = width - canvas.height = heights[i] - const ctx = canvas.getContext('2d')! + const ctx = get2dCtx(width, heights[i]) ctx.drawImage(new Image(), 0, 0) // check return heights[i] } catch (e) { @@ -179,6 +176,13 @@ export function getMaxCanvasHeight(width: number): number { } } +function get2dCtx(width: number, height: number) { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas.getContext('2d')! +} + export function canvasToBlob( canvas: HTMLCanvasElement, options: Options = {}, @@ -217,21 +221,66 @@ export function canvasToBlob( }) } -export function createImage(url: string): Promise { - return new Promise((resolve, reject) => { - const img = new Image() - img.decode = () => resolve(img) as any - img.onload = () => resolve(img) - img.onerror = reject - img.crossOrigin = 'anonymous' - img.decoding = 'async' - img.src = url - }) +export function createImage(urlIn: string) { + //var win = open('about:blank'); + return checkImg(1) + //svg中css的解释逻辑与html中不完全相同,会导致svg中的高度高于实际html的高度。 + //原因诸如:4k屏的1px在html中为0.51px,而在svg中为1px;又如 overflow-y 在svg中失效;background定位不兼容等。 + //为了避免图像底部不完整的情况,这里每次额外增加60px高度,并寻找是否存在底部标志颜色(BCheckColor),直到已存在,说明已经到达底部。 + function checkImg(i: number): Promise { + var url = replaceHeight(urlIn, BCheckHeight * i) + return urlToImg(url).then(function(img: HTMLImageElement): Promise | HTMLImageElement { + var ctx = get2dCtx(1, BCheckHeight) + ctx.drawImage(img, Math.floor(img.width / 2), img.height - BCheckHeight, 1, BCheckHeight, 0, 0, 1, BCheckHeight) + //win.document.write(i + ''); //debug + var dat = ctx.getImageData(0, 0, 1, BCheckHeight).data + let color = padx(dat[dat.length - 4]) + padx(dat[dat.length - 3]) + padx(dat[dat.length - 2]) + if (color !== BCheckColor && i < 50) { + return checkImg(i + 1) + } + //每4个字节为1像素,共4字节,rgba + for (let j = dat.length - 8; j >= 0; j -= 4) { + color = padx(dat[j]) + padx(dat[j + 1]) + padx(dat[j + 2]) + if (color !== BCheckColor) { + url = replaceHeight(urlIn, BCheckHeight * (i - 1) + (j / 4) / getPixelRatio()); + return urlToImg(url) + } + } + return img + }) + } + + function urlToImg(url: string): Promise { + return new Promise(function(resolve, reject) { + var img = new Image() + img.decode = function() { + return resolve(img) + } as any + img.onload = function() { + return resolve(img) + } + img.onerror = reject + img.crossOrigin = 'anonymous' + img.decoding = 'async' + img.src = url + }) + } + + function replaceHeight(url: string, delta: number) { + return url.replace(/(viewBox%3D%220%200%20[\d.]+%20)([\d.]+)%22/, function(_, m1, m2) { + return m1 + (parseInt(m2) + delta) + '%22' + }) + } + + function padx(i: number) { + var r = i.toString(16) + return r.length === 1 ? '0' + r : r + } } export async function svgToDataURL(svg: SVGElement): Promise { return Promise.resolve() - .then(async function () { + .then(async function() { // svg中的图片地址无法离线下载,需要先转成dataUrl const imgs = svg.querySelectorAll('img') return Promise.all( @@ -248,6 +297,9 @@ export async function svgToDataURL(svg: SVGElement): Promise { .then((html) => `data:image/svg+xml;charset=utf-8,${html}`) } +const BCheckColor = '010201' +const BCheckHeight = 60 + export async function nodeToDataURL( node: HTMLElement, width: number, @@ -272,6 +324,10 @@ export async function nodeToDataURL( svg.appendChild(foreignObject) foreignObject.appendChild(node) + foreignObject.insertAdjacentHTML( + 'beforeend', + `
`, + ) if (usePageCss) { const style = document.createElementNS(xmlns, 'style') style.insertAdjacentText('beforeend', await getStyles()) @@ -281,9 +337,8 @@ export async function nodeToDataURL( return svgToDataURL(svg) } -export const isInstanceOfElement = < - T extends typeof Element | typeof HTMLElement | typeof SVGImageElement, ->( +export const isInstanceOfElement = ( node: Element | HTMLElement | SVGImageElement, instance: T, ): node is T['prototype'] => {