mirror of
https://github.com/pionxzh/chatgpt-exporter.git
synced 2026-09-21 05:27:34 -05:00
feat: replace screenshot renderer with SnapDOM
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
Generated
+7
-34
@@ -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'}
|
||||
|
||||
+256
-47
@@ -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<HTMLElement>('#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<HTMLElement>('[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<HTMLElement>('[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<string, HTMLElement>()
|
||||
if (scrollRoot && turnContainerIds.length > 0) {
|
||||
for (const turnContainerId of turnContainerIds) {
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
const container = Array.from(threadEl.querySelectorAll<HTMLElement>('[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<string | null> => {
|
||||
const ratio = window.devicePixelRatio || 1
|
||||
const scale = ratio * 2 * additionalScale // scale up to 2x to avoid blurry images
|
||||
const renderedTurn = container.querySelector<HTMLElement>('[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<HTMLElement>('[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<ReturnType<typeof snapdom>> | 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<Blob | null> => {
|
||||
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<Blob | null> => {
|
||||
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<Blob | null>(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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void>) => Promise<void>,
|
||||
): Promise<Blob> {
|
||||
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' })
|
||||
}
|
||||
+2
-1
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user