Image Tools / Docs
Reference

The .img format

A versioned, ZIP-backed project format — the editor's canonical save. What's inside, how migration works, and how to produce one programmatically.

.img is the editor's first-party project format — the equivalent of Photoshop's .psd or Figma's .fig. It's designed to be open, versioned, and machine-friendly so future automation (and AI agents) can produce valid projects without ever opening the editor UI.

#Container

A .img file is a ZIP archive. Open it with any unzip tool to see:

my-project.img/
├── manifest.json          // version, format, feature flags, resource manifest
├── document.json          // the full document state — layers, groups, history
├── assets/                // user-imported raster sources
├── bitmaps/               // base bitmap layer + decoded bitmaps
├── masks/                 // erase / draw / asset-erase masks
├── preview/               // small thumbnail for file previews
└── ...                    // additional resource folders by kind

Resources are referenced by id from document.json — the document never inlines large binaries.

#Manifest

manifest.json is the front door. It carries:

  • format — always "img"
  • formatVersion — currently 1. Bumped only for container changes (folder layout, manifest shape).
  • schemaVersion — currently 2. Bumped for document model changes — new required fields, renames, removed fields, changed semantics.
  • legacySchemaVersions[1]. Versions that the loader can read via migration.
  • features — opt-in feature flags (e.g. groups, text-on-path, frame-clip) so loaders can warn early on docs they don't fully understand.
  • resources — manifest of every asset / mask / bitmap, with id, kind, mediaType, dimensions, and byte size.

#Document

document.json is the layered project. Top-level shape (simplified):

interface EditorDocument {
  schemaVersion: 2
  canvas: { width: number; height: number; background: BackgroundLayerData }
  crop?: CropState
  cropEnabled?: boolean
  layers: LayerData[]                 // all layer kinds, flat list, z-ordered
  groups: GroupData[]                 // required since v2 — empty array if none
  history: { past: Snapshot[]; future: Snapshot[]; current: Snapshot }
}

Layer kinds (bitmap, shape, text, icon, asset, draw) all share a base shape (id, transform, opacity, blendMode, effects, optional groupId) plus their kind-specific fields.

#Versioning rules

The project's CLAUDE.md sets the contract — read the schema rules (in-repo: image-tool/CLAUDE.md) for the full text. Summary:

ChangeAction
Additive optional field with sane absent defaultNo version bump. Update normalize / clone / serialize / migration carriers.
Additive required, rename, removal, or changed semanticsBump EDITOR_DOCUMENT_SCHEMA_VERSION. Append previous to legacy list. Add a migrator.

The migration runner walks from any legacy version to current — multi-step migrations compose automatically.

#Migration mandatory checklist

  1. schema.ts — bump EDITOR_DOCUMENT_SCHEMA_VERSION, append previous to EDITOR_DOCUMENT_LEGACY_SCHEMA_VERSIONS, update interfaces.
  2. migrate.ts — add migrateVNToVN+1 and register it.
  3. normalize.ts — update cloneDocumentState, createEmptyEditorDocumentState, per-field cloners.
  4. toDocument.ts / fromDocument.ts — both directions spread the new field through, including history entries.
  5. serialize.tscloneDocumentForPackage carries the new field; update collectFeatureFlags.
  6. Document the change in docs/.
  7. npm run check and round-trip a saved .img from the previous version.

#Producing a .img programmatically

You can build a valid project without ever opening the editor. The minimum viable shape:

import JSZip from 'jszip'

const document = {
  schemaVersion: 2,
  canvas: { width: 1080, height: 1080, background: { type: 'color', color: '#0a0a0a' } },
  layers: [
    {
      id: 'lyr_text_1',
      type: 'text',
      x: 80, y: 80, width: 920, height: 200, rotation: 0,
      text: 'Hello world',
      fontFamily: 'Inter', fontSize: 96, fontWeight: 700,
      italic: false, align: 'left', color: '#f59e0b',
      opacity: 1
    }
  ],
  groups: [],
  history: { past: [], future: [], current: { /* same shape as document layers/groups */ } }
}

const manifest = {
  format: 'img',
  formatVersion: 1,
  schemaVersion: 2,
  legacySchemaVersions: [1],
  features: [],
  resources: []
}

const zip = new JSZip()
zip.file('manifest.json', JSON.stringify(manifest, null, 2))
zip.file('document.json', JSON.stringify(document, null, 2))
const blob = await zip.generateAsync({ type: 'blob' })
// `blob` is now a valid `.img` you can save / open in the editor

The editor's serialize.ts is the authoritative implementation — read it if you need the exhaustive shape including bitmaps, masks, and history. The structure shown above is the minimum the loader will accept and migrate.

#Why this format

  • Open — anyone can unzip and inspect.
  • Versioned — schema bumps don't break old files; migrations are explicit.
  • Additive — new features default to absent; old files stay valid.
  • Machine-friendly — JSON-first, no proprietary binary blobs in the document.
  • Compact — ZIP's deflate handles the boilerplate, resources stay in their native binary form.

#See also