npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@acetrumtech/design-to-fabric

npm license node

Turn design files into editable layers — convert design files into Fabric.js JSON.

npm install @acetrumtech/design-to-fabric fabric

One 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.

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 fabric
pnpm add @acetrumtech/design-to-fabric fabric
yarn add @acetrumtech/design-to-fabric fabric

fabric 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-fabric

The 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 pack

That 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.tgz

Or link the source, so there is no re-pack and re-install on every change:

npm install file:/path/to/design-to-fabric

Then 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>                     // /ai

Both 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): PsdWorkerClient

Result 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 baselineShiftdeltaY.

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 it

Fix 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:

  1. emitArtboard: true and let clipToDocument (default) supply the clip.
  2. Set canvas.clipPath to your own artboard object after import.
  3. 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.fontSourcename-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 test
npm run typecheck && npm run build
npm run demo          # Vite + React demo at localhost:5173

The 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.