.koi file spec
What's inside a .koi file: the ZIP layout, manifest, Loro document, tile codec and blobs. For converter authors.
This page describes the .koi format as koi.photo writes it today (format version 2; version 2 only added 16-bit tiles). Notes marked "older files" describe what earlier versions wrote, which readers should still accept. It's for developers who want to write a converter or a reader, and for anyone curious about what's in their files. If you just want to save and open documents, read .koi files instead.
The koi.photo source includes a small standalone reader, scripts/koi-inspect.mjs (Node 22 or newer, plus the @zip.js/zip.js and loro-crdt packages). It prints everything described here and decodes pixels to PNG. Every example output on this page comes from running it on a file saved by the editor.
node scripts/koi-inspect.mjs photo.koi out/Overview
A .koi file is a ZIP archive with four kinds of content:
manifest.json: a small JSON header with the format version, canvas size and a list of every blob.document.loro: the document itself (layer tree, layer settings, text, vector paths) as a Loro CRDT snapshot. This is binary, not JSON.blobs/…: pixel tiles and other binary data, one file per blob, named by its SHA-256 hash.thumbnail.png: a small flattened preview.
It can also hold extensions/…: data other tools stored in the file, which koi.photo keeps when it saves again.
There's no plain-JSON copy of the document and no full-size flattened image in the file. To read layers you need a Loro library. To get a picture without rendering, use thumbnail.png, which is 256 px on its longest side.
Container
| Property | Value |
|---|---|
| File extension | .koi |
| MIME type | application/vnd.photokoi+zip |
| Container | ZIP. Zip64 may appear in very large files, so use a reader that supports it. |
| Magic | PK\x03\x04 at offset 0, then the entry name mimetype at offset 30 and the MIME type at offset 38 |
| Timestamps | DOS timestamps only, no extended-timestamp extra fields |
Entries
photo.koi (ZIP)
├── mimetype STORED "application/vnd.photokoi+zip", always the first entry
├── document.loro STORED Loro snapshot of the document
├── thumbnail.png STORED flattened preview, 256 px on the longest side (optional)
├── preview.webp STORED reserved, not written today (optional)
├── blobs/<sha256 hex> STORED one entry per blob: tiles, fonts, metadata segments
├── extensions/<id>/… STORED other tools' data, kept across saves (optional)
└── manifest.json DEFLATE written last, because it lists every blob's sizeEvery entry is STORED (uncompressed) except manifest.json, which is deflated. Tiles are already compressed by their own codec, so the ZIP layer doesn't compress them again. Only the position of mimetype matters: it's the first entry, STORED, with no extra field and no data descriptor, so the MIME type sits at byte offset 38. Other entries use data descriptors and can come in any order, so read them through the central directory.
Here's the entry list of a sample file, as koi-inspect prints it:
== entries
stored 28 mimetype
stored 2915 document.loro
stored 2731 thumbnail.png
stored 2401 blobs/6da9f37c024d6a05d4da66b98cfefe27bdf8d059d8602fc3bf946e0a42c78ee6
stored 1100 blobs/10ccac5a3e44ab750abe9789f134b4e49a6029dce4ebdc9f2400be10966644ba
…
deflate 1410 manifest.jsonmanifest.json
UTF-8 JSON, one object:
{
"format": "photokoi",
"formatVersion": 2,
"minReaderVersion": 1,
"appVersion": "0.0.1",
"docId": "ylcuxC72nB3i0P5H",
"created": "2026-10-07T17:04:28.190Z",
"modified": "2026-10-07T17:04:34.746Z",
"canvas": { "width": 800, "height": 600, "dpi": 72, "colorProfile": "sRGB", "bitDepth": 8 },
"history": "shallow",
"blobs": [
{ "hash": "sha256:6da9f37c024d6a05d4da66b98cfefe27bdf8d059d8602fc3bf946e0a42c78ee6", "size": 2401, "kind": "tile" }
],
"extensions": {},
"missingBlobs": []
}| Field | Type | Meaning |
|---|---|---|
format | "photokoi" | Constant. |
formatVersion | integer | Format version of the writer. Currently 2. The document tree is the same as in version 1; version 2 added 16-bit (rgba16f) tiles. |
minReaderVersion | integer | Oldest reader version that can open the file: 2 when the file holds 16-bit tiles (a 16-bit document, or a 16-bit tile in an 8-bit one), otherwise 1. A version-1 reader refuses 16-bit files instead of misreading them. |
appVersion | string | koi.photo version that wrote the file. Informational. |
docId | string | Stable document id, the same across saves. koi.photo uses it to match the file to an open document or a local working copy. |
created, modified | string | ISO 8601 date-times in UTC with a Z suffix. |
canvas | object | width, height (px), dpi, colorProfile ("sRGB" or "embedded"), bitDepth (8, 16 or 32). A copy of the document settings. When the two disagree, the settings inside document.loro win. |
history | "shallow" or "full" | Whether document.loro carries edit history. koi.photo always writes "shallow". |
blobs | array | Every blob in blobs/: hash (sha256: + 64 lowercase hex), size (bytes) and kind. |
blobs[].kind | enum | tile, original, font, brush or other. A hint, see below. |
extensions | object | Extension id → { "version": string } for the data in extensions/<id>/. Defaults to {}. See Extensions. |
missingBlobs | string[] | Hashes the document references that weren't available when the file was saved. They aren't in the ZIP. Defaults to []. |
blobs[].kind says what a blob is: tile for blobs referenced from tile maps, font for uploaded fonts and other for image metadata segments. original and brush are reserved and not written yet. Don't depend on it: files written before October 2026 list font files as tile, so to find out what a blob is, look at where the document references it. If you read the manifest strictly, treat a kind you don't know as other; koi.photo does.
Compatibility rules
- A reader must refuse files with
minReaderVersiongreater than the version it supports. koi.photo says "Created with a newer version of koi.photo". - Unknown top-level manifest fields are ignored. koi.photo doesn't keep them: it writes a fresh manifest on every save. Data that should survive a save belongs in
extensions/. - koi.photo validates the manifest strictly. If you write
.koifiles,format, the hash pattern, thekindvalues, thebitDepthvalues and theZdate-times must be exactly as above, or the whole file is rejected. Releases before October 2026 also reject unknownkindvalues, so write one of the five. - There are no migrations yet: version 2 only added 16-bit tiles, and the document tree is the same as in version 1. New props and layer kinds may be added with defaults, so a reader should fill in defaults for missing props and skip props and kinds it doesn't know.
- Changes that older readers handle without help don't bump the version. Files from older versions can contain things newer writers leave out: a
trashroot,settings.blendSpace, font blobs listed astile, and thumbnails smaller than 256 px. Readers should accept both.
Extensions
Other tools can store their own data in a .koi under extensions/<id>/…, where <id> is a reverse-domain or dotted name such as com.example.tool. Declare the id in manifest.extensions with your data's version.
- koi.photo keeps these entries: when it opens a file, it stores them with the document, and every later save writes them back unchanged, under the same paths, with the same version. They survive autosave, reopening and cloud sync.
- If the
extensions/folder of a file holds something different from what the document kept, the file wins. - Limits: 16 MB in total per file. Paths must be relative, with no empty,
.or..segments and no backslashes or control characters. Entries over the limit or with invalid paths are left out, and koi.photo tells the user. - koi.photo never reads or changes the contents. If a user edits the document, your data may no longer match it, so check it against the document (for example by layer
uid) before you trust it. - koi.photo also keeps a copy of this data inside
document.loro(resources.extensions). That copy is internal: read your data fromextensions/.
document.loro
document.loro is a binary snapshot of a Loro document. koi.photo writes it with loro-crdt 1.16 in shallow-snapshot mode at the current version, which means it holds the current state without the edit history. Read it with any Loro 1.x implementation: loro-crdt in JavaScript, or the loro crate in Rust. A shallow snapshot can't be checked out at earlier versions.
import { LoroDoc, LoroMap, LoroText } from "loro-crdt"
const doc = new LoroDoc()
doc.import(await read("document.loro"))
const settings = doc.getMap("settings").toJSON()The document has four root containers:
| Container | Loro type | Contents |
|---|---|---|
tree | LoroTree | The layer tree. |
settings | LoroMap | Canvas size and other document settings. |
meta | LoroMap | Document identity. |
resources | LoroMap | Embedded fonts and image metadata. |
settings
A key may be missing. If it is, use the default.
| Key | Type | Default | Meaning |
|---|---|---|---|
width, height | integer | 1920, 1080 | Canvas size in px. |
dpi | number | 72 | Resolution. Affects unit display and print size only. |
units | px, in, cm, mm, pt | px | Display unit. Everything in the file is stored in px. |
colorProfile | string | "sRGB" | "sRGB", or "embedded" when the pixels are in the ICC profile stored in resources.metadata.icc. Imports keep the source pixel values without colour conversion. |
bitDepth | 8, 16, 32 | 8 | Document depth. In a 16-bit document new pixels are rgba16f tiles; it can still hold 8-bit tiles (from before a conversion), so look at each tile's format byte. 32 is read as 16. |
guides | array | [] | { orientation: "horizontal" or "vertical", position }. |
grid | object or null | null | { spacing, subdivisions }. |
Files from before October 2026 can also have blendSpace (gamma or linear). It was never used, since the renderer always blends gamma-encoded values. Ignore it.
meta
| Key | Type | Meaning |
|---|---|---|
formatVersion | integer | Tree schema version, the same as in the manifest. |
docId | string | The same as manifest.docId. |
createdAt | number | Creation time in Unix milliseconds. |
title | string | Document title. |
resources
| Key | Value |
|---|---|
metadata | LoroMap with keys exif, xmp, iptc and icc. Each value is a 32-byte binary SHA-256: the raw metadata segment the imported image carried, stored as a blob. XMP is UTF-8 text. |
extensions | Internal copy of the extensions/ entries (id → { version, files }, with sha256: string hashes). Read the ZIP entries instead, see Extensions. |
fonts | LoroMap from face id (upload: + 16 hex characters) to { family, style, postscriptName, blob, embedding }. blob is a string sha256:… pointing to the font file exactly as the user uploaded it. embedding is one of installable, editable, preview, restricted. Fonts marked restricted aren't embedded. |
Only fonts uploaded with Text ▸ Upload Font… are embedded. Text set in Google Fonts or system fonts refers to them by family and style name, and the font files aren't in the .koi.
The layer tree
Each node of the tree LoroTree carries a data LoroMap with these reserved keys:
| Key | Type | Meaning |
|---|---|---|
uid | string | Stable layer id (16 characters). Use this, not the Loro TreeID, which can change. |
kind | string | Node kind, see below. |
v | integer | Props schema version for this kind. Currently 1. |
tiles | LoroMap | Tile map. Only on pixel and mask nodes. |
Every other key in data is a prop. Props are plain JSON values, with one exception: the story of a text layer is a LoroText.
Tree shape:
- The top level holds artboards. koi.photo uses one artboard, named "Canvas", whose
rectmatches the canvas. Only the first artboard is rendered. - Files written before October 2026 may also hold a hidden top-level node with
kind: "trash"and nouid. It contains deleted layers kept for undo, and they can reference blobs that aren't in the file. Skip it. Newer files don't contain it. - Children are ordered bottom to top: the first child is the lowest layer. Loro's
node.children()returns them in this order.
Here's the sample file's tree as koi-inspect prints it, with some props left out:
artboard pmWs7cmgV07j509z v1 name: "Canvas", rect: {x:0,y:0,width:800,height:600}
pixel kbixDoBh9OUBFNH5 v1 name: "photo", origin: {x:0,y:0}, format: "rgba8"
tiles: 0,0=sha256:6da9f37c024d… 0,1=sha256:6da9f37c024d… 1,0=rgba:22aa55ff 1,1=rgba:22aa55ff …
mask qsbGRCNik0vh789J v1 source: "pixel", emptyValue: 0, density: 1, feather: 0
tiles: 0,0=sha256:f3b606d61640… 0,1=sha256:8ca7cf54eed5… 1,0=sha256:fd09ede23601… …
adjustment X2dXmJ3IgGmGK2Bi v1 effect: "core.brightnessContrast", params: {brightness:0.2,contrast:0.1}
group yDHwt8r2HNd0gMOZ v1 blendMode: "passThrough", expanded: true
text D4AX8KUDEJ41TT3l v1 story: [{insert:"Hello koi"}], mode: {kind:"artistic"}, transform: [1,0,0,1,60,120]
shape RRVIJtI3qukLBVbw v1 shape: "core.ellipse", size: {width:160,height:100}, transform: [1,0,0,1,380,240]Reading a node's props takes a few lines:
for (const key of data.keys()) {
if (["uid", "kind", "v", "tiles"].includes(key)) continue
const value = data.get(key)
props[key] = value instanceof LoroText ? value.toDelta() : value instanceof LoroMap ? value.toJSON() : value
}What children mean
What a node's children do depends on the node's kind:
| Parent | Children |
|---|---|
artboard, group | Ordinary layers, composited bottom to top. A mask child masks the whole group. |
pixel, vector, shape, text | mask children mask the layer. adjustment and liveFilter children apply to this layer only. Any other child is clipped to the layer, like a clipping mask in Photoshop: it's drawn directly above the layer and only where the layer has alpha. |
booleanGroup | vector, shape and booleanGroup children are the operands of the boolean operation, not layers of their own. Other children are handled like the row above. |
adjustment, liveFilter | mask children limit where the effect applies. Other content is clipped to the adjustment. |
mask | None. |
When several masks are present, the topmost visible pixel mask is the one used. An adjustment or liveFilter placed directly in a group or artboard applies to everything below it in that group.
Units, colours and transforms
- Units: document pixels everywhere. The y axis points down, and the origin is the top-left corner of the canvas.
- Colours:
{ r, g, b, a }with channels from 0 to 1, in sRGB, with straight (not premultiplied) alpha. - Opacity values (
opacity,fillOpacity,density) are 0 to 1. - Transforms:
[a, b, c, d, tx, ty], mapping layer-local (x, y) to (a·x + c·y + tx, b·x + d·y + ty). That's the same layout as Canvas2DsetTransformand SVGmatrix(). A positive rotation turns clockwise on screen. Groups have no transform, so everytransformmaps straight to document space. - Pixel layers have no transform. They're positioned by
origin(whole pixels), and rotating or scaling one resamples its tiles.
Props shared by all layers
All kinds except artboard have these props:
| Prop | Type | Default | Meaning |
|---|---|---|---|
name | string | depends on kind | Layer name. |
visible | boolean | true | |
locked | boolean | false | |
opacity | 0–1 | 1 | Layer opacity, including layer effects. |
fillOpacity | 0–1 | 1 | Opacity of the content only, without layer effects (Photoshop's "Fill"). |
blendMode | enum | normal (passThrough for groups) | See Blend modes. |
effects | array | [] | Layer effects, see Layer effects. |
colorTag | enum or null | null | red, orange, yellow, green, blue, violet or gray. |
transformOrigin | { x, y } or null | null | Rotation centre, 0–1 within the layer's bounds. null means the centre. |
blendRanges | null | Reserved, always null. | |
exportSettings | null | Reserved, always null. | |
psd | object | absent | Photoshop data koi.photo can't represent yet, kept as-is from a PSD import. |
Node kinds
| Kind | Own props (in addition to the shared props) |
|---|---|
artboard | name, visible, locked, rect ({ x, y, width, height }), background (paint or null), clipContent, exportSettings. No shared layer props. |
group | expanded (panel state). A group with any blend mode except passThrough is isolated. |
pixel | origin ({ x, y }, the tile grid offset), format (rgba8; rgba16f is reserved), alphaLocked, and the tiles map. |
mask | source (pixel or vector), emptyValue (mask value where there's no tile: 1 reveals, 0 hides), origin, density, feather (px), invert, enabled, transform and geometry (for vector masks, otherwise null), and the tiles map. |
adjustment | effect (effect id), params (effect-specific JSON), version (1). |
liveFilter | The same props as adjustment. Used for filters like blurs and sharpening. |
vector | transform, geometry (path), appearance (fills and strokes), pixelAlign. |
shape | transform, shape (shape id), params (shape-specific), size ({ width, height }), appearance, pixelAlign. |
booleanGroup | op (add, subtract, intersect or xor), appearance. No transform. |
text | transform, mode, story, defaultCharStyle, defaultParaStyle, appearance. |
fill, image, smartObject | Defined in the schema, but the editor doesn't create or render them yet. |
Kinds containing a dot (namespace.name) are reserved for plugins. Skip any kind you don't know.
Masks: the mask value m (0 to 1) of a pixel comes from the tile, or from emptyValue where there's no tile. It's then feathered (a Gaussian blur with radius feather), flipped if invert is set (1 − m), and scaled by density: the final value is 1 − density · (1 − m). If enabled is false, the mask is ignored. Vector masks (source: "vector") are stored, but the renderer doesn't use them yet.
Shapes: the path isn't stored, so a converter has to build it. The app generates it from shape, params and size in the local box from (0, 0) to (width, height), and then applies transform. The shape ids are core.rect, core.roundedRect, core.ellipse, core.polygon, core.star, core.triangle, core.line and core.arrow. Their params are defined in src/engine/vector/shapes.ts in the koi.photo source.
Paths (geometry): { subpaths, fillRule }. fillRule is nonzero or evenodd. Each subpath is { nodes, closed }, and each node is { p, in, out, type }. p is the anchor, and in and out are absolute control points in layer-local space, or null for a straight segment on that side. Segments are cubic Béziers from one node's out to the next node's in. type (sharp, smooth, symmetric or smart) only matters for editing.
Appearance: { fills, strokes }. Both are drawn bottom to top, and strokes are drawn above all fills unless a stroke has drawBehindFill. Every entry has id, enabled, opacity and blendMode. A fill entry has a paint. A stroke entry has a stroke object: paint, width, align (center, inside or outside), cap (butt, round or square), join (miter, round or bevel), miterLimit, optional dash ({ pattern, offset }), optional arrowheads, scaleWithObject and drawBehindFill.
Paint: one of these:
{ kind: "none" }{ kind: "solid", color }{ kind: "linear" | "radial" | "conical" | "diamond", stops, transform, spread }
stops are { offset, color, midpoint }. spread is pad, repeat or reflect. A gradient's transform maps its unit space into layer-local px. In that unit space, a linear gradient runs from 0 to 1 along x, radial and diamond gradients have radius 1 around the origin, and a conical gradient sweeps clockwise from +x.
Text:
modeis either{ kind: "artistic" }, where the first baseline sits at local y = 0 and lines break only at newlines, or{ kind: "frame", rect, columns, gutter, insets, verticalAlign, overflow }.storyis aLoroText, which you read as a delta ([{ insert, attributes }]). Paragraphs are separated by\n. Character styles are marks namedc_<key>(for examplec_fontSize) and paragraph styles are marks namedp_<key>(for examplep_align). Each mark overridesdefaultCharStyleordefaultParaStyle.- Character style keys:
fontFamily,fontStyle,fontSize(px),variations,features,tracking(1/1000 em),kerning,leading(px or"auto", which is 1.2 × the font size),baselineShift,horizontalScale,verticalScale,caps,position,underline,strikethrough,fillandlanguage. appearanceholds the glyph fills and strokes, like a vector layer's. If the prop is missing, use one black solid fill. A text layer with neither a fill nor a stroke (and nofillin its character style) is invisible; koi.photo warns about it when it opens such a file, and doesn't create one.- Glyph outlines aren't stored. To render text, you need the fonts and your own text layout.
Effects
Adjustment and live filter ids (the effect prop):
| Kind | Effect ids |
|---|---|
adjustment | core.brightnessContrast, core.levels, core.curves, core.exposure, core.vibrance, core.hsl, core.colorBalance, core.blackWhite, core.channelMixer, core.selectiveColor, core.whiteBalance, core.lensFilter, core.gradientMap, core.threshold, core.posterize, core.recolor, core.invert, core.lut |
liveFilter | core.gaussianBlur, core.boxBlur, core.motionBlur, core.median, core.unsharpMask, core.highPass, core.clarity, core.addNoise, core.dustScratches |
params may hold only some of the fields: merge them over the effect's defaults. For example, core.brightnessContrast takes brightness and contrast from −1 to 1, plus legacy (a boolean). koi.photo clamps params to the effect's ranges when they're set and when a file is opened, but files from older versions or other tools can still hold values out of range, so clamp them too. The ranges are defined in src/engine/effects/ in the koi.photo source.
Layer effects
Each entry in a layer's effects array is { type, enabled, blendMode, opacity, …params }. type is one of dropShadow, innerShadow, outerGlow, innerGlow, stroke, bevel, colorOverlay or gradientOverlay.
Blend modes
Blend modes are stored as strings: normal, dissolve, darken, multiply, colorBurn, linearBurn, darkerColor, lighten, screen, colorDodge, linearDodge, lighterColor, overlay, softLight, hardLight, vividLight, linearLight, pinLight, hardMix, difference, exclusion, subtract, divide, hue, saturation, color and luminosity. These are Photoshop's 27 layer modes, in Photoshop's menu order. Groups can also use passThrough. On any other layer, treat passThrough as normal.
Tiles
Raster content (pixel layers and pixel masks) is split into a grid of 256 × 256 px tiles.
- Tile map: a
LoroMapin the node'sdata.tiles. Its keys are"tx,ty", decimal integers that can be negative. - Position: tile
tx,tycovers the document pixels from x =origin.x + 256·txto x =origin.x + 256·tx + 255, and likewise in y. Layers can extend past the canvas. Tiles at the edges are always a full 256², padded with transparent pixels. - Values are raw bytes, not strings:
- 32 bytes are the SHA-256 of a tile blob in
blobs/. - 4 bytes are a uniform tile, where every pixel has the same RGBA8 value, in R, G, B, A order (
koi-inspectprintsrgba:22aa55ff). No blob is stored for it. In a mask, all four bytes hold the mask value. - No key: in a pixel layer, the tile is fully transparent. In a mask, its value is
emptyValue.
- 32 bytes are the SHA-256 of a tile blob in
- One resolution only: the file stores full-resolution tiles and nothing else. The renderer builds its downscaled levels in memory.
- Deduplication: identical tiles share one blob. In the sample, tiles
0,0and0,1point to the same hash.
const tileRef = (b) => (b.length === 4 ? `rgba:${hex(b)}` : `sha256:${hex(b)}`)
const tiles = data.get("tiles") // LoroMap
const tileMap = Object.fromEntries(tiles.entries().map(([key, bytes]) => [key, tileRef(bytes)]))Pixel format
- Pixel layers:
rgba8, 4 bytes per pixel in R, G, B, A order, rows from top to bottom. Alpha is straight, not premultiplied. Colours are sRGB-encoded, or in the embedded profile whencolorProfileis"embedded". - 16-bit pixel layers:
rgba16f, 8 bytes per pixel: R, G, B, A as IEEE 754 half floats (little-endian), 0 to 1, straight alpha, encoded likergba8. Any tile of any document may be either format; an 8-bit valuevequals the half ofv / 255exactly. Uniform tiles stay 4-byte RGBA8 colours. - Masks:
r8, 1 byte per pixel, where 0 hides and 255 reveals.
Tile blob encoding (ps-tile v1)
Every blob referenced from a tile map starts with a 16-byte header. All integers are little-endian.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Magic PSTL (bytes 50 53 54 4C, or 0x4C545350 read as a little-endian uint32) |
| 4 | 1 | Codec version, 1 |
| 5 | 1 | Codec: 0 = raw, 1 = shuffle + deflate-raw. Reject anything else. |
| 6 | 1 | Pixel format: 0 = rgba8, 1 = rgba16f, 2 = rgba32f, 3 = r8, 4 = r16f |
| 7 | 1 | Flags. Bit 0 set means straight alpha, which is always the case. |
| 8 | 2 | Width (256) |
| 10 | 2 | Height (256) |
| 12 | 4 | Uncompressed byte length (width × height × bytes per pixel) |
| 16 | … | Payload |
An rgba8 tile from the sample starts like this: 50 53 54 4C 01 01 00 01 00 01 00 01 00 00 04 00. That's codec 1, rgba8, straight alpha, 256 × 256, and 262,144 bytes once decoded. An rgba16f tile has 01 at offset 6 and 524,288 bytes decoded; codec 1 then shuffles its 8 byte planes (the low and high byte of each half), and nothing else changes.
Codec 1 works like this:
- Split the interleaved pixels into byte planes: all R bytes, then all G bytes, and so on (one plane per byte of the pixel).
- Delta-encode each row of each plane: every byte becomes its difference from the byte to its left, mod 256. The first byte of a row is stored as is.
- Compress the whole buffer with raw DEFLATE (RFC 1951, no zlib or gzip header).
The writer falls back to codec 0 (the raw pixels) when compression doesn't make the tile smaller. Decoding does the steps in reverse:
const planes = zlib.inflateRawSync(payload) // byte planes, each row delta-coded
const pixels = new Uint8Array(length)
for (let p = 0; p < bpp; p++)
for (let y = 0; y < h; y++)
for (let x = 0, prev = 0; x < w; x++) {
prev = (prev + planes[p * w * h + y * w + x]) & 255
pixels[(y * w + x) * bpp + p] = prev
}In a browser, new DecompressionStream("deflate-raw") does what zlib.inflateRawSync does here.
Blob hashes and names
A blob's hash is the lowercase SHA-256 of the whole blob file: for a tile, that's the header plus the payload. A reader should recompute the hash and treat a blob that doesn't match as missing, which is what koi.photo does. Tiles whose blob is missing render as transparent.
The same hash is written in three forms, depending on where it is:
| Where | Form | Example |
|---|---|---|
manifest.json (blobs[].hash, missingBlobs), resources.fonts[].blob, resources.extensions | String: sha256: + 64 hex characters | "sha256:6da9f37c…" |
Tile maps, resources.metadata | 32 raw bytes | Uint8Array(32) |
| ZIP entry names | 64 hex characters after blobs/, no extension | blobs/6da9f37c… |
The binary form keeps document.loro small. The forms won't change, since that would break existing readers.
Other blobs and entries
| What | Where it's referenced | kind | Bytes |
|---|---|---|---|
| Tiles | Tile maps (32-byte hash) | tile | ps-tile encoded, see above. |
| Uploaded fonts | resources.fonts[id].blob (string hash) | font (tile in older files) | The font file exactly as uploaded, not tile-encoded. |
| Image metadata (EXIF, XMP, IPTC, ICC) | resources.metadata.<key> (32-byte hash) | other | The raw segment from the imported file. |
| Placed-image originals, brushes | Nothing today | original, brush | Reserved. |
thumbnail.png is an RGBA PNG of the flattened canvas. Transparent areas stay transparent. It's resampled with a high-quality filter to exactly 256 px on its longest side, keeping the aspect ratio (rounded to whole pixels), so an 800 × 600 document gets a 256 × 192 thumbnail. Documents smaller than 256 px are kept at full size, not enlarged. The thumbnail is optional; .koi files downloaded from the cloud Files window have one when the cloud has a thumbnail for the document. Files from older versions may have smaller thumbnails (scaled down by powers of two).
preview.webp is reserved. koi.photo accepts it when reading a file, but doesn't write it. Don't rely on it.
extensions/ holds other tools' data, see Extensions.
What isn't stored
- Undo history. The snapshot is shallow, so a reopened file starts with an empty history.
- Selections, zoom, the active tool, panel layout and other view state.
- A full-size composite. The only flattened image is the thumbnail.
- Rendered vector and text output. Shape paths, boolean results, text glyphs and layer effects are computed when the document is drawn. There's no cached copy for converters: they need their own renderer, or they can fall back to the thumbnail.
- Fonts that weren't uploaded. Text set in Google Fonts or system fonts refers to them by name only.
- Deleted layers. Files from older versions can still have them in a
trashnode, without their tiles.
Reading a .koi into an RGBA image
- Open the ZIP and check that the
mimetypeentry equalsapplication/vnd.photokoi+zip. - Parse
manifest.json. Stop ifminReaderVersionis greater than the version you support (2 if you decodergba16ftiles, else 1). - If a small preview is enough, decode
thumbnail.pngand you're done. - Load the blobs listed in
manifest.blobs, and check each one's SHA-256 against its name. - Import
document.lorowithloro-crdt, and readsettings.widthandsettings.height. - Find the first top-level node with
kind: "artboard", skippingtrash. - Walk its children bottom to top. For each visible
pixellayer, assemble its tiles atorigin:
for (const [key, ref] of Object.entries(tileMap)) {
const [tx, ty] = key.split(",").map(Number)
const px = ref.startsWith("rgba:") ? uniform(ref) : decodeTile(blobs.get(ref)).pixels // rgba16f: halves, convert as needed
// copy the 256×256 block to (origin.x + tx * 256, origin.y + ty * 256), clipped to the canvas
}- Apply the layer's mask, if it has one, to its alpha.
- Composite. Premultiply, multiply by
opacity×fillOpacity, and blend onto the result with the layer's blend mode. Use the W3C Compositing formulas, on gamma-encoded sRGB values. Treat a group as a separate layer that you composite first, unless its blend mode ispassThrough. - Decide about the other kinds. Adjustments, live filters, layer effects, vectors, shapes and text need their own renderer. A simple converter can skip them and warn, or fall back to the thumbnail.
koi-inspect does steps 1 to 7 for the first pixel layer and writes layer.png. It also decodes one tile to tile.png (16-bit tiles are rounded to 8-bit for these PNGs). Along the way it checks each blob's hash and kind against where the document references it, lists extensions/ entries and flags invalid paths, reports unknown entries and the thumbnail size, warns about invisible text, and marks what older writers left in a file (a trash root, blendSpace, fonts listed as tile) with note:.
Pitfalls
document.loroisn't JSON. It's a binary Loro snapshot, and there's no JSON copy in the file. Use a Loro 1.x library.- Hashes are stored in three forms. The manifest and font resources use the string
sha256:<hex>. Tile maps and metadata use 32 raw bytes. ZIP entry names use bare hex. See Blob hashes and names. - The
trashroot (in older files) contains deleted layers whose blobs are missing. Skip it. - The children order is bottom to top, the reverse of how the Layers panel lists layers.
- Tile alpha is straight, so premultiply before you blend or filter.
- No key in a tile map means transparent for pixel layers, but
emptyValuefor masks. A uniform mask tile repeats the mask value in all four bytes. - The manifest
kindis a hint. Older files list fonts astile. Go by where the document references a blob. - Adjustment
paramscan be partial, and out of range in older files. Merge them over the effect's defaults and clamp them. - Props can be missing in files from older versions. Use the defaults on this page.
- Use
uidas the layer id, not the LoroTreeID. - If you write
.koifiles: putmimetypefirst, STORED, with no extra field. List every blob in the manifest with its exact size and one of the fivekindvalues. Keep anyextensions/entries that aren't yours. UseZdate-times. Store tile hashes as 32 raw bytes. Give every node auid, akindandv: 1, plus atilesLoroMap forpixelandmasknodes. Generate a freshdocId: koi.photo treats two files with the samedocIdas the same document.
Related
- .koi files: saving, opening and sharing
.koifiles. - PSD files: the layered format to use when another app needs to open your work.
- Export: flattened PNG, JPEG, WebP, SVG and PDF output.
- Layers & masks: what the layer kinds above look like in the editor.