@acetrumtech/design-to-fabric
v0.1.3
Published
Turn design files into editable layers — convert Adobe Photoshop (PSD) and Illustrator (AI) files into Fabric.js JSON.
Maintainers
Readme
@acetrumtech/design-to-fabric
Turn design files into editable layers — convert design files into Fabric.js JSON.
npm install @acetrumtech/design-to-fabric fabricOne package, one entry per format — see Entry points. Everything after parsing
— placement, masks, text, assets, artboards, warnings, workers — is shared, so a consumer
importing only /psd never pulls another format's parser.
By Acetrum.
Browser-first TypeScript library. Parses a PSD with ag-psd
and an .ai file with pdf.js, normalises the result into
a stable document model, and emits official Fabric.js JSON plus a namespaced acetrum metadata
object.
On the two commercial templates it is tested against, the rendered result sits within 0.39% and 0.78% of the composite Photoshop saved inside the file.
- Install
- Quick start
- Illustrator files
- Integrating with an editor
- API
- Options
- What it converts
- Fonts
- Running in a Worker
- Troubleshooting
- Limitations
- Development
Why it does what it does — the Fabric and Photoshop behaviours it works around — is in
docs/INTERNALS.md.
Install
The package is published on npm as
@acetrumtech/design-to-fabric.
npm install @acetrumtech/design-to-fabric fabricpnpm add @acetrumtech/design-to-fabric fabricyarn add @acetrumtech/design-to-fabric fabricfabric is listed separately on purpose — it is a peer dependency, so npm 7+ installs it
automatically but pnpm and yarn do not. Installing it explicitly works correctly on all three.
Nothing extra is needed for the scope: @acetrumtech is a public scope, so no login, token
or registry configuration is involved. If an install fails with 404 Not Found or E401, an
.npmrc in the project or home directory is pointing @acetrumtech at a private registry —
check with npm config get @acetrumtech:registry, which should print undefined or
https://registry.npmjs.org/.
Verify the install
npm ls @acetrumtech/design-to-fabricThe package is ESM-only ("type": "module") and ships its own TypeScript types — no
@types/* package to install. Import it and TypeScript resolves the declarations through the
exports map:
import { convertPsdToFabric } from '@acetrumtech/design-to-fabric/psd';For that subpath import to typecheck, TypeScript needs moduleResolution set to "bundler",
"node16" or "nodenext". The legacy "node" setting ignores exports entirely and reports
the subpath as missing types.
Entry points
| Import | Gives you |
|---|---|
| @acetrumtech/design-to-fabric | shared model, host integration, helpers |
| @acetrumtech/design-to-fabric/psd | convertPsdToFabric, parsePsd |
| @acetrumtech/design-to-fabric/ai | convertAiToFabric, parseAi |
| @acetrumtech/design-to-fabric/worker | the PSD worker entry |
Importing only /psd never pulls in another format's parser.
From source, while iterating
Only needed if you are changing this library itself. To test exactly what publishing would install:
npm packThat produces acetrum-design-to-fabric-0.1.0.tgz, which the consuming project installs by
path:
npm install /path/to/acetrum-design-to-fabric-0.1.0.tgzOr link the source, so there is no re-pack and re-install on every change:
npm install file:/path/to/design-to-fabricThen npm run build in this repo and restart the consuming dev server.
Dependencies
fabric is a peer dependency (>=7 <8), not a dependency. Your editor's Fabric copy is
the one that gets used — a second copy in the bundle would break instanceof against your
classes and double the bundle size.
ag-psd, gl-matrix and opentype.js install automatically. fontkit is optional and only
loads if you ask for it.
pdfjs-dist is used only by /ai, and only through a dynamic import(). A consumer that never
converts an .ai file never loads it — the ~1 MB parser stays out of the bundle's initial
chunk.
Requires Node 20+ to build. At runtime it targets browsers and workers.
Quick start
import { convertPsdToFabric } from '@acetrumtech/design-to-fabric/psd';
import { Canvas } from 'fabric';
const result = await convertPsdToFabric(file);
const canvas = new Canvas('c');
canvas.setDimensions({ width: result.document.width, height: result.document.height });
await canvas.loadFromJSON(result.fabricJson);
canvas.requestRenderAll();loadFromJSON does not size the canvas — objects are in the PSD's own coordinates, so a
canvas left at its default shows a cropped corner of the design.
The core converter never imports fabric. result.fabricJson is a plain object; your app
loads it with its own Fabric instance.
Keeping the PSD metadata
Fabric restores unknown properties onto object instances during loadFromJSON, but
toObject() drops them — so acetrum would vanish the first time your editor saves the
canvas. Register it once at start-up:
import { FabricObject } from 'fabric';
import { registerAcetrumProperties } from '@acetrumtech/design-to-fabric/psd';
registerAcetrumProperties(FabricObject);Saving the JSON ⚠️
Assets default to blob URLs, and a blob: URL belongs to the document that created it.
Saving JSON that still contains one produces a file that looks complete, loads without an
error, renders every text layer — and shows nothing where the images should be.
If the JSON goes to a database, an API or a file, make it self-contained:
import { inlineAssets } from '@acetrumtech/design-to-fabric/psd';
const portable = await inlineAssets(result);
await save(portable.fabricJson);Or convert that way from the start, which avoids the second pass:
const result = await convertPsdToFabric(file, { assetFormat: 'data-url' });Or pass an assetStore that uploads to your own storage and returns permanent URLs — the best
option for large documents, since base64 costs about a third more bytes than binary.
Releasing memory
Revoke the blob URLs when the canvas that used them is disposed:
import { revokeAssetUrls } from '@acetrumtech/design-to-fabric/psd';
revokeAssetUrls(result);Illustrator files
.ai has been a PDF since Illustrator 9 (2000), so it is read with a PDF parser rather than a
PostScript interpreter — which is what makes it possible in a browser at all.
import { convertAiToFabric } from '@acetrumtech/design-to-fabric/ai';
const result = await convertAiToFabric(file);
await canvas.loadFromJSON(result.fabricJson);The result shape, warnings, asset handling and host-integration options are the same as for PSD, so everything under Integrating with an editor and Options applies unchanged.
What it converts
| Illustrator | Result |
|---|---|
| Vector artwork | Fabric Path, in canvas coordinates |
| Point text | IText, one object per line, editable |
| Embedded image | Fabric Image, deduplicated across placements |
| Luminosity soft mask | Baked into the image's alpha |
| Group opacity | opacity on each object the group contains |
| Blend mode | globalCompositeOperation where canvas has an equivalent |
| Page size | result.document.width / .height, in points |
No layer tree
The output is a flat list of objects, not a named hierarchy. Illustrator does record its layer names in the PDF's optional-content groups, but on a real file the drawable content turns out not to be partitioned by them: toggling every group changed nothing in the render, and the page carried three marked-content sections against fifteen named groups. The real structure lives in the file's private Illustrator stream, which is proprietary.
parseAi returns the page size and, when the file has them, the optional-content group names —
useful for showing the user what the file claims to contain, not for splitting it up.
Where pdf.js runs
By default the PDF is parsed on the calling thread. That needs no configuration and works in every bundler, which is why it is the default — pdf.js otherwise refuses to start in a browser without a worker URL, and only your bundler knows that URL.
For a large file, hand it the worker instead:
await convertAiToFabric(file, {
workerSrc: new URL('pdfjs-dist/legacy/build/pdf.worker.mjs', import.meta.url).toString(),
});Setting GlobalWorkerOptions.workerSrc yourself works too — an already-configured pdf.js is
left alone.
Outside a browser
A render pass is what makes pdf.js hand over its embedded images. In a browser and in a worker
that happens through OffscreenCanvas with no configuration. Under Node there is no canvas, so
pass one:
import { createCanvas } from '@napi-rs/canvas';
class NodeCanvasFactory {
create(width, height) {
const canvas = createCanvas(Math.max(1, width), Math.max(1, height));
return { canvas, context: canvas.getContext('2d') };
}
reset(target, width, height) { target.canvas.width = width; target.canvas.height = height; }
destroy(target) { target.canvas.width = 0; target.canvas.height = 0; }
}
await convertAiToFabric(buffer, { canvasFactory: NodeCanvasFactory, rasterizer: nodeRasterizer });pdf.js wants a constructor, not an instance. It also paints through Path2D and DOMMatrix,
which Node does not provide — assign them onto globalThis from your canvas library before
converting. Without a factory the conversion still succeeds; the images are skipped and a
warning says so.
In a browser and in a worker none of this applies: OffscreenCanvas is used automatically.
Limits
convertAiToFabric throws with an explanation, rather than a parse error, for a file saved
before Illustrator 9 or with Create PDF Compatible File turned off. Multi-page files convert
page 1 and warn; pick another with { page: 2 }.
Reported but not applied: clipping paths (artwork comes through uncropped), gradient and shading fills. Fonts are matched by name against what the host has, exactly as for PSD — the PDF's embedded font programs are not installed.
Integrating with an editor
Editors model their page in one of two ways. Pick the one that matches yours.
A. The page comes from the JSON
The import is a plain canvas.loadFromJSON(json), and the document is expected to bring its
own page — conventionally a non-selectable rectangle named clip at the bottom of the object
stack, which the editor finds by name to drive zoom-to-fit, page resize and export.
One option covers it, and everything stays at (0, 0):
const result = await convertPsdToFabric(file, {
emitArtboard: true, // the `clip` rect, sized to the PSD, under every layer
assetFormat: 'data-url',
});
await canvas.loadFromJSON(result.fabricJson);
canvas.renderAll();
autoZoom();emitArtboard prepends the page rectangle; clipToDocument (on by default) puts a matching
clip in the JSON, which Fabric restores onto the canvas during loadFromJSON. So the host's
getWorkspace() finds a page sized to the PSD, and nothing paints outside it.
Customise the names if yours differ:
emitArtboard: { name: 'clip', id: 'workspace', fill: 'rgba(255,255,255,1)' }Do not set clipToDocument: false here. Without the clip in the JSON there is nothing to
restore, canvas.clipPath ends up undefined, and every layer that runs past the artboard
shows.
This works even if the importer deletes the JSON's clipPath before loading and re-derives it
from the page object — a common pattern, and the reason the page object matters more than the
clip does.
B. The editor already has an artboard
The page exists before the import, often positioned by canvas.centerObject(...) so it sits at
an arbitrary offset. Resize it to the PSD, place the document on it, and add the objects
rather than loading them — loadFromJSON replaces the whole canvas, taking the workspace and
its clip with it:
import { util } from 'fabric';
import { convertPsdToFabric, applyOrigin } from '@acetrumtech/design-to-fabric/psd';
const result = await convertPsdToFabric(file, {
clipToDocument: false, // the workspace already clips the canvas
assetFormat: 'data-url',
});
// 1. The artboard takes the PSD's dimensions.
const workspace = canvas.getObjects().find((o) => o.name === 'clip');
workspace.set({ width: result.document.width, height: result.document.height });
workspace.setCoords();
canvas.clipPath = workspace;
// 2. Place the document on it — read the position *after* the resize.
const artboard = workspace.getBoundingRect();
applyOrigin(result.fabricJson, { left: artboard.left, top: artboard.top });
// 3. Add, don't load.
const objects = await util.enlivenObjects(result.fabricJson.objects);
objects.forEach((object) => canvas.add(object));
canvas.requestRenderAll();Use getBoundingRect() rather than workspace.left: it gives the top-left whatever origin the
host has configured, so this works both with Fabric 7's center default and with an editor
that sets FabricObject.ownDefaults.originX = 'left'.
applyOrigin is the same offset the origin option performs, applied after the fact — useful
here because the artboard's final position is only known once it has been resized.
Layers panels
Every object carries name, set to its PSD layer name, because that is what editors key their
layers panel on. If your serialiser takes an explicit property list, include it:
canvas.toObject(['name', 'acetrum']);API
convertPsdToFabric(input, options?): Promise<ConversionResult> // /psd
parsePsd(input): Promise<PsdDesign> // /psd
convertAiToFabric(input, options?): Promise<ConversionResult> // /ai
parseAi(input, options?): Promise<PsdDesign> // /aiBoth converters return the same ConversionResult, so a host handles either format with one
code path once it has picked an entry point by file extension.
| | |
|---|---|
| input | ArrayBuffer \| Blob \| File |
| ConversionResult | { fabricJson, document, assets, warnings } |
| PsdDesign | the normalised layer tree, without extracting any pixels |
Nothing throws for a layer it cannot handle — it becomes a hidden placeholder plus a warning, so the rest of the document keeps its z-order.
Helpers
registerAcetrumProperties(fabricObjectClass): void // keep acetrum across saves
loadIntoFabric(canvas, result): Promise<void> // load into a canvas you own
revokeAssetUrls(result): void // release blob URLs
inlineAssets(result): Promise<ConversionResult> // make the JSON self-contained
hasSessionScopedAssets(fabricJson): boolean // true ⇒ its images won't survive a save
applyOrigin(fabricJson, { left, top }): void // move a converted document
createPsdWorkerClient(workerOrFactory): PsdWorkerClientResult shape
interface ConversionResult {
fabricJson: FabricJson; // load this into Fabric
document: PsdDesign; // full layer hierarchy, always nested
assets: ConversionAsset[]; // { id, type, blob, url, width, height }
warnings: ConversionWarning[];
}
interface ConversionWarning {
code: string; // e.g. 'SMART_OBJECT_RASTERIZED'
message: string;
severity: 'info' | 'warning' | 'error';
layerId?: string;
layerName?: string;
}document keeps the full hierarchy whatever the Fabric output flattens, so a layers panel can
show groups even when the objects are flat.
Per-object metadata
object.acetrum = {
sourceLayerId, sourceLayerName, sourceType,
blendMode, sourceOpacity, unsupported, rasterized,
sourceFont, fontEngine, fontSource, textApproximations, sourceText,
referenceAssetId,
}The document itself carries { schemaVersion, source, generator, homepage, psd, flattenedGroups },
so a stray JSON can always be traced back to the tool that made it.
Options
Every field is optional.
Output shape
| Option | Default | |
|---|---|---|
| emitArtboard | false | Add a page rectangle (name: 'clip') under every layer |
| clipToDocument | true | Canvas-level clip at the document's edges |
| cropToDocument | false | Trim each layer to the artboard — needs no host clip |
| origin | { left: 0, top: 0 } | Place the document at a host artboard's position |
| setObjectName | true | Set name to the PSD layer name |
| preserveGroups | false | Emit Fabric Group objects instead of a flat list |
| background | (omitted) | A PSD has no canvas colour; set it if your editor wants one |
| fabricVersion | '7.0.0' | Written to the JSON's version field |
Text
| Option | Default | |
|---|---|---|
| preserveText | true | Editable text objects; false rasterises them |
| textRasterReference | false | Also keep a hidden raster of each text layer |
| fontResolver | (none) | See Fonts |
| fontEngine | 'opentype' | 'fontkit' or 'auto' for shaped measurement |
Assets
| Option | Default | |
|---|---|---|
| extractAssets | true | Off ⇒ structure only, layers become hidden placeholders |
| assetFormat | 'blob-url' | Or 'data-url' |
| assetMimeType | 'image/png' | Or 'image/jpeg', 'image/webp' |
| assetStore | object URLs | Push assets to your own storage instead |
| extractSmartObjectSources | false | Also return each Smart Object's embedded original |
| maxRasterFallbackPixels | 16_000_000 | Larger layers are downscaled, not dropped |
Everything else
| Option | Default | |
|---|---|---|
| includeHidden | true | Hidden PSD layers arrive as visible: false |
| onProgress | (none) | { phase, completed, total, ratio, layerName? } |
| rasterizer | OffscreenCanvas / <canvas> | Override to run outside a browser |
| maxFileBytes | 512 MB | Rejects oversized input |
| maxDocumentPixels | 100_000_000 | Rejects oversized documents |
| worker | false | See Running in a Worker — this flag only warns |
What it converts
| PSD | Result |
|---|---|
| Raster layer | Fabric Image at exact PSD bounds |
| Text layer | Textbox (paragraph) or IText (point), editable |
| Vector shape | Rasterised Image |
| Group | Folded into the object list, or a Fabric Group with preserveGroups |
| Layer mask | Baked into the layer's alpha |
| Group mask | Baked into every descendant's alpha |
| Clipping mask | Fabric clipPath — the clipped layer stays editable |
| Opacity, visibility | opacity, visible |
| Blend mode | globalCompositeOperation where canvas has an equivalent |
| Colour Overlay | Baked into RGB |
| Gradient Overlay | Baked into RGB (linear) |
| Drop Shadow | Fabric's native shadow — stays editable |
| Outer Glow | Fabric shadow with no offset, plus a warning |
| Smart Object | Rasterised; placement and source recorded, source extractable |
Text carries per-character styles, so a headline with one coloured word survives as a single editable object:
"styles": [{ "start": 0, "end": 3, "style": { "fill": "#a4ca00" } }]Mapped per run: fill, fontSize, fontFamily, fontWeight, fontStyle, underline,
linethrough, and baselineShift → deltaY.
Photoshop's all-caps (fontCaps) is a style, not the text — the stored string stays mixed
case. It is folded into the string so the design looks right, with the original kept on
acetrum.sourceText.
Groups are flattened by default because a flat list is what most editors expect from an
import. preserveGroups: true emits nested Fabric Group objects instead; the rendered pixels
are identical either way, and result.document carries the full hierarchy regardless.
Fonts
PSD text layers store only a PostScript name (Arial-BoldMT) — no font file, no family, no
weight. With nothing else to go on, the name is parsed heuristically. That is a convention, not
a spec, so a family whose real name contains "Black" will read as weight 900.
Give the converter a FontResolver and it stops guessing:
const result = await convertPsdToFabric(file, {
fontResolver: {
async resolve(postScriptName) {
const buffer = await myFontService.load(postScriptName);
if (!buffer) return null;
return {
metrics: { family: postScriptName, unitsPerEm: 1000, ascent: 800, descent: -200 },
buffer,
};
},
},
});Return a buffer and the font itself supplies the real family, weight, italic flag and
metrics. Return metrics only and those are used with a heuristic family. Return null and you
get the heuristic, plus a FONT_NOT_FOUND warning.
Every text object records which path was taken:
object.acetrum.fontSource; // 'font-file' | 'metrics-only' | 'name-heuristic'
object.acetrum.fontEngine; // 'opentype' | 'fontkit'Fonts are never fetched automatically — only what your resolver hands over is used, so no copyrighted font is loaded or embedded on your users' behalf. Results are cached per PostScript name for the life of one conversion.
The host has to load the font too. The converter records which family a layer needs; the browser still has to have it, or Fabric renders a fallback and the text looks wrong for reasons that have nothing to do with the conversion.
Choosing a font engine
| | 'opentype' (default) | 'fontkit' | 'auto' |
|---|---|---|---|
| Must be installed | yes (a dependency) | yes (optional) | falls back if missing |
| Family / weight / italic | yes | yes | — |
| Metrics | yes | plus capHeight/xHeight | — |
| Variable-font axes | no | yes | — |
| Shaped measurement | no | yes | — |
Shaped measurement is what fontkit buys you: a box-text layer wider than the box Photoshop
recorded is reported as text-overflows-box — usually the sign that a substituted font is
wider than the original.
fontkit cannot change how glyphs are drawn. Fabric renders text through the canvas
fillText API, which does its own shaping with whatever font the browser has loaded. fontkit
informs and measures here; it does not drive rendering.
Both parsers are imported dynamically and kept external, so neither loads until a resolver supplies a font buffer.
Running in a Worker
A 14 MB, 84-layer PSD takes roughly 750 ms to convert — long enough to drop frames. The Worker is constructed by you, not by this package: a library cannot build a worker URL that reliably resolves inside someone else's bundler.
import { createPsdWorkerClient } from '@acetrumtech/design-to-fabric/psd';
const client = createPsdWorkerClient(
() => new Worker(new URL('@acetrumtech/design-to-fabric/worker', import.meta.url), { type: 'module' }),
);
const result = await client.convert(file, {
onProgress: ({ ratio, phase }) => setBar(ratio, phase),
});
client.dispose();convert() returns the same ConversionResult as the main-thread call — the test suite
asserts the two produce byte-identical fabricJson.
What crosses the boundary
postMessage only carries structured-cloneable values, so every function-shaped option needs a
decision rather than a silent drop:
| Option | Handling |
|---|---|
| onProgress | Stays on the main thread, driven by the worker's progress messages |
| fontResolver | Proxied — each lookup round-trips back to your resolver |
| rasterizer | Ignored; the worker uses OffscreenCanvas. Reported as a warning |
| assetStore | Ignored; the client mints the URLs. Reported as a warning |
An ArrayBuffer input is transferred, not copied, so your copy is detached afterwards.
Pass a File or Blob to avoid that — those clone by reference.
Bundler note
The worker dynamically imports its font parsers, so it needs a worker format that can code-split. In Vite:
export default defineConfig({ worker: { format: 'es' } });Vite defaults to IIFE workers, which cannot split — and the dev server works either way, so the first sign of trouble is a production build failing inside Rollup.
Next.js and webpack 5 handle new Worker(new URL(...)) without configuration. Keep the PSD and
Fabric work inside a Client Component.
Troubleshooting
Every entry here is a failure that actually happened during integration.
Images are missing, text renders fine
Blob URLs. assetFormat defaults to 'blob-url', and those die with the page that created
them. If the JSON is saved and loaded later, every image fails silently.
hasSessionScopedAssets(json); // true ⇒ this is itFix with inlineAssets(result) before saving, or convert with assetFormat: 'data-url', or
pass an assetStore. See Saving the JSON.
Everything is offset, or only a corner shows
loadFromJSON does not size the canvas:
canvas.setDimensions({ width: result.document.width, height: result.document.height });To fit a smaller viewport, scale with setZoom and size the canvas to match. Scaling the
element with CSS alone leaves Fabric's pointer maths at the original scale, so every click lands
in the wrong place.
Layers spill past the artboard
PSD layers routinely extend past the canvas; Photoshop never draws the excess, Fabric has no such boundary. Three ways out, in order of preference:
emitArtboard: trueand letclipToDocument(default) supply the clip.- Set
canvas.clipPathto your own artboard object after import. cropToDocument: true— removes those pixels outright, so no canvas arrangement can reveal them. Also shrinks the assets. The cost: a cropped layer no longer carries the part that was outside, so dragging it will not bring more back.
Check which case you are in:
console.log(canvas.clipPath?.width, canvas.getObjects().find((o) => o.name === 'clip')?.width);The editor's zoom-to-fit / resize / export does nothing
Those features find the page by name. Without emitArtboard: true there is no object named
clip, so getWorkspace() returns undefined and each of them quietly no-ops.
acetrum disappears after the first save
registerAcetrumProperties(FabricObject) was not called. See
Keeping the PSD metadata.
Text is the right size and place but the wrong typeface
The font is not loaded in the browser. Check object.acetrum.fontSource — name-heuristic
means no resolver supplied it, and fontFamily is a best guess from the PostScript name.
"Canvas not initialized" in Node
ag-psd builds its results with the global ImageData, which Node lacks, and falls back to
creating a canvas:
import { createCanvas } from '@napi-rs/canvas';
import { initializeCanvas } from 'ag-psd';
initializeCanvas(createCanvas);Browsers and workers have both, so this is a non-issue there.
Nothing changed at all
Check which converter is actually running. If the host has its own PSD pipeline, installing this one changes nothing until the call site is swapped.
Limitations
Not applied, reported per layer instead: Inner Shadow, Inner Glow, Bevel, Satin, Stroke, Pattern Overlay. These need per-pixel compositing inside the layer, or room outside its pixel rectangle, and a layer raster offers neither. Photoshop stores only the un-effected pixels plus the effect parameters, so rasterising the result would mean computing it.
Group opacity is an approximation. Photoshop composites a group and applies its opacity once; Fabric multiplies the group's alpha into each child. Overlapping children inside a semi-transparent group show their seams. This is a Fabric limitation — real Fabric groups do not fix it.
Paragraph-level runs are not mapped. A layer whose paragraphs differ in alignment or indentation takes the first paragraph's settings.
Untested formats. Both test PSDs are 8-bit RGB. PSB, 16/32-bit, CMYK, Grayscale and Indexed have never been run, and neither have corrupt or truncated files.
.ai is flat, uncropped, and unshaded. No layer tree, clipping paths reported rather than
applied, gradients skipped. See Illustrator files for why.
Development
npm testnpm run typecheck && npm run buildnpm run demo # Vite + React demo at localhost:5173The demo is the only place the library runs in a real browser, against real OffscreenCanvas, a
real Worker and real font loading. Two bugs came straight out of standing it up.
Testing against real files
Drop any .psd into psd/ — tests/realPsd.test.ts runs every file it finds, converts it,
renders through Fabric, and compares against the composite Photoshop saved inside the PSD. .ai
files in ai/ work the same way through tests/ai.test.ts. With either folder empty the suite
skips itself, so no binary needs committing.
Tests otherwise build PSDs in memory with ag-psd's writer and run the whole conversion under
Node via the rasterizer seam — no browser needed.
Roadmap
| Phase | Deliverable | | |---|---|---| | 1 | Parser, layer tree, image layers, Fabric JSON | ✅ | | 2 | Text layers, font resolver, opentype.js metrics | ✅ | | 3 | fontkit metrics, per-character styles | ✅ | | 4 | Groups, transforms, masks, clipping | ✅ | | 5 | Smart Objects, layer effects | ✅ | | 6 | Web Worker, progress events, memory | ✅ | | 7 | Demo, real-PSD test suite | ✅ | | 8 | npm publish | ✅ | | 9 | Semantic versioning, CI | |
Beyond the PSD roadmap: .ai support ships in 0.1.0 (paths, text, images, soft masks). Clipping
paths and gradients are the next two gaps.
