Skip to main content

Lesson 03 — Node Model & Tree Traversal

In Affinity, every object you see in the canvas — every shape, text frame, image, group, and curve — is a node in a tree. This lesson teaches you how that tree is structured, how to walk it, and how to identify what kind of node you are looking at.

Understanding the node tree is the foundation of every non-trivial script. Whether you want to rename all layers, find every text frame, apply an effect to shapes only, or build a custom export pipeline, you need to be able to traverse the tree and identify nodes by type. Without this lesson, most of the rest of the tutorial would be inaccessible.

What this lesson covers​

  • The node tree structure
  • Node type guards (isShapeNode, isTextNode, etc.)
  • Traversing layers recursively
  • /collection — lazy functional traversal
  • Working with selections
  • NodeChildType — Main vs Enclosure child lists

The node tree​

Every object in an Affinity document is a node. The tree looks like this:

DocumentNode (rootNode)
└── SpreadNode
├── GroupNode
│ ├── ShapeNode
│ └── TextNode
├── RasterNode
├── ImageNode
├── PolyCurveNode
└── CurveNode

Nodes expose typed interfaces as properties:

  • node.visibilityInterface — show/hide
  • node.baseBoxInterface — bounding box (x, y, width, height)
  • node.transformInterface — transform matrix (NOT bounding box)
  • node.descriptionInterface — layer name
  • node.editabilityInterface — lock/unlock
  • node.blendModeInterface — blend mode, opacity

Traversing layers​

doc.layers iterates top-level nodes across every spread. To descend into group children, use doc.layers.all.

'use strict';
const { Document } = require('/document.js');

const doc = Document.current;
if (!doc) { console.log('No doc.'); }
else {
// Top-level layers across all spreads (does not descend into groups)
for (const node of doc.layers) {
console.log(node.descriptionInterface?.description ?? '(unnamed)');
}

// ALL nodes recursively across all spreads
for (const node of doc.layers.all) {
console.log(node);
}
}

doc.layers covers all spreads but not group children. It returns top-level nodes across every spread. Use doc.layers.all to descend into groups. Use doc.spreads when you need to know which spread a node belongs to.


Multi-spread traversal​

When you need per-spread context, iterate doc.spreads directly and use getNodeChildren to access nodes within a specific spread.

'use strict';
const { Document } = require('/document.js');
const Nodes = require('/nodes.js');

const doc = Document.current;
if (!doc) return;

let spreadIndex = 0;
for (const spread of doc.spreads) {
spreadIndex++;
console.log(`\n── Spread ${spreadIndex} ──`);

for (const node of Nodes.getNodeChildren(spread.handle, Nodes.NodeChildType.Main, false)) {
const name = node.descriptionInterface?.description ?? '(unnamed)';
console.log(' ', name);
}
}

/collection — lazy functional traversal​

doc.layers, doc.layers.all, and the getNodeChildren* generators all feed Collection objects — lazy, generator-backed sequences with map, filter, take, skip, and reduce. Nothing is computed until a terminal call like toArray(), length, or first, so filtering a large tree costs nothing until you actually need the results.

'use strict';
const { Document } = require('/document.js');
const { Collection } = require('/collection.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const all = doc.layers.all;

console.log('Total nodes:', all.length);
console.log('Text nodes: ', all.countIf(n => n.isTextNode));

const names = all
.map(n => n.descriptionInterface?.description ?? '(unnamed)')
.take(5)
.toArray();

const pageLabels = Collection.range(1, doc.pageCount)
.map(n => `Page ${n}`)
.toArray();
console.log(pageLabels.join(', '));
}

Key methods: map, filter, take(n), skip(n), reduce(fn, seed), countIf(pred), some(pred), first, last, isEmpty, length, join(sep), toArray. Static factories: Collection.range(start, count), Collection.of(...items), Collection.over(seq). See the official adjustPageItems.js example in phase 5 for Collection.range driving a per-page loop.

More chaining ops: append, flat, reverse, takeWhile / skipWhile, repeat(n), leftZip / rightZip, notNull. More terminals: all(pred), any(pred), none(pred), forEach(fn), at(i), entries(), keys(). Factories: Collection.empty(), Collection.fibonacci(), Collection.random().

Collection methods return new Collections, not arrays. Chain freely, then call .toArray() or iterate when you need the values.


Node type guards​

Use these boolean properties to identify node types before accessing type-specific APIs:

isColouredLogicalNode detects colour-coded folder groups (set by the user via the Layers panel colour label). Read the colour index via node.layerColour.

'use strict';
const { Document } = require('/document.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
for (const node of doc.layers.all) {
if (node.isShapeNode) console.log('Shape: ', node.descriptionInterface?.description);
if (node.isTextNode) console.log('Text: ', node.descriptionInterface?.description);
if (node.isRasterNode) console.log('Raster: ', node.descriptionInterface?.description);
if (node.isImageNode) console.log('Image: ', node.descriptionInterface?.description);
if (node.isPolyCurveNode) console.log('PolyCurve:', node.descriptionInterface?.description);
if (node.isGroupNode) console.log('Group: ', node.descriptionInterface?.description);
if (node.isContainerNode) console.log('Container:', node.descriptionInterface?.description);
if (node.isCurveNode) console.log('Curve: ', node.descriptionInterface?.description);
if (node.isColouredLogicalNode) console.log('Coloured: ', node.descriptionInterface?.description, `(colour: ${node.layerColour})`);
}
}

NodeCast dispatches a base-class handle to its concrete node subclass when a getter returns an untyped node.


Working with the selection​

doc.selection.nodes gives you the current selection as an iterable. Access each node's interfaces to read its name, bounding box, etc.

'use strict';
const { Document } = require('/document.js');

const doc = Document.current;
if (!doc) return;

const sel = doc.selection;
const nodes = sel.nodes;

if (nodes.isEmpty) {
console.log('Nothing selected.');
} else {
console.log(`${nodes.length} node(s) selected:`);
for (const node of nodes) {
const name = node.descriptionInterface?.description ?? '(unnamed)';
const bbox = node.baseBoxInterface?.baseBox; // NOT transformInterface.baseBox
console.log(` ${name} — x:${bbox?.x} y:${bbox?.y} w:${bbox?.width} h:${bbox?.height}`);
}
}

Node interfaces reference​

InterfaceProperty on nodeWhat it gives you
VisibilityvisibilityInterface.isVisible, .visibility
TransformtransformInterface.transform (matrix), .unconstrainedTransform
Bounding boxbaseBoxInterface.baseBox (x, y, width, height) — NOT on transformInterface
Spread bounding boxspreadBaseBox (convenience).spreadBaseBox — equivalent to getSpreadBaseBox(true)
DescriptiondescriptionInterface.description (layer name)
EditabilityeditabilityInterface.isEditable
Blend modeblendModeInterface.blendMode, .opacity
TagtagInterface.tagColour
ArtboardartboardInterfaceartboard-specific properties

node.testVisibility(options) answers whether a node would actually render: build the options with VisibilityTestOptions.create() (exportable-visibility, empty-rect, spread-clip and layer flags) and TextVisibilityOptions.create() (special-character, anchor, index-mark and field-highlight flags).


NodeChildType​

Nodes can have two child lists:

TypeUsed for
NodeChildType.MainNormal layer children
NodeChildType.EnclosureEnclosure layers attached to a node (masks, clips) — read via node.enclosures
NodeChildType.MasterSpread / WrapShape / StoryPinMaster-spread, wrap-shape, and story-pin child lists (specialised; values 2/3/4)

Most traversal only needs Main.

Node navigation shortcuts​

// Snippet — assumes node is defined
const sel = node.selfSelection; // one-line Selection for the node itself
const next = node.nextSibling; // typed sibling navigation
const prev = node.previousSibling;
const first = node.getFirstChild(); // Main child list by default
const encl = node.enclosures; // enclosure (mask/clip) child list

Nodes.getNodeSiblings(handle, reverse) iterates siblings; Nodes.getNodesRecursive(handle, childList, reverse) yields a node plus all its descendants (the self-inclusive variant of getNodeChildrenRecursive).


ContainerNode — named layer groups​

ContainerNode is a named group type distinct from the anonymous GroupNode. It appears in the Layers panel as a coloured folder and supports a fill, stroke, and blend mode of its own. The key difference is that ContainerNodeDefinition.create(name) accepts a display name upfront, whereas GroupNode has no programmatic factory.

'use strict';
const { Document } = require('/document.js');
const { ContainerNodeDefinition } = require('/nodes.js');
const { AddChildNodesCommandBuilder } = require('/commands.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
// Create a named container (coloured layer group)
const def = ContainerNodeDefinition.create('My Group');
const builder = AddChildNodesCommandBuilder.create();
builder.addContainerNode(def);
const cmd = builder.createCommand(true);
doc.executeCommand(cmd);

const container = cmd.newNodes[0];
console.log('Container created:', container.descriptionInterface.description);
console.log('Is container:', container.isContainerNode);
}

Use ContainerNodeDefinition.createDefault() when you want an unnamed container and will set def.userDescription separately.


Node convenience methods​

Every Node exposes shorthand methods that wrap the underlying commands:

'use strict';
const { Document } = require('/document.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const node = doc.selection.nodes.first;
if (!node) { console.log('Select a node first.'); }
else {
// Rename
node.userDescription = 'Renamed layer';

// Lock / unlock (sets editability)
node.lock(); // equivalent to DocumentCommand.createSetEditable(sel, false)
node.unlock();

// Duplicate — returns the new node
const copy = node.duplicate();
console.log('Duplicate created:', copy?.descriptionInterface?.description);

// Delete — use Selection.create(doc, node), then doc.deleteSelection()
// node.delete(); // BROKEN: passes a Node where Selection is expected
// CORRECT:
const { Selection } = require('/selections.js');
const nodeSel = Selection.create(doc, node);
doc.deleteSelection(nodeSel);
}
}

node.duplicate() applies no transform by default, so the copy lands on top of the original. Pass a Transform as the first argument to offset it.

doc.addNode(nodeDefinition, targetNode) is a shorthand that wraps AddChildNodesCommandBuilder for single-node additions without the full builder setup:

'use strict';
const { Document } = require('/document.js');
const { ContainerNodeDefinition } = require('/nodes.js');

const doc = Document.current;
if (doc) {
const def = ContainerNodeDefinition.create('Quick group');
doc.addNode(def); // adds to the current spread, top of stack
}

Per-node tag metadata​

The SDK lets you store arbitrary string key-value pairs on any node via DocumentCommand. These persist inside the .af file, making them useful for metadata workflows, asset management systems, and script-to-script communication.

node.tagInterface exposes methods for reading tags back at runtime: hasKey(key), getValueForKey(key), hasPredefinedKey(key), and getValueForPredefinedKey(key).

'use strict';
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { PredefinedTagKey } = require('/taginterface.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else if (doc.selection.nodes.isEmpty) { console.log('Select a node first.'); }
else {
const sel = doc.selection;
const node = sel.nodes.first;

// Write a custom tag (persists in the file, adds to undo stack)
doc.executeCommand(
DocumentCommand.createSetTagValueForKey(sel, 'asset-id', 'IMG-0042')
);
doc.executeCommand(
DocumentCommand.createSetTagValueForKey(sel, 'approved', 'true')
);

// Write a predefined tag (ScriptLabel, AltTextSource, AltTextTag, etc.)
doc.executeCommand(
DocumentCommand.createSetTagValueForPredefinedKey(
sel, PredefinedTagKey.ScriptLabel, 'hero-image')
);

// Read tags back via tagInterface
const tagIface = node.tagInterface;
if (tagIface) {
console.log('Has asset-id:', tagIface.hasKey('asset-id'));
console.log('asset-id value:', tagIface.getValueForKey('asset-id'));
console.log('ScriptLabel:', tagIface.getValueForPredefinedKey(PredefinedTagKey.ScriptLabel));
console.log('Is decoration:', tagIface.isMarkAsDecoration);
}
}

Use cases​

  • Asset management: tag layers with IDs, status, or project codes that survive export/reimport.
  • Script handoff: script A tags nodes, external tools read the .af XML to process them.
  • Conditional logic: write tags that future script runs can detect via other means.
  • Audit trail: write timestamps or version numbers when a layer was last processed.

Exploring nodes with /inspect​

When you encounter an unfamiliar object, console.log won't help — most SDK objects are HandleObject instances whose state lives behind prototype getters, so console.log(node) prints [Node] and tells you nothing.

/inspect provides a Node.js-compatible util.inspect that safely evaluates getters, walks prototype chains, and formats the result as a readable string:

'use strict';
const { Document } = require('/document.js');
const { inspect } = require('/inspect.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
console.log(inspect(doc.selection, { depth: 2 }));
}

patchConsole routes all console.log output through inspect automatically:

'use strict';
const { patchConsole, unpatchConsole } = require('/inspect.js');
const { Document } = require('/document.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
patchConsole({ depth: 3, getters: true });
console.log(doc.currentSpread); // auto-formatted
console.log(doc.selection.nodes.first);
unpatchConsole();
}

Every property in the inspect output corresponds to a getter on the object's prototype. If you see marginsInterface on a spread node, that maps directly to the /marginsinterface module in the SDK primer — this is the discovery loop: inspect → find property → find module → read the primer.

Three formatting modes for enums:

// Snippet — assumes doc is a Document instance
const { inspect } = require('/inspect.js');
const rs = doc.rasterSelection;
inspect(rs.isSelectNone, { enums: 'brief' }); // SelectNone
inspect(rs.isSelectNone, { enums: 'full' }); // RasterSelection.SelectNone
inspect(rs.isSelectNone, { enums: 'raw' }); // the raw enum object

Options: depth (default 2, null = unlimited), showHidden, getters, sorted, enums ('brief'/'full'/'raw'), maxArrayLength.


Key gotchas​

Null-safe interface access — not every node implements every interface. Always use optional chaining (node.descriptionInterface?.description) or check for null first.

Read doc.selection before setCurrentSpread — switching spreads clears the selection.

doc.layers = all spreads, top-level only. It covers every spread but does not descend into groups. Use doc.spreads when you need to know which spread a node belongs to.

doc.layers.all = all spreads, recursive. Descends into groups and returns every node at any depth across all spreads.


Exercises​

Exercise 3.1 — Layer tree printer​

Write a script that prints a tree of all layer names, indented by depth, with the node type shown in brackets.

Expected output:

Background (ShapeNode)
Sky (RasterNode)
Mountains (GroupNode)
Peak left (PolyCurveNode)
Peak right (PolyCurveNode)
Logo (GroupNode)
Text (TextNode)

Exercise 3.2 — Selection inspector​

Write a script that logs the name, type, bounding box, and visibility of every currently selected node.

Exercise 3.3 — Type counter​

Write a script that counts how many of each node type exist in the document and logs a summary:

ShapeNode: 12
TextNode: 3
RasterNode: 1
GroupNode: 5

Exercise 3.4 — Multi-spread walker​

For a multi-page document, walk every spread and log how many top-level nodes each spread contains.

Exercise 3.5 — Collection census​

Rewrite the type counter from Exercise 3.3 using only Collection methods (countIf, filter, length) — no explicit loops or counters.


SDK modules used in this lesson​

  • /document — Document
  • /nodes — NodeChildType, ContainerNodeDefinition, getNodeChildren, getNodeChildrenRecursive
  • /commands — AddChildNodesCommandBuilder
  • /selections — Selection
  • /collection — Collection (lazy functional sequences)
  • /inspect — inspect, patchConsole, unpatchConsole

Further examples​

ExampleWhat it shows
countSelectedItems.jsCount selected objects by type, including nested children
selectObjects.jsSelect objects by type/state through a dialog

See also​



Example scripts​

03a-layer-tree-printer.js​

Download 03a-layer-tree-printer.js

03a-layer-tree-printer.js
'use strict';

// Example 03a — Layer tree printer
// Lesson 03: Node Model
//
// Prints every layer in the document as an indented tree.
// Works across all spreads.

const { Document } = require('/document.js');
const Nodes = require('/nodes.js');

const nodeType = (node) => {
if (node.isShapeNode) return 'Shape';
if (node.isTextNode) return 'Text';
if (node.isRasterNode) return 'Raster';
if (node.isImageNode) return 'Image';
if (node.isPolyCurveNode) return 'PolyCurve';
if (node.isGroupNode) return 'Group';
if (node.isCurveNode) return 'Curve';
return 'Node';
};

const printChildren = (parentHandle, depth) => {
const indent = ' '.repeat(depth);
for (const node of Nodes.getNodeChildren(parentHandle, Nodes.NodeChildType.Main, false)) {
const name = node.descriptionInterface?.description ?? '(unnamed)';
const type = nodeType(node);
const visible = node.visibilityInterface?.isVisible !== false ? '' : ' [hidden]';
console.log(`${indent}${name} (${type})${visible}`);
printChildren(node.handle, depth + 1);
}
};

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
console.log(`Layer tree: ${doc.title}\n`);

let i = 0;
for (const spread of doc.spreads) {
i++;
console.log(`── Spread ${i} ──`);
printChildren(spread.handle, 1);
console.log('');
}
}

03b-selection-inspector.js​

Download 03b-selection-inspector.js

03b-selection-inspector.js
'use strict';

// Example 03b — Selection inspector
// Lesson 03: Node Model
//
// Logs full details of every selected node.

const { Document } = require('/document.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const sel = doc.selection;
const nodes = sel.nodes;

if (nodes.isEmpty) {
console.log('Nothing selected. Select some objects and run again.');
} else {
console.log(`${nodes.length} node(s) selected:\n`);

let i = 0;
for (const node of nodes) {
i++;
const name = node.descriptionInterface?.description ?? '(unnamed)';
const visible = node.visibilityInterface?.isVisible;
const bbox = node.baseBoxInterface?.baseBox; // NOT transformInterface.baseBox

console.log(`[${i}] ${name}`);
if (node.isShapeNode) console.log(' Type: Shape');
else if (node.isTextNode) console.log(' Type: Text');
else if (node.isRasterNode) console.log(' Type: Raster');
else if (node.isImageNode) console.log(' Type: Image');
else if (node.isGroupNode) console.log(' Type: Group');
else if (node.isPolyCurveNode) console.log(' Type: PolyCurve');
else console.log(' Type: (other)');

console.log(' Visible:', visible);
if (bbox) {
console.log(` Bounds: x=${Math.round(bbox.x)} y=${Math.round(bbox.y)} w=${Math.round(bbox.width)} h=${Math.round(bbox.height)}`);
}
}
}
}

03c-collection-walk.js​

Download 03c-collection-walk.js

03c-collection-walk.js
'use strict';

// Example 03c — Collection walk
// Lesson 03: Node Model
//
// Traverses the node tree with the /collection module's lazy functional
// helpers instead of manual loops and counters.

const { Document } = require('/document.js');
const { Collection } = require('/collection.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const all = doc.layers.all;

console.log('Total nodes:', all.length);
console.log('Shape nodes:', all.countIf(n => n.isShapeNode));
console.log('Text nodes: ', all.countIf(n => n.isTextNode));

const firstNames = all
.map(n => n.descriptionInterface?.description ?? '(unnamed)')
.take(5)
.toArray();
console.log('First five layers:', firstNames.join(' | '));

const pageLabels = Collection.range(1, doc.pageCount)
.map(n => `Page ${n}`)
.toArray();
console.log('Page labels:', pageLabels.join(', '));

const hidden = all
.filter(n => !(n.visibilityInterface?.isVisible ?? true))
.toArray();
console.log('Hidden layers:', hidden.length);
hidden.forEach(n => console.log(' ', n.descriptionInterface?.description ?? '(unnamed)'));
}