Skip to main content

Lesson 07 — Text & Story Model

Text in Affinity is not a simple string. Every text node contains a Story — a structured sequence of characters with formatting attached, like a miniature word processor embedded in the document. This lesson explains that model and shows you how to read, replace, style, and build text programmatically.

Automating text is one of the most common scripting use cases — batch-renaming layers, applying consistent styles, generating text from data, or checking for missing fonts. The Story model is more complex than a plain string API, but that complexity is what lets you format a precise range of characters without touching anything else.

Two details trip up most newcomers, so they're worth knowing up front:

  • A TextNode exposes a storyInterface, and the full text range lives on storyInterface.storyRange — not on the story property. Confusing these is the most common mistake in this lesson.
  • Every story ends with a hidden paragraph marker. When selecting the full text, use range.end - 1 to exclude it.

By the end of this lesson you'll be able to read text from any frame, replace it, apply character and paragraph formatting to a precise range, and generate structured text from scratch with StoryBuilder.

A note on scope: the SDK's scripting surface currently has limited support for master-page content. Text frames that originate from a master page and appear on ordinary pages may not respond to the same commands as regular text frames — some operations work, others silently fail. Because of this, text frames derived from master pages are not dealt with in this lesson.

What this lesson covers​

  • The Story model — how text is stored internally
  • StoryBuilder — constructing text content
  • StoryDelta — describing formatting changes
  • TextSelection — targeting ranges of text
  • Constructing text selection ranges correctly (the end - 1 convention)
  • Applying character styles by name
  • Paragraph attributes
  • Glyphs — the typed character model and /glyphs taxonomy

How text works in Affinity SDK​

Text in Affinity is not a simple string. It is a Story — a structured sequence of glyphs with formatting attached. Think of it like a mini word processor document embedded inside each text frame.

TextNode
└── storyInterface
└── story
└── storyRange { begin: 0, end: N }
└── glyphs (characters + formatting)

To change text, you either:

  1. Replace the whole story with StoryBuilder
  2. Apply formatting to a range with StoryDelta

Reading text from a node​

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

const doc = Document.current;
if (!doc) { console.log('No doc.'); }
else {
for (const node of doc.layers.all) {
if (!node.isTextNode) continue;

const si = node.storyInterface;
if (!si) continue;

const range = si.storyRange;
console.log('Text node:', node.descriptionInterface?.description);
console.log('Range: begin=', range.begin, 'end=', range.end);
}
}

Replacing all text in a frame​

This is the most common operation — and has a critical gotcha.

'use strict';
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { Selection, TextSelection } = require('/selections.js');
const { StoryBuilder } = require('/storybuilder.js');

const doc = Document.current;
const sel = doc.selection;
const node = sel.nodes.filter(n => n.isTextNode).first;

if (!node) { console.log('Select a text frame.'); }
else {
// CRITICAL: set current spread before any text work
doc.executeCommand(DocumentCommand.createSetCurrentSpread(doc.currentSpread));

const si = node.storyInterface;
const range = si.storyRange;

// Build a text selection that covers all editable text.
// end - 1 excludes the story terminator. To append instead of replace,
// see the "Appending text" section below and example 07c.
const textSel = TextSelection.create([{
begin: range.begin,
end: range.end - 1 // <-- CRITICAL: -1 excludes the story terminator
}]);

const selection = Selection.create(doc, node);
selection.addSubSelectionForNode(node, textSel);

const cmd = DocumentCommand.createSetText(selection, 'New text content');
doc.executeCommand(cmd);
console.log('Text replaced.');
}

The end - 1 convention. The story's final character is a hidden paragraph marker that is part of the story's internal structure. Including it in your selection (range.end) may work in practice, but the correct form is always range.end - 1 — it keeps the selection within the editable content and avoids touching the terminator. Use it consistently.


Appending text to a frame​

To append without overwriting existing content, use a zero-length TextSelection — one where begin and end are the same value. This is an insertion point rather than a range. Text passed to createSetText is inserted at that position, leaving everything before it untouched.

'use strict';
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { Selection, TextSelection } = require('/selections.js');

const doc = Document.current;
const node = doc.selection.nodes.filter(n => n.isTextNode).first;

if (!node) { console.log('Select a text frame.'); }
else {
doc.executeCommand(DocumentCommand.createSetCurrentSpread(doc.currentSpread));

const si = node.storyInterface;
const range = si.storyRange;

// Insert at end - 1: just before the story terminator.
// begin == end makes this an insertion point, not a selection.
const insertAt = range.end - 1;
const textSel = TextSelection.create([{ begin: insertAt, end: insertAt }]);

const sel = Selection.create(doc, node);
sel.addSubSelectionForNode(node, textSel);

doc.executeCommand(DocumentCommand.createSetText(sel, ' (draft)'));
console.log('Suffix appended.');
}

The contrast with replacement is just in the TextSelection:

Intentbeginend
Replace allrange.beginrange.end - 1
Appendrange.end - 1range.end - 1

The selection setup order matters​

Selection.createEmpty(doc) causes addSubSelectionForNode to silently fail — always use Selection.create(doc, node) instead.

// Snippet
// WRONG — using Selection.createEmpty causes addSubSelectionForNode to silently fail
const selection = Selection.createEmpty(doc);
selection.addSubSelectionForNode(node, textSel); // silently does nothing

// CORRECT — use Selection.create(doc, node) as the base
const selection = Selection.create(doc, node);
selection.addSubSelectionForNode(node, textSel); // works

Applying character formatting (StoryDelta)​

Build a StoryDelta with a factory like createGlyphString, then pass it to DocumentCommand.createFormatText() to apply formatting to a text selection.

'use strict';
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { Selection, TextSelection } = require('/selections.js');
const { StoryDelta } = require('/storydelta.js');
const { GlyphAttStringType } = require('/glyphatts.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const textNode = doc.selection.nodes.filter(n => n.isTextNode).first;
if (!textNode) { console.log('Select a text node first.'); }
else {
doc.executeCommand(DocumentCommand.createSetCurrentSpread(doc.currentSpread));

const si = textNode.storyInterface;
const range = si.storyRange;
const textSel = TextSelection.create([{ begin: range.begin, end: range.end - 1 }]);
const sel = Selection.create(doc, textNode);
sel.addSubSelectionForNode(textNode, textSel);

// Apply a named character style to the text selection
const delta = StoryDelta.createGlyphString(GlyphAttStringType.StyleName, 'Heading Bold');
const cmd = DocumentCommand.createFormatText(sel, delta);
doc.executeCommand(cmd);
console.log('Style applied.');
}
}

StoryDelta convenience factories​

StoryDelta provides higher-level factories for common formatting operations — no need to build raw glyph attribute strings:

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

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
// Apply bold and italic to current text selection in one composite delta
const makeBold = StoryDelta.createWeight(FontWeight.Bold);
const makeItalic = StoryDelta.createItalic(true);
const composite = StoryDelta.createComposite([makeBold, makeItalic]);

// doc.formatText() applies to current selection without needing an explicit Selection object
doc.formatText(composite);
console.log('Bold italic applied.');
}

doc.formatText(delta, selection?) is a shorthand that applies the delta to any selection without manually building a Selection and TextSelection. Use it for quick formatting operations. Similarly, doc.setText(text, selection) replaces text in one call.

More StoryDelta factories​

FactoryWhat it applies
createUnderlineType / createStrikeoutType / createSuperSubType / createCapsTypeUnderline, strikethrough, super/subscript, small caps
createBrushFill / createPenFill / createTransparency / createHighlightFillText colour, stroke, transparency, highlight
createFont(fields, font)A Font object directly (vs the postscript-name string path)
createPostscriptName / createFamilyName / createWidthFont selection (companions to createWeight/createItalic)
createLeftIndent / createRightIndentParagraph indents
createLeadingTypeLeading behaviour
createIsKeepWithPrevious / createIsKeepTogether / createIsPreventWidows / createIsPreventOrphansKeep-together / widow / orphan control
createIsAutoHyphenate / createHyphenateMinLength / createMaxConsecutiveHyphensHyphenation control
createIsNoBreakNo-break runs
createParagraphDouble / createParagraphStringGeneric paragraph-attribute escape hatches (companions to createGlyphDouble/createGlyphString)
createFromGlyphAtts / createFromParagraphAtts / createFromAttsWhole attribute objects converted directly into deltas

StoryBuilder — building text from scratch​

Use StoryBuilder.create() to construct styled text programmatically, then set the default style for artistic or frame text.

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

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
// For artistic text (single-line, not in a frame)
const sb = StoryBuilder.create();
sb.setToArtisticTextDefaultStyle(doc.dpi, doc.format); // INSTANCE method, not static
sb.addText('Hello, Affinity!');
console.log('StoryBuilder ready — use with AddChildNodesCommandBuilder to add to doc.');

// For frame text use:
// sb.setToFrameTextDefaultStyle(doc.dpi, doc.format);
}

sb.addParagraphBreak() starts a new paragraph — required for multi-paragraph stories (a raw \n in addText is not equivalent). sb.clearText() resets the builder for reuse. sb.setGlyphAtts(atts) / sb.setParagraphAtts(atts) apply full attribute bundles to subsequently added text, and the sb.glyphAtts / sb.paragraphAtts getters read the pending style back.


TableTextNode​

TableTextNode is a grid-based text node. Cells are separated by HardBreakType.TableCell glyphs in the node's story.

'use strict';
const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { Selection, TextSelection } = require('/selections.js');
const { TableTextNodeDefinition } = require('/nodes.js');
const { StoryDelta } = require('/storydelta.js');
const { GlyphAttDoubleType } = require('/glyphatts.js');
const { FontWeight } = require('/fonts.js');

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
// Create a 3x3 table
const box = { x: 100, y: 100, width: 300, height: 200 };
const def = TableTextNodeDefinition.create(box, { width: 3, height: 3 });
doc.addNode(def);
const table = doc.layers.first; // newly added table

// Walk cells via HardBreakType
const story = table.storyInterface.story;
const len = story.length;
const starts = [0];
for (let i = 0; i < len; i++) {
if (story.getHardBreakType(i)?.value === 2) { // HardBreakType.TableCell
starts.push(i + 1);
}
}

// Fill cells in REVERSE order so positions stay stable as text grows
const data = [['A1','B1','C1'],['A2','B2','C2'],['A3','B3','C3']];
for (let k = data.length - 1; k >= 0; k--) {
for (let j = data[k].length - 1; j >= 0; j--) {
const begin = starts[k * 3 + j];
const end = (k * 3 + j + 1 < starts.length) ? starts[k * 3 + j + 1] - 1 : len;
const textSel = TextSelection.create([{ begin, end }]);
const sel = Selection.create(doc, table);
sel.addSubSelectionForNode(table, textSel);
doc.executeCommand(DocumentCommand.createSetText(sel, data[k][j]));
}
}

// Bold the header row
const headerSel = TextSelection.create([{ begin: 0, end: starts[3] }]);
const sel = Selection.create(doc, table);
sel.addSubSelectionForNode(table, headerSel);
const boldDelta = StoryDelta.createComposite([
StoryDelta.createWeight(FontWeight.Bold),
StoryDelta.createGlyphDouble(GlyphAttDoubleType.Height, 14 * (doc.dpi / 72))
]);
doc.executeCommand(DocumentCommand.createFormatText(sel, boldDelta));
console.log('Table created and populated.');
}

TableTextNodeDefinition.create(box, {width, height}) creates a grid. Cells are indexed left-to-right, top-to-bottom. Always fill cells in reverse order so earlier cell positions stay stable as the story grows.


GlyphAtts and ParagraphAtts — attribute wrappers​

GlyphAtts and ParagraphAtts provide proper wrapper types for reading and building text attributes:

'use strict';
const { GlyphAtts, GlyphAttStringType, GlyphAttDoubleType } = require('/glyphatts.js');
const { ParagraphAtts } = require('/paragraphatts.js');
const { FontWeight } = require('/fonts.js');
const { Document } = require('/document.js');

const doc = Document.current;
const node = doc?.selection.nodes.filter(n => n.isTextNode).first;

if (node) {
// Build glyph attributes
const glyphAtts = GlyphAtts.create();
glyphAtts.setStringValue(GlyphAttStringType.StyleName, 'Heading');
glyphAtts.setDoubleValue(GlyphAttDoubleType.Height, 24);

// Build paragraph attributes
const { ParagraphAlignXType } = require('/paragraphatts.js');
const paraAtts = ParagraphAtts.create();
paraAtts.alignXType = ParagraphAlignXType.Centre; // centre alignment
paraAtts.isPreventWidows = true; // keep lines together
paraAtts.isAutoHyphenate = false; // no hyphens

// Read existing attributes from a story
const si = node.storyInterface;
const story = si.story;
const existing = story.getGlyphAtts(0);
console.log('Font loaded:', existing.getDoubleValue(GlyphAttDoubleType.Height));
}

Attribute runs on Story​

Story can enumerate contiguous runs of formatting, making it easy to walk the styling of a story without inspecting every glyph:

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

const doc = Document.current;
const node = doc?.selection.nodes.filter(n => n.isTextNode).first;

if (node) {
const story = node.storyInterface.story;

// Enumerate all glyph attribute runs
for (const run of story.glyphAttRuns) {
console.log(`Glyphs ${run.begin}–${run.end}`, run.glyphAtts);
}

// Enumerate all paragraph attribute runs
for (const run of story.paragraphAttRuns) {
console.log(`Paragraphs ${run.begin}–${run.end}`, run.paragraphAtts);
}

// Combined glyph + paragraph runs
for (const run of story.attRuns) {
console.log(`Combined run ${run.begin}–${run.end}`, run);
}

// Partial range queries — start position only, runs are enumerated to end of story
const partialRuns = story.getGlyphAttRunsFrom(5);
for (const run of partialRuns) {
console.log(`Sub-run ${run.begin}–${run.end}`);
}
}

Each run yields { begin, end, glyphAtts, paragraphAtts } so you can process homogeneous spans in one step.

Reading text and searching words​

story.getText(startPos, maxLength, format) and story.getTextRange(range, format) extract text directly — no range arithmetic needed. story.getParagraphAtts(pos) reads paragraph attributes at a position (companion to getGlyphAtts).

// Snippet — assumes story is a Story
const text = story.getText(0, story.length - 1);
const begin = story.findWordBegin(10); // word/paragraph search family:
const end = story.findWordEnd(10); // findWordBegin/End/Part, findParagraphBreak,
// reverse twins: rFindWordBegin/End/Part (search backwards from end),
// range helpers: getWordRange(pos), getParagraphRange(pos)
// enumerators: story.wordRanges, story.paragraphRanges
// predicates: story.isWordBegin(pos), story.isWordEnd(pos)
// StoryRange.isForwards / .isBackwards report range direction
console.log(text.slice(begin, end));

Glyphs — the typed character model​

Every position in a story holds a typed glyph, not a plain character. The /glyphs module exposes the full taxonomy — around 28 glyph classes including CharGlyph, HardBreakGlyph, tab state on the base Glyph class (isTab), PageNumberGlyph, FieldGlyph, PinGlyph, ListNumberGlyph, and IndexMarkGlyph — plus the GlyphType, HardBreakType, SoftBreakType, and WordPartType enums. TableTextNode cells, covered earlier in this lesson, are delimited by exactly these HardBreakType.TableCell glyphs.

Story hands out pre-filtered glyph collections so you never need to classify manually: story.glyphs (all), charGlyphs, hardBreakGlyphs, pageNumberGlyphs, fieldGlyphs, anchorGlyphs, pinGlyphs, and more. Every glyph also answers predicate questions directly:

PredicateAnswers
isParagraphBreak / isSoftBreakEnds a paragraph / a wrapped line
isTabTab character
isAlphaNumericOrdinary letter or digit (CharGlyph)
isZeroWidthTakes no horizontal space (anchors, marks)
isDeletableSafe to remove from the story
isCombiningCombining diacritic
'use strict';
const { Document } = require('/document.js');
const { CharGlyph } = require('/glyphs.js');

const doc = Document.current;
const node = doc?.selection.nodes.filter(n => n.isTextNode).first;

if (!node) { console.log('Select a text node.'); }
else {
const story = node.storyInterface.story;

let chars = 0, breaks = 0, tabs = 0, other = 0;
for (const glyph of story.glyphs) {
if (glyph.isParagraphBreak || glyph.isSoftBreak) breaks++;
else if (glyph.isTab) tabs++;
else if (glyph.isAlphaNumeric) chars++;
else other++;
}
console.log('Alphanumeric:', chars, '| Breaks:', breaks, '| Tabs:', tabs, '| Other:', other);

console.log('Hard breaks in story:');
for (const hb of story.hardBreakGlyphs) {
console.log(' ', hb.description ?? '(hard break)');
}

const heart = CharGlyph.create(0x2665);
console.log('Built CharGlyph:', heart.string, '(char32', heart.char32 + ')');
}

CharGlyph.create(char32) builds a character glyph (code point, not a one-character string) for use with doc.insertGlyph(glyph, sel) or DocumentCommand.createInsertGlyph(sel, glyph).


Batch text update — rename all text nodes​

Iterate all text nodes in the document and replace their content using a CompoundCommandBuilder for a single undo step.

'use strict';
const { Document } = require('/document.js');
const { DocumentCommand, CompoundCommandBuilder } = require('/commands.js');
const { Selection, TextSelection } = require('/selections.js');

const doc = Document.current;
const compound = CompoundCommandBuilder.create();

// Capture selection BEFORE setCurrentSpread
const spreadCmd = DocumentCommand.createSetCurrentSpread(doc.currentSpread);

let count = 0;
for (const node of doc.layers.all) {
if (!node.isTextNode) continue;

const si = node.storyInterface;
if (!si) continue;
const range = si.storyRange;

const textSel = TextSelection.create([{ begin: range.begin, end: range.end - 1 }]);
const nodeSel = Selection.create(doc, node);
nodeSel.addSubSelectionForNode(node, textSel);

compound.addCommand(DocumentCommand.createSetText(nodeSel, `Updated text ${++count}`));
}

if (count > 0) {
doc.executeCommand(spreadCmd);
doc.executeCommand(compound.createCommand());
console.log(`Updated ${count} text node(s).`);
} else {
console.log('No text nodes found.');
}

Text frame state​

The /textframeinterface module gives read-only access to text frame properties via node.textFrameInterface: ignoreBaselineGrid, ignoreTextWraps, isMultiFrameTextFlow, isTextFlowBack, isTextFlowFront, isWrappingText, storyToDomainTransform, and more.

// Snippet — assumes node is a text node
const tfi = node.textFrameInterface;
if (tfi) {
console.log('Ignore baseline grid:', tfi.ignoreBaselineGrid);
console.log('Multi-frame flow:', tfi.isMultiFrameTextFlow);
}

Threading text frames. Link two FrameTextNodes so overflowing text flows from one into the next — the SDK provides DocumentCommand.createLinkTextFrame(srcNode, destNode) and DocumentCommand.createUnlinkTextFrame(node) as command factories. Both require two existing FrameTextNodes on the spread.

For a complete workflow with querying thread state, linking/unlinking, and reading across linked stories, see Lesson 10: Text Threading.


Key gotchas​

end - 1 to stay within editable content. The final character in any story is a hidden paragraph marker. Including it in a TextSelection (range.end) may work without error in practice, but the correct and safe form is range.end - 1. Use it consistently rather than testing where the boundary actually bites.

setToArtisticTextDefaultStyle() is an instance method. Call it as sb.setToArtisticTextDefaultStyle(dpi, format) on a StoryBuilder instance, not as a static.

createSetCurrentSpread before text editing. Always set the spread before any text commands — even if you think it's already the current spread.

Fill cells in reverse order. When populating a TableTextNode, fill cells from the last cell to the first. Inserting text at earlier positions shifts later positions; reverse order avoids position drift.

Format each cell individually. formatText across cell separators (HardBreakType.TableCell) only partially applies — the SDK stops at the separator boundary. When styling multiple cells (e.g. a header row), loop over each cell and format it separately.

Capture doc.selection BEFORE setCurrentSpread. See Lesson 02.


Exercises​

Exercise 7.1 — Text reader​

Write a script that finds every text node in the document and logs its layer name and current text content.

Exercise 7.2 — Suffix appender​

For every selected text node, append (draft) to its text content. Use a zero-length TextSelection at range.end - 1. See 07c-append-text.js for a working implementation.

Exercise 7.3 — Style applier​

Build a script that applies a named character style (e.g. "Bold") to the full text of each selected text node.

Exercise 7.4 — Text census​

Count how many characters total are across all text nodes in the document (use range.end - range.begin).


SDK modules used in this lesson​

  • /story — Story, StoryRange
  • /storybuilder — StoryBuilder
  • /storydelta — StoryDelta
  • /storyinterface — StoryInterface
  • /glyphatts — GlyphAtts, GlyphAttStringType, GlyphAttDoubleType
  • /glyphs — CharGlyph, GlyphType, HardBreakType, SoftBreakType, and the glyph class taxonomy
  • /paragraphatts — ParagraphAtts, ParagraphAttDoubleType, ParagraphAttStringType
  • /selections — Selection, TextSelection
  • /commands — DocumentCommand
  • /nodes — TableTextNodeDefinition, FrameTextNodeDefinition, ArtTextNodeDefinition

Further examples​

ExampleWhat it shows
boldItalics.jsGlyphAtts, ParagraphAtts for bold/italic; DocumentCommand for text formatting
textOnCurvesAndShapes.jsThe four curve- and shape-backed text node types; createSetTextFrameShape
tableFromJson.jsTableTextNodeDefinition, StoryBuilder, populating a table from JSON data
makeNumbersSequence.jsNumber selected text objects with prefix/suffix/padding

See also​


Example scripts​

07a-text-reader.js​

Download 07a-text-reader.js

07a-text-reader.js
'use strict';

// Example 07a — Text reader
// Lesson 07: Text & Story
//
// Finds every text node in the document and logs its content.

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

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

for (const node of doc.layers.all) {
if (!node.isTextNode) continue;

const name = node.descriptionInterface?.description ?? '(unnamed)';
const si = node.storyInterface;

if (!si) {
console.log(`[${name}] — no storyInterface`);
continue;
}

const range = si.storyRange;
const charCount = range.end - range.begin;

console.log(`\n[${name}]`);
console.log(' Chars:', charCount);
console.log(' Range: begin=' + range.begin + ' end=' + range.end);
count++;
}

if (count === 0) {
console.log('No text nodes found in document.');
} else {
console.log(`\nTotal text nodes: ${count}`);
}
}

07b-replace-text.js​

Download 07b-replace-text.js

07b-replace-text.js
'use strict';

// Example 07b — Replace text in selected frame
// Lesson 07: Text & Story
//
// Replaces ALL text in the selected text node.
// Demonstrates the critical end - 1 rule.

const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { Selection, TextSelection } = require('/selections.js');

const NEW_TEXT = 'This text was set by the Affinity SDK.';

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
// Capture the node BEFORE touching spreads
const textNodes = doc.selection.nodes.filter(n => n.isTextNode);

if (textNodes.isEmpty) {
console.log('Select a text frame and run again.');
} else {
const node = textNodes.first;
const name = node.descriptionInterface?.description ?? '(unnamed)';

// Always set current spread before text operations
doc.executeCommand(DocumentCommand.createSetCurrentSpread(doc.currentSpread));

const si = node.storyInterface;
const range = si.storyRange;

// CORRECT: end - 1 excludes the story's hidden paragraph terminator.
// range.end may work in practice but end - 1 is the safe, correct form.
const textSel = TextSelection.create([{
begin: range.begin,
end: range.end - 1
}]);

const sel = Selection.create(doc, node);
sel.addSubSelectionForNode(node, textSel);

doc.executeCommand(DocumentCommand.createSetText(sel, NEW_TEXT));
console.log(`Replaced text in "${name}".`);
}
}

07c-append-text.js​

Download 07c-append-text.js

07c-append-text.js
'use strict';

// Example 07c — Append text to selected frame
// Lesson 07: Text & Story
//
// Appends a suffix to the existing text in the selected text node
// without disturbing the content already there.
//
// The key difference from replacing:
// Replace — TextSelection covering begin to end - 1 (selects all editable content)
// Append — TextSelection with begin == end == end - 1 (zero-length, at the end)
//
// A zero-length selection is an insertion point. createSetText at that
// position inserts text without overwriting anything before it.

const { Document } = require('/document.js');
const { DocumentCommand } = require('/commands.js');
const { Selection, TextSelection } = require('/selections.js');

const SUFFIX = ' (draft)';

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const textNodes = doc.selection.nodes.filter(n => n.isTextNode);

if (textNodes.isEmpty) {
console.log('Select a text frame and run again.');
} else {
const node = textNodes.first;
const name = node.descriptionInterface?.description ?? '(unnamed)';

// Always set current spread before text operations
doc.executeCommand(DocumentCommand.createSetCurrentSpread(doc.currentSpread));

const si = node.storyInterface;
const range = si.storyRange;

// Insertion point: begin and end both set to range.end - 1.
// This places the cursor just before the story terminator — text
// is inserted here, leaving everything before it intact.
const insertAt = range.end - 1;
const textSel = TextSelection.create([{ begin: insertAt, end: insertAt }]);

const sel = Selection.create(doc, node);
sel.addSubSelectionForNode(node, textSel);

doc.executeCommand(DocumentCommand.createSetText(sel, SUFFIX));
console.log(`Appended "${SUFFIX}" to "${name}".`);
}
}

07d-table-from-data.js​

Download 07d-table-from-data.js

07d-table-from-data.js
'use strict';

// Example 07d — Data-driven table
// Lesson 07: Text & Story
//
// Builds a table from a hardcoded dataset.
// Cells are filled in reverse order so earlier positions stay stable.
// A bold header row is applied via StoryDelta.

const { Document } = require('/document.js');
const { Selection, TextSelection } = require('/selections.js');
const { TableTextNodeDefinition } = require('/nodes.js');
const { StoryDelta, GlyphAttDoubleType } = require('/storydelta.js');
const { FontWeight } = require('/fonts.js');
const { HardBreakType } = require('/glyphs.js');
const { FillDescriptor } = require('/fills.js');
const { RGBA8 } = require('/colours.js');

const DATA = [
['Country', 'Capital', 'Population', 'Area km²'],
['France', 'Paris', '67M', '551_695'],
['Germany', 'Berlin', '83M', '357_022'],
['Japan', 'Tokyo', '125M', '377_975'],
['Brazil', 'Brasília', '213M', '8_515_767'],
];

const COLS = DATA[0].length;
const ROWS = DATA.length;

const doc = Document.current;
if (!doc) { console.log('No document open.'); }
else {
const box = { x: 40, y: 40, width: 700, height: 40 * ROWS };
const def = TableTextNodeDefinition.create(box, { width: COLS, height: ROWS });
doc.addNode(def);
const table = doc.layers.first;

const story = table.storyInterface.story;

// Set default font size and text colour so the table is visible
const allSel = TextSelection.create([{ begin: 0, end: story.length }]);
const allNodeSel = Selection.create(doc, table);
allNodeSel.addSubSelectionForNode(table, allSel);
doc.formatText(StoryDelta.createComposite([
StoryDelta.createGlyphDouble(GlyphAttDoubleType.Height, 24),
StoryDelta.createBrushFill(FillDescriptor.createSolid(RGBA8(0, 0, 0, 255))),
]), allNodeSel);

// Walk cells via HardBreakType
const starts = [0];
for (let i = 0; i < story.length; i++) {
const hb = story.getHardBreakType(i);
if (hb && hb.value === HardBreakType.TableCell.value) starts.push(i + 1);
}

// Fill cells in REVERSE order so positions stay stable
const texts = DATA.flat();
for (let k = texts.length - 1; k >= 0; k--) {
const begin = starts[k];
const end = (k + 1 < starts.length) ? starts[k + 1] - 1 : story.length;
const textSel = TextSelection.create([{ begin, end }]);
const sel = Selection.create(doc, table);
sel.addSubSelectionForNode(table, textSel);
doc.setText(String(texts[k]), sel);
}

// Bold and enlarge each header cell individually
// (formatting across cell separators only partially applies)
for (let c = 0; c < COLS; c++) {
const begin = starts[c];
const end = (c + 1 < COLS) ? starts[c + 1] - 1 : starts[COLS] - 1;
const hSel = Selection.create(doc, table);
hSel.addSubSelectionForNode(table, TextSelection.create([{ begin, end }]));
doc.formatText(StoryDelta.createComposite([
StoryDelta.createWeight(FontWeight.Bold),
StoryDelta.createGlyphDouble(GlyphAttDoubleType.Height, 30),
]), hSel);
}

console.log(`${ROWS}×${COLS} table created and populated.`);
}

07e-glyph-inspector.js​

Download 07e-glyph-inspector.js

07e-glyph-inspector.js
'use strict';

// Example 07e — Glyph inspector
// Lesson 07: Text & Story
//
// Classifies every glyph in a story (characters, tabs, breaks, specials)
// and reports the census. Also builds a CharGlyph ready for insertion.

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

const doc = Document.current;
const node = doc?.selection.nodes.filter(n => n.isTextNode).first;

if (!node) { console.log('Select a text node.'); }
else {
const story = node.storyInterface.story;

let chars = 0, breaks = 0, tabs = 0, other = 0;
for (const glyph of story.glyphs) {
if (glyph.isParagraphBreak || glyph.isSoftBreak) breaks++;
else if (glyph.isTab) tabs++;
else if (glyph.isAlphaNumeric) chars++;
else other++;
}
console.log('Alphanumeric:', chars, '| Breaks:', breaks, '| Tabs:', tabs, '| Other:', other);

console.log('Hard breaks in story:');
for (const hb of story.hardBreakGlyphs) {
console.log(' ', hb.description ?? '(hard break)');
}

const heart = CharGlyph.create(0x2665);
console.log('Built CharGlyph:', heart.string, '(char32', heart.char32 + ')');
}