Affinity Photo · standalone script

Grid Slicer

Cuts a photo into a numbered grid of square tiles for cut-and-rearrange collage art — run inside Affinity Photo as a macro script over the MCP connection, no manual crop tool work required.

tested & verified — 2026-07-13

Why this exists

I cut printed photos into a grid of squares and rearrange the pieces by hand as artwork, and wanted Affinity to automate the crop-and-export step. Native macro recording bakes in fixed pixel coordinates, so it only replays correctly across photos that share the exact same dimensions — a poor fit for arbitrary source photos. Standalone_GridSlicer.js recomputes the grid from each image's actual pixel size instead, so it works on any photo, at any resolution, on demand.

What the script does

  1. Reads the active document's true pixel dimensions, via the SDK's rendering engine rather than the document's own (occasionally stale) canvas metadata.
  2. Computes the largest square tile that fits the requested Rows × Cols grid, then centers the grid on the image — trimming any excess border evenly, with no stretching or padding.
  3. Exports each tile as its own PNG, numbered in reading order from the top-left: tile_<N>_r<row>c<col>.png.
  4. Optionally exports one overview.png — the full cropped grid, downsampled, with thin amber lines marking exactly where the cuts fall — so the tiles can be mapped back to the original image.
  5. A dialog sets rows, columns, the output subfolder, and whether to build the overview; a SKIP_DIALOG flag at the top of the file lets it run headless for scripted or repeated batches.

Parameters

Defaults — all editable in the dialog
ParameterDefaultNotes
Rows31–20
Columns31–20
Output folderDesktop/GridSlicesSDK file I/O is sandboxed to the Desktop
Overview sheetoncheckbox
Overview max size640pxreference sheet, not a print deliverable
Overview sheet showing the cropped church photo with thin amber lines marking the four cut boundaries
overview.png — the cropped grid over the church photo with amber cut-lines, generated alongside the numbered tiles so pieces can be matched back to their original position.
1r1c1 Tile 1, row 1 column 1 — church spire and tower
2r1c2 Tile 2, row 1 column 2 — church roofline and sky
3r2c1 Tile 3, row 2 column 1 — church facade and arches
4r2c2 Tile 4, row 2 column 2 — church windows and trees

Get the script

Save it to the Affinity script library as "Grid Slicer" and run it directly from Claude Code over the MCP connection — the same describe → run → check loop covered in the main tutorial.

Affinity Photo's Scripts panel showing the Grid Slicer entry, with a photo open in the canvas above it
The script library entry in Affinity's Scripts panel.
View full script source
'use strict';
/*
 * GOAL 6 — Grid Slicer
 *
 * Slices the active document's flattened image into a ROWS x COLS grid of
 * equal square tiles, for cutting up a photo and rearranging the pieces as
 * artwork. Exports each tile as its own numbered PNG, plus one overview PNG
 * of the full cropped grid with thin separator lines showing the cut layout.
 *
 * Reading order is row-major from the top-left: tile 1 = row 1 col 1,
 * tile 2 = row 1 col 2, etc. Each filename also encodes its row/col so pieces
 * can be re-assembled without needing to remember the numbering scheme.
 *
 * SDK finding (2026-07-13): DocumentCommand.createAddArtboard() was tested
 * against a flat (non-artboard) document and found to PERMANENTLY resize the
 * document's canvas/page to the artboard's bounds — even after the artboard
 * node is deleted afterward. Artboards are therefore never used here. Tiles
 * are cut by rendering the source layer to an in-memory pixel buffer
 * (NodeRenderingEngine + PixelBuffer.copyTo) and exporting via a transient
 * plain ImageNode + FileExportArea.createForSelection(), which never touches
 * the document's own canvas/page structure. Confirmed safe: doc.widthPixels/
 * heightPixels unchanged before/after a full run.
 */

const { app } = require('/application');
const { Document, FileExportOptions, FileExportArea } = require('/document');
const { NodeRenderingEngine, RasterFormat, PixelBuffer } = require('/rasterobject');
const { ImageNodeDefinition } = require('/nodes');
const { AddChildNodesCommandBuilder, DocumentCommand, InsertionMode } = require('/commands');
const { Selection } = require('/selections');
const { Dialog, DialogResult, UnitType } = require('/dialog');

// Set true to run with DEFAULTS below and skip the dialog (used for MCP/automated testing).
const SKIP_DIALOG = false;

// ── Defaults (shown pre-filled in the dialog, or used directly if SKIP_DIALOG) ─
const DEFAULTS = {
    rows: 3,
    cols: 3,
    subfolder: 'GridSlices',
    makeOverview: true,
};

function getDoc() {
    const d = Document.current;
    if (!d) throw new Error('No document open.');
    return d;
}

function getSourceLayer(doc) {
    for (const l of doc.layers.toArray()) {
        const tag = l[Symbol.toStringTag];
        if (tag === 'RasterNode' || tag === 'ImageNode' || tag === 'PixelNode') return l;
    }
    return doc.layers.first;
}

// RGBA16/RGBAUF docs return an all-zero render if you ask NodeRenderingEngine
// for RGBA8 — the engine format must match the document's own bit depth.
function rasterFormatFor(doc) {
    const f = (doc.format && doc.format.value !== undefined) ? doc.format.value : doc.format;
    if (f === 1) return RasterFormat.RGBA16;
    if (f === 9) return RasterFormat.RGBAUF;
    return RasterFormat.RGBA8;
}

function typedArrayAndMaxFor(format) {
    if (format.value === RasterFormat.RGBA16.value) return { Ctor: Uint16Array, max: 65535 };
    if (format.value === RasterFormat.RGBAUF.value) return { Ctor: Float32Array, max: 1.0 };
    return { Ctor: Uint8Array, max: 255 };
}

function addTempImageNode(doc, refLayer, bitmap, name) {
    const def = ImageNodeDefinition.create(bitmap.format);
    def.bitmap = bitmap;
    def.userDescription = name;
    const cmd = AddChildNodesCommandBuilder.create();
    cmd.addNode(def);
    cmd.setInsertionTarget(refLayer);
    cmd.setInsertionMode(InsertionMode.Behind);
    const dcmd = cmd.createCommand();
    doc.executeCommand(dcmd);
    return dcmd.newNodes[0];
}

function exportAndRemove(doc, node, path, opts) {
    doc.export(path, opts, FileExportArea.createForSelection(Selection.create(doc, node)), undefined);
    doc.executeCommand(DocumentCommand.createDeleteSelection(Selection.create(doc, node), true));
}

// SDK finding: doc.export() was observed to silently clip a single large
// exported node's output beyond some threshold size (reproduced ~700px+ on a
// document whose page state had previously been touched by an artboard —
// root cause unconfirmed, see file header). The overview is only a reference
// sheet, not a print deliverable, so it is capped to a safe max dimension via
// plain nearest-neighbour downsampling here rather than depending on that
// undiagnosed limit.
const MAX_OVERVIEW_DIM = 640;

function downsample(buf, w, h, format, maxDim) {
    const scale = Math.min(1, maxDim / Math.max(w, h));
    if (scale >= 1) return { buf, w, h, tileScale: 1 };
    const ow = Math.max(1, Math.round(w * scale)), oh = Math.max(1, Math.round(h * scale));
    const { Ctor } = typedArrayAndMaxFor(format);
    const src = new Ctor(buf.buffer);
    const out = PixelBuffer.create(ow, oh, format);
    const dst = new Ctor(out.buffer);
    for (let y = 0; y < oh; y++) {
        const sy = Math.min(h - 1, Math.floor(y / scale));
        for (let x = 0; x < ow; x++) {
            const sx = Math.min(w - 1, Math.floor(x / scale));
            const si = (sy * w + sx) * 4, di = (y * ow + x) * 4;
            dst[di] = src[si]; dst[di + 1] = src[si + 1]; dst[di + 2] = src[si + 2]; dst[di + 3] = src[si + 3];
        }
    }
    return { buf: out, w: ow, h: oh, tileScale: scale };
}

// Draws 2px separator lines directly into a pixel buffer's typed array.
// Typed-array element access silently no-ops on a non-integer index, so every
// coordinate here MUST be an integer (tile may be a fractional post-downscale
// value) — this bit once, so guard it centrally in setPixel.
function drawGridLines(buf, w, h, format, tile, rows, cols) {
    const { Ctor, max } = typedArrayAndMaxFor(format);
    const arr = new Ctor(buf.buffer);
    const colour = [max, max * 0.85, 0, max]; // opaque amber line
    function setPixel(x, y) {
        x = Math.round(x); y = Math.round(y);
        if (x < 0 || x >= w || y < 0 || y >= h) return;
        const i = (y * w + x) * 4;
        arr[i] = colour[0]; arr[i + 1] = colour[1]; arr[i + 2] = colour[2]; arr[i + 3] = colour[3];
    }
    for (let c = 1; c < cols; c++) {
        const x = Math.round(c * tile);
        for (let y = 0; y < h; y++) { setPixel(x - 1, y); setPixel(x, y); }
    }
    for (let r = 1; r < rows; r++) {
        const y = Math.round(r * tile);
        for (let x = 0; x < w; x++) { setPixel(x, y - 1); setPixel(x, y); }
    }
}

function buildDialog() {
    const dlg = Dialog.create('Grid Slicer');
    const col = dlg.addColumn();
    const grid = col.addGroup('Grid');
    dlg.rows = grid.addUnitValueEditor('Rows', UnitType.Number, UnitType.Number, DEFAULTS.rows, 1, 20).setPrecision(0);
    dlg.cols = grid.addUnitValueEditor('Columns', UnitType.Number, UnitType.Number, DEFAULTS.cols, 1, 20).setPrecision(0);
    const out = col.addGroup('Output');
    dlg.subfolder = out.addTextBox('Desktop subfolder', DEFAULTS.subfolder);
    dlg.overview = out.addCheckBox('Also export a labelled overview sheet', DEFAULTS.makeOverview);
    return dlg;
}

function main() {
    const doc = getDoc();

    let ROWS, COLS, SUBFOLDER, MAKE_OVERVIEW;
    if (SKIP_DIALOG) {
        ROWS = DEFAULTS.rows;
        COLS = DEFAULTS.cols;
        SUBFOLDER = DEFAULTS.subfolder;
        MAKE_OVERVIEW = DEFAULTS.makeOverview;
    } else {
        const dlg = buildDialog();
        if (dlg.runModal().value !== DialogResult.Ok.value) {
            console.log('Grid Slicer cancelled.');
            return;
        }
        ROWS = Math.max(1, Math.round(dlg.rows.value));
        COLS = Math.max(1, Math.round(dlg.cols.value));
        SUBFOLDER = (dlg.subfolder.text || DEFAULTS.subfolder).trim() || DEFAULTS.subfolder;
        MAKE_OVERVIEW = dlg.overview.value;
    }

    const srcLayer = getSourceLayer(doc);
    const fmt = rasterFormatFor(doc);
    const engine = NodeRenderingEngine.createDefault(srcLayer, fmt);
    const W = engine.width, H = engine.height;
    if (W === 0 || H === 0) throw new Error('Could not read source image pixels (unsupported document format?).');

    const tile = Math.floor(Math.min(W / COLS, H / ROWS));
    if (tile < 1) throw new Error('Image is too small for a ' + ROWS + 'x' + COLS + ' grid.');
    const cropW = tile * COLS, cropH = tile * ROWS;
    const offX = Math.floor((W - cropW) / 2), offY = Math.floor((H - cropH) / 2);

    const OUTPUT_DIR = app.userDesktopPath + '\\' + SUBFOLDER;
    const opts = FileExportOptions.createWithPresetName('PNG');

    console.log('source ' + W + 'x' + H + ' -> ' + ROWS + 'x' + COLS + ' grid, tile ' + tile + 'px, offset (' + offX + ',' + offY + ')');
    console.log('output folder: ' + OUTPUT_DIR);

    const digits = String(ROWS * COLS).length;
    for (let r = 0; r < ROWS; r++) {
        for (let c = 0; c < COLS; c++) {
            const sx = offX + c * tile, sy = offY + r * tile;
            const buf = PixelBuffer.create(tile, tile, fmt);
            engine.copyTo(buf, { x: 0, y: 0, width: tile, height: tile }, sx, sy);
            const bitmap = buf.createCompatibleBitmap(true);
            const node = addTempImageNode(doc, srcLayer, bitmap, 'g6_tile_tmp');
            const idx = r * COLS + c + 1;
            const fname = 'tile_' + String(idx).padStart(digits, '0') + '_r' + (r + 1) + 'c' + (c + 1) + '.png';
            exportAndRemove(doc, node, OUTPUT_DIR + '\\' + fname, opts);
            console.log('exported ' + fname);
        }
    }

    if (MAKE_OVERVIEW) {
        const fullBuf = PixelBuffer.create(cropW, cropH, fmt);
        engine.copyTo(fullBuf, { x: 0, y: 0, width: cropW, height: cropH }, offX, offY);
        const ov = downsample(fullBuf, cropW, cropH, fmt, MAX_OVERVIEW_DIM);
        drawGridLines(ov.buf, ov.w, ov.h, fmt, tile * ov.tileScale, ROWS, COLS);
        const bitmap = ov.buf.createCompatibleBitmap(true);
        const node = addTempImageNode(doc, srcLayer, bitmap, 'g6_overview_tmp');
        exportAndRemove(doc, node, OUTPUT_DIR + '\\overview.png', opts);
        console.log('exported overview.png (' + ov.w + 'x' + ov.h + '; reading order: row-major from top-left, matches tile_01, tile_02, ...)');
    }

    console.log('Done. ' + (ROWS * COLS) + ' tiles written to ' + OUTPUT_DIR);
}

main();