plotcoder-board 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ // Fountain out (R23, slice a; roadmap item 8).
2
+ //
3
+ // The wall as a Fountain document: a plain-text screenplay a writer can open
4
+ // in any Fountain editor and paginate. One card is one scene, in wall order:
5
+ // a forced scene heading from the place (or the headline when the card has
6
+ // no place), the headline as a synopsis, the change line as action, and the
7
+ // cast and the fold as notes. Beats open sections, so an outline view shows
8
+ // the story's turns. Nothing here is a page yet: this is the stepping stone
9
+ // from the wall to a document, in the wall's own words.
10
+ //
11
+ // Fountain marks used: `Title:` block; `# ` section; `.` forced heading;
12
+ // `= ` synopsis; `[[ ]]` note. All of them are invisible on the printed page
13
+ // except the heading and the action.
14
+
15
+ import { formatPages, boardEighths } from "./reducer.js";
16
+ import { readingOrder } from "./readWall.js";
17
+
18
+ function upper(text) {
19
+ return text.trim().replace(/\s+/g, " ").toUpperCase();
20
+ }
21
+
22
+ /** A forced scene heading: the place, or the headline when the card has none. */
23
+ export function sceneHeading(note) {
24
+ const place = typeof note.location === "string" ? note.location.trim() : "";
25
+ const words = place || note.headline || "UNTITLED";
26
+ return `.${upper(words)}`;
27
+ }
28
+
29
+ /** The title page block. `titles` is what the writer would put above the script. */
30
+ export function titlePage({ title, credit, author, draftDate, notes }) {
31
+ const lines = [];
32
+ if (title) lines.push(`Title: ${title}`);
33
+ if (credit) lines.push(`Credit: ${credit}`);
34
+ if (author) lines.push(`Author: ${author}`);
35
+ if (draftDate) lines.push(`Draft date: ${draftDate}`);
36
+ if (notes && notes.length) {
37
+ lines.push("Notes:");
38
+ for (const line of notes) lines.push(`\t${line}`);
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+
43
+ /**
44
+ * The whole wall as Fountain text.
45
+ *
46
+ * @param state the board
47
+ * @param options.title the board's name; options.project the project's name
48
+ * (a series title); options.premise; options.draftDate an ISO date string
49
+ */
50
+ export function toFountain(state, options = {}) {
51
+ const nameOf = new Map((state.characters ?? []).map((character) => [character.id, character.name]));
52
+ const order = readingOrder(state.notes);
53
+ const beats = order.filter((note) => note.rank === "beat").length;
54
+
55
+ const notes = [];
56
+ if (options.premise) notes.push(`Premise: ${options.premise}`);
57
+ if (state.logline) notes.push(`Logline: ${state.logline}`);
58
+ notes.push(
59
+ `From the wall: ${order.length} card${order.length === 1 ? "" : "s"}, ${beats} beat${beats === 1 ? "" : "s"}, about ${formatPages(boardEighths(state))} of ${formatPages(state.targetEighths)} pages.`,
60
+ );
61
+
62
+ const head = titlePage({
63
+ title: options.title || "Untitled",
64
+ credit: options.project && options.project !== options.title ? `An episode of ${options.project}` : undefined,
65
+ author: options.author,
66
+ draftDate: options.draftDate ? options.draftDate.slice(0, 10) : undefined,
67
+ notes,
68
+ });
69
+
70
+ const body = [];
71
+ let beat = 0;
72
+ for (const note of order) {
73
+ if (note.rank === "beat") {
74
+ beat += 1;
75
+ body.push(`# ${beat}. ${note.headline || "Untitled beat"}`);
76
+ body.push("");
77
+ }
78
+ body.push(sceneHeading(note));
79
+ body.push("");
80
+ if (note.headline && sceneHeading(note) !== `.${upper(note.headline)}`) {
81
+ body.push(`= ${note.headline}`);
82
+ body.push("");
83
+ }
84
+ const marks = [];
85
+ const cast = (note.characterIds ?? []).map((id) => nameOf.get(id)).filter(Boolean);
86
+ if (cast.length) marks.push(`with ${cast.join(", ")}`);
87
+ if (note.plants) marks.push("plants something to pay off later");
88
+ if (marks.length) {
89
+ body.push(`[[${marks.join(" · ")}]]`);
90
+ body.push("");
91
+ }
92
+ // The scene's text when it is written; the change line stands in until then.
93
+ body.push(note.text && note.text.trim() ? note.text.trim() : note.change || "");
94
+ body.push("");
95
+ }
96
+
97
+ return `${head}\n\n${body.join("\n").trimEnd()}\n`;
98
+ }
99
+
100
+ // --- Fountain in (R23, slice b) ---------------------------------------------
101
+
102
+ const HEADING = /^(\.(?!\.)\s*(.+)|(?:INT|EXT|EST|INT\.?\/EXT|I\/E)[.\s].*)$/i;
103
+
104
+ /**
105
+ * A Fountain document as scenes: heading, synopsis, section, and the body
106
+ * lines under the heading. Title-page keys, sections (`#`), synopses (`=`),
107
+ * notes (`[[ ]]`) and boneyards (slash-star comments) are read and kept out
108
+ * of the body, so what comes back onto a card is the writing.
109
+ */
110
+ export function fromFountain(text) {
111
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
112
+ const titles = {};
113
+ let i = 0;
114
+ // Title page: `Key: value` lines (values may continue on indented lines) up to the first blank line.
115
+ if (/^[A-Za-z][A-Za-z ]*:/.test(lines[0] ?? "")) {
116
+ let key = null;
117
+ while (i < lines.length && lines[i].trim() !== "") {
118
+ const match = /^([A-Za-z][A-Za-z ]*):\s*(.*)$/.exec(lines[i]);
119
+ if (match && !/^\s/.test(lines[i])) {
120
+ key = match[1].trim().toLowerCase();
121
+ titles[key] = match[2].trim();
122
+ } else if (key) {
123
+ titles[key] = `${titles[key]}${titles[key] ? "\n" : ""}${lines[i].trim()}`;
124
+ }
125
+ i += 1;
126
+ }
127
+ }
128
+ const scenes = [];
129
+ let current = null;
130
+ let section = null;
131
+ let inBoneyard = false;
132
+ for (; i < lines.length; i += 1) {
133
+ const raw = lines[i];
134
+ const line = raw.trim();
135
+ if (inBoneyard) {
136
+ if (line.includes("*/")) inBoneyard = false;
137
+ continue;
138
+ }
139
+ if (line.startsWith("/*")) {
140
+ if (!line.includes("*/")) inBoneyard = true;
141
+ continue;
142
+ }
143
+ if (line.startsWith("#")) {
144
+ section = line.replace(/^#+\s*/, "");
145
+ continue;
146
+ }
147
+ const heading = HEADING.exec(line);
148
+ if (heading) {
149
+ current = {
150
+ heading: (heading[2] ?? line).trim().replace(/\s+/g, " "),
151
+ forced: line.startsWith("."),
152
+ synopsis: "",
153
+ section,
154
+ notes: [],
155
+ body: [],
156
+ };
157
+ scenes.push(current);
158
+ continue;
159
+ }
160
+ if (!current) continue;
161
+ if (line.startsWith("=")) {
162
+ current.synopsis = `${current.synopsis}${current.synopsis ? " " : ""}${line.replace(/^=+\s*/, "")}`.trim();
163
+ continue;
164
+ }
165
+ if (line.startsWith("[[") && line.endsWith("]]")) {
166
+ current.notes.push(line.slice(2, -2).trim());
167
+ continue;
168
+ }
169
+ current.body.push(raw.replace(/\s+$/, ""));
170
+ }
171
+ for (const scene of scenes) {
172
+ while (scene.body.length && scene.body[0].trim() === "") scene.body.shift();
173
+ while (scene.body.length && scene.body.at(-1).trim() === "") scene.body.pop();
174
+ scene.text = scene.body.join("\n");
175
+ delete scene.body;
176
+ }
177
+ return { titles, scenes };
178
+ }
179
+
180
+ function sameWords(a, b) {
181
+ return a.trim().replace(/\s+/g, " ").toLowerCase() === b.trim().replace(/\s+/g, " ").toLowerCase();
182
+ }
183
+
184
+ /**
185
+ * Lay a parsed document onto the wall: the commands that write each scene's
186
+ * text onto the card with the same heading (its place, or its headline) in
187
+ * order, and create a card for a scene the wall does not have, after the
188
+ * last matched one. Never deletes; a card the document does not mention keeps
189
+ * its text. Returns commands for the kernel, so every door applies the same.
190
+ */
191
+ export function mergeFountain(state, parsed) {
192
+ const order = readingOrder(state.notes);
193
+ const used = new Set();
194
+ const commands = [];
195
+ let cursor = 0; // where in the wall's order the last match was
196
+ const matched = [];
197
+ for (const scene of parsed.scenes) {
198
+ const headingOf = (note) => sceneHeading(note).slice(1);
199
+ const wanted = scene.heading.toUpperCase();
200
+ // First: the same heading at or after the cursor; then anywhere unused;
201
+ // then a card whose headline is the synopsis.
202
+ let found =
203
+ order.slice(cursor).find((note) => !used.has(note.id) && sameWords(headingOf(note), wanted)) ??
204
+ order.find((note) => !used.has(note.id) && sameWords(headingOf(note), wanted)) ??
205
+ (scene.synopsis ? order.find((note) => !used.has(note.id) && sameWords(note.headline, scene.synopsis)) : undefined);
206
+ if (found) {
207
+ used.add(found.id);
208
+ cursor = order.indexOf(found) + 1;
209
+ // A scene whose body is the card's own change line is the export of an
210
+ // unwritten card coming back: still unwritten, not a page.
211
+ const standIn = !(found.text ?? "").trim() && sameWords(scene.text, found.change ?? "");
212
+ if ((found.text ?? "") !== scene.text && !standIn) {
213
+ commands.push({ type: "set_text", id: found.id, text: scene.text });
214
+ }
215
+ matched.push({ id: found.id, heading: scene.heading, created: false });
216
+ continue;
217
+ }
218
+ // A new card, after the last matched one on the wall.
219
+ const anchor = order[cursor - 1] ?? order.at(-1);
220
+ const headline = scene.synopsis || titleCase(scene.heading);
221
+ const isPlace = scene.forced && Boolean(scene.synopsis);
222
+ const id = `scene-${Math.random().toString(36).slice(2, 8)}`;
223
+ commands.push({
224
+ type: "create_note",
225
+ id,
226
+ headline,
227
+ change: scene.text ? firstSentence(scene.text) : "What changes?",
228
+ location: isPlace ? titleCase(scene.heading) : "",
229
+ text: scene.text,
230
+ x: anchor ? anchor.x + 40 : 140,
231
+ y: anchor ? anchor.y + 40 : 140,
232
+ });
233
+ used.add(id);
234
+ matched.push({ id, heading: scene.heading, created: true });
235
+ }
236
+ return { commands, matched };
237
+ }
238
+
239
+ function titleCase(words) {
240
+ return words.toLowerCase().replace(/(^|\s)([a-z])/g, (m) => m.toUpperCase());
241
+ }
242
+
243
+ function firstSentence(text) {
244
+ const first = text.trim().split(/(?<=[.!?])\s+/)[0] ?? "";
245
+ return first.slice(0, 120);
246
+ }
@@ -0,0 +1,13 @@
1
+ // Type surface for numbering.js — locked scene numbers and revision marks (Roadmap 2, item 8).
2
+
3
+ import type { BoardNote } from "./reducer";
4
+
5
+ export type Lock = { at: string; numbers: Record<string, string> };
6
+ export type Snapshot = { headline: string; change: string; location: string; text: string };
7
+ export type Revision = { name: string; color: string; since: string; snapshot: Record<string, Snapshot> };
8
+
9
+ export declare function sceneNumbers(order: ReadonlyArray<Pick<BoardNote, "id">>, lock: Lock | null | undefined): Map<string, string>;
10
+ export declare function lockFrom(order: ReadonlyArray<Pick<BoardNote, "id">>, existing: Lock | null | undefined, at: string): Lock;
11
+ export declare function revisedLines(text: string | undefined, snapshotText: string | null | undefined): number[];
12
+ export declare function isRevised(note: BoardNote, snapshot: Snapshot | undefined): boolean;
13
+ export declare const REVISION_COLORS: readonly string[];
@@ -0,0 +1,89 @@
1
+ // Locked scene numbers and revision marks (Roadmap 2, item 8; question 23).
2
+ //
3
+ // Until a draft goes out, scene numbers follow the wall's order. Lock them
4
+ // and they stop moving: every scene keeps the number it had, a scene added
5
+ // between 14 and 15 is 14A (then 14B), one added before the first is A1, and
6
+ // moving cards never renumbers what is locked. A revision is a name and a
7
+ // colour over a snapshot of the scenes; a line that differs from the snapshot
8
+ // is marked, so the changed lines print in the revision's colour with a star.
9
+ // All pure: the kernel holds the lock and the snapshot, this module reads them.
10
+
11
+ const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
12
+
13
+ function letter(index) {
14
+ return index < LETTERS.length ? LETTERS[index] : `${LETTERS[Math.floor(index / LETTERS.length) - 1]}${LETTERS[index % LETTERS.length]}`;
15
+ }
16
+
17
+ /**
18
+ * Scene numbers for cards in wall order. `lock` is the board's lock, or null:
19
+ * { numbers: { [noteId]: "14" } }. Returns a Map of id → number as printed.
20
+ */
21
+ export function sceneNumbers(order, lock) {
22
+ const numbers = new Map();
23
+ if (!lock || !lock.numbers) {
24
+ order.forEach((note, index) => numbers.set(note.id, String(index + 1)));
25
+ return numbers;
26
+ }
27
+ let lastLocked = null;
28
+ let sinceLocked = 0;
29
+ const leading = [];
30
+ for (const note of order) {
31
+ const locked = lock.numbers[note.id];
32
+ if (locked) {
33
+ // Scenes before the first locked one count backwards from it: A1, B1…
34
+ if (lastLocked === null && leading.length) {
35
+ leading.forEach((id, index) => numbers.set(id, `${letter(index)}${locked}`));
36
+ leading.length = 0;
37
+ }
38
+ numbers.set(note.id, locked);
39
+ lastLocked = locked;
40
+ sinceLocked = 0;
41
+ continue;
42
+ }
43
+ if (lastLocked === null) {
44
+ leading.push(note.id);
45
+ continue;
46
+ }
47
+ numbers.set(note.id, `${lastLocked}${letter(sinceLocked)}`);
48
+ sinceLocked += 1;
49
+ }
50
+ // Nothing locked came after: the leading scenes run on from a lock of none.
51
+ leading.forEach((id, index) => numbers.set(id, `A${index + 1}`));
52
+ return numbers;
53
+ }
54
+
55
+ /** The lock to store: every scene's number as it stands, by wall order. */
56
+ export function lockFrom(order, existing, at) {
57
+ const current = sceneNumbers(order, existing);
58
+ const numbers = {};
59
+ for (const note of order) numbers[note.id] = current.get(note.id);
60
+ return { at, numbers };
61
+ }
62
+
63
+ /**
64
+ * The lines of a scene's text that are not in its snapshot: those are the
65
+ * revised ones. A scene with no snapshot is wholly new, and every line is.
66
+ */
67
+ export function revisedLines(text, snapshotText) {
68
+ const now = (text ?? "").replace(/\r\n?/g, "\n").split("\n");
69
+ if (snapshotText === undefined || snapshotText === null) return now.map((_line, index) => index);
70
+ const before = new Set(snapshotText.replace(/\r\n?/g, "\n").split("\n").map((line) => line.trim()));
71
+ const revised = [];
72
+ now.forEach((line, index) => {
73
+ if (line.trim() && !before.has(line.trim())) revised.push(index);
74
+ });
75
+ return revised;
76
+ }
77
+
78
+ /** True when anything on the card — headline, change, place, text — differs from the snapshot. */
79
+ export function isRevised(note, snapshot) {
80
+ if (!snapshot) return true;
81
+ return (
82
+ note.headline !== snapshot.headline ||
83
+ note.change !== snapshot.change ||
84
+ (note.location ?? "") !== (snapshot.location ?? "") ||
85
+ (note.text ?? "") !== (snapshot.text ?? "")
86
+ );
87
+ }
88
+
89
+ export const REVISION_COLORS = ["white", "blue", "pink", "yellow", "green", "goldenrod", "buff", "salmon", "cherry"];
@@ -0,0 +1,24 @@
1
+ // Type surface for organize.js — Organize along the arrows (R34).
2
+
3
+ import type { BoardState, Pose } from "./reducer";
4
+
5
+ export declare const ROW_CARDS: number;
6
+ export declare const GAP: number;
7
+ /** A row is five cards wide on the wall, on any screen. */
8
+ export declare const ROW_WIDTH: number;
9
+
10
+ /**
11
+ * The cards in story order: "follows" arrows first, reading order to break
12
+ * ties and cycles. Restricted to `ids` when given.
13
+ */
14
+ export declare function arrowOrder(state: BoardState, ids?: ReadonlyArray<string>): string[];
15
+
16
+ /**
17
+ * Poses for Organize. With beats in scope, a row per beat with long runs
18
+ * wrapped under themselves; otherwise rows wrapped by width. Groups travel as
19
+ * blocks. A selection is laid out from its own top-left.
20
+ */
21
+ export declare function organizePoses(
22
+ state: BoardState,
23
+ options?: { onlyIds?: ReadonlyArray<string> },
24
+ ): Pose[];
@@ -0,0 +1,151 @@
1
+ // Organize along the arrows (R34).
2
+ //
3
+ // Organize was written before an arrow existed and tidied by reading order,
4
+ // which left the arrows pointing every way across a neat grid. Now reading
5
+ // order is the base and each "follows" arrow pulls its source in front of its
6
+ // target, so a card is never placed before something that points at it and a
7
+ // wall with no arrows keeps its order. A two-way pair is a tie on purpose.
8
+ // Setups are not sequence and do not order anything.
9
+ //
10
+ // Two layouts, one Organize: with beats on the wall, each beat starts a row and
11
+ // the scenes that follow it fill the row to its right, wrapping under
12
+ // themselves when a run is long; with no beats yet, rows wrap by width. Groups
13
+ // travel as blocks to where their first card falls. A row is five cards wide
14
+ // on the wall, on any screen (open question 21, closed).
15
+ //
16
+ // Pure and DOM-free like the kernel: the app and the MCP server both call it,
17
+ // and it returns poses for apply_poses rather than touching anything.
18
+
19
+ import { readingOrder } from "./readWall.js";
20
+ import { NOTE_HEIGHT, NOTE_WIDTH } from "./reducer.js";
21
+
22
+ export const ROW_CARDS = 5;
23
+ export const GAP = 28;
24
+ export const ROW_WIDTH = ROW_CARDS * NOTE_WIDTH + (ROW_CARDS - 1) * GAP;
25
+ const ORIGIN_X = 88;
26
+ const ORIGIN_Y = 110;
27
+ const STEP_X = NOTE_WIDTH + GAP;
28
+ const STEP_Y = NOTE_HEIGHT + GAP;
29
+
30
+ /**
31
+ * The cards in story order. Reading order — the same rows-then-left-to-right
32
+ * order read the wall uses — is the base; each "follows" arrow pulls its
33
+ * source in front of its target. So a wall with no arrows keeps its order, and
34
+ * an arrow moves only what it has to. A two-way pair is a tie and reading
35
+ * order keeps it; a longer cycle is cut where reading order says. Only the ids
36
+ * given (or every card) take part; arrows to cards outside are ignored.
37
+ */
38
+ export function arrowOrder(state, ids) {
39
+ const scope = ids ? new Set(ids) : null;
40
+ const notes = state.notes.filter((note) => !scope || scope.has(note.id));
41
+ const reading = readingOrder(notes).map((note) => note.id);
42
+ const rank = new Map(reading.map((id, index) => [id, index]));
43
+ const preds = new Map(reading.map((id) => [id, []]));
44
+ for (const arrow of state.arrows) {
45
+ if (arrow.kind === "setup") continue;
46
+ if (!rank.has(arrow.from) || !rank.has(arrow.to)) continue;
47
+ preds.get(arrow.to).push(arrow.from);
48
+ }
49
+ for (const list of preds.values()) list.sort((a, b) => rank.get(a) - rank.get(b));
50
+
51
+ const placed = new Set();
52
+ const visiting = new Set();
53
+ const order = [];
54
+ function visit(id) {
55
+ if (placed.has(id) || visiting.has(id)) return;
56
+ visiting.add(id);
57
+ for (const from of preds.get(id)) {
58
+ // A pair pointing both ways is a tie: reading order keeps it.
59
+ if (preds.get(from).includes(id)) continue;
60
+ visit(from);
61
+ }
62
+ visiting.delete(id);
63
+ placed.add(id);
64
+ order.push(id);
65
+ }
66
+ for (const id of reading) visit(id);
67
+ return order;
68
+ }
69
+
70
+ /** Pull each group's members up to its first member, keeping their order. */
71
+ function keepGroupsTogether(order, groups) {
72
+ const groupOf = new Map();
73
+ groups.forEach((group, index) => group.noteIds.forEach((id) => groupOf.set(id, index)));
74
+ const placed = new Set();
75
+ const result = [];
76
+ for (const id of order) {
77
+ if (placed.has(id)) continue;
78
+ const group = groupOf.get(id);
79
+ if (group === undefined) {
80
+ result.push(id);
81
+ placed.add(id);
82
+ continue;
83
+ }
84
+ for (const member of order) {
85
+ if (groupOf.get(member) === group && !placed.has(member)) {
86
+ result.push(member);
87
+ placed.add(member);
88
+ }
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+
94
+ function wrapRows(order, originX, originY) {
95
+ const poses = [];
96
+ let x = originX;
97
+ let y = originY;
98
+ for (const id of order) {
99
+ if (x > originX && x + NOTE_WIDTH > originX + ROW_WIDTH) {
100
+ x = originX;
101
+ y += STEP_Y;
102
+ }
103
+ poses.push({ id, x, y, rotate: 0 });
104
+ x += STEP_X;
105
+ }
106
+ return poses;
107
+ }
108
+
109
+ function beatRows(order, byId, originX, originY) {
110
+ const poses = [];
111
+ let x = originX;
112
+ let y = originY - STEP_Y;
113
+ let started = false;
114
+ for (const id of order) {
115
+ const note = byId.get(id);
116
+ if (note.rank === "beat" || !started) {
117
+ // A beat owns the left edge of its row; the opening scenes get a row too.
118
+ y += STEP_Y;
119
+ x = originX;
120
+ started = true;
121
+ } else if (x + NOTE_WIDTH > originX + ROW_WIDTH) {
122
+ // A long run wraps under itself, indented one card, never under the beat.
123
+ y += STEP_Y;
124
+ x = originX + STEP_X;
125
+ }
126
+ poses.push({ id, x, y, rotate: 0 });
127
+ x += STEP_X;
128
+ }
129
+ return poses;
130
+ }
131
+
132
+ /**
133
+ * Poses for Organize: every card, or only `onlyIds` when a selection is being
134
+ * tidied, in which case the layout starts where the selection's top-left is.
135
+ */
136
+ export function organizePoses(state, options = {}) {
137
+ const wanted = options.onlyIds ? new Set(options.onlyIds) : null;
138
+ const scope = state.notes.filter((note) => !wanted || wanted.has(note.id));
139
+ if (scope.length === 0) return [];
140
+ const byId = new Map(scope.map((note) => [note.id, note]));
141
+ const scopeIds = new Set(byId.keys());
142
+ const groups = state.groups
143
+ .map((group) => ({ ...group, noteIds: group.noteIds.filter((id) => scopeIds.has(id)) }))
144
+ .filter((group) => group.noteIds.length >= 2);
145
+
146
+ const order = keepGroupsTogether(arrowOrder(state, [...scopeIds]), groups);
147
+ const originX = wanted ? Math.min(...scope.map((note) => note.x)) : ORIGIN_X;
148
+ const originY = wanted ? Math.min(...scope.map((note) => note.y)) : ORIGIN_Y;
149
+ const hasBeats = scope.some((note) => note.rank === "beat");
150
+ return hasBeats ? beatRows(order, byId, originX, originY) : wrapRows(order, originX, originY);
151
+ }
@@ -0,0 +1,51 @@
1
+ // Type surface for paginate.js — the paginator (R23, slice c1).
2
+
3
+ export declare const LINES_PER_PAGE: number;
4
+ export declare const WIDTH: Record<string, number>;
5
+ export declare const TRANSITION: RegExp;
6
+
7
+ export type Element =
8
+ | { kind: "action" | "transition" | "centered"; text: string; at: number }
9
+ | { kind: "break"; at: number }
10
+ | { kind: "speech"; name: string; dual: boolean; at: number; parts: Array<{ kind: "dialogue" | "parenthetical"; text: string; at?: number }> };
11
+
12
+ export type LineKind = "blank" | "action" | "character" | "parenthetical" | "dialogue" | "transition" | "centered" | "break" | "note";
13
+ export declare function classifyLines(text: string): LineKind[];
14
+
15
+ export type Line = {
16
+ kind: "heading" | "action" | "character" | "parenthetical" | "dialogue" | "transition" | "centered" | "more" | "blank" | "dual";
17
+ text?: string;
18
+ sceneNumber?: number | string | null;
19
+ noteId?: string;
20
+ /** The source line of the scene's text this printed line came from; -1 for the heading. */
21
+ src?: number;
22
+ left?: Line;
23
+ right?: Line;
24
+ };
25
+
26
+ export type Block = {
27
+ kind: string;
28
+ name?: string;
29
+ parts?: Array<{ kind: "dialogue" | "parenthetical"; text: string }>;
30
+ width?: number;
31
+ lines: Line[];
32
+ };
33
+ export type Page = { number: number; lines: Line[] };
34
+
35
+ export declare function wrap(text: string, width: number): string[];
36
+ export declare function parseScene(text: string): Element[];
37
+ export declare function layoutScene(elements: Element[], heading: string | null, sceneNumber: number | null): Block[];
38
+ export declare function paginateBlocks(scenes: Array<{ id: string; blocks: Block[] }>): {
39
+ pages: Page[];
40
+ placement: Map<string, { page: number; endPage: number }>;
41
+ };
42
+ export declare function splitSpeech(block: Block, space: number): { head: Line[]; tail: Line[] } | null;
43
+ export declare function sceneLineCount(text: string | undefined): number;
44
+
45
+ export type SceneInput = { id: string; heading: string; text: string; change: string; written: boolean; number?: string | number };
46
+ export type Pagination = {
47
+ pages: Page[];
48
+ scenes: Array<{ id: string; number: number | string; page: number; endPage: number }>;
49
+ pageCount: number;
50
+ };
51
+ export declare function paginate(scenes: SceneInput[]): Pagination;