This commit is contained in:
2026-06-14 15:19:45 +03:30
commit 678be1e8dd
23 changed files with 4643 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.idea/
node_modules/
dist/
coverage/
screenshots/
example/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
.DS_Store

18
README.md Normal file
View File

@@ -0,0 +1,18 @@
# OpenXD
OpenXD is a private, browser-only viewer for Adobe XD files. Files are parsed
locally and are never uploaded.
## Development
```sh
npm install
npm run dev
```
OpenXD provides best-effort rendering because Adobe XD's package format is
proprietary. Common artboards, shapes, paths, text, images, fills, strokes, and
transforms are supported; unknown content is reported as a compatibility
warning.

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#11131a" />
<title>OpenXD</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3169
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "openxd",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview"
},
"dependencies": {
"fflate": "^0.8.2",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^27.0.0",
"typescript": "~5.9.3",
"vite": "^7.1.7",
"vitest": "^3.2.4"
}
}

181
src/App.tsx Normal file
View File

@@ -0,0 +1,181 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { DocumentView } from "./DocumentView";
import type { ParsedDocument } from "./types";
import { artboardBounds, fitBounds, focusArtboard, type DocumentBounds } from "./viewport";
import "./styles.css";
const MIN_ZOOM = 0.01;
const MAX_ZOOM = 4;
function DropScreen({ openFile, loading, error }: { openFile: (file: File) => void; loading: boolean; error: string }) {
const inputRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
return (
<main
className={`drop-screen ${dragging ? "dragging" : ""}`}
onDragOver={(event) => { event.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
const file = event.dataTransfer.files[0];
if (file) openFile(file);
}}
>
<section className="drop-card">
<div className="logo">XD</div>
<p className="eyebrow">Private, browser-only viewer</p>
<h1>Open an Adobe XD file</h1>
<p className="lede">Inspect artboards without uploading your design. Your file stays in this browser tab.</p>
<button className="primary" onClick={() => inputRef.current?.click()} disabled={loading}>
{loading ? "Opening file..." : "Choose .xd file"}
</button>
<input
ref={inputRef}
type="file"
accept=".xd,application/zip"
hidden
onChange={(event) => event.target.files?.[0] && openFile(event.target.files[0])}
/>
<span className="drop-hint">or drop a file anywhere here</span>
{error && <p className="error" role="alert">{error}</p>}
</section>
</main>
);
}
export default function App() {
const [document, setDocument] = useState<ParsedDocument | null>(null);
const [active, setActive] = useState(0);
const [zoom, setZoom] = useState(0.1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [bounds, setBounds] = useState<DocumentBounds>({ x: 0, y: 0, width: 1, height: 1 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [warningsOpen, setWarningsOpen] = useState(false);
const dragRef = useRef<{ x: number; y: number; originX: number; originY: number } | null>(null);
const canvasRef = useRef<HTMLElement>(null);
const fit = useCallback(() => {
if (!document) return;
const canvas = canvasRef.current?.getBoundingClientRect();
const transform = fitBounds(bounds, {
width: canvas?.width ?? window.innerWidth - 280,
height: canvas?.height ?? window.innerHeight - 64,
});
setZoom(Math.max(MIN_ZOOM, transform.zoom));
setPan(transform.pan);
}, [bounds, document]);
useEffect(() => { fit(); }, [fit]);
const openFile = useCallback(async (file: File) => {
setLoading(true);
setError("");
if (!file.name.toLowerCase().endsWith(".xd")) {
setLoading(false);
setError("Choose a file with the .xd extension.");
return;
}
const worker = new Worker(new URL("./parser.worker.ts", import.meta.url), { type: "module" });
try {
const buffer = await file.arrayBuffer();
worker.onmessage = (event: MessageEvent<{ document?: ParsedDocument; error?: string }>) => {
setLoading(false);
worker.terminate();
if (event.data.error) setError(event.data.error);
else if (event.data.document) {
setDocument(event.data.document);
setActive(0);
setBounds(artboardBounds(event.data.document));
setWarningsOpen(event.data.document.warnings.length > 0);
}
};
worker.onerror = () => {
setLoading(false);
setError("The viewer could not parse this file.");
worker.terminate();
};
worker.postMessage({ buffer, name: file.name }, [buffer]);
} catch {
setLoading(false);
setError("The selected file could not be read.");
worker.terminate();
}
}, []);
if (!document) return <DropScreen openFile={openFile} loading={loading} error={error} />;
const focus = (index: number) => {
const artboard = document.artboards[index];
if (!artboard) return;
const canvas = canvasRef.current?.getBoundingClientRect();
const transform = focusArtboard(artboard, bounds, {
width: canvas?.width ?? window.innerWidth - 280,
height: canvas?.height ?? window.innerHeight - 64,
});
setActive(index);
setZoom(Math.max(MIN_ZOOM, transform.zoom));
setPan(transform.pan);
};
return (
<div className="app-shell">
<header className="topbar">
<div className="brand"><span>XD</span><strong>{document.name}</strong>{document.version && <small>v{document.version}</small>}</div>
<div className="toolbar">
<button onClick={() => setZoom((value) => Math.max(MIN_ZOOM, value - 0.1))} aria-label="Zoom out"></button>
<output>{Math.round(zoom * 100)}%</output>
<button onClick={() => setZoom((value) => Math.min(MAX_ZOOM, value + 0.1))} aria-label="Zoom in">+</button>
<button onClick={fit}>Fit</button>
<button className="clear" onClick={() => { setDocument(null); setError(""); }}>Close file</button>
</div>
</header>
<aside className="sidebar">
<div className="sidebar-title"><strong>Artboards</strong><span>{document.artboards.length}</span></div>
<div className="artboard-list">
{document.artboards.map((item, index) => (
<button className={index === active ? "active" : ""} key={item.id + index} onClick={() => focus(index)}>
<span className="miniature" style={{ aspectRatio: `${item.width}/${item.height}`, background: item.background }} />
<span><strong>{item.name}</strong><small>{item.width} × {item.height}</small></span>
</button>
))}
{!document.artboards.length && <p className="empty">No artboards found.</p>}
</div>
{!!document.warnings.length && (
<div className="warnings">
<button onClick={() => setWarningsOpen((open) => !open)}>
<span>Compatibility warnings</span><b>{document.warnings.length}</b>
</button>
{warningsOpen && <ul>{document.warnings.map((warning, index) => <li key={index}>{warning.message}{warning.layer && <small>{warning.layer}</small>}</li>)}</ul>}
</div>
)}
</aside>
<main
ref={canvasRef}
className="canvas"
onWheel={(event) => {
event.preventDefault();
setZoom((value) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value * (event.deltaY > 0 ? 0.9 : 1.1))));
}}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { x: event.clientX, y: event.clientY, originX: pan.x, originY: pan.y };
}}
onPointerMove={(event) => {
if (!dragRef.current) return;
setPan({ x: dragRef.current.originX + event.clientX - dragRef.current.x, y: dragRef.current.originY + event.clientY - dragRef.current.y });
}}
onPointerUp={() => { dragRef.current = null; }}
>
{document.artboards.length || document.pasteboardLayers.length ? (
<div
className="stage document-stage"
style={{ width: bounds.width, height: bounds.height, transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
>
<DocumentView document={document} bounds={bounds} onBoundsChange={setBounds} />
</div>
) : <div className="canvas-empty">This file has no displayable artboards.</div>}
</main>
</div>
);
}

103
src/ArtboardView.test.tsx Normal file
View File

@@ -0,0 +1,103 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ArtboardView } from "./ArtboardView";
import type { ParsedDocument } from "./types";
const document: ParsedDocument = {
name: "Demo",
assets: {},
warnings: [],
pasteboardLayers: [],
artboards: [{
id: "board",
name: "Welcome",
x: 0,
y: 0,
width: 320,
height: 640,
background: "#fff",
layers: [{
id: "title",
name: "Title",
type: "text",
x: 20,
y: 20,
width: 200,
height: 40,
opacity: 1,
visible: true,
text: "Hello OpenXD",
fontSize: 24,
}],
}],
};
describe("ArtboardView", () => {
it("renders the selected artboard and its text", () => {
render(<ArtboardView artboard={document.artboards[0]} document={document} />);
expect(screen.getByRole("img", { name: "Welcome" })).toBeInTheDocument();
expect(screen.getByText("Hello OpenXD")).toBeInTheDocument();
});
it("renders radial gradients, image patterns, and blend modes", () => {
const painted: ParsedDocument = {
...document,
assets: { texture: "data:image/png;base64,AAAA" },
artboards: [{
...document.artboards[0],
layers: [{
id: "gradient",
name: "Glow",
type: "ellipse",
x: 0,
y: 0,
width: 100,
height: 100,
opacity: 1,
visible: true,
blendMode: "screen",
fill: {
type: "radial-gradient",
cx: 0.5,
cy: 0.5,
r: 0.5,
stops: [{ offset: 0, color: "#fff" }, { offset: 1, color: "rgba(255, 255, 255, 0)" }],
},
}, {
id: "pattern",
name: "Texture",
type: "rect",
x: 0,
y: 100,
width: 100,
height: 100,
opacity: 1,
visible: true,
fill: { type: "pattern", asset: "texture" },
}],
}],
};
const { container } = render(<ArtboardView artboard={painted.artboards[0]} document={painted} />);
expect(container.querySelector("radialGradient")).toBeInTheDocument();
expect(container.querySelector("pattern image")).toHaveAttribute("href", painted.assets.texture);
expect(container.querySelector('g[style*="screen"]')).toBeInTheDocument();
});
it("renders positioned text lines at XD coordinates", () => {
const positioned: ParsedDocument = {
...document,
artboards: [{
...document.artboards[0],
layers: [{
...document.artboards[0].layers[0],
textLines: [{ text: "Placed", x: -80, y: 4, fontSize: 32 }],
}],
}],
};
const { container } = render(<ArtboardView artboard={positioned.artboards[0]} document={positioned} />);
const line = container.querySelector("tspan");
expect(line).toHaveAttribute("x", "-80");
expect(line).toHaveAttribute("y", "4");
expect(line).toHaveAttribute("font-size", "32");
});
});

115
src/ArtboardView.tsx Normal file
View File

@@ -0,0 +1,115 @@
import type { Artboard, Layer, ParsedDocument } from "./types";
type Props = {
artboard: Artboard;
document: ParsedDocument;
};
function assetUrl(document: ParsedDocument, reference?: string): string | undefined {
if (!reference) return undefined;
if (reference.startsWith("data:")) return reference;
return document.assets[reference] ?? document.assets[reference.replace(/^\.?\//, "")] ??
document.assets[reference.split("/").pop() ?? reference];
}
function blendMode(value?: string): React.CSSProperties["mixBlendMode"] {
if (!value) return undefined;
return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) as React.CSSProperties["mixBlendMode"];
}
export function LayerView({ layer, document, path }: { layer: Layer; document: ParsedDocument; path: string }) {
if (!layer.visible || layer.type === "unknown") return null;
const transform = layer.matrix
? `matrix(${layer.matrix.a} ${layer.matrix.b} ${layer.matrix.c} ${layer.matrix.d} ${layer.x} ${layer.y})${layer.rotation ? ` rotate(${layer.rotation} ${layer.width / 2} ${layer.height / 2})` : ""}`
: `translate(${layer.x} ${layer.y})${layer.rotation ? ` rotate(${layer.rotation} ${layer.width / 2} ${layer.height / 2})` : ""}`;
const gradientId = `gradient-${path.replace(/[^a-z0-9]/gi, "-")}`;
const fill = layer.fill?.type === "solid" ? layer.fill.color : layer.fill ? `url(#${gradientId})` : "transparent";
const common = {
fill,
stroke: layer.stroke ?? "none",
strokeWidth: layer.strokeWidth ?? 0,
opacity: layer.opacity,
};
const children = layer.children?.map((child, index) => (
<LayerView key={child.id + index} layer={child} document={document} path={`${path}-${index}`} />
));
const paintDefinition = layer.fill?.type === "linear-gradient" ? (
<defs>
<linearGradient id={gradientId} gradientTransform={`rotate(${layer.fill.angle})`}>
{layer.fill.stops.map((stop, index) => <stop key={index} offset={stop.offset} stopColor={stop.color} />)}
</linearGradient>
</defs>
) : layer.fill?.type === "radial-gradient" ? (
<defs>
<radialGradient id={gradientId} cx={layer.fill.cx} cy={layer.fill.cy} r={layer.fill.r}>
{layer.fill.stops.map((stop, index) => <stop key={index} offset={stop.offset} stopColor={stop.color} />)}
</radialGradient>
</defs>
) : layer.fill?.type === "pattern" && assetUrl(document, layer.fill.asset) ? (
<defs>
<pattern id={gradientId} width="1" height="1" patternContentUnits="objectBoundingBox">
<image href={assetUrl(document, layer.fill.asset)} width="1" height="1" preserveAspectRatio="xMidYMid slice" />
</pattern>
</defs>
) : null;
let shape: React.ReactNode;
switch (layer.type) {
case "rect":
shape = <rect width={layer.width} height={layer.height} rx={layer.radius} {...common} />;
break;
case "ellipse":
shape = <ellipse cx={layer.width / 2} cy={layer.height / 2} rx={layer.width / 2} ry={layer.height / 2} {...common} />;
break;
case "path":
shape = <path d={layer.path} {...common} />;
break;
case "text":
shape = (
<text
x={0}
y={0}
fill={layer.textColor}
opacity={layer.opacity}
fontSize={layer.fontSize}
fontFamily={layer.fontFamily}
fontWeight={layer.fontWeight}
>
{layer.textLines?.map((line, index) => (
<tspan key={index} x={line.x} y={line.y} fontSize={line.fontSize}>{line.text}</tspan>
)) ?? layer.text?.split("\n").map((line, index) => (
<tspan key={index} x={0} y={index * (layer.fontSize ?? 16) * 1.2}>{line}</tspan>
))}
</text>
);
break;
case "image": {
const href = assetUrl(document, layer.imageAsset);
shape = href
? <image href={href} width={layer.width} height={layer.height} preserveAspectRatio="xMidYMid slice" opacity={layer.opacity} />
: <rect width={layer.width} height={layer.height} fill="#e6e8ef" stroke="#a8adbd" strokeDasharray="6 4" />;
break;
}
default:
shape = null;
}
return <g transform={transform} style={{ mixBlendMode: blendMode(layer.blendMode) }}>{paintDefinition}{shape}{children}</g>;
}
export function ArtboardView({ artboard, document }: Props) {
return (
<svg
className="artboard"
width={artboard.width}
height={artboard.height}
viewBox={`0 0 ${artboard.width} ${artboard.height}`}
role="img"
aria-label={artboard.name}
>
<rect width={artboard.width} height={artboard.height} fill={artboard.background} />
{artboard.layers.map((layer, index) => (
<LayerView key={layer.id + index} layer={layer} document={document} path={`${artboard.id}-${index}`} />
))}
</svg>
);
}

43
src/DocumentView.test.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { DocumentView } from "./DocumentView";
import type { ParsedDocument } from "./types";
beforeAll(() => {
Object.defineProperty(SVGElement.prototype, "getBBox", {
configurable: true,
value: () => ({ x: -100, y: -50, width: 700, height: 500 }),
});
});
const document: ParsedDocument = {
name: "Full page",
assets: {},
warnings: [],
artboards: [
{ id: "a", name: "First", x: -100, y: 0, width: 200, height: 300, background: "#fff", layers: [] },
{ id: "b", name: "Second", x: 300, y: 0, width: 200, height: 300, background: "#fff", layers: [] },
],
pasteboardLayers: [{
id: "note",
name: "Note",
type: "text",
x: 120,
y: 350,
width: 100,
height: 20,
opacity: 1,
visible: true,
text: "Outside annotation",
}],
};
describe("DocumentView", () => {
it("renders all artboards and pasteboard annotations in one SVG", () => {
render(<DocumentView document={document} bounds={{ x: -100, y: -50, width: 700, height: 500 }} onBoundsChange={vi.fn()} />);
expect(screen.getByRole("img", { name: "Full page pasteboard" })).toBeInTheDocument();
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.getByText("Second")).toBeInTheDocument();
expect(screen.getByText("Outside annotation")).toBeInTheDocument();
});
});

75
src/DocumentView.tsx Normal file
View File

@@ -0,0 +1,75 @@
import { useLayoutEffect, useRef } from "react";
import { LayerView } from "./ArtboardView";
import type { ParsedDocument } from "./types";
import type { DocumentBounds } from "./viewport";
type Props = {
document: ParsedDocument;
bounds: DocumentBounds;
onBoundsChange: (bounds: DocumentBounds) => void;
};
export function DocumentView({ document, bounds, onBoundsChange }: Props) {
const pasteboardRef = useRef<SVGGElement>(null);
useLayoutEffect(() => {
const artboardLeft = document.artboards.length ? Math.min(...document.artboards.map((artboard) => artboard.x)) : 0;
const artboardTop = document.artboards.length ? Math.min(...document.artboards.map((artboard) => artboard.y)) : 0;
const artboardRight = document.artboards.length ? Math.max(...document.artboards.map((artboard) => artboard.x + artboard.width)) : 1;
const artboardBottom = document.artboards.length ? Math.max(...document.artboards.map((artboard) => artboard.y + artboard.height)) : 1;
const pasteboardBox = document.pasteboardLayers.length ? pasteboardRef.current?.getBBox() : undefined;
const left = Math.min(artboardLeft, pasteboardBox?.x ?? artboardLeft);
const top = Math.min(artboardTop, pasteboardBox?.y ?? artboardTop);
const right = Math.max(artboardRight, pasteboardBox ? pasteboardBox.x + pasteboardBox.width : artboardRight);
const bottom = Math.max(artboardBottom, pasteboardBox ? pasteboardBox.y + pasteboardBox.height : artboardBottom);
const padding = 40;
const measured = {
x: Math.floor(left - padding),
y: Math.floor(top - padding),
width: Math.ceil(right - left + padding * 2),
height: Math.ceil(bottom - top + padding * 2),
};
if (measured.x !== bounds.x || measured.y !== bounds.y || measured.width !== bounds.width || measured.height !== bounds.height) {
onBoundsChange(measured);
}
}, [bounds, document, onBoundsChange]);
return (
<svg
className="document-view"
width={bounds.width}
height={bounds.height}
viewBox={`${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`}
role="img"
aria-label={`${document.name} pasteboard`}
>
<defs>
{document.artboards.map((artboard, index) => (
<clipPath id={`artboard-clip-${index}`} key={artboard.id + index}>
<rect x={artboard.x} y={artboard.y} width={artboard.width} height={artboard.height} />
</clipPath>
))}
</defs>
<g>
{document.artboards.map((artboard, artboardIndex) => (
<g key={artboard.id + artboardIndex}>
<text className="document-artboard-label" x={artboard.x} y={artboard.y - 12}>{artboard.name}</text>
<g clipPath={`url(#artboard-clip-${artboardIndex})`}>
<rect x={artboard.x} y={artboard.y} width={artboard.width} height={artboard.height} fill={artboard.background} />
<g transform={`translate(${artboard.x} ${artboard.y})`}>
{artboard.layers.map((layer, index) => (
<LayerView key={layer.id + index} layer={layer} document={document} path={`${artboard.id}-${index}`} />
))}
</g>
</g>
</g>
))}
<g ref={pasteboardRef}>
{document.pasteboardLayers.map((layer, index) => (
<LayerView key={layer.id + index} layer={layer} document={document} path={`pasteboard-${index}`} />
))}
</g>
</g>
</svg>
);
}

9
src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

231
src/parser.test.ts Normal file
View File

@@ -0,0 +1,231 @@
import { strToU8, zipSync } from "fflate";
import { describe, expect, it } from "vitest";
import { parseXdBuffer } from "./parser";
function packageBuffer(artwork: unknown, manifest: unknown = { version: "60.0" }): ArrayBuffer {
const bytes = zipSync({
manifest: strToU8(JSON.stringify(manifest)),
"artwork/graphicContent.agc": strToU8(JSON.stringify(artwork)),
});
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
function multiArtboardPackage(): ArrayBuffer {
const bytes = zipSync({
manifest: strToU8(JSON.stringify({
children: [{ children: [{ path: "artboard-board-1", name: "Manifest name", "uxdesign#bounds": { x: 100, y: 50, width: 360, height: 800 } }] }],
})),
"artwork/artboard-board-1/graphics/graphicContent.agc": strToU8(JSON.stringify({
artboards: { board: {} },
children: [{
type: "artboard",
id: "board-1",
artboard: { children: [{ type: "rectangle", transform: { tx: 120, ty: 80 }, shape: { type: "rect", width: 40, height: 20 } }] },
}],
})),
});
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
describe("parseXdBuffer", () => {
it("parses an artboard and common layers", () => {
const document = parseXdBuffer(packageBuffer({
children: [{
type: "artboard",
id: "board-1",
name: "Login",
bounds: { x: 0, y: 0, width: 375, height: 812 },
background: "#ffffff",
children: [
{ type: "rectangle", name: "Button", bounds: { x: 20, y: 100, width: 200, height: 44 }, fill: "#6633ff" },
{ type: "text", name: "Label", bounds: { x: 30, y: 110, width: 100, height: 20 }, text: "Continue", textStyle: { fontSize: 16, color: "#ffffff" } },
],
}],
}), "sample.xd");
expect(document.name).toBe("sample");
expect(document.version).toBe("60.0");
expect(document.artboards).toHaveLength(1);
expect(document.artboards[0].layers.map((layer) => layer.type)).toEqual(["rect", "text"]);
expect(document.warnings).toHaveLength(0);
});
it("keeps parsing and warns about unknown layers", () => {
const document = parseXdBuffer(packageBuffer({
children: [{ type: "artboard", children: [{ type: "future-3d-layer", name: "Model" }] }],
}));
expect(document.artboards).toHaveLength(1);
expect(document.warnings[0]).toMatchObject({ code: "unsupported-layer", layer: "Model" });
});
it("supports nested AGC geometry, transforms, and rich text", () => {
const document = parseXdBuffer(packageBuffer({
children: [{
type: "artboard",
artboard: { name: "Nested", width: 400, height: 300 },
children: [{
type: "shape",
transform: { tx: 40, ty: 60 },
shape: { type: "rect", width: 120, height: 32, cornerRadius: 8 },
style: { fill: { type: "solid", color: { value: "#123456" } } },
}, {
type: "text",
text: { paragraphs: [{ lines: [{ spans: [{ text: "Rich text" }] }] }] },
}],
}],
}));
expect(document.artboards[0]).toMatchObject({ name: "Nested", width: 400, height: 300 });
expect(document.artboards[0].layers[0]).toMatchObject({
type: "rect", x: 40, y: 60, width: 120, height: 32, radius: 8,
fill: { type: "solid", color: "#123456" },
});
expect(document.artboards[0].layers[1].text).toBe("Rich text");
});
it("rejects a non-archive", () => {
const buffer = new TextEncoder().encode("not an xd file").buffer;
expect(() => parseXdBuffer(buffer)).toThrow("not a readable XD/ZIP package");
});
it("reports artwork packages with no artboards", () => {
const document = parseXdBuffer(packageBuffer({ children: [] }));
expect(document.warnings).toContainEqual(expect.objectContaining({ code: "no-artboards" }));
});
it("parses separately packaged artboards using manifest names and origins", () => {
const document = parseXdBuffer(multiArtboardPackage());
expect(document.artboards[0]).toMatchObject({ name: "Manifest name", width: 360, height: 800 });
expect(document.artboards[0].layers[0]).toMatchObject({ type: "rect", x: 20, y: 30, width: 40, height: 20 });
});
it("recognizes XD pattern fills as image paints", () => {
const document = parseXdBuffer(packageBuffer({
children: [{
type: "artboard",
children: [{
type: "shape",
shape: { type: "rect", width: 100, height: 80 },
style: { fill: { type: "pattern", pattern: { meta: { ux: { uid: "image-id" } } } } },
}],
}],
}));
expect(document.artboards[0].layers[0]).toMatchObject({ type: "rect", fill: { type: "pattern", asset: "image-id" } });
});
it("preserves nested color alpha and does not render compound operands twice", () => {
const document = parseXdBuffer(packageBuffer({
children: [{
type: "artboard",
children: [{
type: "shape",
style: {
blendMode: "soft-light",
fill: { type: "solid", color: { value: { r: 10, g: 20, b: 30 }, alpha: 0.25 } },
},
shape: {
type: "compound",
path: "M 0 0 L 10 0 L 10 10 Z",
children: [{ type: "shape", shape: { type: "path", path: "M 0 0 L 1 1" } }],
},
}],
}],
}));
expect(document.artboards[0].layers[0]).toMatchObject({
type: "path",
blendMode: "soft-light",
fill: { type: "solid", color: "rgba(10, 20, 30, 0.25)" },
});
expect(document.artboards[0].layers[0].children).toBeUndefined();
});
it("uses ellipse radii and positioned RTL text lines", () => {
const document = parseXdBuffer(packageBuffer({
children: [{
type: "artboard",
children: [{
type: "group",
transform: { tx: 100, ty: 200 },
group: { children: [{
type: "shape",
transform: { tx: 10, ty: 20 },
shape: { type: "ellipse", cx: 50, cy: 40, rx: 50, ry: 40 },
}, {
type: "text",
transform: { tx: 30, ty: 60 },
style: { font: { size: 24 } },
text: {
rawText: "اشتراک 12",
paragraphs: [{ lines: [[
{ from: 7, to: 9, x: -90, y: 4 },
{ from: 0, to: 7 },
]] }],
},
}] },
}],
}],
}));
const group = document.artboards[0].layers[0];
expect(group.children?.[0]).toMatchObject({ type: "ellipse", width: 100, height: 80 });
expect(group.children?.[1].textLines).toEqual([{ text: "اشتراک 12", x: -90, y: 4, fontSize: 24 }]);
});
it("resolves shared syncRef components and nested XD colors", () => {
const bytes = zipSync({
manifest: strToU8(JSON.stringify({})),
"resources/graphics/graphicContent.agc": strToU8(JSON.stringify({
resources: { meta: { ux: { symbols: [{
type: "shape",
id: "source-shape",
shape: { type: "rect", width: 50, height: 30 },
style: { fill: { type: "solid", color: { value: { r: 126, g: 61, b: 254 } } } },
}] } } },
})),
"artwork/artboard-board-1/graphics/graphicContent.agc": strToU8(JSON.stringify({
children: [{
type: "artboard",
artboard: { children: [{ type: "syncRef", syncSourceGuid: "source-shape", transform: { tx: 10, ty: 20 } }] },
}],
})),
});
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
const document = parseXdBuffer(buffer);
expect(document.warnings).toHaveLength(0);
expect(document.artboards[0].layers[0]).toMatchObject({
type: "rect",
x: 10,
y: 20,
fill: { type: "solid", color: "rgba(126, 61, 254, 1)" },
});
});
it("extracts pasteboard annotations with global coordinates", () => {
const bytes = zipSync({
manifest: strToU8(JSON.stringify({})),
"artwork/artboard-board-1/graphics/graphicContent.agc": strToU8(JSON.stringify({
children: [{ type: "artboard", artboard: { children: [] } }],
})),
"artwork/pasteboard/graphics/graphicContent.agc": strToU8(JSON.stringify({
children: [{
type: "text",
name: "Annotation",
transform: { tx: -200, ty: 300 },
text: { rawText: "Outside note" },
}, {
type: "shape",
name: "Arrow",
transform: { tx: 40, ty: 50 },
shape: { type: "path", path: "M 0 0 L 20 20" },
}],
})),
});
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
const document = parseXdBuffer(buffer);
expect(document.pasteboardLayers).toHaveLength(2);
expect(document.pasteboardLayers[0]).toMatchObject({ type: "text", x: -200, y: 300, text: "Outside note" });
expect(document.pasteboardLayers[1]).toMatchObject({ type: "path", x: 40, y: 50 });
});
});

394
src/parser.ts Normal file
View File

@@ -0,0 +1,394 @@
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,
};
}

9
src/parser.worker.ts Normal file
View File

@@ -0,0 +1,9 @@
import { parseXdBuffer } from "./parser";
self.onmessage = (event: MessageEvent<{ buffer: ArrayBuffer; name: string }>) => {
try {
self.postMessage({ document: parseXdBuffer(event.data.buffer, event.data.name) });
} catch (error) {
self.postMessage({ error: error instanceof Error ? error.message : "Could not open this XD file." });
}
};

70
src/styles.css Normal file
View File

@@ -0,0 +1,70 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #eef0f7;
background: #11131a;
font-synthesis: none;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 720px; min-height: 100vh; overflow: hidden; }
button { font: inherit; }
.drop-screen {
min-height: 100vh;
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 25%, rgba(182, 61, 255, .18), transparent 35%),
linear-gradient(145deg, #101219, #171923);
transition: background .2s;
}
.drop-screen.dragging { background-color: #21182d; }
.drop-card { width: min(520px, 90vw); padding: 54px; text-align: center; border: 1px solid #343746; border-radius: 24px; background: rgba(25, 27, 37, .82); box-shadow: 0 30px 80px #06070a80; }
.logo, .brand span { display: inline-grid; place-items: center; color: white; font-weight: 800; letter-spacing: -2px; background: linear-gradient(135deg, #ff61f6, #8b48ff); }
.logo { width: 68px; height: 68px; border-radius: 18px; font-size: 25px; box-shadow: 0 12px 35px #a850ee55; }
.eyebrow { margin: 26px 0 8px; color: #b986e8; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 1.6px; }
h1 { margin: 0; font-size: 34px; letter-spacing: -1.3px; }
.lede { color: #a8acbb; line-height: 1.6; margin: 16px auto 28px; }
.primary { border: 0; border-radius: 10px; padding: 13px 22px; color: white; background: #8c52ff; font-weight: 700; cursor: pointer; }
.primary:disabled { opacity: .6; cursor: wait; }
.drop-hint { display: block; margin-top: 14px; color: #737887; font-size: 12px; }
.error { margin: 20px 0 0; padding: 10px; border-radius: 8px; color: #ffb0bb; background: #b82a3d22; font-size: 13px; }
.app-shell { height: 100vh; display: grid; grid-template: 64px 1fr / 280px 1fr; }
.topbar { grid-column: 1 / -1; z-index: 2; display: flex; align-items: center; justify-content: space-between; padding: 0 18px; border-bottom: 1px solid #2b2e39; background: #191b24; }
.brand { display: flex; align-items: center; gap: 11px; min-width: 0; }
.brand span { width: 34px; height: 34px; border-radius: 9px; font-size: 13px; letter-spacing: -1px; }
.brand strong { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.brand small { color: #777c8b; }
.toolbar { display: flex; align-items: center; gap: 4px; }
.toolbar button, .toolbar output { height: 34px; min-width: 36px; padding: 0 10px; border: 1px solid #343744; border-radius: 7px; color: #daddE7; background: #222530; cursor: pointer; }
.toolbar output { display: grid; place-items: center; min-width: 60px; color: #aeb2c1; font-size: 12px; }
.toolbar .clear { margin-left: 10px; color: #c3a8f7; }
.sidebar { min-height: 0; display: flex; flex-direction: column; border-right: 1px solid #2b2e39; background: #181a22; }
.sidebar-title { display: flex; justify-content: space-between; padding: 20px; border-bottom: 1px solid #292c36; font-size: 13px; }
.sidebar-title span { color: #7d8292; }
.artboard-list { flex: 1; overflow: auto; padding: 10px; }
.artboard-list button { width: 100%; display: flex; align-items: center; gap: 12px; padding: 9px; border: 1px solid transparent; border-radius: 9px; color: #cfd2dc; background: transparent; text-align: left; cursor: pointer; }
.artboard-list button:hover { background: #21242e; }
.artboard-list button.active { border-color: #7748c7; background: #7e4ed41e; }
.artboard-list button > span:last-child { min-width: 0; display: grid; gap: 4px; }
.artboard-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.artboard-list small { color: #737888; font-size: 10px; }
.miniature { width: 40px; max-height: 50px; flex: 0 0 40px; border: 1px solid #484c59; box-shadow: 0 2px 8px #0004; }
.empty, .canvas-empty { color: #7f8492; font-size: 13px; text-align: center; }
.warnings { max-height: 45%; border-top: 1px solid #292c36; overflow: auto; }
.warnings > button { width: 100%; display: flex; justify-content: space-between; padding: 15px 18px; border: 0; color: #e1bf7e; background: #302616; font-size: 12px; cursor: pointer; }
.warnings b { min-width: 20px; padding: 2px 6px; border-radius: 10px; background: #9b6a20; color: white; font-size: 10px; }
.warnings ul { list-style: none; padding: 4px 16px 12px; margin: 0; }
.warnings li { padding: 8px 0; border-bottom: 1px solid #292c36; color: #aeb2bf; font-size: 11px; line-height: 1.4; }
.warnings li small { display: block; color: #777c8b; }
.canvas { position: relative; display: grid; place-items: center; overflow: hidden; touch-action: none; cursor: grab; background-color: #11131a; background-image: radial-gradient(#343743 1px, transparent 1px); background-size: 20px 20px; }
.canvas:active { cursor: grabbing; }
.stage { position: relative; transform-origin: center; transition: transform .05s linear; }
.document-stage { flex: 0 0 auto; }
.document-view { display: block; overflow: visible; }
.document-artboard-label { fill: #969baa; font-size: 12px; }
.artboard { display: block; overflow: visible; background: white; }
.artboard-label { position: absolute; left: 0; top: -28px; max-width: 100%; color: #969baa; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

1
src/test/setup.ts Normal file
View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

59
src/types.ts Normal file
View File

@@ -0,0 +1,59 @@
export type Warning = {
code: string;
message: string;
layer?: string;
};
export type Paint =
| { type: "solid"; color: string }
| { type: "linear-gradient"; angle: number; stops: Array<{ offset: number; color: string }> }
| { type: "radial-gradient"; cx: number; cy: number; r: number; stops: Array<{ offset: number; color: string }> }
| { type: "pattern"; asset: string };
export type Layer = {
id: string;
name: string;
type: "group" | "rect" | "ellipse" | "path" | "text" | "image" | "unknown";
x: number;
y: number;
width: number;
height: number;
opacity: number;
visible: boolean;
blendMode?: string;
rotation?: number;
matrix?: { a: number; b: number; c: number; d: number };
fill?: Paint;
stroke?: string;
strokeWidth?: number;
radius?: number;
path?: string;
text?: string;
textLines?: Array<{ text: string; x: number; y: number; fontSize?: number }>;
fontSize?: number;
fontFamily?: string;
fontWeight?: string | number;
textColor?: string;
imageAsset?: string;
children?: Layer[];
};
export type Artboard = {
id: string;
name: string;
x: number;
y: number;
width: number;
height: number;
background: string;
layers: Layer[];
};
export type ParsedDocument = {
name: string;
version?: string;
artboards: Artboard[];
pasteboardLayers: Layer[];
assets: Record<string, string>;
warnings: Warning[];
};

29
src/viewport.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import type { Artboard, ParsedDocument } from "./types";
import { artboardBounds, fitBounds, focusArtboard } from "./viewport";
const artboards: Artboard[] = [
{ id: "a", name: "A", x: -500, y: -100, width: 200, height: 400, background: "#fff", layers: [] },
{ id: "b", name: "B", x: 300, y: 500, width: 300, height: 200, background: "#fff", layers: [] },
];
const document: ParsedDocument = { name: "Demo", artboards, pasteboardLayers: [], assets: {}, warnings: [] };
describe("viewport helpers", () => {
it("measures artboards with negative document coordinates", () => {
expect(artboardBounds(document)).toEqual({ x: -500, y: -100, width: 1100, height: 800 });
});
it("fits complete bounds inside the viewport", () => {
expect(fitBounds({ x: -500, y: -100, width: 1100, height: 800 }, { width: 1200, height: 900 }, 50)).toEqual({
zoom: 1,
pan: { x: 0, y: 0 },
});
});
it("focuses an artboard relative to the document center", () => {
const transform = focusArtboard(artboards[0], artboardBounds(document), { width: 1000, height: 800 }, 100);
expect(transform.zoom).toBe(1.5);
expect(transform.pan.x).toBeGreaterThan(0);
expect(transform.pan.y).toBeGreaterThan(0);
});
});

36
src/viewport.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { Artboard, ParsedDocument } from "./types";
export type DocumentBounds = { x: number; y: number; width: number; height: number };
export type ViewportSize = { width: number; height: number };
export type ViewTransform = { zoom: number; pan: { x: number; y: number } };
export function artboardBounds(document: ParsedDocument): DocumentBounds {
if (!document.artboards.length) return { x: 0, y: 0, width: 1, height: 1 };
const x = Math.min(...document.artboards.map((artboard) => artboard.x));
const y = Math.min(...document.artboards.map((artboard) => artboard.y));
const right = Math.max(...document.artboards.map((artboard) => artboard.x + artboard.width));
const bottom = Math.max(...document.artboards.map((artboard) => artboard.y + artboard.height));
return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) };
}
export function fitBounds(bounds: DocumentBounds, viewport: ViewportSize, padding = 80, maxZoom = 1): ViewTransform {
const availableWidth = Math.max(1, viewport.width - padding * 2);
const availableHeight = Math.max(1, viewport.height - padding * 2);
return {
zoom: Math.min(maxZoom, availableWidth / bounds.width, availableHeight / bounds.height),
pan: { x: 0, y: 0 },
};
}
export function focusArtboard(artboard: Artboard, bounds: DocumentBounds, viewport: ViewportSize, padding = 80): ViewTransform {
const zoom = Math.min(2, (viewport.width - padding * 2) / artboard.width, (viewport.height - padding * 2) / artboard.height);
const documentCenter = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
const artboardCenter = { x: artboard.x + artboard.width / 2, y: artboard.y + artboard.height / 2 };
return {
zoom,
pan: {
x: -(artboardCenter.x - documentCenter.x) * zoom,
y: -(artboardCenter.y - documentCenter.y) * zoom,
},
};
}

21
tsconfig.app.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"noEmit": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["vite.config.ts"]
}

10
vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
},
});