- 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 || "");
}