From 77175a9dc4d2acc83b5391fd1db68054fa79c692 Mon Sep 17 00:00:00 2001 From: Orange Mug Date: Sat, 29 Apr 2023 23:10:01 +0100 Subject: [PATCH] Added `pHYs` to import/export of png images (#1200) Added the following - Always export pngs with a pixel-ratio of `2` - Added the `pHYs` png metadata chunk describing the pixel ratio so it opens with the correct size - When importing PNGs read the `pHYs` chunk for the sizing info All the exporting is done via just modifying the bytes from the browsers native image handling. https://user-images.githubusercontent.com/235915/234309015-19f39f3a-66ce-4ec2-b7d0-b34a07ed346b.mov I've also added `ANALYZE=true` option to get the build metadata from esbuild on boot of `yarn dev` which allow me to see the bundle size info in https://esbuild.github.io/analyze/ ![esbuild github io_analyze_](https://user-images.githubusercontent.com/235915/234310302-c6fe8109-c82d-480a-8c65-c7638b09e71e.png) You can see that `crc` adds about `4.4kb` Screenshot 2023-04-25 at 15 33 26 --------- Co-authored-by: Steve Ruiz --- .gitignore | 2 + apps/examples/scripts/dev.mjs | 13 +- packages/editor/package.json | 2 + packages/editor/src/lib/utils/assets.ts | 44 ++++--- packages/editor/src/lib/utils/export.ts | 8 +- packages/editor/src/lib/utils/png.ts | 144 +++++++++++++++++------ packages/ui/src/lib/hooks/useCopyAs.ts | 12 +- packages/ui/src/lib/hooks/useExportAs.ts | 4 +- public-yarn.lock | 23 ++++ 9 files changed, 191 insertions(+), 61 deletions(-) diff --git a/.gitignore b/.gitignore index 993be3bd9..bd4f0f5e0 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,5 @@ packages/*/api apps/examples/www/index.css apps/examples/www/index.js .tsbuild + +apps/examples/build.esbuild.json diff --git a/apps/examples/scripts/dev.mjs b/apps/examples/scripts/dev.mjs index bea134129..ac10b9dd0 100644 --- a/apps/examples/scripts/dev.mjs +++ b/apps/examples/scripts/dev.mjs @@ -7,6 +7,7 @@ import { createServer, request } from 'http' import ip from 'ip' import chalk from 'kleur' import * as url from 'url' +import fs from 'fs' const LOG_REQUEST_PATHS = false @@ -22,7 +23,9 @@ const OUT_DIR = dirname + '/../www/' const clients = [] async function main() { - await esbuild.build({ + const isAnalyzeEnabled = process.env.ANALYZE === 'true' + + const result = await esbuild.build({ entryPoints: ['src/index.tsx'], outdir: OUT_DIR, bundle: true, @@ -32,6 +35,7 @@ async function main() { format: 'cjs', external: ['*.woff'], target: browserslist(['defaults']), + metafile: isAnalyzeEnabled, define: { process: '{ "env": { "NODE_ENV": "development"} }', }, @@ -53,6 +57,13 @@ async function main() { }, }) + if (isAnalyzeEnabled) { + await fs.promises.writeFile('build.esbuild.json', JSON.stringify(result.metafile)); + console.log(await esbuild.analyzeMetafile(result.metafile, { + verbose: true, + })) + } + esbuild.serve({ servedir: OUT_DIR, port: 8009 }, {}).then(({ host, port: esbuildPort }) => { const handler = async (req, res) => { const { url, method, headers } = req diff --git a/packages/editor/package.json b/packages/editor/package.json index 28c39a358..4796b5a88 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -53,6 +53,7 @@ "@tldraw/utils": "workspace:*", "@use-gesture/react": "^10.2.24", "classnames": "^2.3.2", + "crc": "^4.3.2", "escape-string-regexp": "^5.0.0", "eventemitter3": "^4.0.7", "is-plain-object": "^5.0.0", @@ -71,6 +72,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@types/benchmark": "^2.1.2", + "@types/crc": "^3.8.0", "@types/lodash.throttle": "^4.1.7", "@types/lodash.uniq": "^4.5.7", "@types/react-test-renderer": "^18.0.0", diff --git a/packages/editor/src/lib/utils/assets.ts b/packages/editor/src/lib/utils/assets.ts index f37194611..29fe9585b 100644 --- a/packages/editor/src/lib/utils/assets.ts +++ b/packages/editor/src/lib/utils/assets.ts @@ -16,7 +16,7 @@ import uniq from 'lodash.uniq' import { App } from '../app/App' import { MAX_ASSET_HEIGHT, MAX_ASSET_WIDTH } from '../constants' import { isAnimated } from './is-gif-animated' -import { getPngDataView, getPngPixelRatio } from './png' +import { findChunk, isPng, parsePhys } from './png' /** @public */ export const ACCEPTED_IMG_TYPE = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'] @@ -47,6 +47,18 @@ export async function getVideoSizeFromSrc(src: string): Promise<{ w: number; h: }) } +/** + * @param dataURL - The file as a string. + * @internal + * + * from https://stackoverflow.com/a/53817185 + */ +export async function base64ToFile(dataURL: string) { + return fetch(dataURL).then(function (result) { + return result.arrayBuffer() + }) +} + /** * Get the size of an image from its source. * @@ -56,33 +68,33 @@ export async function getVideoSizeFromSrc(src: string): Promise<{ w: number; h: export async function getImageSizeFromSrc(dataURL: string): Promise<{ w: number; h: number }> { return await new Promise((resolve, reject) => { const img = new Image() - - // When the image loads, get its size using the image dimensions - // and, if possible, the pixel ratio derived from the image. Pngs - // have a pixel img.onload = async () => { - let pixelRatio = 1 - try { - const buffer = await fetch(dataURL).then((d) => d.arrayBuffer()) - const dataView = getPngDataView(buffer) - if (dataView) { - pixelRatio = getPngPixelRatio(dataView) + const blob = await base64ToFile(dataURL) + const view = new DataView(blob) + if (isPng(view, 0)) { + const physChunk = findChunk(view, 'pHYs') + if (physChunk) { + const physData = parsePhys(view, physChunk.dataOffset) + if (physData.unit === 0 && physData.ppux === physData.ppuy) { + const pixelRatio = Math.round(physData.ppux / 2834.5) + resolve({ w: img.width / pixelRatio, h: img.height / pixelRatio }) + return + } + } } + + resolve({ w: img.width, h: img.height }) } catch (err) { console.error(err) + resolve({ w: img.width, h: img.height }) } - - resolve({ w: img.width / pixelRatio, h: img.height / pixelRatio }) } - img.onerror = (err) => { console.error(err) reject(new Error('Could not get image size')) } - img.crossOrigin = 'anonymous' - img.src = dataURL }) } diff --git a/packages/editor/src/lib/utils/export.ts b/packages/editor/src/lib/utils/export.ts index dcbb39d48..dad0aa89c 100644 --- a/packages/editor/src/lib/utils/export.ts +++ b/packages/editor/src/lib/utils/export.ts @@ -1,5 +1,6 @@ import { TLGeoShape, TLNoteShape, TLShape } from '@tldraw/tlschema' import { debugFlags } from './debug-flags' +import { setPhysChunk } from './png' /** @public */ export type TLCopyType = 'svg' | 'png' | 'jpeg' | 'json' @@ -86,7 +87,12 @@ export async function getSvgAsImage( ) ) - return blob + if (!blob) return null + + const view = new DataView(await blob.arrayBuffer()) + return setPhysChunk(view, scale, { + type: 'image/' + type, + }) } /** @public */ diff --git a/packages/editor/src/lib/utils/png.ts b/packages/editor/src/lib/utils/png.ts index b0658184a..1276ad3d5 100644 --- a/packages/editor/src/lib/utils/png.ts +++ b/packages/editor/src/lib/utils/png.ts @@ -1,45 +1,119 @@ -const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] -const PIXELS_PER_METER = 2834.5 +import crc32 from 'crc/crc32' -/** - * Returns a data view for a PNG image. - * @param arrayBuffer - The ArrayBuffer containing the PNG image. - * @returns A DataView for the PNG image, or null if the image is not a PNG. - */ - -export function getPngDataView(arrayBuffer: ArrayBuffer): DataView | null { - const dataView = new DataView(arrayBuffer) - - for (let i = 0; i < PNG_SIGNATURE.length; i++) { - if (dataView.getUint8(i) !== PNG_SIGNATURE[i]) { - return null - } +export function isPng(view: DataView, offset: number) { + if ( + view.getUint8(offset + 0) === 0x89 && + view.getUint8(offset + 1) === 0x50 && + view.getUint8(offset + 2) === 0x4e && + view.getUint8(offset + 3) === 0x47 && + view.getUint8(offset + 4) === 0x0d && + view.getUint8(offset + 5) === 0x0a && + view.getUint8(offset + 6) === 0x1a && + view.getUint8(offset + 7) === 0x0a + ) { + return true } - - return dataView + return false } -/** - * Get the pixel ratio of a PNG data from its pHYs data. - * - * @param dataView - A dataview created from the image's array buffer. - * @returns The pixel ratio. - */ -export function getPngPixelRatio(dataView: DataView): number { - let offset = 8 // Start after PNG signature +function getChunkType(view: DataView, offset: number) { + return [ + String.fromCharCode(view.getUint8(offset)), + String.fromCharCode(view.getUint8(offset + 1)), + String.fromCharCode(view.getUint8(offset + 2)), + String.fromCharCode(view.getUint8(offset + 3)), + ].join('') +} - while (offset < dataView.byteLength) { - if ( - dataView.getUint8(offset + 4) === 0x70 && - dataView.getUint8(offset + 5) === 0x48 && - dataView.getUint8(offset + 6) === 0x59 && - dataView.getUint8(offset + 7) === 0x73 - ) { - return Math.ceil(dataView.getUint32(offset + 8) / PIXELS_PER_METER) +export function crc(arrayBuffer: ArrayBuffer) { + return crc32(arrayBuffer) +} + +const LEN_SIZE = 4 +const CRC_SIZE = 4 + +export function readChunks(view: DataView, offset = 0) { + const chunks: Record = {} + if (!isPng(view, offset)) { + throw new Error('Not a PNG') + } + offset += 8 + + while (offset <= view.buffer.byteLength) { + const start = offset + const len = view.getInt32(offset) + offset += 4 + const chunkType = getChunkType(view, offset) + + if (chunkType === 'IDAT' && chunks[chunkType]) { + offset += len + LEN_SIZE + CRC_SIZE + continue } - offset = offset + 8 + dataView.getUint32(offset) + 4 // Move to next chunk (4 bytes for CRC) + if (chunkType === 'IEND') { + break + } + + chunks[chunkType] = { + start, + dataOffset: offset + 4, + size: len, + } + offset += len + LEN_SIZE + CRC_SIZE } - return 1 // Didn't find a pixel ratio, so return default (1) + return chunks +} + +export function parsePhys(view: DataView, offset: number) { + return { + ppux: view.getUint32(offset), + ppuy: view.getUint32(offset + 4), + unit: view.getUint8(offset + 4), + } +} + +export function findChunk(view: DataView, type: string) { + const chunks = readChunks(view) + return chunks[type] +} + +export function setPhysChunk(view: DataView, dpr = 1, options?: BlobPropertyBag) { + let offset = 46 + let size = 0 + const res1 = findChunk(view, 'pHYs') + if (res1) { + offset = res1.start + size = res1.size + } + + const res2 = findChunk(view, 'IDAT') + if (res2) { + offset = res2.start + size = 0 + } + + const pHYsData = new ArrayBuffer(21) + const pHYsDataView = new DataView(pHYsData) + + pHYsDataView.setUint32(0, 9) + + pHYsDataView.setUint8(4, 'p'.charCodeAt(0)) + pHYsDataView.setUint8(5, 'H'.charCodeAt(0)) + pHYsDataView.setUint8(6, 'Y'.charCodeAt(0)) + pHYsDataView.setUint8(7, 's'.charCodeAt(0)) + + const DPI_96 = 2835.5 + + pHYsDataView.setInt32(8, DPI_96 * dpr) + pHYsDataView.setInt32(12, DPI_96 * dpr) + pHYsDataView.setInt8(16, 1) + + const crcBit = new Uint8Array(pHYsData.slice(4, 17)) + pHYsDataView.setInt32(17, crc(crcBit)) + + const startBuf = view.buffer.slice(0, offset) + const endBuf = view.buffer.slice(offset + size) + + return new Blob([startBuf, pHYsData, endBuf], options) } diff --git a/packages/ui/src/lib/hooks/useCopyAs.ts b/packages/ui/src/lib/hooks/useCopyAs.ts index e37d8d828..901ea6e83 100644 --- a/packages/ui/src/lib/hooks/useCopyAs.ts +++ b/packages/ui/src/lib/hooks/useCopyAs.ts @@ -37,7 +37,7 @@ export function useCopyAs() { ]) } else { fallbackWriteTextAsync(async () => - getSvgAsString(await getExportSvgElement(app, ids, format)) + getSvgAsString(await getExportSvgElement(app, ids)) ) } } @@ -100,9 +100,9 @@ export function useCopyAs() { ) } -async function getExportSvgElement(app: App, ids: TLShapeId[], format: TLCopyType) { +async function getExportSvgElement(app: App, ids: TLShapeId[]) { const svg = await app.getSvg(ids, { - scale: format === 'svg' ? 1 : 2, + scale: 1, background: app.instanceState.exportBackground, }) @@ -112,16 +112,16 @@ async function getExportSvgElement(app: App, ids: TLShapeId[], format: TLCopyTyp } async function getExportedSvgBlob(app: App, ids: TLShapeId[]) { - return new Blob([getSvgAsString(await getExportSvgElement(app, ids, 'svg'))], { + return new Blob([getSvgAsString(await getExportSvgElement(app, ids))], { type: 'text/plain', }) } async function getExportedImageBlob(app: App, ids: TLShapeId[], format: 'png' | 'jpeg') { - return await getSvgAsImage(await getExportSvgElement(app, ids, format), { + return await getSvgAsImage(await getExportSvgElement(app, ids), { type: format, quality: 1, - scale: 1, + scale: 2, }) } diff --git a/packages/ui/src/lib/hooks/useExportAs.ts b/packages/ui/src/lib/hooks/useExportAs.ts index 12f37113f..14c1db1ce 100644 --- a/packages/ui/src/lib/hooks/useExportAs.ts +++ b/packages/ui/src/lib/hooks/useExportAs.ts @@ -28,7 +28,7 @@ export function useExportAs() { } const svg = await app.getSvg(ids, { - scale: format === 'svg' ? 1 : 2, + scale: 1, background: app.instanceState.exportBackground, }) @@ -56,7 +56,7 @@ export function useExportAs() { const image = await getSvgAsImage(svg, { type: format, quality: 1, - scale: 1, + scale: 2, }) if (!image) { diff --git a/public-yarn.lock b/public-yarn.lock index b33bd3631..da1502d38 100644 --- a/public-yarn.lock +++ b/public-yarn.lock @@ -4333,6 +4333,7 @@ __metadata: "@tldraw/tlvalidate": "workspace:*" "@tldraw/utils": "workspace:*" "@types/benchmark": ^2.1.2 + "@types/crc": ^3.8.0 "@types/lodash.throttle": ^4.1.7 "@types/lodash.uniq": ^4.5.7 "@types/react-test-renderer": ^18.0.0 @@ -4340,6 +4341,7 @@ __metadata: "@use-gesture/react": ^10.2.24 benchmark: ^2.1.4 classnames: ^2.3.2 + crc: ^4.3.2 escape-string-regexp: ^5.0.0 eventemitter3: ^4.0.7 fake-indexeddb: ^4.0.0 @@ -4783,6 +4785,15 @@ __metadata: languageName: node linkType: hard +"@types/crc@npm:^3.8.0": + version: 3.8.0 + resolution: "@types/crc@npm:3.8.0" + dependencies: + "@types/node": "*" + checksum: bcf040c3026ec812f4ac87423f42e7ef869483e8b87967184bd2bd26c9a9c358fce41217383adf83a36b53e821a608e784f6a973fd02192b540f55a5395c9180 + languageName: node + linkType: hard + "@types/debug@npm:^4.0.0": version: 4.1.7 resolution: "@types/debug@npm:4.1.7" @@ -7422,6 +7433,18 @@ __metadata: languageName: node linkType: hard +"crc@npm:^4.3.2": + version: 4.3.2 + resolution: "crc@npm:4.3.2" + peerDependencies: + buffer: ">=6.0.3" + peerDependenciesMeta: + buffer: + optional: true + checksum: 8231cc25331727083ffd22da3575110fc49b4dc8725de973bd43261d4426aba134ed3a75cc247f7c5e97a6e171f87dffc3325b82890e86d032de2e6bcef09c32 + languageName: node + linkType: hard + "create-require@npm:^1.1.0": version: 1.1.1 resolution: "create-require@npm:1.1.1"