openpencil/packages/fig/src/thumbnail.ts
2026-08-18 03:43:38 -03:00

150 lines
5.2 KiB
TypeScript

import { inflateSync } from 'fflate'
export interface FigRangeReader {
readonly size: number
read(start: number, endExclusive: number): Promise<Uint8Array>
}
export type FigThumbnailLimits = {
maxTailBytes?: number
maxCompressedBytes?: number
maxOutputBytes?: number
}
type ThumbnailEntry = {
method: number
compressedSize: number
outputSize: number
localOffset: number
}
const EOCD_SIGNATURE = 0x06054b50
const CENTRAL_SIGNATURE = 0x02014b50
const LOCAL_SIGNATURE = 0x04034b50
const EOCD_MIN_SIZE = 22
const MAX_ZIP_COMMENT = 65_535
const DEFAULT_MAX_TAIL = 4 * 1024 * 1024
const DEFAULT_MAX_COMPRESSED = 8 * 1024 * 1024
const DEFAULT_MAX_OUTPUT = 16 * 1024 * 1024
const THUMBNAIL_NAME = 'thumbnail.png'
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
function view(bytes: Uint8Array): DataView {
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
}
function findEOCD(bytes: Uint8Array): number {
const data = view(bytes)
for (let offset = bytes.byteLength - EOCD_MIN_SIZE; offset >= 0; offset--) {
if (data.getUint32(offset, true) === EOCD_SIGNATURE) return offset
}
return -1
}
function boundedLimit(value: number | undefined, fallback: number): number {
return Number.isFinite(value) && value && value > 0 ? value : fallback
}
function hasPNGSignature(bytes: Uint8Array): boolean {
return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)
}
function hasUsablePNGDimensions(bytes: Uint8Array): boolean {
if (bytes.byteLength < 24 || !hasPNGSignature(bytes)) return false
const data = view(bytes)
return data.getUint32(16) > 1 && data.getUint32(20) > 1
}
function findThumbnailEntry(
central: Uint8Array,
maxCompressed: number,
maxOutput: number
): ThumbnailEntry | null {
const data = view(central)
const decoder = new TextDecoder()
for (let offset = 0; offset + 46 <= central.byteLength; ) {
if (data.getUint32(offset, true) !== CENTRAL_SIGNATURE) return null
const method = data.getUint16(offset + 10, true)
const compressedSize = data.getUint32(offset + 20, true)
const outputSize = data.getUint32(offset + 24, true)
const nameLength = data.getUint16(offset + 28, true)
const next =
offset +
46 +
nameLength +
data.getUint16(offset + 30, true) +
data.getUint16(offset + 32, true)
if (next > central.byteLength) return null
const name = decoder.decode(central.subarray(offset + 46, offset + 46 + nameLength))
if (name === THUMBNAIL_NAME) {
if (compressedSize > maxCompressed || outputSize > maxOutput) return null
return {
method,
compressedSize,
outputSize,
localOffset: data.getUint32(offset + 42, true)
}
}
offset = next
}
return null
}
async function readEntryPayload(
reader: FigRangeReader,
entry: ThumbnailEntry,
maxOutput: number
): Promise<Uint8Array | null> {
const header = await reader.read(entry.localOffset, Math.min(reader.size, entry.localOffset + 30))
if (header.byteLength < 30 || view(header).getUint32(0, true) !== LOCAL_SIGNATURE) return null
const headerView = view(header)
const dataStart =
entry.localOffset + 30 + headerView.getUint16(26, true) + headerView.getUint16(28, true)
if (dataStart + entry.compressedSize > reader.size) return null
const compressed = await reader.read(dataStart, dataStart + entry.compressedSize)
if (entry.method === 0) {
return compressed.byteLength === entry.outputSize && hasUsablePNGDimensions(compressed)
? compressed
: null
}
if (entry.method !== 8) return null
const output = (() => {
try {
return inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) })
} catch {
return null
}
})()
return output?.byteLength === entry.outputSize && hasUsablePNGDimensions(output) ? output : null
}
/**
* Extract Figma's canonical `thumbnail.png` from a remote `.fig` ZIP through
* bounded range reads. The complete document is never requested.
*/
export async function extractFigThumbnailFromReader(
reader: FigRangeReader,
limits: FigThumbnailLimits = {}
): Promise<Uint8Array | null> {
if (!Number.isSafeInteger(reader.size) || reader.size < EOCD_MIN_SIZE) return null
const maxCentral = boundedLimit(limits.maxTailBytes, DEFAULT_MAX_TAIL)
const maxCompressed = boundedLimit(limits.maxCompressedBytes, DEFAULT_MAX_COMPRESSED)
const maxOutput = boundedLimit(limits.maxOutputBytes, DEFAULT_MAX_OUTPUT)
const tailSize = Math.min(reader.size, EOCD_MIN_SIZE + MAX_ZIP_COMMENT)
const tailStart = reader.size - tailSize
const tail = await reader.read(tailStart, reader.size)
const eocd = findEOCD(tail)
if (eocd < 0) return null
const tailView = view(tail)
const centralSize = tailView.getUint32(eocd + 12, true)
const centralOffset = tailView.getUint32(eocd + 16, true)
if (centralSize > maxCentral || centralOffset + centralSize > reader.size) return null
const central =
centralOffset >= tailStart && centralOffset + centralSize <= reader.size
? tail.subarray(centralOffset - tailStart, centralOffset - tailStart + centralSize)
: await reader.read(centralOffset, centralOffset + centralSize)
const entry = findThumbnailEntry(central, maxCompressed, maxOutput)
return entry ? readEntryPayload(reader, entry, maxOutput) : null
}