From f6c38b2bb09052ad6a205df88b3e1ab04cd172a2 Mon Sep 17 00:00:00 2001 From: Pionxzh Date: Fri, 28 Aug 2026 11:35:00 +0800 Subject: [PATCH] feat: replace screenshot renderer with SnapDOM --- package.json | 2 +- pnpm-lock.yaml | 41 +----- src/exporter/image.ts | 303 +++++++++++++++++++++++++++++++++++------- src/utils/download.ts | 1 + src/utils/png.ts | 116 ++++++++++++++++ vite.config.ts | 3 +- 6 files changed, 383 insertions(+), 83 deletions(-) create mode 100644 src/utils/png.ts diff --git a/package.json b/package.json index e4c7dbb..773178b 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "@headlessui/react": "^1.7.14", "@radix-ui/react-dialog": "^1.0.3", "@radix-ui/react-hover-card": "^1.0.5", + "@zumer/snapdom": "2.24.10", "hast-util-to-html": "^8.0.4", - "html2canvas": "^1.4.1", "i18next": "^22.5.1", "jszip": "3.9.1", "mdast": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fa40d5..75c8c0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,12 @@ dependencies: '@radix-ui/react-hover-card': specifier: ^1.0.5 version: 1.0.5(react-dom@18.3.1)(react@18.3.1) + '@zumer/snapdom': + specifier: 2.24.10 + version: 2.24.10 hast-util-to-html: specifier: ^8.0.4 version: 8.0.4 - html2canvas: - specifier: ^1.4.1 - version: 1.4.1 i18next: specifier: ^22.5.1 version: 22.5.1 @@ -1624,6 +1624,10 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true + /@zumer/snapdom@2.24.10: + resolution: {integrity: sha512-yK+5HvcP96aZCG8dcOuJDsOD1TACDeSTI0wlsmQkMeeGaM/JVHdBQZPS4h0Uae6bY/zx+WQCJtOKnrbB6D7NgQ==} + dev: false + /JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -1809,11 +1813,6 @@ packages: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true - /base64-arraybuffer@1.0.2: - resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} - engines: {node: '>= 0.6.0'} - dev: false - /big-integer@1.6.51: resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} engines: {node: '>=0.6'} @@ -2120,12 +2119,6 @@ packages: shebang-command: 2.0.0 which: 2.0.2 - /css-line-break@2.1.0: - resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} - dependencies: - utrie: 1.0.2 - dev: false - /css-select@5.1.0: resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} dependencies: @@ -3335,14 +3328,6 @@ packages: resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==} dev: false - /html2canvas@1.4.1: - resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} - engines: {node: '>=8.0.0'} - dependencies: - css-line-break: 2.1.0 - text-segmentation: 1.0.3 - dev: false - /htmlparser2@9.0.0: resolution: {integrity: sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==} dependencies: @@ -5503,12 +5488,6 @@ packages: engines: {node: '>=8'} dev: true - /text-segmentation@1.0.3: - resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} - dependencies: - utrie: 1.0.2 - dev: false - /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true @@ -5777,12 +5756,6 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - /utrie@1.0.2: - resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} - dependencies: - base64-arraybuffer: 1.0.2 - dev: false - /uvu@0.5.6: resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} engines: {node: '>=8'} diff --git a/src/exporter/image.ts b/src/exporter/image.ts index 476fadd..7ceeac9 100644 --- a/src/exporter/image.ts +++ b/src/exporter/image.ts @@ -1,13 +1,34 @@ -import html2canvas from 'html2canvas' +import { snapdom } from '@zumer/snapdom' import i18n from '../i18n' import { checkIfConversationStarted, getChatIdFromUrl } from '../page' -import { downloadUrl, getFileNameWithFormat } from '../utils/download' +import { downloadFile, getFileNameWithFormat } from '../utils/download' import { Effect } from '../utils/effect' +import { encodePng } from '../utils/png' import { sleep } from '../utils/utils' -// https://github.com/niklasvh/html2canvas/issues/2792#issuecomment-1042948572 -function fnIgnoreElements(el: any) { - return typeof el.shadowRoot === 'object' && el.shadowRoot !== null +const MAX_SCREENSHOT_DIMENSION = 16_000 +const MAX_TILE_PIXELS = 16_000_000 + +function scrollElementWithinRoot(scrollRoot: HTMLElement, target: HTMLElement, block: 'start' | 'center') { + const scrollRect = scrollRoot.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const offset = targetRect.top - scrollRect.top + const alignment = block === 'center' ? (scrollRoot.clientHeight - targetRect.height) / 2 : 0 + const requestedScrollTop = Math.max(0, Math.min( + scrollRoot.scrollHeight - scrollRoot.clientHeight, + scrollRoot.scrollTop + offset - alignment, + )) + + scrollRoot.scrollTop = requestedScrollTop + scrollRoot.dispatchEvent(new Event('scroll', { bubbles: true })) +} + +function findCommonAncestor(elements: HTMLElement[]) { + let ancestor = elements[0]?.parentElement + while (ancestor && !elements.every(element => ancestor!.contains(element))) { + ancestor = ancestor.parentElement + } + return ancestor } export async function exportToPng(fileNameFormat: string) { @@ -18,18 +39,27 @@ export async function exportToPng(fileNameFormat: string) { const effect = new Effect() - const thread = document.querySelector('#thread div:has(> [data-testid="conversation-turn-1"]') + const conversationTurns = Array.from(document.querySelectorAll('#thread [data-testid^="conversation-turn-"]')) + const thread = findCommonAncestor(conversationTurns) if (!thread || thread.children.length === 0 || thread.scrollHeight < 50) { alert(i18n.t('Failed to export to PNG. Failed to find the element node.')) return false } const isDarkMode = document.documentElement.classList.contains('dark') + const threadEl = thread as HTMLElement + const turnContainers = Array.from(threadEl.querySelectorAll('[data-turn-id-container][data-is-intersecting]')) + .filter(element => !!element.querySelector('[data-testid^="conversation-turn-"]') || element.offsetHeight > 0 || !!element.style.getPropertyValue('--last-known-height')) + const turnContainerIds = turnContainers + .map(element => element.dataset.turnIdContainer) + .filter((id): id is string => !!id && id !== 'client-created-root') effect.add(() => { + threadEl.setAttribute('data-chatgpt-exporter-screenshot-root', '') + const style = document.createElement('style') style.textContent = ` - #thread div:has(> [data-testid="conversation-turn-1"]), + [data-chatgpt-exporter-screenshot-root], #thread [data-testid^="conversation-turn-"] { color: ${isDarkMode ? '#ececec' : '#0d0d0d'}; background-color: ${isDarkMode ? '#212121' : '#fff'}; @@ -51,8 +81,10 @@ export async function exportToPng(fileNameFormat: string) { #page-header, #thread-bottom-container, + /* date separators such as "Yesterday 10:08 AM" */ + [data-chatgpt-exporter-screenshot-root] [role="separator"], /* any other elements that are not conversation turns */ - #thread div:has(> [data-testid="conversation-turn-1"]) > :not([data-testid^="conversation-turn-"]), + [data-chatgpt-exporter-screenshot-root] > :not([data-turn-id-container]):not([data-testid^="conversation-turn-"]):not(:has([data-testid^="conversation-turn-"])), /* hide back to top button */ button.absolute, /* question button */ @@ -60,81 +92,258 @@ export async function exportToPng(fileNameFormat: string) { display: none; } - /* conversation action bar */ - .group\\/conversation-turn > div > div.absolute, + /* Preserve the action row's spacing while hiding its toolbar. */ + [data-testid^="conversation-turn-"] [role="group"]:has([data-testid="copy-turn-action-button"]), /* code block buttons */ #thread pre button { visibility: hidden; } + + /* Later user turns currently have much larger top padding than the first one. */ + [data-testid^="conversation-turn-"][data-turn="user"] > h4 + div { + padding-top: 0 !important; + } ` - thread!.appendChild(style) - return () => style.remove() + threadEl.appendChild(style) + return () => { + style.remove() + threadEl.removeAttribute('data-chatgpt-exporter-screenshot-root') + } }) - const threadEl = thread as HTMLElement + const scrollRoot = threadEl.closest('[data-scroll-root]') + if (scrollRoot) { + effect.add(() => { + const scrollTop = scrollRoot.scrollTop + const scrollLeft = scrollRoot.scrollLeft + const overflowAnchor = scrollRoot.style.overflowAnchor + scrollRoot.style.overflowAnchor = 'none' + + return () => { + scrollRoot.style.overflowAnchor = overflowAnchor + scrollRoot.scrollTop = scrollTop + scrollRoot.scrollLeft = scrollLeft + } + }) + } effect.run() - await sleep(100) + // ChatGPT virtualizes off-screen turns. Visit every placeholder in DOM + // order and preserve a clone while each one is mounted. React cannot keep + // distant turns mounted at the same time, so the clones become our stable + // capture source. + const turnSnapshots = new Map() + if (scrollRoot && turnContainerIds.length > 0) { + for (const turnContainerId of turnContainerIds) { + for (let pass = 0; pass < 10; pass++) { + const container = Array.from(threadEl.querySelectorAll('[data-turn-id-container][data-is-intersecting]')) + .find(element => element.dataset.turnIdContainer === turnContainerId) + if (!container) break - const passLimit = 10 - const takeScreenshot = async (width: number, height: number, additionalScale = 1, currentPass = 1): Promise => { - const ratio = window.devicePixelRatio || 1 - const scale = ratio * 2 * additionalScale // scale up to 2x to avoid blurry images + const renderedTurn = container.querySelector('[data-testid^="conversation-turn-"]') + if (renderedTurn) { + turnSnapshots.set(turnContainerId, container.cloneNode(true) as HTMLElement) + break + } + + scrollElementWithinRoot(scrollRoot, container, 'center') + await sleep(250) + } + + if (!turnSnapshots.has(turnContainerId)) { + const placeholder = Array.from(threadEl.querySelectorAll('[data-turn-id-container][data-is-intersecting]')) + .find(element => element.dataset.turnIdContainer === turnContainerId) + if (placeholder) turnSnapshots.set(turnContainerId, placeholder.cloneNode(true) as HTMLElement) + } + } + } + else if (scrollRoot && conversationTurns[0]) { + scrollElementWithinRoot(scrollRoot, conversationTurns[0], 'start') + await sleep(250) + } + await sleep(500) + + let screenshotEl = threadEl + if (turnSnapshots.size > 0) { + const staticThread = threadEl.cloneNode(false) as HTMLElement + staticThread.setAttribute('data-chatgpt-exporter-screenshot-root', '') + staticThread.style.position = 'absolute' + staticThread.style.left = '-100000px' + staticThread.style.top = '0' + staticThread.style.width = `${threadEl.offsetWidth}px` + staticThread.style.height = 'auto' + staticThread.style.minHeight = '0' + staticThread.style.maxHeight = 'none' + staticThread.style.overflow = 'visible' + staticThread.style.pointerEvents = 'none' + + for (const turnContainerId of turnContainerIds) { + const snapshot = turnSnapshots.get(turnContainerId) + if (snapshot) staticThread.appendChild(snapshot) + } + + effect.add(() => { + document.body.appendChild(staticThread) + return () => staticThread.remove() + }) + effect.run() + screenshotEl = staticThread + await sleep(100) + } + + effect.add(() => { + const minHeight = screenshotEl.style.minHeight + screenshotEl.style.minHeight = `${screenshotEl.scrollHeight}px` + return () => { + screenshotEl.style.minHeight = minHeight + } + }) + effect.run() + + await sleep(0) + + const backgroundColor = isDarkMode ? '#212121' : '#fff' + const width = Math.max(screenshotEl.offsetWidth, screenshotEl.scrollWidth) + const height = Math.max(screenshotEl.offsetHeight, screenshotEl.scrollHeight) + + let capture: Awaited> | null = null + try { + capture = await snapdom(screenshotEl, { + embedFonts: true, + backgroundColor, + }) + } + catch (error) { + console.error('Failed to capture screenshot DOM', error) + } + + const sourceWidth = capture?.meta.vbW || width + const sourceHeight = capture?.meta.vbH || height + // Keep export quality consistent across operating systems, display + // densities and browser zoom levels. Only reduce it when a tile would + // exceed the browser's maximum canvas width. + const requestedScale = Math.min(2, MAX_SCREENSHOT_DIMENSION / sourceWidth) + const desiredWidth = Math.max(1, Math.floor(sourceWidth * requestedScale)) + // Use one exact scale derived from SnapDOM's viewBox. Mixing live DOM + // dimensions with capture dimensions can stretch and blur every tile. + const desiredScale = desiredWidth / sourceWidth + const desiredHeight = Math.max(1, Math.floor(sourceHeight * desiredScale)) + + const takeTiledScreenshot = async (): Promise => { + if (!capture) { + console.warn('[ChatGPT Exporter:screenshot] tiled capture unavailable') + return null + } + if (typeof CompressionStream === 'undefined') { + console.warn('[ChatGPT Exporter:screenshot] CompressionStream unavailable; using downscaled fallback') + return null + } + + const tileHeight = Math.max(1, Math.min( + MAX_SCREENSHOT_DIMENSION, + Math.floor(MAX_TILE_PIXELS / desiredWidth), + )) - let canvas: HTMLCanvasElement | null = null try { - canvas = await html2canvas(threadEl, { - scale, - useCORS: true, - scrollX: -window.scrollX, - scrollY: -window.scrollY, - windowWidth: width, - windowHeight: height, - ignoreElements: fnIgnoreElements, + return await encodePng(desiredWidth, async (appendRows) => { + for (let targetY = 0; targetY < desiredHeight; targetY += tileHeight) { + const targetTileHeight = Math.min(tileHeight, desiredHeight - targetY) + const sourceY = sourceHeight * targetY / desiredHeight + const sourceBottom = sourceHeight * (targetY + targetTileHeight) / desiredHeight + const canvas = await capture!.toCanvas({ + crop: { + x: 0, + y: sourceY, + width: sourceWidth, + height: sourceBottom - sourceY, + }, + scale: desiredScale, + dpr: 1, + backgroundColor, + }) + if (canvas.width !== desiredWidth) { + throw new Error(`Unexpected screenshot tile width: ${canvas.width}`) + } + const context = canvas.getContext('2d', { willReadFrequently: true }) + if (!context) throw new Error('Failed to read screenshot tile') + + const imageData = context.getImageData(0, 0, canvas.width, canvas.height) + await appendRows(imageData) + canvas.width = 1 + canvas.height = 1 + } }) } catch (error) { - // eslint-disable-next-line no-console - console.log(`ChatGPT Exporter:takeScreenshot with height=${height} width=${width} scale=${scale}`) + console.error('Failed to encode tiled screenshot', error) + return null + } + } + + const passLimit = 10 + const takeDownscaledScreenshot = async (additionalScale = 1, currentPass = 1): Promise => { + if (!capture) return null + + const scale = Math.min( + requestedScale, + MAX_SCREENSHOT_DIMENSION / sourceWidth, + MAX_SCREENSHOT_DIMENSION / sourceHeight, + ) * additionalScale + const targetWidth = Math.max(1, Math.floor(sourceWidth * scale)) + const targetHeight = Math.max(1, Math.floor(sourceHeight * scale)) + + let canvas: HTMLCanvasElement | null = null + try { + canvas = await capture.toCanvas({ + scale, + dpr: 1, + backgroundColor, + }) + + const context = canvas.getContext('2d') + if (context) context.imageSmoothingEnabled = false + + const blob = await new Promise(resolve => canvas!.toBlob(resolve, 'image/png', 1)) + if (blob) return blob + } + catch (error) { console.error('Failed to take screenshot', error) } - const context = canvas?.getContext('2d') - if (context) context.imageSmoothingEnabled = false - - const dataUrl = canvas?.toDataURL('image/png', 1) - .replace(/^data:image\/[^;]/, 'data:application/octet-stream') + // eslint-disable-next-line no-console + console.log(`ChatGPT Exporter:takeScreenshot with height=${height} width=${width} targetHeight=${targetHeight} targetWidth=${targetWidth}`) /** - * corrupted image - * meaning we might hit on the canvas size limit + * A browser may return an empty canvas instead of throwing when a + * raster exceeds its canvas or SVG decode limit. * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas#maximum_canvas_size * Chromium will not throw, we can only get an empty canvas * Firefox will throw "DOMException: CanvasRenderingContext2D.scale: Canvas exceeds max size." */ - if (!canvas || !dataUrl || dataUrl === 'data:,') { - if (currentPass > passLimit) return null + if (currentPass > passLimit) return null - // 1.4 ^ 5 ~= 5.37, should be enough for most cases - return takeScreenshot(width, height, additionalScale / 1.4, currentPass + 1) - } - - return dataUrl + // 1.4 ^ 5 ~= 5.37, should be enough for most cases + return takeDownscaledScreenshot(additionalScale / 1.4, currentPass + 1) } - const dataUrl = await takeScreenshot(thread.scrollWidth, thread.scrollHeight) + const shouldTile = desiredHeight > MAX_SCREENSHOT_DIMENSION + || desiredWidth * desiredHeight > MAX_SCREENSHOT_DIMENSION * MAX_SCREENSHOT_DIMENSION + let png = shouldTile ? await takeTiledScreenshot() : await takeDownscaledScreenshot() + if (!png && shouldTile) { + console.warn('[ChatGPT Exporter:screenshot] tiled export failed; using downscaled fallback') + png = await takeDownscaledScreenshot() + } effect.dispose() - if (!dataUrl) { + if (!png) { alert('Failed to export to PNG. This might be caused by the size of the conversation. Please try to export a smaller conversation.') return false } const chatId = getChatIdFromUrl() || undefined const fileName = getFileNameWithFormat(fileNameFormat, 'png', { chatId }) - downloadUrl(fileName, dataUrl) - window.URL.revokeObjectURL(dataUrl) + downloadFile(fileName, 'image/png', png) return true } diff --git a/src/utils/download.ts b/src/utils/download.ts index 14800ae..30922fd 100644 --- a/src/utils/download.ts +++ b/src/utils/download.ts @@ -10,6 +10,7 @@ export function downloadFile(filename: string, type: string, content: string | B document.body.appendChild(a) a.click() document.body.removeChild(a) + setTimeout(() => URL.revokeObjectURL(url), 1000) } export function downloadUrl(filename: string, url: string) { diff --git a/src/utils/png.ts b/src/utils/png.ts new file mode 100644 index 0000000..f085327 --- /dev/null +++ b/src/utils/png.ts @@ -0,0 +1,116 @@ +const PNG_SIGNATURE = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) +const PNG_TYPE_IHDR = new Uint8Array([73, 72, 68, 82]) +const PNG_TYPE_IDAT = new Uint8Array([73, 68, 65, 84]) +const PNG_TYPE_IEND = new Uint8Array([73, 69, 78, 68]) +const ROW_CHUNK_BYTES = 256 * 1024 + +const crcTable = new Uint32Array(256) +for (let index = 0; index < crcTable.length; index++) { + let value = index + for (let bit = 0; bit < 8; bit++) { + value = value & 1 ? 0xEDB88320 ^ (value >>> 1) : value >>> 1 + } + crcTable[index] = value >>> 0 +} + +function writeUint32(target: Uint8Array, offset: number, value: number) { + target[offset] = value >>> 24 + target[offset + 1] = value >>> 16 + target[offset + 2] = value >>> 8 + target[offset + 3] = value +} + +function pngChunk(type: Uint8Array, data = new Uint8Array()): Uint8Array { + const chunk = new Uint8Array(12 + data.length) + writeUint32(chunk, 0, data.length) + chunk.set(type, 4) + chunk.set(data, 8) + + let crc = 0xFFFFFFFF + for (let index = 4; index < 8 + data.length; index++) { + crc = crcTable[(crc ^ chunk[index]) & 0xFF] ^ (crc >>> 8) + } + writeUint32(chunk, 8 + data.length, (crc ^ 0xFFFFFFFF) >>> 0) + return chunk +} + +function pngHeader(width: number, height: number): Uint8Array { + const data = new Uint8Array(13) + writeUint32(data, 0, width) + writeUint32(data, 4, height) + data[8] = 8 // bit depth + data[9] = 6 // RGBA + return pngChunk(PNG_TYPE_IHDR, data) +} + +export interface RgbaRows { + data: Uint8ClampedArray + width: number + height: number +} + +export async function encodePng( + width: number, + renderRows: (appendRows: (rows: RgbaRows) => Promise) => Promise, +): Promise { + if (!Number.isInteger(width) || width <= 0) { + throw new RangeError('PNG width must be a positive integer') + } + if (typeof CompressionStream === 'undefined') { + throw new TypeError('CompressionStream is not supported by this browser') + } + + const compression = new CompressionStream('deflate') + const writer = compression.writable.getWriter() + const idatChunks: Uint8Array[] = [] + const readCompressedData = (async () => { + const reader = compression.readable.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + idatChunks.push(pngChunk(PNG_TYPE_IDAT, value)) + } + })() + + let writtenRows = 0 + try { + await renderRows(async ({ data, width: rowWidth, height: rowCount }) => { + if (rowWidth !== width || data.length !== rowWidth * rowCount * 4) { + throw new RangeError('Invalid RGBA rows supplied to PNG encoder') + } + + const rgbaStride = width * 4 + const pngStride = rgbaStride + 1 + const rowsPerWrite = Math.max(1, Math.floor(ROW_CHUNK_BYTES / pngStride)) + for (let startRow = 0; startRow < rowCount; startRow += rowsPerWrite) { + const rowsInWrite = Math.min(rowsPerWrite, rowCount - startRow) + const filteredRows = new Uint8Array(pngStride * rowsInWrite) + for (let row = 0; row < rowsInWrite; row++) { + const sourceOffset = (startRow + row) * rgbaStride + const targetOffset = row * pngStride + // Filter type 0 is intentionally simple and leaves compression + // to the browser's native deflate implementation. + filteredRows[targetOffset] = 0 + filteredRows.set(data.subarray(sourceOffset, sourceOffset + rgbaStride), targetOffset + 1) + } + await writer.write(filteredRows) + } + writtenRows += rowCount + }) + if (writtenRows === 0) throw new RangeError('PNG must contain at least one row') + await writer.close() + await readCompressedData + } + catch (error) { + await writer.abort(error).catch(() => {}) + await readCompressedData.catch(() => {}) + throw error + } + + return new Blob([ + PNG_SIGNATURE, + pngHeader(width, writtenRows), + ...idatChunks, + pngChunk(PNG_TYPE_IEND), + ], { type: 'image/png' }) +} diff --git a/vite.config.ts b/vite.config.ts index d13ff9d..3a87e52 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -62,7 +62,8 @@ export default defineConfig({ fileName: 'chatgpt.user.js', externalGlobals: [ ['jszip', cdn.jsdelivr('JSZip', 'dist/jszip.min.js')], - ['html2canvas', cdn.jsdelivr('html2canvas', 'dist/html2canvas.min.js')], + // SnapDOM's IIFE exposes its named export as window.snapdom. + ['@zumer/snapdom', cdn.jsdelivr('window', 'dist/snapdom.js')], ], cssSideEffects() { return (e) => {