Skip to content

Instantly share code, notes, and snippets.

@gnat
Last active October 3, 2026 03:22
Show Gist options
  • Select an option

  • Save gnat/b25c51ff8e41af9098f93e5dcc87c127 to your computer and use it in GitHub Desktop.

Select an option

Save gnat/b25c51ff8e41af9098f93e5dcc87c127 to your computer and use it in GitHub Desktop.
Affinity Designer Export by Layers

Affinity Designer 3+

Auto export by layer, with optional background layers, using layer name as file name.

image

Setup

  • Edit ➡️ Settings ➡️ Scripting ➡️ Enable Affinity Scripting ✅
  • Edit ➡️ Settings ➡️ Scripting ➡️ Access the file system ✅
  • Edit ➡️ Settings ➡️ Scripting ➡️ File System Access ➡️ Add ➡️ Your target directory.
  • Window ➡️ Scripting ➡️ Script Editor ➡️ Add the script, save.

Double click the script in the target document to run it.

/**
 * name: Export Layers - Full Canvas
 * description: Export top-level layers from top to bottom.
 */
'use strict';


const { Document, FileExportOptions, FileExportArea } = require('/document.js');
const { Dialog, DialogResult } = require('/dialog.js');
const { app } = require('/application.js');
const { fs } = require('/fs.js');
const { Size } = require('/geometry.js');
const { EnumerationResult } = require('affinity:common');

// Defaults for the window's resolution inputs. Set both to 0 to default to
// the document's dimensions. You can change these values in the window.
const EXPORT_WIDTH_PX = 0;
const EXPORT_HEIGHT_PX = 0;
const TITLE = 'Export Layers - Full Canvas';

function isOk(result) {
    const value = result && result.value !== undefined ? result.value : result;
    const ok = DialogResult.Ok.value !== undefined ? DialogResult.Ok.value : DialogResult.Ok;
    return value === ok;
}

function guessExtension(preset) {
    const match = String(preset).match(/\b(png|jpe?g|webp|tiff?|gif|bmp|pdf|svg|eps|psd|exr|hdr|jxl|avif)\b/i);
    if (!match) return '';
    const ext = match[1].toLowerCase();
    return ext === 'jpeg' ? 'jpg' : ext === 'tiff' ? 'tif' : ext;
}

function safeName(name, index) {
    let result = String(name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim();
    result = result.replace(/[ .]+$/g, '');
    if (!result || /^\.+$/.test(result)) result = 'Layer ' + (index + 1);
    if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(result)) result = '_' + result;
    // Leave room for the extension and suffix on Windows.
    return result.slice(0, 160).replace(/[ .]+$/g, '');
}

function documentFolder(path) {
    const normalized = String(path || '').replace(/\\/g, '/');
    const slash = normalized.lastIndexOf('/');
    return slash < 0 ? '' : normalized.slice(0, slash + 1);
}

function documentBaseName(document) {
    const path = String(document.path || '').replace(/\\/g, '/');
    const filename = path.slice(path.lastIndexOf('/') + 1);
    const name = filename ? filename.replace(/\.[^.]+$/, '') :
        String(document.title || '').replace(/\.(afdesign|afphoto|afpub|af)$/i, '');
    if (!name.trim()) throw new Error('Could not determine the document name. Save the document first.');
    return safeName(name, 0);
}

function nextPath(folder, stem, extension, used) {
    const prefix = /[\\/]$/.test(folder) ? folder : folder + '/';
    let suffix = 1;
    let path;
    do {
        path = prefix + stem + (suffix === 1 ? '' : ' (' + suffix + ')') + '.' + extension;
        suffix++;
    } while (used.has(path.toLowerCase()));
    used.add(path.toLowerCase());
    return path;
}

function main() {
    let document;
    let original = [];
    let changed = false;
    let completed = false;
    let restoreOnSuccess = false;
    let exported = 0;
    let failure = null;
    const warnings = [];
    let folder = '';

    try {
        document = Document.current;
        if (!document) throw new Error('Open a document first.');
        if (document.isReadOnly) throw new Error('The document is read-only.');
        const spread = document.currentSpread;
        if (document.rootNode.pageCount !== 1 || spread.artboardCount > 0) {
            throw new Error('This script needs a single canvas without artboards.');
        }

        // Native children run back to front. Reverse for the Layers panel order.
        const layers = Array.from(spread.layers).reverse();
        if (!layers.length) throw new Error('There are no top-level layers to export.');
        original = layers.map(node => ({ node, visible: node.isVisible }));
        const bounds = spread.getSpreadExtents({ includeSpread: true, includeBleed: false, includeChildren: false });
        const width = Math.round(bounds.width);
        const height = Math.round(bounds.height);
        if (!(width > 0 && height > 0)) throw new Error('Could not determine the canvas size.');
        const useDocumentSize = EXPORT_WIDTH_PX === 0 && EXPORT_HEIGHT_PX === 0;

        const presets = FileExportOptions.allPresetNames;
        if (!presets.length) throw new Error('No export presets are available. Save an export preset first.');
        const pngIndex = presets.findIndex(name => /\bpng\b/i.test(name));
        const initialIndex = pngIndex >= 0 ? pngIndex : 0;
        const dialog = Dialog.create(TITLE);
        dialog.initialWidth = 720;
        const group = dialog.addColumn().addGroup('Export settings');
        group.addStaticText('', layers.length + ' top-level layers; canvas ' + width + ' × ' + height + ' px.');
        const presetControl = group.addComboBox('Saved export preset', presets, initialIndex);
        const folderControl = group.addTextBox('Output folder', documentFolder(document.path));
        const documentFolderControl = group.addCheckBox('Use a subfolder named after the document', true);
        const extensionControl = group.addTextBox('File extension (match preset)', guessExtension(presets[initialIndex]));
        const widthControl = group.addTextBox('Output width (px)', String(useDocumentSize ? width : EXPORT_WIDTH_PX));
        const heightControl = group.addTextBox('Output height (px)', String(useDocumentSize ? height : EXPORT_HEIGHT_PX));
        presetControl.onValueChangedHandler = () => {
            extensionControl.text = guessExtension(presets[presetControl.selectedIndex]);
        };
        const keepBackgroundControl = group.addCheckBox('Keep visible layers as backgrounds; export hidden layers only', true);
        const restoreControl = group.addCheckBox('Restore starting visibility after export', true);
        if (!isOk(dialog.runModal())) return;

        const widthText = String(widthControl.text || '').trim();
        const heightText = String(heightControl.text || '').trim();
        const outputWidth = Number(widthText);
        const outputHeight = Number(heightText);
        if (!/^\d+$/.test(widthText) || !/^\d+$/.test(heightText) ||
            !Number.isSafeInteger(outputWidth) || !Number.isSafeInteger(outputHeight) ||
            outputWidth < 1 || outputHeight < 1) {
            throw new Error('Enter a positive whole number for both output width and output height.');
        }
        const size = new Size();
        size.width = outputWidth;
        size.height = outputHeight;

        folder = String(folderControl.text || '').trim().replace(/^"(.*)"$/, '$1');
        const extension = String(extensionControl.text || '').trim().replace(/^\./, '').toLowerCase();
        if (!folder) throw new Error('Enter an output folder.');
        if (!/^[a-z0-9]+$/.test(extension)) throw new Error('Enter a file extension matching your preset, such as png or jpg.');
        const preset = presets[presetControl.selectedIndex];
        if (!preset) throw new Error('Choose an export preset.');
        const options = FileExportOptions.createWithPresetName(preset);
        const area = FileExportArea.createForCurrentSpread();
        restoreOnSuccess = restoreControl.value;
        const targets = keepBackgroundControl.value ? original.filter(item => !item.visible) : original;
        if (!targets.length) throw new Error('No hidden layers to export. Turn off the background option to export all layers.');

        if (documentFolderControl.value) {
            const prefix = /[\\/]$/.test(folder) ? folder : folder + '/';
            folder = prefix + documentBaseName(document);
        }

        fs.createDirectories(folder);
        if (!fs.isDirectory(folder)) throw new Error('Could not create or use the output folder: ' + folder);

        const used = new Set();
        const outputs = targets.map((item, index) => ({
            node: item.node,
            path: nextPath(folder, safeName(item.node.userDescription || item.node.description, index), extension, used)
        }));

        changed = true;
        if (!keepBackgroundControl.value) {
            for (const item of original) document.setVisible(false, item.node);
        }
        for (const output of outputs) {
            document.setVisible(true, output.node);
            try {
                const records = document.export(output.path, options, area, size);
                if (!records || records.count === 0) throw new Error('Affinity returned no export results.');
                const errors = [];
                records.enumerate(record => {
                    if (!record.isSuccess) errors.push(record.errorMessage || 'Affinity reported an export error.');
                    if (record.hasWarnings) warnings.push(record.warningMessage || 'Export warning.');
                    return EnumerationResult.Continue;
                });
                if (errors.length) throw new Error(errors.join('\n'));
                if (!fs.exists(output.path)) throw new Error('The expected output file was not created: ' + output.path);
                exported++;
            } finally {
                document.setVisible(false, output.node);
            }
        }
        completed = true;
    } catch (error) {
        failure = error;
    } finally {
        if (changed && (!completed || restoreOnSuccess)) {
            const restoreErrors = [];
            for (const item of original) {
                try { document.setVisible(item.visible, item.node); }
                catch (error) { restoreErrors.push(String(error.message || error)); }
            }
            if (restoreErrors.length) {
                failure = new Error((failure ? String(failure.message || failure) + '\n\n' : '') +
                    'Some layer visibility could not be restored:\n' + restoreErrors.join('\n'));
            }
        }
    }

    if (failure) {
        app.alert('Export stopped. ' + exported + ' file(s) completed.\n\n' + String(failure.message || failure), TITLE);
    } else if (completed) {
        app.alert('Exported ' + exported + ' file(s) to:\n' + folder +
            (warnings.length ? '\n\nWarnings:\n' + Array.from(new Set(warnings)).join('\n') : ''), TITLE);
    }
}

module.exports.main = main;


console.log("Calling main");

try {
    main();
    console.log("main finished");
} catch (error) {
    console.log("main failed: " + (error.message || String(error)));
    console.log(error.stack || "");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment