- Detect unwrapped fig-kiwi containers before ZIP extraction - Preserve trailing PNG thumbnail chunks and cover the format with a regression test
138 lines
4.5 KiB
TypeScript
138 lines
4.5 KiB
TypeScript
import { unzipSync, zipSync, type Unzipped, type Zippable } from 'fflate'
|
|
|
|
import type { FigPageManifestEntry } from '@open-pencil/kiwi/fig'
|
|
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
|
import { buildFigKiwi, parseFigKiwiChunks } from '@open-pencil/kiwi/fig/container'
|
|
import { decodeFigKiwiCanvas } from '@open-pencil/kiwi/fig/parse'
|
|
|
|
import { hasPNGSignature } from './thumbnail'
|
|
|
|
export interface FigImageEntry {
|
|
name: string
|
|
data: Uint8Array
|
|
}
|
|
|
|
export interface WriteFigArchiveInput {
|
|
schemaDeflated: Uint8Array
|
|
kiwiData: Uint8Array
|
|
thumbnailPNG: Uint8Array
|
|
metaJSON: string
|
|
images?: FigImageEntry[]
|
|
figKiwiVersion?: number
|
|
}
|
|
|
|
export interface FigParseResult {
|
|
nodeChanges: NodeChange[]
|
|
blobs: Uint8Array[]
|
|
images: Array<[string, Uint8Array]>
|
|
figKiwiVersion: number
|
|
/** Deflated Kiwi schema bytes from the original file, retained for round-trip fidelity. */
|
|
figSchemaDeflated: Uint8Array
|
|
thumbnailPNG: Uint8Array | null
|
|
metaJSON: string | null
|
|
}
|
|
|
|
function isLikelyAsset(name: string): boolean {
|
|
const lower = name.toLowerCase()
|
|
return lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.json')
|
|
}
|
|
|
|
function findCanvasData(entries: Partial<Record<string, Uint8Array>>): Uint8Array | null {
|
|
const canonical = entries['canvas.fig'] ?? entries.canvas
|
|
if (canonical) return canonical
|
|
|
|
let largest: Uint8Array | null = null
|
|
for (const [name, data] of Object.entries(entries)) {
|
|
if (!data || isLikelyAsset(name)) continue
|
|
if (!largest || data.byteLength > largest.byteLength) largest = data
|
|
}
|
|
return largest
|
|
}
|
|
|
|
function isCanonicalCanvasEntry(name: string): boolean {
|
|
return name === 'canvas.fig' || name === 'canvas'
|
|
}
|
|
|
|
function parseRawFigKiwi(
|
|
bytes: Uint8Array,
|
|
onPages?: (pages: FigPageManifestEntry[]) => void
|
|
): FigParseResult | null {
|
|
const chunks = parseFigKiwiChunks(bytes)
|
|
if (!chunks) return null
|
|
|
|
const decoded = decodeFigKiwiCanvas(bytes, onPages)
|
|
const thumbnailPNG = chunks.slice(2).find(hasPNGSignature) ?? null
|
|
return { ...decoded, images: [], thumbnailPNG, metaJSON: null }
|
|
}
|
|
|
|
/** Parse a complete zipped or legacy raw `.fig` file into its protocol payload and resources. */
|
|
export function parseFigBuffer(
|
|
buffer: ArrayBuffer,
|
|
onPages?: (pages: FigPageManifestEntry[]) => void
|
|
): FigParseResult {
|
|
const bytes = new Uint8Array(buffer)
|
|
const raw = parseRawFigKiwi(bytes, onPages)
|
|
if (raw) return raw
|
|
|
|
const canvasArchive = unzipSync(bytes, { filter: ({ name }) => isCanonicalCanvasEntry(name) })
|
|
let canvasData = findCanvasData(canvasArchive)
|
|
let archive: Unzipped
|
|
let decoded: ReturnType<typeof decodeFigKiwiCanvas>
|
|
if (canvasData) {
|
|
decoded = decodeFigKiwiCanvas(canvasData, onPages)
|
|
archive = unzipSync(bytes, { filter: ({ name }) => !isCanonicalCanvasEntry(name) })
|
|
} else {
|
|
archive = unzipSync(bytes)
|
|
canvasData = findCanvasData(archive)
|
|
if (!canvasData) {
|
|
throw new Error(
|
|
`No canvas data found in .fig file. Entries: ${Object.keys(archive).join(', ')}`
|
|
)
|
|
}
|
|
decoded = decodeFigKiwiCanvas(canvasData, onPages)
|
|
}
|
|
|
|
const metaBytes = archive['meta.json']
|
|
const images = Object.entries(archive)
|
|
.filter(([name]) => name.startsWith('images/') && name !== 'images/')
|
|
.map(([name, data]) => [name.slice('images/'.length), data] as [string, Uint8Array])
|
|
|
|
return {
|
|
...decoded,
|
|
images,
|
|
thumbnailPNG: archive['thumbnail.png'] ?? null,
|
|
metaJSON: Object.hasOwn(archive, 'meta.json') ? new TextDecoder().decode(metaBytes) : null
|
|
}
|
|
}
|
|
|
|
/** Assemble a complete zipped `.fig` archive from an encoded Kiwi message and resources. */
|
|
export function writeFigArchive(input: WriteFigArchiveInput): Uint8Array {
|
|
const canvasData = buildFigKiwi(input.schemaDeflated, input.kiwiData, input.figKiwiVersion)
|
|
const entries: Zippable = {
|
|
'canvas.fig': [canvasData, { level: 0 }],
|
|
'thumbnail.png': [input.thumbnailPNG, { level: 0 }],
|
|
'meta.json': new TextEncoder().encode(input.metaJSON)
|
|
}
|
|
for (const image of input.images ?? []) entries[image.name] = [image.data, { level: 0 }]
|
|
return zipSync(entries)
|
|
}
|
|
|
|
/** Compatibility signature used by core while archive assembly migrates to this package. */
|
|
export function compressFigDataSync(
|
|
schemaDeflated: Uint8Array,
|
|
kiwiData: Uint8Array,
|
|
thumbnailPNG: Uint8Array,
|
|
metaJSON: string,
|
|
imageEntries: FigImageEntry[],
|
|
figKiwiVersion?: number
|
|
): Uint8Array {
|
|
return writeFigArchive({
|
|
schemaDeflated,
|
|
kiwiData,
|
|
thumbnailPNG,
|
|
metaJSON,
|
|
images: imageEntries,
|
|
figKiwiVersion
|
|
})
|
|
}
|