Files
odysseus/static/js/editor/psd.js
T
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00

498 lines
19 KiB
JavaScript

/** Minimal Adobe Photoshop (PSD) reader/writer.
*
* Exports a flattened 8-bit RGB composite as a valid `8BPS` document with
* no layer records, and parses the same shape back. Also decodes RLE
* (PackBits) image data, which is what Photoshop normally writes.
*/
const SIGNATURE = 0x38425053; // '8BPS'
const COLOR_MODE_RGB = 3;
const COLOR_MODE_GRAYSCALE = 1;
const COMPRESSION_RAW = 0;
const COMPRESSION_RLE = 1;
function writeUint16(view, offset, value) {
view.setUint16(offset, value & 0xffff, false);
}
function writeUint32(view, offset, value) {
view.setUint32(offset, value >>> 0, false);
}
function writeUint64(view, offset, value) {
view.setBigUint64(offset, BigInt(value), false);
}
/** PackBits (RLE) encode a byte array. */
export function packBits(input) {
const out = [];
const length = input.length;
let i = 0;
while (i < length) {
let runLength = 1;
while (i + runLength < length && runLength < 128 && input[i + runLength] === input[i]) runLength++;
if (runLength >= 2) {
out.push((256 - (runLength - 1)) & 0xff, input[i]);
i += runLength;
continue;
}
let literalStart = i;
let literalLength = 0;
while (i < length && literalLength < 128) {
let nextRun = 1;
while (i + nextRun < length && nextRun < 128 && input[i + nextRun] === input[i]) nextRun++;
if (nextRun >= 2 && literalLength > 0) break;
i += 1;
literalLength += 1;
if (i >= length) break;
}
if (i === literalStart) i += 1;
const count = i - literalStart;
out.push(count - 1, ...input.slice(literalStart, i));
}
return Uint8Array.from(out);
}
/** PackBits (RLE) decode `length` bytes starting at `offset`. */
export function unpackBits(data, offset, length) {
const out = new Uint8Array(length);
let outPos = 0;
let pos = offset;
while (outPos < length && pos < data.length) {
const header = data[pos] < 128 ? data[pos] : data[pos] - 256;
pos += 1;
if (header >= 0) {
const count = header + 1;
for (let i = 0; i < count && outPos < length; i++) out[outPos++] = data[pos + i];
pos += count;
} else if (header !== -128) {
const count = 1 - header;
const value = data[pos++];
for (let i = 0; i < count && outPos < length; i++) out[outPos++] = value;
}
}
return out;
}
/** Serialize a canvas as a flattened 8-bit RGB PSD. */
export function exportPsd(sourceCanvas) {
const width = sourceCanvas.width;
const height = sourceCanvas.height;
const pixels = sourceCanvas.getContext('2d').getImageData(0, 0, width, height).data;
const pixelCount = width * height;
const red = new Uint8Array(pixelCount);
const green = new Uint8Array(pixelCount);
const blue = new Uint8Array(pixelCount);
for (let i = 0; i < pixelCount; i++) {
red[i] = pixels[i * 4];
green[i] = pixels[i * 4 + 1];
blue[i] = pixels[i * 4 + 2];
}
const channels = [red, green, blue];
const rowLengths = [];
const rows = [];
for (const channel of channels) {
let channelLength = 0;
for (let y = 0; y < height; y++) {
const row = packBits(channel.subarray(y * width, (y + 1) * width));
rowLengths.push(row.length);
rows.push(row);
channelLength += row.length;
}
}
const rleTableBytes = rowLengths.length * 2;
const rleBodyBytes = rows.reduce((sum, row) => sum + row.length, 0);
const headerBytes = 26;
const imageDataBytes = 2 + rleTableBytes + rleBodyBytes;
const total = headerBytes + 4 * 3 + imageDataBytes;
const buffer = new ArrayBuffer(total);
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
let offset = 0;
writeUint32(view, 0, SIGNATURE);
writeUint16(view, 4, 1);
offset = 10; // reserved 6 bytes
writeUint16(view, offset, 3); offset += 2;
writeUint32(view, offset, height); offset += 4;
writeUint32(view, offset, width); offset += 4;
writeUint16(view, offset, 8); offset += 2;
writeUint16(view, offset, COLOR_MODE_RGB); offset += 2;
// Color mode data + image resources + layer/mask sections are empty.
writeUint32(view, offset, 0); offset += 4;
writeUint32(view, offset, 0); offset += 4;
writeUint32(view, offset, 0); offset += 4;
writeUint16(view, offset, COMPRESSION_RLE); offset += 2;
for (const length of rowLengths) {
writeUint16(view, offset, length); offset += 2;
}
for (const row of rows) {
bytes.set(row, offset);
offset += row.length;
}
return bytes;
}
function readSection(view, bytes, offset, long = false) {
if (long) {
const length = Number(view.getBigUint64(offset, false));
return offset + 8 + length;
}
const length = view.getUint32(offset, false);
return offset + 4 + length;
}
/** Parse an 8BPS buffer into `{ width, height, canvas }`. */
export function parsePsd(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
const view = new DataView(arrayBuffer);
if (view.getUint32(0, false) !== SIGNATURE) throw new Error('Not a PSD file');
const version = view.getUint16(4, false);
if (version !== 1 && version !== 2) throw new Error('Unsupported PSD version');
const psb = version === 2;
const channels = view.getUint16(12, false);
const height = view.getUint32(14, false);
const width = view.getUint32(18, false);
const depth = view.getUint16(22, false);
const colorMode = view.getUint16(24, false);
if (depth !== 8) throw new Error('Unsupported PSD bit depth');
if (colorMode !== COLOR_MODE_RGB && colorMode !== COLOR_MODE_GRAYSCALE) {
throw new Error('Unsupported PSD color mode');
}
let offset = 26;
offset = readSection(view, bytes, offset); // color mode data (4-byte length)
offset = readSection(view, bytes, offset); // image resources (4-byte length)
offset = readSection(view, bytes, offset, psb); // layer & mask info (8-byte length in PSB)
const compression = view.getUint16(offset, false);
offset += 2;
const pixelCount = width * height;
const planes = [];
if (compression === COMPRESSION_RAW) {
for (let c = 0; c < channels; c++) {
planes.push(bytes.subarray(offset, offset + pixelCount));
offset += pixelCount;
}
} else if (compression === COMPRESSION_RLE) {
const rowCount = channels * height;
const rowLengths = [];
for (let i = 0; i < rowCount; i++) {
rowLengths.push(psb ? view.getUint32(offset, false) : view.getUint16(offset, false));
offset += psb ? 4 : 2;
}
for (let c = 0; c < channels; c++) {
const plane = new Uint8Array(pixelCount);
for (let y = 0; y < height; y++) {
const rowIndex = c * height + y;
const decoded = unpackBits(bytes, offset, rowLengths[rowIndex]);
plane.set(decoded.subarray(0, width), y * width);
offset += rowLengths[rowIndex];
}
planes.push(plane);
}
} else {
throw new Error('Unsupported PSD compression');
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(width, height);
const isGray = colorMode === COLOR_MODE_GRAYSCALE;
for (let i = 0; i < pixelCount; i++) {
const r = planes[0] ? planes[0][i] : 0;
const g = isGray ? r : (planes[1] ? planes[1][i] : 0);
const b = isGray ? r : (planes[2] ? planes[2][i] : 0);
image.data[i * 4] = r;
image.data[i * 4 + 1] = g;
image.data[i * 4 + 2] = b;
image.data[i * 4 + 3] = 255;
}
ctx.putImageData(image, 0, 0);
return { width, height, canvas };
}
// ---------------------------------------------------------------------------
// Layered PSD (8BPS v1, 8-bit RGB) — layer records + raw channel planes.
// Encoder/decoder operate on plain RGBA arrays (no canvas) so they are
// usable and testable outside the browser.
// ---------------------------------------------------------------------------
function rgbaToPlanes(data, width, height) {
const count = width * height;
const r = new Uint8Array(count);
const g = new Uint8Array(count);
const b = new Uint8Array(count);
const a = new Uint8Array(count);
for (let i = 0; i < count; i++) {
r[i] = data[i * 4];
g[i] = data[i * 4 + 1];
b[i] = data[i * 4 + 2];
a[i] = data[i * 4 + 3];
}
return [r, g, b, a];
}
function blendRgbaInto(target, layer, docWidth, docHeight) {
const lw = layer.width;
const lh = layer.height;
const ox = layer.x | 0;
const oy = layer.y | 0;
const alpha = layer.opacity == null ? 1 : layer.opacity;
const data = layer.data;
for (let y = 0; y < lh; y++) {
const ty = oy + y;
if (ty < 0 || ty >= docHeight) continue;
for (let x = 0; x < lw; x++) {
const tx = ox + x;
if (tx < 0 || tx >= docWidth) continue;
const si = (y * lw + x) * 4;
const ti = (ty * docWidth + tx) * 4;
const sa = (data[si + 3] / 255) * alpha;
if (sa <= 0) continue;
const da = target[ti + 3] / 255;
const outA = sa + da * (1 - sa);
for (let c = 0; c < 3; c++) {
const sc = data[si + c];
const dc = target[ti + c];
target[ti + c] = outA ? Math.round((sc * sa + dc * da * (1 - sa)) / outA) : 0;
}
target[ti + 3] = Math.round(outA * 255);
}
}
}
function latin1Bytes(text) {
const out = [];
for (const char of String(text)) out.push(char.charCodeAt(0) & 0xff);
return out;
}
/** Serialize layered RGBA layers + a flattened composite as an 8BPS PSD
* (version 1) or PSB (version 2, "large document format", 8-byte lengths). */
export function encodeLayeredPsd({ width, height, layers = [], psb = false } = {}) {
const longLen = psb ? 8 : 4;
const composite = new Uint8ClampedArray(width * height * 4);
const ordered = layers.slice().reverse(); // PSD stores bottom-first
for (const layer of ordered) {
if (layer.visible === false) continue;
blendRgbaInto(composite, layer, width, height);
}
const records = [];
const channelBlocks = [];
for (const layer of ordered) {
const lw = layer.width;
const lh = layer.height;
const planes = rgbaToPlanes(layer.data, lw, lh);
const channelIds = [0, 1, 2, -1];
const channelLength = 2 + lw * lh;
const name = latin1Bytes(layer.name || 'Layer');
const namePadded = 1 + name.length + ((4 - ((1 + name.length) % 4)) % 4);
const extraLength = 4 + 4 + namePadded;
const record = new Uint8Array(4 * 4 + 2 + channelIds.length * (2 + longLen) + 4 + 4 + 4 + 4 + extraLength);
const rv = new DataView(record.buffer);
let o = 0;
rv.setInt32(o, layer.y | 0, false); o += 4;
rv.setInt32(o, layer.x | 0, false); o += 4;
rv.setInt32(o, (layer.y | 0) + lh, false); o += 4;
rv.setInt32(o, (layer.x | 0) + lw, false); o += 4;
rv.setUint16(o, channelIds.length, false); o += 2;
for (const id of channelIds) {
rv.setInt16(o, id, false); o += 2;
if (psb) { rv.setBigUint64(o, BigInt(channelLength), false); o += 8; }
else { rv.setUint32(o, channelLength, false); o += 4; }
}
record.set([0x38, 0x42, 0x49, 0x4d], o); o += 4; // '8BIM'
record.set(latin1Bytes((layer.blendKey || 'norm').padEnd(4, ' ').slice(0, 4)), o); o += 4;
record[o++] = Math.round((layer.opacity == null ? 1 : layer.opacity) * 255);
record[o++] = 0; // clipping
record[o++] = layer.visible === false ? 2 : 0; // flags (bit1 = hidden)
record[o++] = 0; // filler
rv.setUint32(o, extraLength, false); o += 4;
rv.setUint32(o, 0, false); o += 4; // layer mask data length
rv.setUint32(o, 0, false); o += 4; // layer blending ranges length
record[o++] = name.length;
record.set(name, o); o += name.length;
while (o < record.length) record[o++] = 0; // name padding
records.push(record);
for (const plane of planes) {
const block = new Uint8Array(2 + plane.length);
block[0] = 0;
block[1] = COMPRESSION_RAW;
block.set(plane, 2);
channelBlocks.push(block);
}
}
const recordBytes = records.reduce((sum, record) => sum + record.length, 0);
const channelBytes = channelBlocks.reduce((sum, block) => sum + block.length, 0);
const layerInfoLength = 2 + recordBytes + channelBytes;
const layerInfo = new Uint8Array(longLen + layerInfoLength);
const liv = new DataView(layerInfo.buffer);
if (psb) liv.setBigUint64(0, BigInt(layerInfoLength), false); else liv.setUint32(0, layerInfoLength, false);
liv.setInt16(longLen, ordered.length, false);
let p = longLen + 2;
for (const record of records) { layerInfo.set(record, p); p += record.length; }
for (const block of channelBlocks) { layerInfo.set(block, p); p += block.length; }
const sectionDataLength = layerInfo.length + 4; // layerInfo blob + global mask (0)
const section = new Uint8Array(longLen + sectionDataLength);
const sv = new DataView(section.buffer);
if (psb) sv.setBigUint64(0, BigInt(sectionDataLength), false); else sv.setUint32(0, sectionDataLength, false);
section.set(layerInfo, longLen);
sv.setUint32(longLen + layerInfo.length, 0, false); // global layer mask info length
// Composite image data (RLE), matching exportPsd.
const compositePlanes = rgbaToPlanes(composite, width, height);
const rowLengths = [];
const rows = [];
for (const plane of compositePlanes) {
for (let y = 0; y < height; y++) {
const row = packBits(plane.subarray(y * width, (y + 1) * width));
rowLengths.push(row.length);
rows.push(row);
}
}
const rleTableBytes = rowLengths.length * (psb ? 4 : 2);
const rleBodyBytes = rows.reduce((sum, row) => sum + row.length, 0);
const imageDataBytes = 2 + rleTableBytes + rleBodyBytes;
const headerBytes = 26;
const total = headerBytes + 4 + 4 + section.length + imageDataBytes;
const buffer = new ArrayBuffer(total);
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
writeUint32(view, 0, SIGNATURE);
writeUint16(view, 4, psb ? 2 : 1);
writeUint16(view, 12, 3); // channels
writeUint32(view, 14, height);
writeUint32(view, 18, width);
writeUint16(view, 22, 8);
writeUint16(view, 24, COLOR_MODE_RGB);
writeUint32(view, 26, 0); // color mode data
writeUint32(view, 30, 0); // image resources
let offset = 34;
bytes.set(section, offset); offset += section.length;
writeUint16(view, offset, COMPRESSION_RLE); offset += 2;
for (const length of rowLengths) {
if (psb) { writeUint32(view, offset, length); offset += 4; }
else { writeUint16(view, offset, length); offset += 2; }
}
for (const row of rows) { bytes.set(row, offset); offset += row.length; }
return bytes;
}
/** Decode a layered PSD into `{ width, height, layers }` (RGBA planes, no canvas). */
export function decodeLayeredPsd(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
const view = new DataView(arrayBuffer);
if (view.getUint32(0, false) !== SIGNATURE) throw new Error('Not a PSD file');
const version = view.getUint16(4, false);
if (version !== 1 && version !== 2) throw new Error('Unsupported PSD version');
const psb = version === 2;
const longLen = psb ? 8 : 4;
const height = view.getUint32(14, false);
const width = view.getUint32(18, false);
if (view.getUint16(22, false) !== 8) throw new Error('Unsupported PSD bit depth');
let offset = 26;
offset = readSection(view, bytes, offset); // color mode data (4-byte length)
offset = readSection(view, bytes, offset); // image resources (4-byte length)
const sectionLength = psb ? Number(view.getBigUint64(offset, false)) : view.getUint32(offset, false);
offset += longLen;
if (!sectionLength) return { width, height, layers: [] };
const sectionEnd = offset + sectionLength;
const layerInfoLength = psb ? Number(view.getBigUint64(offset, false)) : view.getUint32(offset, false);
offset += longLen;
const layerInfoEnd = offset + layerInfoLength;
const rawCount = view.getInt16(offset, false);
offset += 2;
const layerCount = Math.abs(rawCount);
const metas = [];
for (let l = 0; l < layerCount; l++) {
const top = view.getInt32(offset, false); offset += 4;
const left = view.getInt32(offset, false); offset += 4;
const bottom = view.getInt32(offset, false); offset += 4;
const right = view.getInt32(offset, false); offset += 4;
const channelCount = view.getUint16(offset, false); offset += 2;
const channels = [];
for (let c = 0; c < channelCount; c++) {
const id = view.getInt16(offset, false); offset += 2;
const length = psb ? Number(view.getBigUint64(offset, false)) : view.getUint32(offset, false);
offset += longLen;
channels.push({ id, length });
}
offset += 4; // '8BIM'
const blendKey = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); offset += 4;
const opacity = bytes[offset++] / 255;
offset += 1; // clipping
const flags = bytes[offset++];
offset += 1; // filler
const extraLength = view.getUint32(offset, false); offset += 4;
const extraEnd = offset + extraLength;
const maskLength = view.getUint32(offset, false); offset += 4 + maskLength;
const rangesLength = view.getUint32(offset, false); offset += 4 + rangesLength;
const nameLength = bytes[offset++];
let name = '';
for (let i = 0; i < nameLength; i++) name += String.fromCharCode(bytes[offset + i]);
offset = extraEnd;
metas.push({ top, left, bottom, right, channelCount, channels, blendKey, opacity, visible: (flags & 2) === 0, name });
}
// Channel image data follows the records; the metas loop above already
// left `offset` at the start of the channel data.
const layers = [];
for (const meta of metas) {
const lw = meta.right - meta.left;
const lh = meta.bottom - meta.top;
const count = lw * lh;
const planes = { 0: new Uint8Array(count), 1: new Uint8Array(count), 2: new Uint8Array(count), '-1': new Uint8Array(count) };
for (let c = 0; c < meta.channelCount; c++) {
const channel = meta.channels[c];
const compression = view.getUint16(offset, false);
offset += 2;
if (compression === COMPRESSION_RAW) {
const plane = bytes.subarray(offset, offset + count);
if (planes[channel.id]) planes[channel.id].set(plane);
offset += count;
} else if (compression === COMPRESSION_RLE) {
const rowLengths = [];
for (let y = 0; y < lh; y++) {
rowLengths.push(psb ? view.getUint32(offset, false) : view.getUint16(offset, false));
offset += psb ? 4 : 2;
}
const plane = planes[channel.id] || new Uint8Array(count);
for (let y = 0; y < lh; y++) {
const decoded = unpackBits(bytes, offset, rowLengths[y]);
plane.set(decoded.subarray(0, lw), y * lw);
offset += rowLengths[y];
}
planes[channel.id] = plane;
} else {
throw new Error('Unsupported PSD layer compression');
}
}
const data = new Uint8ClampedArray(count * 4);
const alpha = planes['-1'];
for (let i = 0; i < count; i++) {
data[i * 4] = planes[0][i];
data[i * 4 + 1] = planes[1][i];
data[i * 4 + 2] = planes[2][i];
data[i * 4 + 3] = alpha ? alpha[i] : 255;
}
layers.push({
name: meta.name,
x: meta.left,
y: meta.top,
width: lw,
height: lh,
opacity: meta.opacity,
visible: meta.visible,
blendKey: meta.blendKey,
data,
});
}
return { width, height, layers: layers.reverse() };
}