Files
OpenXD/src/parser.ts
2026-06-15 19:42:59 +03:30

395 lines
18 KiB
TypeScript

import { unzipSync, strFromU8 } from "fflate";
import type { Artboard, Layer, Paint, ParsedDocument, Warning } from "./types";
const MAX_ARCHIVE_BYTES = 200 * 1024 * 1024;
const MAX_ENTRY_BYTES = 50 * 1024 * 1024;
const MAX_ENTRIES = 5000;
type Json = Record<string, unknown>;
type Archive = Record<string, Uint8Array>;
const object = (value: unknown): Json => (value && typeof value === "object" ? value as Json : {});
const array = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
const string = (value: unknown, fallback = ""): string => typeof value === "string" ? value : fallback;
const number = (value: unknown, fallback = 0): number => typeof value === "number" && Number.isFinite(value) ? value : fallback;
function color(value: unknown, fallback = "transparent"): string {
if (typeof value === "string") return value;
const c = object(value);
if (typeof c.value === "string") return c.value;
if (c.value && typeof c.value === "object") {
const nested = object(c.value);
const r = number(nested.r ?? nested.red, 0);
const g = number(nested.g ?? nested.green, 0);
const b = number(nested.b ?? nested.blue, 0);
const a = number(c.alpha ?? nested.a ?? nested.alpha, 1);
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
if (c.color && c.color !== value) return color(c.color, fallback);
const r = number(c.r ?? c.red, 0);
const g = number(c.g ?? c.green, 0);
const b = number(c.b ?? c.blue, 0);
const a = number(c.a ?? c.alpha, 1);
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function childNodes(node: Json): unknown[] {
const shape = object(node.shape);
return array(node.children ?? node.layers ?? object(node.group).children ?? (shape.path ? undefined : shape.children));
}
function bounds(node: Json): { x: number; y: number; width: number; height: number } {
const shape = object(node.shape);
const b = object(node.bounds ?? node.frame ?? node.globalBounds ?? shape);
const transform = object(node.transform);
const rx = number(shape.rx);
const ry = number(shape.ry);
return {
x: number(transform.tx ?? transform.x ?? b.x ?? node.x),
y: number(transform.ty ?? transform.y ?? b.y ?? node.y),
width: number(b.width ?? b.w ?? node.width, rx * 2),
height: number(b.height ?? b.h ?? node.height, ry * 2),
};
}
function paint(value: unknown): Paint | undefined {
if (!value) return undefined;
const p = object(value);
const type = string(p.type).toLowerCase();
if (type === "none") return undefined;
if (type === "pattern") {
const pattern = object(p.pattern);
const uid = string(object(object(pattern.meta).ux).uid);
return uid ? { type: "pattern", asset: uid } : undefined;
}
const gradient = object(p.gradient);
const gradientResources = object(object(object(gradient.meta).ux).gradientResources);
const stops = array(p.stops ?? p.colorStops ?? gradient.stops ?? gradientResources.stops);
if (type.includes("gradient") && stops.length) {
const normalizedStops = stops.map((stop) => {
const s = object(stop);
return { offset: number(s.offset ?? s.position), color: color(s.color ?? s) };
});
if (string(gradientResources.type).toLowerCase() === "radial") {
return {
type: "radial-gradient",
cx: number(gradient.cx, 0.5),
cy: number(gradient.cy, 0.5),
r: number(gradient.r, 0.5),
stops: normalizedStops,
};
}
return {
type: "linear-gradient",
angle: number(p.angle ?? gradient.angle),
stops: normalizedStops,
};
}
return { type: "solid", color: color(p.color ?? p.value ?? value) };
}
function inferType(node: Json): Layer["type"] {
const shape = object(node.shape);
const raw = string(shape.type ?? node.type ?? node.kind ?? node.shape).toLowerCase();
if (raw.includes("artboard")) return "group";
if (raw.includes("group") || raw.includes("container")) return "group";
if (raw.includes("rect")) return "rect";
if (raw.includes("ellipse") || raw.includes("circle")) return "ellipse";
if (raw.includes("path") || raw.includes("line") || raw.includes("compound") || shape.path || node.path || node.pathData) return "path";
if (raw.includes("text") || node.text || node.content) return "text";
if (raw.includes("image") || node.image || node.href) return "image";
if (childNodes(node).length) return "group";
return "unknown";
}
function textContent(value: unknown): string {
if (typeof value === "string") return value;
const root = object(value);
if (typeof root.rawText === "string") return root.rawText;
const paragraphs = array(root.paragraphs);
if (!paragraphs.length) return string(root.text ?? root.value);
return paragraphs.map((paragraph) => {
const p = object(paragraph);
const lines = array(p.lines);
return lines.map((line) => {
const spans = Array.isArray(line) ? line : array(object(line).spans);
return spans.map((span) => string(object(span).text ?? span)).join("");
}).join("\n");
}).join("\n");
}
function textLines(value: unknown, fallbackSize: number): Array<{ text: string; x: number; y: number; fontSize?: number }> | undefined {
const root = object(value);
const rawText = string(root.rawText ?? root.text ?? root.value);
const paragraphs = array(root.paragraphs);
if (!rawText || !paragraphs.length) return undefined;
const lines: Array<{ text: string; x: number; y: number; fontSize?: number }> = [];
for (const paragraph of paragraphs) {
for (const lineValue of array(object(paragraph).lines)) {
const spans = Array.isArray(lineValue) ? lineValue : array(object(lineValue).spans);
const first = object(spans[0]);
if (!spans.length) continue;
const from = Math.min(...spans.map((span) => number(object(span).from)));
const to = Math.max(...spans.map((span) => number(object(span).to, from)));
if (to <= from) continue;
const lineText = rawText.slice(from, to);
const lineFont = object(object(first.style).font);
lines.push({
text: lineText,
x: number(first.x),
y: number(first.y),
fontSize: number(lineFont.size, fallbackSize),
});
}
}
return lines.length ? lines : undefined;
}
type ResourceIndex = Map<string, Json>;
function mergeSyncRef(node: Json, resources: ResourceIndex): Json {
if (string(node.type) !== "syncRef") return node;
const source = resources.get(string(node.syncSourceGuid));
if (!source) return node;
const sourceGroup = object(source.group);
const sourceShape = object(source.shape);
const instanceGroup = object(node.group);
const instanceShape = object(node.shape);
return {
...source,
...node,
type: source.type,
name: node.name ?? source.name,
style: node.style ?? source.style,
transform: node.transform ?? source.transform,
group: Object.keys(instanceGroup).length ? { ...sourceGroup, ...instanceGroup } : source.group,
shape: Object.keys(instanceShape).length ? { ...sourceShape, ...instanceShape } : source.shape,
};
}
function normalizeLayer(
nodeValue: unknown,
warnings: Warning[],
index: number,
offset = { x: 0, y: 0 },
resources: ResourceIndex = new Map(),
): Layer {
const original = object(nodeValue);
const node = mergeSyncRef(original, resources);
const b = bounds(node);
const style = object(node.style);
const shape = object(node.shape);
const pattern = object(object(style.fill).pattern);
const patternMeta = object(object(pattern.meta).ux);
const textStyle = object(node.textStyle ?? style.text ?? style.font);
const fontSize = number(textStyle.fontSize ?? textStyle.size ?? node.fontSize, 16);
const type = inferType(node);
const name = string(node.name, `${type} ${index + 1}`);
if (type === "unknown") {
warnings.push({ code: "unsupported-layer", message: "An unsupported layer was skipped.", layer: name });
}
const children = childNodes(node).map((child, childIndex) => normalizeLayer(child, warnings, childIndex, { x: 0, y: 0 }, resources));
const stroke = object(style.stroke ?? node.stroke);
const hasStroke = Boolean(style.stroke || node.stroke) && string(stroke.type).toLowerCase() !== "none";
const transform = object(node.transform);
return {
id: string(node.id ?? node.guid, `${type}-${index}`),
name,
type,
...b,
x: b.x + offset.x,
y: b.y + offset.y,
opacity: number(style.opacity ?? node.opacity, 1),
visible: node.visible !== false && node.hidden !== true,
blendMode: string(style.blendMode ?? node.blendMode) || undefined,
rotation: number(node.rotation ?? object(node.transform).rotation) || undefined,
matrix: transform.a !== undefined || transform.b !== undefined || transform.c !== undefined || transform.d !== undefined
? { a: number(transform.a, 1), b: number(transform.b), c: number(transform.c), d: number(transform.d, 1) }
: undefined,
fill: paint(style.fill ?? node.fill),
stroke: hasStroke ? color(stroke.color ?? style.stroke ?? node.stroke) : undefined,
strokeWidth: hasStroke ? number(stroke.width ?? style.strokeWidth ?? node.strokeWidth) || undefined : undefined,
radius: number(shape.radius ?? shape.cornerRadius ?? node.radius ?? node.cornerRadius ?? style.cornerRadius) || undefined,
path: string(shape.path ?? shape.pathData ?? node.path ?? node.pathData ?? node.d) || undefined,
text: textContent(node.text ?? node.content ?? node.value) || undefined,
textLines: textLines(node.text ?? node.content ?? node.value, fontSize),
fontSize,
fontFamily: string(textStyle.fontFamily ?? textStyle.family ?? node.fontFamily, "Arial"),
fontWeight: string(textStyle.fontWeight ?? textStyle.style ?? node.fontWeight, "400"),
textColor: color(textStyle.color ?? style.fill ?? node.fill, "#111111"),
imageAsset: string(node.image ?? node.href ?? node.resource ?? patternMeta.uid ?? pattern.href ?? object(style.fill).href ?? object(style.fill).ref) || undefined,
children: children.length ? children : undefined,
};
}
function parseJsonEntry(archive: Archive, name: string): Json | undefined {
try {
return object(JSON.parse(strFromU8(archive[name])));
} catch {
return undefined;
}
}
function collectArtboardNodes(root: Json): unknown[] {
const direct = childNodes(root);
const explicit = direct.filter((entry) => string(object(entry).type).toLowerCase().includes("artboard"));
if (explicit.length) return explicit;
const resources = object(root.resources);
const resourceArtboards = array(resources.artboards);
return resourceArtboards.length ? resourceArtboards : direct;
}
type ManifestArtboard = { id: string; path: string; name: string; bounds: { x: number; y: number; width: number; height: number } };
function manifestArtboards(manifest: Json): ManifestArtboard[] {
const found: ManifestArtboard[] = [];
const visit = (value: unknown) => {
const node = object(value);
const path = string(node.path);
if (path.startsWith("artboard-")) {
const b = object(node["uxdesign#bounds"]);
found.push({
id: path.replace(/^artboard-/, ""),
path,
name: string(node.name, path),
bounds: { x: number(b.x), y: number(b.y), width: number(b.width, 375), height: number(b.height, 812) },
});
}
array(node.children).forEach(visit);
};
visit(manifest);
return found;
}
function normalizeArtboard(value: unknown, warnings: Warning[], index: number, info?: ManifestArtboard, resources: ResourceIndex = new Map()): Artboard {
const node = object(value);
const artboard = object(node.artboard);
const artboardOwnsLayers = childNodes(artboard).length > 0;
const b = bounds(Object.keys(artboard).length ? { ...node, ...artboard } : node);
const viewport = object(node.viewportHeight ? node : artboard);
const width = info?.bounds.width || b.width || number(viewport.width, 375);
const height = info?.bounds.height || b.height || number(viewport.height ?? viewport.viewportHeight, 812);
const origin = info?.bounds ?? { x: 0, y: 0 };
const layers = childNodes(artboardOwnsLayers ? artboard : node);
return {
id: string(node.id ?? node.guid, `artboard-${index}`),
name: info?.name ?? string(node.name ?? artboard.name, `Artboard ${index + 1}`),
x: origin.x || b.x,
y: origin.y || b.y,
width,
height,
background: color(node.background ?? object(node.style).fill, "#ffffff"),
layers: layers.map((layer, layerIndex) => normalizeLayer(layer, warnings, layerIndex, { x: -origin.x, y: -origin.y }, resources)),
};
}
function indexResources(root: Json | undefined): ResourceIndex {
const index: ResourceIndex = new Map();
const seen = new Set<unknown>();
const visit = (value: unknown) => {
if (!value || typeof value !== "object" || seen.has(value)) return;
seen.add(value);
const node = object(value);
const id = string(node.id);
if (id) index.set(id, node);
Object.values(node).forEach((child) => {
if (Array.isArray(child)) child.forEach(visit);
else if (child && typeof child === "object") visit(child);
});
};
visit(root);
return index;
}
function imageMime(bytes: Uint8Array, name: string): string | undefined {
if (/\.svg$/i.test(name) || strFromU8(bytes.subarray(0, 100)).includes("<svg")) return "image/svg+xml";
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
if (bytes[0] === 0xff && bytes[1] === 0xd8) return "image/jpeg";
if (strFromU8(bytes.subarray(0, 4)) === "GIF8") return "image/gif";
if (strFromU8(bytes.subarray(8, 12)) === "WEBP") return "image/webp";
return undefined;
}
function extractAssets(archive: Archive, warnings: Warning[]): Record<string, string> {
const assets: Record<string, string> = {};
for (const [name, bytes] of Object.entries(archive)) {
const mime = imageMime(bytes, name);
if (!mime) continue;
try {
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join("");
assets[name] = `data:${mime};base64,${btoa(binary)}`;
assets[name.split("/").pop() ?? name] = assets[name];
} catch {
warnings.push({ code: "asset-error", message: `Could not decode image asset ${name}.` });
}
}
return assets;
}
export function parseXdBuffer(buffer: ArrayBuffer, fileName = "Untitled.xd"): ParsedDocument {
if (buffer.byteLength > MAX_ARCHIVE_BYTES) throw new Error("The XD file is larger than the 200 MB safety limit.");
let archive: Archive;
let totalOriginalSize = 0;
let entryCount = 0;
try {
archive = unzipSync(new Uint8Array(buffer), {
filter: (entry) => {
entryCount += 1;
totalOriginalSize += entry.originalSize;
if (entryCount > MAX_ENTRIES) throw new Error("The XD file contains too many archive entries.");
if (entry.originalSize > MAX_ENTRY_BYTES) throw new Error("The XD file contains an entry larger than the 50 MB safety limit.");
if (totalOriginalSize > MAX_ARCHIVE_BYTES) throw new Error("The expanded XD file is larger than the 200 MB safety limit.");
return /(^|\/)manifest$/i.test(entry.name) || /\.(agc|json|png|jpe?g|gif|webp|svg)$/i.test(entry.name) ||
/^resources\/[^/]+$/i.test(entry.name);
},
});
} catch (error) {
if (error instanceof Error && error.message.includes("safety limit")) throw error;
if (error instanceof Error && error.message.includes("too many archive entries")) throw error;
throw new Error("The selected file is not a readable XD/ZIP package.");
}
const warnings: Warning[] = [];
const manifestEntry = Object.keys(archive).find((name) => /(^|\/)manifest$/i.test(name));
const manifest = manifestEntry ? parseJsonEntry(archive, manifestEntry) : undefined;
const manifestBoards = manifest ? manifestArtboards(manifest) : [];
const sharedResourcesName = Object.keys(archive).find((name) => /^resources\/graphics\/graphicContent\.agc$/i.test(name));
const resources = indexResources(sharedResourcesName ? parseJsonEntry(archive, sharedResourcesName) : undefined);
const pasteboardName = Object.keys(archive).find((name) => /^artwork\/pasteboard\/graphics\/graphicContent\.agc$/i.test(name));
const pasteboard = pasteboardName ? parseJsonEntry(archive, pasteboardName) : undefined;
const boardEntries = Object.keys(archive).filter((name) => /^artwork\/artboard-[^/]+\/graphics\/graphicContent\.agc$/i.test(name));
let nodes: Array<{ node: unknown; info?: ManifestArtboard }> = [];
for (const name of boardEntries) {
const artwork = parseJsonEntry(archive, name);
if (!artwork) continue;
const path = name.split("/")[1];
const info = manifestBoards.find((board) => board.path === path);
nodes.push(...collectArtboardNodes(artwork).map((node) => ({ node, info })));
}
if (!nodes.length) {
const fallbackName = Object.keys(archive).find((name) => /(^|\/)(graphicContent\.agc|artwork\.json|document\.json)$/i.test(name));
const artwork = fallbackName ? parseJsonEntry(archive, fallbackName) : undefined;
if (!artwork) throw new Error("This XD package does not contain readable artwork data.");
nodes = collectArtboardNodes(artwork).map((node) => ({ node }));
}
if (!nodes.length) warnings.push({ code: "no-artboards", message: "No artboards were found in the artwork data." });
let version: string | undefined;
if (manifest) {
try {
version = string(manifest.version ?? manifest.appVersion) || undefined;
} catch {
warnings.push({ code: "manifest-error", message: "The package manifest could not be read." });
}
}
return {
name: fileName.replace(/\.xd$/i, ""),
version,
artboards: nodes.map(({ node, info }, index) => normalizeArtboard(node, warnings, index, info, resources)),
pasteboardLayers: pasteboard
? childNodes(pasteboard).map((layer, index) => normalizeLayer(layer, warnings, index, { x: 0, y: 0 }, resources))
: [],
assets: extractAssets(archive, warnings),
warnings,
};
}