diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d3cc2c..e43c12b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ### Features -* support Web Worker and `placeholderImage` as a callback ([#5](https://github.com/qq15725/modern-screenshot/issues/5)) ([f22b0bc](https://github.com/qq15725/modern-screenshot/commit/f22b0bcf75d660637617af151ac95200773c0282)) +* Support Web Worker ([#5](https://github.com/qq15725/modern-screenshot/issues/5)) ([f22b0bc](https://github.com/qq15725/modern-screenshot/commit/f22b0bcf75d660637617af151ac95200773c0282)) diff --git a/index.html b/index.html index a8c9a86..6558376 100644 --- a/index.html +++ b/index.html @@ -28,7 +28,7 @@ document.body.appendChild( await domToImage( document.querySelector('#root'), - { debug: true }, + { debug: true, scale: 2 }, ), ) diff --git a/src/change-jpeg-dpi.ts b/src/change-jpeg-dpi.ts new file mode 100644 index 0000000..495d6f2 --- /dev/null +++ b/src/change-jpeg-dpi.ts @@ -0,0 +1,8 @@ +export function changeJpegDpi(uint8Array: Uint8Array, dpi: number) { + uint8Array[13] = 1 // 1 pixel per inch or 2 pixel per cm + uint8Array[14] = dpi >> 8 // dpiX high byte + uint8Array[15] = dpi & 0xFF // dpiX low byte + uint8Array[16] = dpi >> 8 // dpiY high byte + uint8Array[17] = dpi & 0xFF // dpiY low byte + return uint8Array +} diff --git a/src/change-png-dpi.ts b/src/change-png-dpi.ts new file mode 100644 index 0000000..f5e36d2 --- /dev/null +++ b/src/change-png-dpi.ts @@ -0,0 +1,120 @@ +const _P = 'p'.charCodeAt(0) +const _H = 'H'.charCodeAt(0) +const _Y = 'Y'.charCodeAt(0) +const _S = 's'.charCodeAt(0) + +let pngDataTable: ReturnType + +function createPngDataTable() { + /* Table of CRCs of all 8-bit messages. */ + const crcTable = new Int32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) { + c = (c & 1) ? 0xEDB88320 ^ (c >>> 1) : c >>> 1 + } + crcTable[n] = c + } + return crcTable +} + +function calcCrc(uint8Array: Uint8Array) { + let c = -1 + if (!pngDataTable) pngDataTable = createPngDataTable() + for (let n = 0; n < uint8Array.length; n++) { + c = pngDataTable[(c ^ uint8Array[n]) & 0xFF] ^ (c >>> 8) + } + return c ^ -1 +} + +function searchStartOfPhys(uint8Array: Uint8Array) { + const length = uint8Array.length - 1 + // we check from the end since we cut the string in proximity of the header + // the header is within 21 bytes from the end. + for (let i = length; i >= 4; i--) { + if (uint8Array[i - 4] === 9 && uint8Array[i - 3] === _P + && uint8Array[i - 2] === _H && uint8Array[i - 1] === _Y + && uint8Array[i] === _S) { + return i - 3 + } + } + return 0 +} + +export function changePngDpi(uint8Array: Uint8Array, dpi: number, overwritepHYs = false) { + const physChunk = new Uint8Array(13) + // chunk header pHYs + // 9 bytes of data + // 4 bytes of crc + // this multiplication is because the standard is dpi per meter. + dpi *= 39.3701 + physChunk[0] = _P + physChunk[1] = _H + physChunk[2] = _Y + physChunk[3] = _S + physChunk[4] = dpi >>> 24 // dpiX highest byte + physChunk[5] = dpi >>> 16 // dpiX veryhigh byte + physChunk[6] = dpi >>> 8 // dpiX high byte + physChunk[7] = dpi & 0xFF // dpiX low byte + physChunk[8] = physChunk[4] // dpiY highest byte + physChunk[9] = physChunk[5] // dpiY veryhigh byte + physChunk[10] = physChunk[6] // dpiY high byte + physChunk[11] = physChunk[7] // dpiY low byte + physChunk[12] = 1 // dot per meter.... + + const crc = calcCrc(physChunk) + + const crcChunk = new Uint8Array(4) + crcChunk[0] = crc >>> 24 + crcChunk[1] = crc >>> 16 + crcChunk[2] = crc >>> 8 + crcChunk[3] = crc & 0xFF + + if (overwritepHYs) { + const startingIndex = searchStartOfPhys(uint8Array) + uint8Array.set(physChunk, startingIndex) + uint8Array.set(crcChunk, startingIndex + 13) + return uint8Array + } else { + // i need to give back an array of data that is divisible by 3 so that + // dataurl encoding gives me integers, for luck this chunk is 17 + 4 = 21 + // if it was we could add a text chunk contaning some info, untill desired + // length is met. + + // chunk structur 4 bytes for length is 9 + const chunkLength = new Uint8Array(4) + chunkLength[0] = 0 + chunkLength[1] = 0 + chunkLength[2] = 0 + chunkLength[3] = 9 + + const finalHeader = new Uint8Array(54) + finalHeader.set(uint8Array, 0) + finalHeader.set(chunkLength, 33) + finalHeader.set(physChunk, 37) + finalHeader.set(crcChunk, 50) + return finalHeader + } +} + +// those are 3 possible signature of the physBlock in base64. +// the pHYs signature block is preceed by the 4 bytes of lenght. The length of +// the block is always 9 bytes. So a phys block has always this signature: +// 0 0 0 9 p H Y s. +// However the data64 encoding aligns we will always find one of those 3 strings. +// this allow us to find this particular occurence of the pHYs block without +// converting from b64 back to string +const b64PhysSignature1 = 'AAlwSFlz' +const b64PhysSignature2 = 'AAAJcEhZ' +const b64PhysSignature3 = 'AAAACXBI' + +export function detectPhysChunkFromDataUrl(dataUrl: string) { + let b64index = dataUrl.indexOf(b64PhysSignature1) + if (b64index === -1) { + b64index = dataUrl.indexOf(b64PhysSignature2) + } + if (b64index === -1) { + b64index = dataUrl.indexOf(b64PhysSignature3) + } + return b64index +} diff --git a/src/create-canvas-clone.ts b/src/clone-canvas.ts similarity index 92% rename from src/create-canvas-clone.ts rename to src/clone-canvas.ts index e2fb7d2..609d95d 100644 --- a/src/create-canvas-clone.ts +++ b/src/clone-canvas.ts @@ -1,6 +1,6 @@ import { consoleWarn, createImage } from './utils' -export function createCanvasClone( +export function cloneCanvas( canvas: T, ): HTMLCanvasElement | HTMLImageElement { if (canvas.ownerDocument) { diff --git a/src/clone-element.ts b/src/clone-element.ts new file mode 100644 index 0000000..6b15b44 --- /dev/null +++ b/src/clone-element.ts @@ -0,0 +1,29 @@ +import { isCanvasElement, isIFrameElement, isImageElement, isVideoElement } from './utils' +import { cloneIframe } from './clone-iframe' +import { cloneCanvas } from './clone-canvas' +import { cloneVideo } from './clone-video' +import { cloneImage } from './clone-image' +import type { Context } from './context' + +export function cloneElement( + node: T, + context: Context, +): HTMLElement | SVGElement { + if (isCanvasElement(node)) { + return cloneCanvas(node) + } + + if (isIFrameElement(node)) { + return cloneIframe(node, context) + } + + if (isImageElement(node)) { + return cloneImage(node) + } + + if (isVideoElement(node)) { + return cloneVideo(node) + } + + return node.cloneNode(false) as T +} diff --git a/src/create-iframe-clone.ts b/src/clone-iframe.ts similarity index 87% rename from src/create-iframe-clone.ts rename to src/clone-iframe.ts index 66f0ad5..e5312e4 100644 --- a/src/create-iframe-clone.ts +++ b/src/clone-iframe.ts @@ -2,7 +2,7 @@ import { cloneNode } from './clone-node' import { consoleWarn } from './utils' import type { Context } from './context' -export function createIframeClone( +export function cloneIframe( iframe: T, context: Context, ): HTMLIFrameElement | HTMLBodyElement { diff --git a/src/create-image-clone.ts b/src/clone-image.ts similarity index 82% rename from src/create-image-clone.ts rename to src/clone-image.ts index b39bde0..bef8ff2 100644 --- a/src/create-image-clone.ts +++ b/src/clone-image.ts @@ -1,4 +1,4 @@ -export function createImageClone( +export function cloneImage( image: T, ): HTMLImageElement { const clone = image.cloneNode(false) as T diff --git a/src/clone-node.ts b/src/clone-node.ts index 4b3bade..b463cb0 100644 --- a/src/clone-node.ts +++ b/src/clone-node.ts @@ -11,7 +11,7 @@ import { isTextNode, isVideoElement, } from './utils' -import { createElementClone } from './create-element-clone' +import { cloneElement } from './clone-element' import type { Context } from './context' function appendChildNode( @@ -85,7 +85,7 @@ export function cloneNode( return ownerDocument.createComment(node.tagName.toLowerCase()) } - const clone = createElementClone(node, context) + const clone = cloneElement(node, context) const cloneStyle = clone.style copyCssStyles(node, style, clone, isRoot, context) diff --git a/src/create-video-clone.ts b/src/clone-video.ts similarity index 82% rename from src/create-video-clone.ts rename to src/clone-video.ts index 19dd67d..4e885a2 100644 --- a/src/create-video-clone.ts +++ b/src/clone-video.ts @@ -1,7 +1,7 @@ -import { createCanvasClone } from './create-canvas-clone' +import { cloneCanvas } from './clone-canvas' import { consoleWarn, createImage } from './utils' -export function createVideoClone( +export function cloneVideo( video: T, ): HTMLCanvasElement | HTMLImageElement | HTMLVideoElement { if (video.ownerDocument) { @@ -17,7 +17,7 @@ export function createVideoClone( } catch (error) { consoleWarn('Failed to clone video', error) } - return createCanvasClone(canvas) + return cloneCanvas(canvas) } if (video.poster) { diff --git a/src/context.ts b/src/context.ts index 8a8a06c..0ed6f3c 100644 --- a/src/context.ts +++ b/src/context.ts @@ -13,6 +13,15 @@ export interface InternalContext { */ __CONTEXT__: true + /** + * Logger + */ + log: { + time: (label: string) => void + timeEnd: (label: string) => void + warn: (...args: any[]) => void + } + /** * Node */ @@ -28,6 +37,13 @@ export interface InternalContext { */ ownerWindow?: Window + /** + * DPI + * + * scale === 1 ? null : 96 * scale + */ + dpi: number | null + /** * The `style` element under the root `svg` element */ diff --git a/src/converts/canvas-to-blob.ts b/src/converts/canvas-to-blob.ts deleted file mode 100644 index 93fcc0a..0000000 --- a/src/converts/canvas-to-blob.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IN_BROWSER } from '../utils' - -export async function canvasToblob( - canvas: HTMLCanvasElement, - options?: { quality?: number; type?: string }, -): Promise { - const { type = 'image/png', quality = 1 } = options || {} - - if (canvas.toBlob) { - return new Promise(resolve => canvas.toBlob(resolve, type, quality)) - } - - if (!IN_BROWSER) return null - const dataURL = canvas.toDataURL(type, quality).split(',')[1] - const binaryString = window.atob(dataURL) - const len = binaryString.length - const binaryArray = new Uint8Array(len) - - for (let i = 0; i < len; i += 1) { - binaryArray[i] = binaryString.charCodeAt(i) - } - - return new Blob([binaryArray], { type }) -} diff --git a/src/converts/dom-to-blob.ts b/src/converts/dom-to-blob.ts index f85abe3..04b7eda 100644 --- a/src/converts/dom-to-blob.ts +++ b/src/converts/dom-to-blob.ts @@ -1,17 +1,29 @@ -import { createContext } from '../create-context' -import { isContext } from '../utils' +import { changeJpegDpi } from '../change-jpeg-dpi' +import { changePngDpi } from '../change-png-dpi' +import { orCreateContext } from '../create-context' +import { blobToArrayBuffer, canvasToBlob } from '../utils' import { domToCanvas } from './dom-to-canvas' -import { canvasToblob } from './canvas-to-blob' import type { Context } from '../context' import type { Options } from '../options' -export async function domToBlob(node: T, options?: Options): Promise -export async function domToBlob(context: Context): Promise +export async function domToBlob(node: T, options?: Options): Promise +export async function domToBlob(context: Context): Promise export async function domToBlob(node: any, options?: any) { - const context = isContext(node) - ? node - : await createContext(node, { ...options, autodestruct: true }) - const { type, quality } = context + const context = await orCreateContext(node, options) + const { log, type, quality, dpi } = context const canvas = await domToCanvas(context) - return await canvasToblob(canvas, { type, quality }) + log.time('canvas to blob') + const blob = await canvasToBlob(canvas, type, quality) + if (['image/png', 'image/jpeg'].includes(type) && dpi) { + const arrayBuffer = await blobToArrayBuffer(blob.slice(0, 33)) + let uint8Array = new Uint8Array(arrayBuffer) + if (type === 'image/png') { + uint8Array = changePngDpi(uint8Array, dpi) + } else if (type === 'image/jpeg') { + uint8Array = changeJpegDpi(uint8Array, dpi) + } + return new Blob([uint8Array, blob.slice(33)], { type }) + } + log.timeEnd('canvas to blob') + return blob } diff --git a/src/converts/dom-to-canvas.ts b/src/converts/dom-to-canvas.ts index 2f000ad..78554f7 100644 --- a/src/converts/dom-to-canvas.ts +++ b/src/converts/dom-to-canvas.ts @@ -1,7 +1,7 @@ -import { createContext, createStyleElement } from '../create-context' -import { createImage, isContext, svgToDataUrl } from '../utils' +import { createStyleElement, orCreateContext } from '../create-context' +import { createImage, svgToDataUrl } from '../utils' +import { imageToCanvas } from '../image-to-canvas' import { domToForeignObjectSvg } from './dom-to-foreign-object-svg' -import { imageToCanvas } from './image-to-canvas' import type { Context } from '../context' import type { Options } from '../options' @@ -9,9 +9,7 @@ import type { Options } from '../options' export async function domToCanvas(node: T, options?: Options): Promise export async function domToCanvas(context: Context): Promise export async function domToCanvas(node: any, options?: any) { - const context = isContext(node) - ? node - : await createContext(node, { ...options, autodestruct: true }) + const context = await orCreateContext(node, options) const svg = await domToForeignObjectSvg(context) const dataUrl = svgToDataUrl(svg) if (!context.autodestruct) { diff --git a/src/converts/dom-to-data-url.ts b/src/converts/dom-to-data-url.ts index 480f84b..fb4476a 100644 --- a/src/converts/dom-to-data-url.ts +++ b/src/converts/dom-to-data-url.ts @@ -1,5 +1,7 @@ -import { createContext } from '../create-context' -import { consoleTime, consoleTimeEnd, isContext } from '../utils' +import { changeJpegDpi } from '../change-jpeg-dpi' +import { changePngDpi, detectPhysChunkFromDataUrl } from '../change-png-dpi' +import { orCreateContext } from '../create-context' +import { SUPPORT_ATOB, SUPPORT_BTOA } from '../utils' import { domToCanvas } from './dom-to-canvas' import type { Context } from '../context' import type { Options } from '../options' @@ -7,13 +9,47 @@ import type { Options } from '../options' export async function domToDataUrl(node: T, options?: Options): Promise export async function domToDataUrl(context: Context): Promise export async function domToDataUrl(node: any, options?: any) { - const context = isContext(node) - ? node - : await createContext(node, { ...options, autodestruct: true }) - const { debug, quality, type } = context + const context = await orCreateContext(node, options) + const { log, quality, type, dpi } = context const canvas = await domToCanvas(context) - debug && consoleTime('canvas to data url') - const dataURL = canvas.toDataURL(type, quality) - debug && consoleTimeEnd('canvas to data url') - return dataURL + log.time('canvas to data url') + let dataUrl = canvas.toDataURL(type, quality) + if ( + ['image/png', 'image/jpeg'].includes(type) + && dpi + && SUPPORT_ATOB + && SUPPORT_BTOA + ) { + const [format, body] = dataUrl.split(',') + let headerLength = 0 + let overwritepHYs = false + if (type === 'image/png') { + const b64Index = detectPhysChunkFromDataUrl(body) + // 28 bytes in dataUrl are 21bytes, length of phys chunk with everything inside. + if (b64Index >= 0) { + headerLength = Math.ceil((b64Index + 28) / 3) * 4 + overwritepHYs = true + } else { + headerLength = 33 / 3 * 4 + } + } else if (type === 'image/jpeg') { + headerLength = 18 / 3 * 4 + } + // 33 bytes are ok for pngs and jpegs + // to contain the information. + const stringHeader = body.substring(0, headerLength) + const restOfData = body.substring(headerLength) + const headerBytes = window.atob(stringHeader) + const uint8Array = new Uint8Array(headerBytes.length) + for (let i = 0; i < uint8Array.length; i++) { + uint8Array[i] = headerBytes.charCodeAt(i) + } + const finalArray = type === 'image/png' + ? changePngDpi(uint8Array, dpi, overwritepHYs) + : changeJpegDpi(uint8Array, dpi) + const base64Header = window.btoa(String.fromCharCode(...finalArray)) + dataUrl = [format, ',', base64Header, restOfData].join('') + } + log.timeEnd('canvas to data url') + return dataUrl } diff --git a/src/converts/dom-to-foreign-object-svg.ts b/src/converts/dom-to-foreign-object-svg.ts index 61a8dec..e8316f6 100644 --- a/src/converts/dom-to-foreign-object-svg.ts +++ b/src/converts/dom-to-foreign-object-svg.ts @@ -1,14 +1,11 @@ import { cloneNode } from '../clone-node' -import { createContext } from '../create-context' +import { orCreateContext } from '../create-context' import { destroyContext } from '../destroy-context' import { embedWebFont } from '../embed-web-font' import { embedNode } from '../embed-node' import { - consoleTime, - consoleTimeEnd, consoleWarn, createSvg, - isContext, isElementNode, isSVGElementNode, } from '../utils' @@ -18,14 +15,12 @@ import type { Options } from '../options' export async function domToForeignObjectSvg(node: T, options?: Options): Promise export async function domToForeignObjectSvg(context: Context): Promise export async function domToForeignObjectSvg(node: any, options?: any) { - const context = isContext(node) - ? node - : await createContext(node, { ...options, autodestruct: true }) + const context = await orCreateContext(node, options) if (isElementNode(context.node) && isSVGElementNode(context.node)) return context.node const { - debug, + log, tasks, svgStyleElement, font, @@ -36,19 +31,19 @@ export async function domToForeignObjectSvg(node: any, options?: any) { onCreateForeignObjectSvg, } = context - debug && consoleTime('clone node') + log.time('clone node') const clone = cloneNode(context.node, context, true) - debug && consoleTimeEnd('clone node') + log.timeEnd('clone node') onCloneNode?.(clone) if (font !== false && isElementNode(clone)) { - debug && consoleTime('embed web font') + log.time('embed web font') await embedWebFont(clone, context) - debug && consoleTimeEnd('embed web font') + log.timeEnd('embed web font') } - debug && consoleTime('embed node') + log.time('embed node') embedNode(clone, context) const count = tasks.length let current = 0 @@ -66,7 +61,7 @@ export async function domToForeignObjectSvg(node: any, options?: any) { } progress?.(current, count) await Promise.all([...Array(4)].map(runTask)) - debug && consoleTimeEnd('embed node') + log.timeEnd('embed node') onEmbedNode?.(clone) diff --git a/src/converts/dom-to-image.ts b/src/converts/dom-to-image.ts index 93faf6b..1cb2fb7 100644 --- a/src/converts/dom-to-image.ts +++ b/src/converts/dom-to-image.ts @@ -1,5 +1,5 @@ -import { createContext } from '../create-context' -import { createImage, isContext } from '../utils' +import { orCreateContext } from '../create-context' +import { createImage } from '../utils' import { domToDataUrl } from './dom-to-data-url' import { domToSvg } from './dom-to-svg' import type { Context } from '../context' @@ -8,9 +8,7 @@ import type { Options } from '../options' export async function domToImage(node: T, options?: Options): Promise export async function domToImage(context: Context): Promise export async function domToImage(node: any, options?: any) { - const context = isContext(node) - ? node - : await createContext(node, { ...options, autodestruct: true }) + const context = await orCreateContext(node, options) const { ownerDocument, width, height, scale, type } = context const url = type === 'image/svg+xml' ? await domToSvg(context) diff --git a/src/converts/dom-to-jpeg.ts b/src/converts/dom-to-jpeg.ts index 49715ec..fd0f7af 100644 --- a/src/converts/dom-to-jpeg.ts +++ b/src/converts/dom-to-jpeg.ts @@ -1,5 +1,4 @@ -import { createContext } from '../create-context' -import { isContext } from '../utils' +import { orCreateContext } from '../create-context' import { domToDataUrl } from './dom-to-data-url' import type { Context } from '../context' import type { Options } from '../options' @@ -7,8 +6,7 @@ import type { Options } from '../options' export async function domToJpeg(node: T, options?: Options): Promise export async function domToJpeg(context: Context): Promise export async function domToJpeg(node: any, options?: any): Promise { - const context = isContext(node) - ? node - : await createContext(node, { ...options, autodestruct: true, type: 'image/jpeg' }) - return domToDataUrl(context) + return domToDataUrl( + await orCreateContext(node, { ...options, type: 'image/jpeg' }), + ) } diff --git a/src/converts/dom-to-pixel.ts b/src/converts/dom-to-pixel.ts index 413c147..3d5c8f7 100644 --- a/src/converts/dom-to-pixel.ts +++ b/src/converts/dom-to-pixel.ts @@ -1,5 +1,4 @@ -import { createContext } from '../create-context' -import { isContext } from '../utils' +import { orCreateContext } from '../create-context' import { domToCanvas } from './dom-to-canvas' import type { Context } from '../context' import type { Options } from '../options' @@ -7,9 +6,7 @@ import type { Options } from '../options' export async function domToPixel(node: T, options?: Options): Promise export async function domToPixel(context: Context): Promise export async function domToPixel(node: any, options?: any) { - const context = isContext(node) - ? node - : await createContext(node, { ...options, autodestruct: true }) + const context = await orCreateContext(node, options) const canvas = await domToCanvas(context) return canvas.getContext('2d')! .getImageData(0, 0, canvas.width, canvas.height) diff --git a/src/converts/dom-to-png.ts b/src/converts/dom-to-png.ts index 4be01b4..33dc92e 100644 --- a/src/converts/dom-to-png.ts +++ b/src/converts/dom-to-png.ts @@ -1,5 +1,4 @@ -import { createContext } from '../create-context' -import { isContext } from '../utils' +import { orCreateContext } from '../create-context' import { domToDataUrl } from './dom-to-data-url' import type { Context } from '../context' import type { Options } from '../options' @@ -7,8 +6,7 @@ import type { Options } from '../options' export async function domToPng(node: T, options?: Options): Promise export async function domToPng(context: Context): Promise export async function domToPng(node: any, options?: any) { - const context = isContext(node) - ? node - : await createContext(node, { ...options, autodestruct: true, type: 'image/png' }) - return domToDataUrl(context) + return domToDataUrl( + await orCreateContext(node, { ...options, type: 'image/png' }), + ) } diff --git a/src/converts/dom-to-svg.ts b/src/converts/dom-to-svg.ts index 3d65cfd..cd03343 100644 --- a/src/converts/dom-to-svg.ts +++ b/src/converts/dom-to-svg.ts @@ -1,5 +1,5 @@ -import { createContext } from '../create-context' -import { createSvg, isContext, svgToDataUrl } from '../utils' +import { orCreateContext } from '../create-context' +import { createSvg, svgToDataUrl } from '../utils' import { domToDataUrl } from './dom-to-data-url' import type { Context } from '../context' import type { Options } from '../options' @@ -7,9 +7,7 @@ import type { Options } from '../options' export async function domToSvg(node: T, options?: Options): Promise export async function domToSvg(context: Context): Promise export async function domToSvg(node: any, options?: any) { - const context = isContext(node) - ? node - : await createContext(node, { ...options, autodestruct: true }) + const context = await orCreateContext(node, options) const { width, height, ownerDocument } = context const dataUrl = await domToDataUrl(context) const svg = createSvg(width, height, ownerDocument) diff --git a/src/converts/dom-to-webp.ts b/src/converts/dom-to-webp.ts index c0332ab..27c6ceb 100644 --- a/src/converts/dom-to-webp.ts +++ b/src/converts/dom-to-webp.ts @@ -1,5 +1,4 @@ -import { createContext } from '../create-context' -import { isContext } from '../utils' +import { orCreateContext } from '../create-context' import { domToDataUrl } from './dom-to-data-url' import type { Context } from '../context' import type { Options } from '../options' @@ -7,8 +6,7 @@ import type { Options } from '../options' export async function domToWebp(node: T, options?: Options): Promise export async function domToWebp(context: Context): Promise export async function domToWebp(node: any, options?: any) { - const context = isContext(node) - ? node - : await createContext(node, { ...options, autodestruct: true, type: 'image/webp' }) - return domToDataUrl(context) + return domToDataUrl( + await orCreateContext(node, { ...options, type: 'image/webp' }), + ) } diff --git a/src/create-context.ts b/src/create-context.ts index 0dfa509..8a6542e 100644 --- a/src/create-context.ts +++ b/src/create-context.ts @@ -1,18 +1,23 @@ +import { createLogger } from './create-logger' import { getDefaultRequestInit } from './get-default-request-init' import { IN_BROWSER, SUPPORT_WEB_WORKER, - consoleTime, - consoleTimeEnd, + isContext, isElementNode, - supportWebp, - waitUntilLoad, + supportWebp, waitUntilLoad, } from './utils' import type { Context, Request } from './context' import type { Options } from './options' +export async function orCreateContext(context: Context): Promise> +export async function orCreateContext(node: T, options?: Options): Promise> +export async function orCreateContext(node: any, options?: Options): Promise { + return isContext(node) ? node : createContext(node, { ...options, autodestruct: true }) +} + export async function createContext(node: T, options?: Options & { autodestruct?: boolean }): Promise> { - const { workerUrl, workerNumber = 1 } = options || {} + const { scale = 1, workerUrl, workerNumber = 1 } = options || {} const debug = Boolean(options?.debug) @@ -26,7 +31,7 @@ export async function createContext(node: T, options?: Options & height: 0, quality: 1, type: 'image/png', - scale: 1, + scale, backgroundColor: null, style: null, filter: null, @@ -50,12 +55,13 @@ export async function createContext(node: T, options?: Options & autodestruct: false, ...options, - __CONTEXT__: true, - // InternalContext + __CONTEXT__: true, + log: createLogger(debug), node, ownerDocument, ownerWindow, + dpi: scale === 1 ? null : 96 * scale, svgStyleElement: createStyleElement(ownerDocument), defaultComputedStyles: new Map>(), workers: [ @@ -94,9 +100,9 @@ export async function createContext(node: T, options?: Options & tasks: [], } - debug && consoleTime('wait until load') + context.log.time('wait until load') await waitUntilLoad(node, context.timeout) - debug && consoleTimeEnd('wait until load') + context.log.timeEnd('wait until load') const { width, height } = resolveBoundingBox(node, context) context.width = width diff --git a/src/create-element-clone.ts b/src/create-element-clone.ts deleted file mode 100644 index eac84d2..0000000 --- a/src/create-element-clone.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createIframeClone } from './create-iframe-clone' -import { isCanvasElement, isIFrameElement, isImageElement, isVideoElement } from './utils' -import { createCanvasClone } from './create-canvas-clone' -import { createVideoClone } from './create-video-clone' -import { createImageClone } from './create-image-clone' -import type { Context } from './context' - -export function createElementClone( - node: T, - context: Context, -): HTMLElement | SVGElement { - if (isCanvasElement(node)) { - return createCanvasClone(node) - } - - if (isIFrameElement(node)) { - return createIframeClone(node, context) - } - - if (isImageElement(node)) { - return createImageClone(node) - } - - if (isVideoElement(node)) { - return createVideoClone(node) - } - - return node.cloneNode(false) as T -} diff --git a/src/create-logger.ts b/src/create-logger.ts new file mode 100644 index 0000000..aec9686 --- /dev/null +++ b/src/create-logger.ts @@ -0,0 +1,9 @@ +import { consoleTime, consoleTimeEnd, consoleWarn } from './utils' + +export function createLogger(debug: boolean) { + return { + time: (label: string) => debug && consoleTime(label), + timeEnd: (label: string) => debug && consoleTimeEnd(label), + warn: (...args: any[]) => debug && consoleWarn(...args), + } +} diff --git a/src/css-url.ts b/src/css-url.ts index d626bf7..f59b0a7 100644 --- a/src/css-url.ts +++ b/src/css-url.ts @@ -19,7 +19,7 @@ export async function replaceCssUrlToDataUrl( ? resolveUrl(url, baseUrl) : url, requestType: isImage ? 'image' : 'text', - responseType: 'base64', + responseType: 'dataUrl', }, ) cssText = cssText.replace(toRE(url), `$1${ dataUrl }$3`) diff --git a/src/embed-image-element.ts b/src/embed-image-element.ts index f278989..5a71675 100644 --- a/src/embed-image-element.ts +++ b/src/embed-image-element.ts @@ -15,7 +15,7 @@ export function embedImageElement( url, imageDom: clone, requestType: 'image', - responseType: 'base64', + responseType: 'dataUrl', }).then(url => { clone.src = url }), @@ -28,7 +28,7 @@ export function embedImageElement( url, imageDom: clone, requestType: 'image', - responseType: 'base64', + responseType: 'dataUrl', }).then(url => { clone.href.baseVal = url }), diff --git a/src/embed-web-font.ts b/src/embed-web-font.ts index e6c26d9..9f4d420 100644 --- a/src/embed-web-font.ts +++ b/src/embed-web-font.ts @@ -132,7 +132,7 @@ async function embedFonts(cssText: string, baseUrl: string, context: Context): P return contextFetch(context, { url, requestType: 'text', - responseType: 'base64', + responseType: 'dataUrl', }).then(base64 => { // Side Effect cssText = cssText.replace(location, `url(${ base64 })`) diff --git a/src/fetch.ts b/src/fetch.ts index b8985b7..6a6677c 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,10 +1,10 @@ -import { consoleWarn } from './utils' +import { blobToDataUrl, consoleWarn } from './utils' import type { Context, Request } from './context' export type BaseFetchOptions = RequestInit & { url: string timeout?: number - responseType?: 'text' | 'base64' + responseType?: 'text' | 'dataUrl' } export type ContextFetchOptions = BaseFetchOptions & { @@ -25,21 +25,8 @@ export function baseFetch(options: BaseFetchOptions): Promise { .finally(() => clearTimeout(timer)) .then(response => { switch (responseType) { - case 'base64': - return response.blob().then(blob => { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onloadend = () => { - if (reader.result) { - resolve(reader.result as string) - } else { - reject(new Error(`Empty response content by ${ response.url }`)) - } - } - reader.onerror = reject - reader.readAsDataURL(blob) - }) - }) + case 'dataUrl': + return response.blob().then(blobToDataUrl) case 'text': default: return response.text() diff --git a/src/converts/image-to-canvas.ts b/src/image-to-canvas.ts similarity index 80% rename from src/converts/image-to-canvas.ts rename to src/image-to-canvas.ts index 2db3b9e..2656a69 100644 --- a/src/converts/image-to-canvas.ts +++ b/src/image-to-canvas.ts @@ -1,24 +1,18 @@ -import { createContext } from '../create-context' -import { IN_SAFARI, consoleTime, consoleTimeEnd, consoleWarn, isContext, loadMedia } from '../utils' -import type { Context } from '../context' -import type { Options } from '../options' +import { IN_SAFARI, consoleWarn, loadMedia } from './utils' +import type { Context } from './context' export async function imageToCanvas( image: T, - options?: Options | Context, + context: Context, ): Promise { - const context = isContext(options) - ? options - : await createContext(image, { ...options, autodestruct: true }) - const { + log, requestImagesCount, timeout, drawImageInterval, - debug, } = context - debug && consoleTime('image to canvas') + log.time('image to canvas') const loaded = await loadMedia(image, { timeout }) const { canvas, context2d } = createCanvas(image.ownerDocument, context) const drawImage = () => { @@ -40,7 +34,7 @@ export async function imageToCanvas( }) } } - debug && consoleTimeEnd('image to canvas') + log.timeEnd('image to canvas') return canvas } diff --git a/src/index.ts b/src/index.ts index d1aaaf1..abb0b01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -export { canvasToblob } from './converts/canvas-to-blob' export { domToBlob } from './converts/dom-to-blob' export { domToCanvas } from './converts/dom-to-canvas' export { domToDataUrl } from './converts/dom-to-data-url' @@ -9,7 +8,6 @@ export { domToPixel } from './converts/dom-to-pixel' export { domToPng } from './converts/dom-to-png' export { domToSvg } from './converts/dom-to-svg' export { domToWebp } from './converts/dom-to-webp' -export { imageToCanvas } from './converts/image-to-canvas' export { createContext } from './create-context' export { destroyContext } from './destroy-context' export { loadMedia, waitUntilLoad } from './utils' diff --git a/src/utils.ts b/src/utils.ts index b5c4bf1..bc1a13f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,8 @@ import type { Context } from './context' export const PREFIX = '[modern-screenshot]' export const IN_BROWSER = typeof window !== 'undefined' export const SUPPORT_WEB_WORKER = IN_BROWSER && 'Worker' in window +export const SUPPORT_ATOB = IN_BROWSER && 'atob' in window +export const SUPPORT_BTOA = IN_BROWSER && 'btoa' in window export const USER_AGENT = IN_BROWSER ? window.navigator?.userAgent : '' export const IN_CHROME = USER_AGENT.includes('Chrome') export const IN_SAFARI = USER_AGENT.includes('AppleWebKit') && !IN_CHROME @@ -95,6 +97,57 @@ export function svgToDataUrl(svg: SVGElement) { return `data:image/svg+xml;charset=utf-8,${ encodeURIComponent(xhtml) }` } +// To Blob +export async function canvasToBlob(canvas: HTMLCanvasElement, type = 'image/png', quality = 1): Promise { + try { + return await new Promise((resolve, reject) => { + canvas.toBlob(blob => { + if (blob) { + resolve(blob) + } else { + reject(new Error('Blob is null')) + } + }, type, quality) + }) + } catch (error) { + if (SUPPORT_ATOB) { + consoleWarn('Failed canvas to blob', { type, quality }, error) + return dataUrlToBlob(canvas.toDataURL(type, quality)) + } + throw error + } +} +export function dataUrlToBlob(dataUrl: string) { + const [header, base64] = dataUrl.split(',') + const type = header.match(/data:(.+);/)?.[1] ?? undefined + const decoded = window.atob(base64) + const length = decoded.length + const buffer = new Uint8Array(length) + for (let i = 0; i < length; i += 1) { + buffer[i] = decoded.charCodeAt(i) + } + return new Blob([buffer], { type }) +} + +// Blob to +export function readBlob(blob: Blob, type: 'dataUrl'): Promise +export function readBlob(blob: Blob, type: 'arrayBuffer'): Promise +export function readBlob(blob: Blob, type: 'dataUrl' | 'arrayBuffer') { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result) + reader.onerror = () => reject(reader.error) + reader.onabort = () => reject(new Error(`Failed read blob to ${ type }`)) + if (type === 'dataUrl') { + reader.readAsDataURL(blob) + } else if (type === 'arrayBuffer') { + reader.readAsArrayBuffer(blob) + } + }) +} +export const blobToDataUrl = (blob: Blob) => readBlob(blob, 'dataUrl') +export const blobToArrayBuffer = (blob: Blob) => readBlob(blob, 'arrayBuffer') + export function createImage(url: string, ownerDocument?: Document | null, useCORS = false): HTMLImageElement { const img = getDocument(ownerDocument).createElement('img') if (useCORS) {