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`

<img width="280" alt="Screenshot 2023-04-25 at 15 33 26"
src="https://user-images.githubusercontent.com/235915/234310669-99e3e787-ddca-4ad2-81cf-b4a541631d62.png">

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
Orange Mug 2023-04-29 23:10:01 +01:00 committed by GitHub
parent 731da1bc77
commit 77175a9dc4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 191 additions and 61 deletions

2
.gitignore vendored
View file

@ -75,3 +75,5 @@ packages/*/api
apps/examples/www/index.css
apps/examples/www/index.js
.tsbuild
apps/examples/build.esbuild.json

View file

@ -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

View file

@ -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",

View file

@ -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
})
}

View file

@ -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 */

View file

@ -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<string, { dataOffset: number; size: number; start: number }> = {}
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)
}

View file

@ -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,
})
}

View file

@ -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) {

View file

@ -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"