fountain-structure / index.html
MorganTitcher's picture
Sync from inkwell 5d9c37790
bf864b2 verified
Raw History Blame Contribute Delete
23.7 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fountain structure β€” Inkwell</title>
<style>
:root {
--bg: oklch(0.18 0.03 264);
--panel: oklch(0.22 0.032 264);
--ink: oklch(0.93 0.02 264);
--muted: oklch(0.72 0.03 264);
--line: oklch(0.35 0.03 264);
--accent: oklch(0.72 0.09 264);
--scene: oklch(0.78 0.08 230);
--char: oklch(0.82 0.09 80);
--dlg: oklch(0.8 0.06 150);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font: 16px/1.5 ui-sans-serif, system-ui, sans-serif;
}
header {
padding: 1.5rem 1.25rem 1rem;
border-bottom: 1px solid var(--line);
}
header a { color: var(--accent); text-decoration: none; }
h1 { margin: 0.25rem 0 0; font-size: 1.35rem; font-weight: 600; }
.sub { color: var(--muted); font-size: 0.95rem; max-width: 40rem; }
main {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 1rem 1.25rem 2rem;
}
@media (max-width: 800px) { main { grid-template-columns: 1fr; } }
textarea {
width: 100%;
min-height: 28rem;
resize: vertical;
background: var(--panel);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 10px;
padding: 1rem;
font: 14px/1.45 ui-monospace, "Courier New", monospace;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
padding: 1rem 1.1rem;
}
.panel h2 { margin: 0 0 0.6rem; font-size: 0.8rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); }
.counts { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 1rem; }
.chip { border: 1px solid var(--line); border-radius: 999px; padding: 0.15rem 0.6rem; font-size: 0.8rem; }
ul { margin: 0; padding-left: 1.1rem; }
li { margin: 0.2rem 0; }
.scene { color: var(--scene); }
.char { color: var(--char); }
.note { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; }
footer { padding: 0 1.25rem 2rem; color: var(--muted); font-size: 0.85rem; }
footer a { color: var(--accent); }
</style>
</head>
<body>
<header>
<a href="https://www.inkwell.software/?utm_source=huggingface&amp;utm_medium=referral&amp;utm_campaign=demo_spaces&amp;utm_content=fountain_structure_app_home" target="_blank" rel="noopener">Inkwell</a>
<h1>Fountain structure</h1>
<p class="sub">Paste a scene. See headings, speakers, and element counts. Runs in your browser β€” nothing is uploaded.</p>
</header>
<main>
<textarea id="src" spellcheck="false" aria-label="Fountain source"></textarea>
<div class="panel" id="out" aria-live="polite"></div>
</main>
<footer>
Demo tokenizer, not Plot’s parser.
<a href="https://www.inkwell.software/plot?utm_source=huggingface&amp;utm_medium=referral&amp;utm_campaign=demo_spaces&amp;utm_content=fountain_structure_app_plot" target="_blank" rel="noopener">Plot</a> Β·
<a href="https://app.inkwell.software/signup?utm_source=huggingface&amp;utm_medium=referral&amp;utm_campaign=demo_spaces&amp;utm_content=fountain_structure_app_signup" target="_blank" rel="noopener">Start writing free</a>
</footer>
<script>
const FIXTURE = `Title: THE REWRITE
Credit: Written by
Author: A. Fictional
INT. COFFEE SHOP β€” MORNING
Maya stares at the cursor. The room is too quiet.
MAYA
(half a smile)
What if the ending was the setup?
JULES
Then we already wrote it.
CUT TO:
INT. EDIT BAY β€” NIGHT
Jules pins a pink page to the wall.
JULES (V.O.)
Lock it. Then change it on purpose.
`;
// ---------------------------------------------------------------------
// The patterns and control flow below mirror the Fountain parser Inkwell
// uses: every pattern it matches, in the order it matches them, with the
// two pieces of state it keys on β€” `prevBlank` and `inDialogue` β€” rather
// than inferring them from the previous element's kind.
// ---------------------------------------------------------------------
const CHARACTER_NAME_WORD = "[A-Z][A-Z0-9.'_/&,-]*[A-Z0-9]";
const CHARACTER_NAME_SEGMENT = "[A-Z](?:[A-Z0-9.'_/&,-]*[A-Z0-9])?";
const CHARACTER_NAME_JOIN = "(?:[.,]?[ ]|[ ][&][ ])";
const CHARACTER_NAME_TOKEN_SOURCE =
`(?:${CHARACTER_NAME_WORD}(?:${CHARACTER_NAME_JOIN}${CHARACTER_NAME_SEGMENT})*` +
`|[A-Z](?:${CHARACTER_NAME_JOIN}${CHARACTER_NAME_SEGMENT})+)`;
// The `|[A-Z]` alternative is the parser's deliberate delta: a lone capital
// ("Q") is a legal full cue name.
const CHARACTER_PATTERN = new RegExp(
`^(${CHARACTER_NAME_TOKEN_SOURCE}|[A-Z])(\\s*\\(([^)]+)\\))?(\\s*\\^)?$`
);
const SCENE_HEADING_PATTERN = /^(INT|EXT|EST|INT\.\/EXT|INT\/EXT|I\/E)[.\s]/i;
const FORCED_SCENE_HEADING = /^\./;
const FORCED_ACTION = /^!/;
const FORCED_CHARACTER = /^@/;
const FORCED_TRANSITION = /^>/;
const TRANSITION_PATTERN = /^[A-Z\s]+TO:$/;
const PAGE_BREAK_PATTERN = /^={3,}\s*$/;
const SYNOPSIS_PATTERN = /^=(?!=)\s*(.+)$/;
const CENTERED_PATTERN = /^>(.+)<\s*$/;
const SECTION_PATTERN = /^(#{1,6})\s+(.+)$/;
const NOTE_PATTERN = /^\[\[(.*)\]\]$/;
const NOTE_OPEN_PATTERN = /^\[\[(.*)$/;
const NOTE_CLOSE_PATTERN = /^(.*)\]\]\s*$/;
const LYRIC_PATTERN = /^~(.*)$/;
const TITLE_PAGE_KEY_PATTERN = /^[A-Za-z][A-Za-z\s]*:/;
const EXTENSION_PATTERN = /\(([^)]+)\)\s*$/;
const DUAL_DIALOGUE_MARKER = /\^\s*$/;
const characterExtensions = ["V.O.", "O.S.", "CONT'D", "O.C.", "FILTER", "SUBTITLE"];
const KNOWN_EXTENSIONS = new Set(characterExtensions);
// Everything below is pasted by the visitor, so it is untrusted input even
// though it never leaves the browser. Without this, a scene heading like
// `INT. ROOM <img src=x onerror=...>` becomes an active event handler and
// can read the very draft this page promises to keep local.
function esc(value) {
return String(value).replace(/[&<>"']/g, (ch) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
})[ch]);
}
// `lineStartsFountainStructure` β€” the parser exports this, and its action
// collector breaks on it. A line that starts a structure therefore ENDS the
// action block above it even when the line itself is finally classified as
// action: `He waits.` followed by a bare `.` is two action nodes, because
// `/^\./` breaks the collector and a lone dot is not a usable forced slug.
//
// The raw-vs-trimmed split below is the parser's, verbatim. Section,
// synopsis and lyric are tested against the RAW line and so do not match
// when indented; the rest are tested trimmed. Normalising that away here
// would silently widen the rule.
// `parseCharacterCue`'s name projection. Strip in the parser's order:
// caret, then extension. The trim between them matters β€” removing the
// caret from `JULES (V.O.) ^` leaves a trailing space, and the extension
// pattern is anchored on `)$`, so without it the extension stays.
//
// The extension comes off ONLY when it is a KNOWN one. The parser keeps
// an unrecognised parenthetical as part of the name β€” `@BOB (WHISPERING)`
// is a character called `BOB (WHISPERING)`, not `BOB` with a stage
// direction β€” so stripping it unconditionally would rename the speaker. The lookup
// is on the UPPERCASED inner text because the parser upper-cases before
// testing, which is what makes `(v.o.)` a known extension too.
function speakerName(line) {
const withoutCaret = line.trim().replace(/^@/, "").replace(DUAL_DIALOGUE_MARKER, "").trim();
const ext = EXTENSION_PATTERN.exec(withoutCaret);
if (ext && KNOWN_EXTENSIONS.has((ext[1] ?? "").trim().toUpperCase())) {
return withoutCaret.replace(EXTENSION_PATTERN, "").trim();
}
return withoutCaret;
}
// Would the action collector have swallowed this line? It is NOT simply
// the negation of `lineStartsFountainStructure`: forced action is checked
// FIRST and continues the block with its `!` stripped. That carve-out is
// deliberate: a `!` line inside a multi-line action continues that
// paragraph instead of splitting it into several.
function actionCollectorConsumes(line) {
return FORCED_ACTION.test(line.trim()) || !lineStartsFountainStructure(line);
}
function lineStartsFountainStructure(line) {
const trimmed = line.trim();
return (
SECTION_PATTERN.test(line) ||
SYNOPSIS_PATTERN.test(line) ||
PAGE_BREAK_PATTERN.test(trimmed) ||
LYRIC_PATTERN.test(line) ||
FORCED_SCENE_HEADING.test(trimmed) ||
FORCED_ACTION.test(trimmed) ||
FORCED_CHARACTER.test(trimmed) ||
FORCED_TRANSITION.test(trimmed) ||
CENTERED_PATTERN.test(trimmed) ||
NOTE_PATTERN.test(trimmed) ||
trimmed.startsWith("[[")
);
}
// Boneyard is a PRE-PASS in the parser (`extractBoneyard`), not a line
// rule: `/* ... */` spans lines, and the interior is removed before any
// classification runs. Testing `startsWith("/*")` per line would read
// every interior line as action or dialogue.
function extractBoneyard(rawLines) {
const cleaned = [];
// RUNS, not lines: the parser emits one boneyard node per `/* … */`
// pair however many lines it spans, so a four-line run is one element.
let boneyardRuns = 0;
let inBoneyard = false;
// Body of the run currently open. Counting on the OPENER would report a
// node the parser never creates: at the end of the file it keeps an
// unclosed run only if the run has content, so a dangling `/*` with a
// blank or whitespace-only body emits nothing. A run is therefore
// counted when it CLOSES, and an unclosed one only if it has content.
let openText = "";
for (const raw of rawLines) {
if (inBoneyard) {
const close = raw.indexOf("*/");
if (close === -1) {
openText += `${raw}\n`;
continue;
}
inBoneyard = false;
openText = "";
boneyardRuns++;
cleaned.push(raw.slice(close + 2));
continue;
}
const open = raw.indexOf("/*");
if (open === -1) {
cleaned.push(raw);
continue;
}
const before = raw.slice(0, open);
if (before.trim()) cleaned.push(before);
const afterOpen = raw.slice(open + 2);
const close = afterOpen.indexOf("*/");
if (close === -1) {
inBoneyard = true;
// The parser seeds its buffer the same way β€” `afterOpen` plus the
// newline β€” so `/* cut me` with no closer still has a body.
openText = `${afterOpen}\n`;
cleaned.push("");
continue;
}
boneyardRuns++;
const rest = afterOpen.slice(close + 2);
cleaned.push(rest.trim() ? rest : "");
}
if (inBoneyard && openText.trim()) boneyardRuns++;
return { lines: cleaned, boneyardRuns };
}
// Title page is the other pre-pass (`parseTitlePage`). It reads only the
// document PREFIX, ends at the first blank line, accepts any `Key: value`
// rather than a fixed list, and consumes NOTHING if a line is neither a
// key nor an indented continuation. A per-line keyword test would fire on
// `Contact: he said` in the middle of a scene.
function countTitlePageLines(lines) {
let currentKey = "";
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? "";
if (line.trim() === "") return i;
if (TITLE_PAGE_KEY_PATTERN.test(line)) {
currentKey = line.split(":")[0].trim();
continue;
}
if (currentKey && (line.startsWith(" ") || line.startsWith("\t"))) continue;
return 0;
}
return currentKey ? lines.length : 0;
}
// One step of `parseFountain`'s classification loop: the same branches, in
// the same order, returning the same two state bits. Order is load-bearing
// in three places β€” `===` is a page break only because it is tested before
// the synopsis rule, `>text<` is centered only because it is tested before
// the forced transition, and a forced `.slug` / `!action` / `@cue` beats
// every unforced rule below it.
function step(rawLine, state, nextRaw) {
const t = rawLine.trim();
// A `[[` note run consumes EVERY line until `]]` β€” including blank ones
// β€” because the parser's inner loop does not re-classify inside the run.
// This is checked before the blank test for that reason. Without it a
// two-line note would read as two action lines: wrong kind AND wrong count.
if (state.inNote) {
return {
kind: "note",
continues: true,
prevBlank: false,
inDialogue: state.inDialogue,
inNote: !NOTE_CLOSE_PATTERN.test(rawLine),
};
}
if (t === "") return { kind: "blank", prevBlank: true, inDialogue: false };
// A dialogue block's CONTINUATION lines are swallowed by the parser's
// inner loop, which breaks ONLY on a blank line or a parenthetical β€” it
// never re-enters the branch chain. So `#`, `~`, `[[…]]`, `>x<`, `!x`,
// `.x`, `@x` and `===` inside dialogue are all dialogue, not the element
// their shape suggests.
//
// The asymmetry is real and this keys on it: the FIRST line after a cue
// DOES go through the full chain, so `@McClane` then `# hashtag` really
// is a section. Only a line following an emitted DIALOGUE line is an
// unconditional continuation β€” hence `prevKind`, not `inDialogue`.
if (
state.prevKind === "dialogue" &&
!(/^\s*\(/.test(rawLine) && /\)\s*$/.test(t))
) {
return { kind: "dialogue", continues: true, prevBlank: false, inDialogue: true };
}
if (PAGE_BREAK_PATTERN.test(t))
// A page break leaves prevBlank TRUE: the parser treats the line after
// it as though a blank preceded it, so a cue can follow one directly.
return { kind: "page_break", prevBlank: true, inDialogue: false };
// SECTION, SYNOPSIS and LYRIC take `rawLine`, NOT `t`. The parser
// classifies these three with `line.match(...)` on the unmodified line
// while every other branch here tests `line.trim()`, so an indented
// ` # Act One`, ` = synopsis` or ` ~sing it` is ACTION upstream. Testing
// them trimmed widens all three rules and the page counts a structural
// element the parser never emitted.
//
// This is the same asymmetry `lineStartsFountainStructure` below
// documents and mirrors.
if (SECTION_PATTERN.test(rawLine))
return { kind: "section", prevBlank: false, inDialogue: false };
if (SYNOPSIS_PATTERN.test(rawLine))
return { kind: "synopsis", prevBlank: false, inDialogue: false };
// Notes and lyrics deliberately do NOT break dialogue context.
if (NOTE_PATTERN.test(t))
return { kind: "note", prevBlank: false, inDialogue: state.inDialogue };
// `[[` with no closing `]]` on the same line opens a run.
if (NOTE_OPEN_PATTERN.test(t) && !NOTE_PATTERN.test(t))
return { kind: "note", prevBlank: false, inDialogue: state.inDialogue, inNote: true };
if (LYRIC_PATTERN.test(rawLine))
return { kind: "lyric", prevBlank: false, inDialogue: state.inDialogue };
if (CENTERED_PATTERN.test(t))
return { kind: "centered", prevBlank: false, inDialogue: false };
// A forced slug carries its marker as SYNTAX, not as heading text: the
// parser stores `.SNOWY EXTERIOR` as "SNOWY EXTERIOR". A bare `.` has no
// text at all and falls through to the rules below, exactly as there.
if (FORCED_SCENE_HEADING.test(t) && !t.startsWith("..")) {
const forcedText = t.slice(1).trim();
if (forcedText) {
return {
kind: "scene_heading",
text: forcedText,
prevBlank: false,
inDialogue: false,
};
}
}
// `!` forces action even mid-dialogue, which is the whole point of it β€”
// reached here, above the dialogue branch, exactly as the parser does.
if (FORCED_ACTION.test(t))
return { kind: "action", prevBlank: false, inDialogue: false };
if (FORCED_CHARACTER.test(t))
return { kind: "character", prevBlank: false, inDialogue: true };
// An unforced slugline needs a blank line before it, or the document
// start. Without that gate a line of dialogue reading `INT. ROOM is
// printed on the card` would be counted as a scene.
if ((state.prevBlank || state.atDocumentStart) && SCENE_HEADING_PATTERN.test(t))
return { kind: "scene_heading", prevBlank: false, inDialogue: false };
if (
state.prevBlank &&
TRANSITION_PATTERN.test(t) &&
(nextRaw === undefined || nextRaw.trim() === "")
) {
return { kind: "transition", prevBlank: false, inDialogue: false };
}
// Forced transition: `>FADE OUT.` β€” no `TO:`, no trailing `<`, and no
// blank-line context required.
if (FORCED_TRANSITION.test(t) && !CENTERED_PATTERN.test(t))
return { kind: "transition", prevBlank: false, inDialogue: false };
// A parenthetical is only a parenthetical INSIDE dialogue; standing
// alone in action it is action.
if (state.inDialogue && /^\s*\(/.test(rawLine) && /\)\s*$/.test(t))
return { kind: "parenthetical", prevBlank: false, inDialogue: true };
if (state.inDialogue)
return { kind: "dialogue", prevBlank: false, inDialogue: true };
// An unforced cue needs a blank line before, a non-blank line after, and
// the shared name shape. CHARACTER_PATTERN already admits the extension
// and the dual-dialogue caret, so no pre-stripping and no length cap:
// the token grammar is the length rule. It also rejects what a free
// charset would accept β€” a connector-ended name like `THE CAR
// EXPLODES.` is action, and a lone `Q` is a cue.
if (
state.prevBlank &&
CHARACTER_PATTERN.test(t) &&
nextRaw !== undefined &&
nextRaw.trim() !== ""
) {
return { kind: "character", prevBlank: false, inDialogue: true };
}
return { kind: "action", prevBlank: false, inDialogue: false };
}
function analyze(text) {
// BOTH replacements, in the parser's order. A lone `\r` is a classic-Mac
// line ending, and without that second pass such a file would read as a
// single line β€” the whole screenplay one scene heading with carriage
// returns inside it and no speakers at all.
const raw = text
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n");
const { lines, boneyardRuns } = extractBoneyard(raw);
const titleLines = countTitlePageLines(lines);
const counts = {};
const scenes = [];
const speakers = new Set();
if (boneyardRuns) counts.boneyard = boneyardRuns;
// A title page is ONE element however many `Key: value` entries it
// holds β€” `countTitlePageLines` returns the lines to SKIP, not a count.
if (titleLines) counts.title_page = 1;
// `prevBlank` starts TRUE only when a title page precedes the body, since
// a title page ends with a blank line. A blanket `true` also opens the
// character-cue branch on line one and reads a leading uppercase action
// like `BLACK.` as a speaker, which the parser guards against the same
// way.
const state = {
prevBlank: titleLines > 0,
inDialogue: false,
inNote: false,
atDocumentStart: true,
prevKind: "blank",
};
for (let i = titleLines; i < lines.length; i++) {
const line = lines[i] ?? "";
const next = lines[i + 1];
const result = step(line, state, next);
// Count ELEMENTS, not lines. `parseFountain` emits ONE node for a
// multi-line action or dialogue block, and the page labels these
// "Elements" β€” so `JULES / First line. / Second line.` is one dialogue
// element, not two. Action and dialogue are the only kinds the parser
// collects across lines (its loops break on a blank, and on anything
// `lineStartsFountainStructure` matches β€” which is exactly the set of
// branches above these two); notes are collected by the run above.
// A same-kind neighbour is only a CONTINUATION if the collector would
// actually have swallowed it β€” merging on kind alone would report
// `He waits.` + `.` as one action where the parser emits two, because
// `/^\./` breaks the collector but a lone dot is not a usable forced
// slug so it lands back on action. Dialogue continuations arrive with
// `continues: true` already set: that loop breaks only on a blank line
// or a parenthetical and never consults the structure predicate.
const collectsAcrossLines = result.kind === "action" || result.kind === "dialogue";
const continues =
result.continues === true ||
(collectsAcrossLines && state.prevKind === result.kind && actionCollectorConsumes(line));
if (!continues) counts[result.kind] = (counts[result.kind] || 0) + 1;
if (result.kind === "scene_heading") scenes.push(result.text ?? line.trim());
if (result.kind === "character") speakers.add(speakerName(line));
state.prevBlank = result.prevBlank;
state.inDialogue = result.inDialogue;
state.inNote = result.inNote === true;
state.prevKind = result.kind;
// `atDocumentStart` is a one-shot: the parser snapshots it on the
// first NON-BLANK body line and clears it immediately after.
if (result.kind !== "blank") state.atDocumentStart = false;
}
return { counts, scenes, speakers: [...speakers] };
}
function render(text) {
const { counts, scenes, speakers } = analyze(text);
const skip = new Set(["blank"]);
const chips = Object.entries(counts)
.filter(([k]) => !skip.has(k))
.sort((a, b) => b[1] - a[1])
.map(([k, n]) => `<span class="chip">${esc(k.replace("_", " "))} ${n}</span>`)
.join("");
const sceneList = scenes.length
? scenes.map((s) => `<li class="scene">${esc(s)}</li>`).join("")
: "<li class=\"note\">No scene headings yet.</li>";
const speakerList = speakers.length
? speakers.map((s) => `<li class="char">${esc(s)}</li>`).join("")
: "<li class=\"note\">No speakers yet.</li>";
document.getElementById("out").innerHTML = `
<h2>Elements</h2>
<div class="counts">${chips || "<span class=\"note\">Paste Fountain to begin.</span>"}</div>
<h2>Scenes</h2>
<ul>${sceneList}</ul>
<h2>Speakers</h2>
<ul>${speakerList}</ul>
<p class="note">The sample scene is fictional.</p>`;
}
const src = document.getElementById("src");
src.value = FIXTURE;
src.addEventListener("input", () => render(src.value));
render(src.value);
</script>
</body>
</html>