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:
parent
731da1bc77
commit
77175a9dc4
9 changed files with 191 additions and 61 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -75,3 +75,5 @@ packages/*/api
|
|||
apps/examples/www/index.css
|
||||
apps/examples/www/index.js
|
||||
.tsbuild
|
||||
|
||||
apps/examples/build.esbuild.json
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue