openpencil/packages/fig/src/thumbnail.ts
Danila Poyarkov dde7376bd3 feat(storage): show workspace document previews
- Load embedded Figma thumbnails through bounded S3 byte-range requests

- Add a headless Vue workspace composable with lazy previews and refresh lifecycle

- Cache local previews and refresh the workspace after saves and synchronization
2026-08-10 17:21:36 +03:00

123 lines
4.6 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
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'
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 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, 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 <= maxOutput ? compressed : null
if (entry.method !== 8) return null
const output = inflateSync(compressed, { out: new Uint8Array(maxOutput + 1) })
return output.byteLength <= maxOutput ? 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
}