Skip to main content

Lesson 10 — Text Threading

When a story is too long for a single text frame, the overflow text needs somewhere to go. In Affinity, you can thread text frames together so that overflow flows automatically from one frame into the next — like columns in a magazine or pages in a book. This lesson explains how to query, create, and break those threads programmatically.

Text threading is the foundation for multi-page layouts. A novel, a catalogue, or any document where text flows across pages relies on threaded frames. Without threading, overflow text simply vanishes — it still exists in the story, but there is nowhere on screen to see it. With threading, you control exactly where text goes when a frame fills up.

Two things are worth knowing before you start:

  • Threaded frames share a single story. Reading storyInterface.text from any frame in the chain returns the full text across all linked frames, not just the portion visible in that one frame.
  • The SDK lets you query thread state and break existing threads, but creating new threads programmatically requires care — the createLinkTextFrame command links two existing FrameTextNodes, and both must already exist on the spread.

By the end of this lesson you will be able to detect whether a text frame is part of a thread, link frames together so overflow text flows between them, and unlink them when you need to restructure a layout.


What this lesson covers​

  • How text threading works in Affinity
  • Querying thread state with TextFrameInterface properties
  • Linking two text frames with DocumentCommand.createLinkTextFrame
  • Unlinking frames with DocumentCommand.createUnlinkTextFrame
  • Reading text across a linked story
  • Gotchas and limitations

How threading works​

A text frame in Affinity has a textFrameInterface that exposes several properties for querying its thread state:

// Snippet — assumes node is a text frame node
const tfi = node.textFrameInterface;
console.log('Is multi-frame:', tfi.isMultiFrameTextFlow);
console.log('Is front of chain:', tfi.isTextFlowFront);
console.log('Is back of chain:', tfi.isTextFlowBack);
console.log('Flow index:', tfi.textFlowIndex);

Here is what each property means:

PropertyMeaning
isMultiFrameTextFlowtrue if this frame is part of a thread chain (two or more linked frames)
isTextFlowFronttrue if this frame is the first in the chain (the story starts here)
isTextFlowBacktrue if this frame is the last in the chain (overflow ends here)
textFlowIndexThe position of this frame in the chain (0 = first, 1 = second, etc.)

A frame that is not linked to any other frame will have isMultiFrameTextFlow === false and both isTextFlowFront and isTextFlowBack will also be false. Once you link two frames, the first reports isTextFlowFront === true and the last reports isTextFlowBack === true.

The storyInterface also has an isMultiFrameTextFlow property that reports the same thing — it is simply a convenience so you do not need to reach through textFrameInterface every time.

Check the capability guards before changing frame behaviour: tfi.canHideOverflow, tfi.canUseBaselineGrid, tfi.canUseTextWraps tell you whether the frame supports the corresponding flag, and tfi.hasScaledText reports scaled text.

To walk the whole chain, tfi.textFlowNodes returns every text node in the linked flow as an array; tfi.enumerateTextFlowNodes(callback) provides the same traversal via an EnumerationResult callback without allocating the array.


Linking frames​

To thread two frames together, use DocumentCommand.createLinkTextFrame. It takes two arguments: the source frame (where text flows from) and the destination frame (where overflow goes):

// Snippet — assumes frameA and frameB are FrameTextNode references
const doc = Document.current;
const spread = doc.currentSpread;

const cmd = DocumentCommand.createLinkTextFrame(frameA, frameB);
doc.executeCommand(cmd);

After linking, any text that overflows frameA will automatically flow into frameB. The two frames now share a single story. If you read the story text from either frame, you get the full content across both.

You can chain more than two frames — link frameB to frameC, and text flows A → B → C. Each frame's textFlowIndex reflects its position in the chain.

// Snippet — assumes frameA, frameB and frameC are FrameTextNode references
// Chain three frames
doc.executeCommand(DocumentCommand.createLinkTextFrame(frameA, frameB));
doc.executeCommand(DocumentCommand.createLinkTextFrame(frameB, frameC));

Unlinking frames​

To break a thread, use DocumentCommand.createUnlinkTextFrame. It takes a single argument — the frame where you want to break the chain:

// Snippet — assumes frameB is part of an existing thread chain
// Break the chain after frameB
// frameA's story ends at frameA; frameB and frameC form a new chain (or become independent)
doc.executeCommand(DocumentCommand.createUnlinkTextFrame(frameB));

Unlinking splits the story. The frame you pass to createUnlinkTextFrame and everything after it forms a separate chain (or becomes unlinked if it is the only frame left). The frames before it retain their original story.

After unlinking, overflow from the first chain no longer reaches the second. Text that was previously visible in the second chain may disappear if the frames are too small to contain their share of the story — but the text is still in the story, just not rendered.


Reading text across linked frames​

When frames are threaded, they share one story. Reading storyInterface.text from any frame in the chain returns the full text — not just the portion visible in that particular frame:

// Snippet — assumes frameB is part of a thread chain
const si = frameB.storyInterface;
// Returns ALL text across the entire thread, not just what's in frameB
console.log('Full story:', si.text);
console.log('Story range:', si.storyRange);

This is a common source of confusion. If you have a 10,000-word story flowing across five frames, reading the text from frame 3 still gives you all 10,000 words. The storyRange covers the entire story, not the subset visible in that frame.

To determine which portion of the story is visible in a specific frame, you need the frame's textBegin property and the story ranges of adjacent frames. This is an advanced pattern — most scripts work with the full story text and let the layout engine handle the visual distribution.


Putting it together​

Here is a minimal example that queries the thread state of every text frame in the document:

'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.isFrameTextNode) continue;
const tfi = node.textFrameInterface;
if (!tfi) continue;

const name = node.descriptionInterface?.description || '(unnamed)';
if (tfi.isMultiFrameTextFlow) {
const pos = tfi.textFlowIndex;
const role = tfi.isTextFlowFront ? ' [FRONT]' :
tfi.isTextFlowBack ? ' [BACK]' : '';
console.log(`"${name}" — thread position ${pos}${role}`);
} else {
console.log(`"${name}" — standalone (not threaded)`);
}
}
}

This is useful for auditing a document — finding which frames are linked, which are the front and back of a chain, and which are standalone.


Key gotchas​

Overflow text is invisible but still there. When a frame is too small to display all its text and the overflow has nowhere to go (no linked frame), the extra text still exists in the story. It just has no visible home. Linking a new frame to the back of the chain makes it reappear.

storyInterface.text returns everything. Do not assume that reading text from frame B in a chain gives you only frame B's portion. It gives you the entire story. Use textBegin and adjacent frame ranges to isolate a specific frame's visible text if needed.

storyInterface.isMultiFrameTextFlow mirrors textFrameInterface.isMultiFrameTextFlow. Both properties report the same value. Use whichever is more convenient — storyInterface if you are already working with the story, textFrameInterface if you are inspecting frame properties.

Linking requires both frames to exist on the spread. You cannot link a frame that has not been added to the document yet. Create both frames first, then link them.

The SDK does not support creating pre-linked frames in a single call. There is no FrameTextNodeDefinition.createLinked(pair) factory. You must create each frame individually and then issue createLinkTextFrame as a separate command.

FrameTextNodeDefinition.createFromStoryBuilder bounds are in points, not pixels. The SDK geometry system uses points as its native unit. At 300dpi, raw numbers like width: 200 produce frames only ~53pt wide — barely visible. Always convert: const pxPerPt = doc.dpi / 72; { x: N * pxPerPt, y: N * pxPerPt, width: N * pxPerPt, height: N * pxPerPt }. Without this, frames appear as tiny slivers on the spread. See examples/10a-link-and-flow.js for a working example.

Always seed the StoryBuilder with frame-text defaults. Call sb.setToFrameTextDefaultStyle(doc.dpi, doc.rasterFormat) immediately after StoryBuilder.create() and before sb.addText(). Without it, text renders at an invisibly small size (~0.24pt). See examples/10a-link-and-flow.js.


Exercises​

Exercise 10.1 — Audit threaded frames​

Write a script that finds all FrameTextNodes in the document and logs their thread state: whether they are threaded, their position in the chain, and whether they are the front or back. Group the output by chain.

Write a script that takes the first two FrameTextNodes in the current selection and threads them together using createLinkTextFrame. After linking, log the thread state of both frames to confirm the link.

Exercise 10.3 — Break and re-thread​

Write a script that finds an existing thread chain, unlinks the middle frame, and then re-links it to create a different flow order. Log the thread state before and after to show the change.


SDK modules used in this lesson​

  • /document — Document, DocumentCommand
  • /nodes — FrameTextNode, textFrameInterface
  • /storybuilder — StoryBuilder

Further examples​

ExampleWhat it shows
breakFrame.jsBreak a text frame at the cursor into two frames
splitStory.jsSplit a story at the cursor into two separate text frames

See also​


Example scripts​

Download 10a-link-and-flow.js

10a-link-and-flow.js
'use strict';

// Example 10a — Link and flow
// Lesson 10: Text Threading
//
// Creates two text frames on the current spread, fills the first with
// enough text to overflow, links them so the overflow flows into the
// second frame, then logs the thread state of both frames.

const { Document } = require('/document.js');
const { DocumentCommand, AddChildNodesCommandBuilder } = require('/commands.js');
const { StoryBuilder } = require('/storybuilder.js');
const { FrameTextNodeDefinition } = require('/nodes.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const spread = doc.currentSpread;
if (!spread) { console.log('No current spread.'); }
else {
// Build a story with enough text to overflow a small frame
const sb = StoryBuilder.create();
sb.setToFrameTextDefaultStyle(doc.dpi, doc.rasterFormat);
const longText = [
'Text threading lets overflow flow from one frame into the next.',
'This is essential for multi-page layouts like books and catalogues.',
'When a frame fills up, the excess text automatically appears in the',
'linked frame downstream. You can chain multiple frames together,',
'and each frame in the chain shares a single story. Reading the story',
'from any frame returns the full text across the entire chain, not just',
'the portion visible in that particular frame. This shared-story model',
'is what makes long-form text layout possible in Affinity.',
'',
'To link frames, use DocumentCommand.createLinkTextFrame(src, dest).',
'To break a link, use DocumentCommand.createUnlinkTextFrame(node).',
'The textFrameInterface properties isMultiFrameTextFlow, isTextFlowFront,',
'isTextFlowBack, and textFlowIndex let you inspect the thread state.',
'',
'This example creates two frames, links them, and logs the result.'
].join(' ');
sb.addText(longText);

// Bounds are in points (like all geometry in the SDK); convert to document
// pixels for createFromStoryBuilder. At 300dpi: pts * 300/72 = pixels.
// Using 100pt / 250pt / 350pt as convenient round-point values.
const pxPerPt = doc.dpi / 72;

// Create the first (source) frame — small so text overflows
const defA = FrameTextNodeDefinition.createFromStoryBuilder(
{ x: 100 * pxPerPt, y: 50 * pxPerPt, width: 250 * pxPerPt, height: 350 * pxPerPt },
sb
);
const addA = AddChildNodesCommandBuilder.create();
addA.addNode(defA);
addA.setInsertionTarget(spread);
const cmdA = addA.createCommand(true);
doc.executeCommand(cmdA);

// Find the frame we just created (last node added)
const frameA = cmdA.newNodes[cmdA.newNodes.length - 1];
console.log('Created frame A:', frameA.descriptionInterface?.description || '(unnamed)');

// Create the second (destination) frame — off to the right
const defB = FrameTextNodeDefinition.createFromStoryBuilder(
{ x: 400 * pxPerPt, y: 50 * pxPerPt, width: 250 * pxPerPt, height: 350 * pxPerPt },
sb // reuse the same StoryBuilder so both frames have the long text
);
const addB = AddChildNodesCommandBuilder.create();
addB.addNode(defB);
addB.setInsertionTarget(spread);
const cmdB = addB.createCommand(true);
doc.executeCommand(cmdB);

const frameB = cmdB.newNodes[cmdB.newNodes.length - 1];
console.log('Created frame B:', frameB.descriptionInterface?.description || '(unnamed)');

// Link frame A -> frame B
const linkCmd = DocumentCommand.createLinkTextFrame(frameA, frameB);
doc.executeCommand(linkCmd);
console.log('Linked frame A -> frame B');

// Query and log thread state
const tfiA = frameA.textFrameInterface;
const tfiB = frameB.textFrameInterface;

console.log('');
console.log('--- Thread state after linking ---');
console.log('Frame A:');
console.log(' isMultiFrameTextFlow:', tfiA.isMultiFrameTextFlow);
console.log(' isTextFlowFront:', tfiA.isTextFlowFront);
console.log(' isTextFlowBack:', tfiA.isTextFlowBack);
console.log(' textFlowIndex:', tfiA.textFlowIndex);

console.log('Frame B:');
console.log(' isMultiFrameTextFlow:', tfiB.isMultiFrameTextFlow);
console.log(' isTextFlowFront:', tfiB.isTextFlowFront);
console.log(' isTextFlowBack:', tfiB.isTextFlowBack);
console.log(' textFlowIndex:', tfiB.textFlowIndex);

// Show that reading from either frame gives the full story
const siA = frameA.storyInterface;
const siB = frameB.storyInterface;
console.log('');
console.log('Story from frame A:', siA.text.substring(0, 80) + '...');
console.log('Story from frame B:', siB.text.substring(0, 80) + '...');
console.log('Same story?', siA.text === siB.text ? 'YES' : 'NO');
}
}