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-13I 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.
Rows × Cols grid, then centers the grid on the image — trimming any excess
border evenly, with no stretching or padding.tile_<N>_r<row>c<col>.png.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.SKIP_DIALOG flag at the top of the file lets it run
headless for scripted or repeated batches.| Parameter | Default | Notes |
|---|---|---|
| Rows | 3 | 1–20 |
| Columns | 3 | 1–20 |
| Output folder | Desktop/GridSlices | SDK file I/O is sandboxed to the Desktop |
| Overview sheet | on | checkbox |
| Overview max size | 640px | reference sheet, not a print deliverable |
2×2 grid · 768×1024 source photo
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.
'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();