Files
hypit/packages/script/src/parser.ts

853 lines
35 KiB
TypeScript

import { ScriptSyntaxError } from "./error.js";
import {
cleanProjection,
joinProjection,
lexicalUnits,
analyzeProse,
splitDisplayPrefix,
} from "./lexical.js";
import type {
CaptionWordAttribute,
Affinity,
MarkerBoundary,
ParsedAtom,
ParsedCaptionRegion,
ParsedMoment,
ParsedNarrative,
ParsedSegment,
ParsedSelection,
ParsedToken,
ParsedTurn,
SemanticAnchor,
} from "./types.js";
type Marker = {
readonly id: string;
readonly kind: "open" | "close" | "moment";
readonly affinity: Affinity;
readonly length: number;
};
type MutableSegment = {
readonly id: string;
readonly index: number;
readonly atoms: ParsedAtom[];
readonly tokenStart: number;
readonly sourceStart: number;
readonly contentStart: number;
};
const SEGMENT_ID = /^[a-z][a-z0-9_-]{0,63}$/u;
const TEMPORAL_ID = /^[a-z][a-z0-9_-]{0,63}$/u;
const ROLE_LABEL = /^[\p{L}\p{M}\p{N}_](?:[\p{L}\p{M}\p{N}_. -]{0,30}[\p{L}\p{M}\p{N}_.-])?$/u;
const RESERVED_SEGMENT_IDS = new Set(["script"]);
const ATTRIBUTE_NAME = /^[a-z][a-z0-9_-]{0,63}$/u;
const ATTRIBUTE_VALUE = /^[A-Za-z0-9_.:-]+$/u;
/** A marker's structural position before its affinity picks one anchor. */
type RawMarkerBoundary = { -readonly [K in keyof Omit<MarkerBoundary, "anchorId">]: Omit<MarkerBoundary, "anchorId">[K] };
type RawEdge<T> = Omit<T, "boundary"> & { readonly boundary: RawMarkerBoundary };
type RawSelection = Omit<ParsedSelection, "startAnchorId" | "endAnchorId" | "open" | "close"> & {
readonly open: RawEdge<ParsedSelection["open"]>;
readonly close: RawEdge<ParsedSelection["close"]>;
};
type RawMoment = Omit<ParsedMoment, "anchorId" | "boundary"> & { readonly boundary: RawMarkerBoundary };
function normalizeWord(value: string): string {
return value
.normalize("NFKC")
.toLocaleLowerCase("en")
.replace(/[^\p{L}\p{M}\p{N}]+/gu, "");
}
function parseMarker(source: string, offset: number): Marker | undefined {
const match = /^@\{(~)?(\/)?([a-z][a-z0-9_-]{0,63})([!~])?\}/u.exec(source.slice(offset));
if (!match) return undefined;
const [, left, close, id, suffix] = match;
if (close) {
if (left || suffix === "!") return undefined;
return { id: id!, kind: "close", affinity: suffix === "~" ? "right" : "left", length: match[0].length };
}
if (suffix === "~") return undefined;
return { id: id!, kind: suffix === "!" ? "moment" : "open", affinity: left ? "left" : "right", length: match[0].length };
}
function segmentAnchorId(segmentId: string, edge: "start" | "end"): string {
return `segment:${segmentId}:${edge}`;
}
function tokenId(segmentId: string, segmentTokenIndex: number): string {
return `segment:${segmentId}:token:${segmentTokenIndex + 1}`;
}
function tokenAnchorId(segmentId: string, segmentTokenIndex: number, edge: "start" | "end"): string {
return `${tokenId(segmentId, segmentTokenIndex)}:${edge}`;
}
function findUnescaped(value: string, character: string, from = 0): number {
for (let index = from; index < value.length; index += 1) {
if (value[index] !== character) continue;
let slashes = 0;
for (let before = index - 1; before >= 0 && value[before] === "\\"; before -= 1) slashes += 1;
if (slashes % 2 === 0) return index;
}
return -1;
}
type ParsedAttributeBlock = {
readonly attributes: readonly CaptionWordAttribute[];
readonly length: number;
};
function parseAttributeValue(raw: string): string | number | boolean {
if (raw === "true") return true;
if (raw === "false") return false;
if (/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)$/u.test(raw)) return Number(raw);
return raw;
}
export function parseScript(
sourceName: string,
input: string,
sourceOffset = 0,
): ParsedNarrative {
// Source ranges are a public inverse used by Studio and diagnostics. Parse
// the exact UTF-16 text so CRLF and decomposed Unicode never shift an edit.
const source = input;
const fail = (code: string, message: string, offset?: number): never => {
throw new ScriptSyntaxError(code, message, sourceName, offset === undefined ? undefined : sourceOffset + offset);
};
const segments: ParsedSegment[] = [];
const tokens: ParsedToken[] = [];
const captionRegions: ParsedCaptionRegion[] = [];
let precedingCaptionRegion: number | undefined;
let pendingCaptionText = "";
const selections = new Map<string, RawSelection>();
const moments = new Map<string, RawMoment>();
const captionBreaks: Array<{ readonly tokenIndex: number; readonly range: { readonly start: number; readonly end: number } }> = [];
const openSelections = new Map<string, {
readonly affinity: Affinity;
readonly boundary: RawMarkerBoundary;
readonly start: number;
readonly end: number;
}>();
let current: MutableSegment | undefined;
let offset = 0;
let structuralPosition = 0;
// One prose run crosses annotations and comments. Only an explicit correspondence,
// speaker change or Segment edge ends it. Source positions stay local to parsing.
let runText = "";
let runCaption = "";
let runCapture = true;
let runStarts: number[] = [];
let runEnds: number[] = [];
let runBoundaries: Array<{ position: number; offset: number; kind: "marker" | "cue"; value: { tokenIndex: number } }> = [];
let runAttributes: Array<{ position: number; offset: number; end: number; attributes: readonly CaptionWordAttribute[] }> = [];
const boundary = (start: number): RawMarkerBoundary => {
const value = { tokenIndex: tokens.length, structuralPosition,
...(current ? { segmentId: current.id } : {}) };
if (current) runBoundaries.push({ position: runText.length, offset: start, kind: "marker", value });
return value;
};
const finishLexicalRun = (): ParsedCaptionRegion["marks"] => {
if (!current) return [];
const { units, editRanges: ranges } = analyzeProse(runText, runCapture && precedingCaptionRegion !== undefined);
const tokenStart = tokens.length;
for (const event of runBoundaries) {
if (ranges.some(range => range.start < event.position && event.position < range.end)) {
fail(event.kind === "marker" ? "SCRIPT_MARKER_TOKEN_BOUNDARY" : "SCRIPT_CAPTION_BREAK_TOKEN_BOUNDARY",
"A semantic marker or Cue break cannot split a word and its attached punctuation.", event.offset);
}
event.value.tokenIndex = tokenStart + units.filter(unit => unit.index + unit.text.length <= event.position).length;
if (event.kind === "cue" && event.value.tokenIndex === current.tokenStart) {
fail("SCRIPT_CAPTION_BREAK_EMPTY", "Caption Cue break must follow visible Script text.", event.offset);
}
}
if (units.length === 0 && runAttributes.length > 0) fail("SCRIPT_ATTRIBUTE_TARGET", "A spoken display attribute needs a complete word.", runAttributes[0]!.offset);
const marks = bindDisplayAttributes(runText, runAttributes, units);
for (const [ordinal, unit] of units.entries()) {
const index = tokens.length;
const segmentTokenIndex = index - current.tokenStart;
const range = ranges[ordinal]!;
const attachedAttributes = runAttributes.filter(attribute =>
attribute.position >= unit.index + unit.text.length && attribute.position <= range.end);
tokens.push({ id: tokenId(current.id, segmentTokenIndex), index, segmentId: current.id, segmentTokenIndex,
startAnchorId: tokenAnchorId(current.id, segmentTokenIndex, "start"),
endAnchorId: tokenAnchorId(current.id, segmentTokenIndex, "end"),
text: unit.text, normalized: normalizeWord(unit.text),
range: { start: runStarts[unit.index]!, end: runEnds[unit.index + unit.text.length - 1]! },
editRange: { start: runStarts[range.start]!, end: Math.max(runEnds[range.end - 1]!, ...attachedAttributes.map(attribute => sourceOffset + attribute.end)) },
});
}
if (runText || runCaption) {
const range = { start: runStarts[0]!, end: runEnds.at(-1)! };
current.atoms.push({ kind: "text", speech: runText, caption: runCaption, tokenStart, tokenEndExclusive: tokens.length, range });
if (runCapture) addCaptionRegion(runCaption, current.id, tokenStart, tokens.length, "identity", range, marks);
}
runText = ""; runCaption = ""; runStarts = []; runEnds = []; runBoundaries = []; runAttributes = [];
return marks;
};
const addMarker = (marker: Marker, start: number): void => {
if (!TEMPORAL_ID.test(marker.id)) fail("SCRIPT_TEMPORAL_ID", `Invalid temporal id "${marker.id}".`, start);
if (marker.kind === "moment") {
if (openSelections.has(marker.id) || selections.has(marker.id)) {
fail("SCRIPT_TEMPORAL_TYPE", `Temporal name "${marker.id}" cannot be both Selection and Moment.`, start);
}
if (moments.has(marker.id)) {
fail("SCRIPT_MOMENT_DUPLICATE", `Moment "${marker.id}" is declared more than once.`, start);
}
moments.set(marker.id, {
id: marker.id,
affinity: marker.affinity,
boundary: boundary(start),
range: { start: sourceOffset + start, end: sourceOffset + start + marker.length },
});
return;
}
if (marker.kind === "open") {
if (moments.has(marker.id)) {
fail("SCRIPT_TEMPORAL_TYPE", `Temporal name "${marker.id}" cannot be both Selection and Moment.`, start);
}
if (openSelections.has(marker.id)) {
fail("SCRIPT_SELECTION_REOPENED", `Selection "${marker.id}" is reopened before it closes.`, start);
}
if (selections.has(marker.id)) {
fail("SCRIPT_SELECTION_DUPLICATE", `Selection "${marker.id}" is declared more than once.`, start);
}
openSelections.set(marker.id, {
affinity: marker.affinity,
boundary: boundary(start),
start: sourceOffset + start,
end: sourceOffset + start + marker.length,
});
return;
}
const open = openSelections.get(marker.id)
?? fail("SCRIPT_SELECTION_CLOSE", `Selection "${marker.id}" closes without an open.`, start);
openSelections.delete(marker.id);
selections.set(marker.id, {
id: marker.id,
open: {
affinity: open.affinity,
boundary: open.boundary,
range: { start: open.start, end: open.end },
},
close: {
affinity: marker.affinity,
boundary: boundary(start),
range: { start: sourceOffset + start, end: sourceOffset + start + marker.length },
},
});
};
const literalPieces = (
raw: string,
absoluteStart: number,
dual = false,
): Array<{ readonly value: string; readonly start: number; readonly end: number; readonly positions: readonly number[] }> => {
const pieces: Array<{ value: string; start: number; end: number; positions: readonly number[] }> = [];
let buffer = "";
let bufferStart = 0;
const positions = [absoluteStart];
const flush = (end: number): void => {
if (buffer) pieces.push({ value: buffer, start: absoluteStart + bufferStart, end: absoluteStart + end, positions });
buffer = "";
bufferStart = end;
};
for (let index = 0; index < raw.length;) {
if (raw[index] === "\\") {
const known = ["@", "<", "\\", "|", "{", "}", ...(dual ? [">"] : [])].includes(raw[index + 1] ?? "")
? { value: raw[index + 1]!, length: 2 }
: undefined;
const escape = known
?? fail("SCRIPT_ESCAPE", `Unknown Script escape "${raw.slice(index, index + 2)}".`, absoluteStart + index);
buffer += escape.value;
index += escape.length;
positions.push(absoluteStart + index);
continue;
}
if (dual && raw[index] === "<") fail("SCRIPT_DUAL_NESTED", "Dual Text cannot nest; escape a literal < as \\<.", absoluteStart + index);
buffer += raw[index];
index += 1;
positions.push(absoluteStart + index);
}
flush(raw.length);
return pieces;
};
const literalString = (raw: string, start: number, dual = false): string =>
literalPieces(raw, start, dual).map((piece) => piece.value).join("");
const parseAttributeBlock = (raw: string, absoluteStart: number): ParsedAttributeBlock => {
const close = findUnescaped(raw, "}", 1);
if (close < 0) fail("SCRIPT_ATTRIBUTE", "Unclosed token attribute block.", absoluteStart);
if (raw.slice(1, close).includes("{")) {
fail("SCRIPT_ATTRIBUTE_NESTED", "Token attributes cannot be nested.", absoluteStart + 1);
}
const body = raw.slice(1, close).trim();
if (!body) fail("SCRIPT_ATTRIBUTE_EMPTY", "Token attribute block must name at least one attribute.", absoluteStart);
const attributes: CaptionWordAttribute[] = [];
for (const part of body.split(",")) {
const entry = part.trim();
if (!entry) fail("SCRIPT_ATTRIBUTE", "Token attribute list contains an empty entry.", absoluteStart + 1);
const equal = entry.indexOf("=");
const name = (equal < 0 ? entry : entry.slice(0, equal)).trim();
const valueRaw = equal < 0 ? undefined : entry.slice(equal + 1).trim();
if (!ATTRIBUTE_NAME.test(name)) {
fail("SCRIPT_ATTRIBUTE_NAME", `Invalid token attribute name "${name}".`, absoluteStart + 1);
}
if (valueRaw !== undefined && (!valueRaw || !ATTRIBUTE_VALUE.test(valueRaw))) {
fail("SCRIPT_ATTRIBUTE_VALUE", `Invalid value for token attribute "${name}".`, absoluteStart + 1);
}
if (attributes.some((attribute) => attribute.name === name)) {
fail("SCRIPT_ATTRIBUTE_DUPLICATE", `Token attribute "${name}" is repeated.`, absoluteStart + 1);
}
attributes.push({ name, value: valueRaw === undefined ? true : parseAttributeValue(valueRaw) });
}
return { attributes, length: close + 1 };
};
const bindDisplayAttributes = (
display: string,
annotations: readonly { position: number; offset: number; attributes: readonly CaptionWordAttribute[] }[],
units = lexicalUnits(display),
): ParsedCaptionRegion["marks"] => {
const marks: Array<ParsedCaptionRegion["marks"][number]> = [];
for (const annotation of annotations) {
const displayIndex = units.length === 0
? (display.trim() && annotation.position === display.trimEnd().length ? 0 : -1)
: units.findLastIndex(unit => unit.index + unit.text.length <= annotation.position);
if (displayIndex < 0 || units.some(unit => unit.index < annotation.position && annotation.position < unit.index + unit.text.length)) {
fail("SCRIPT_ATTRIBUTE_TARGET", "A display attribute must follow a complete word.", annotation.offset);
}
if (marks.some(mark => mark.displayIndex === displayIndex)) fail("SCRIPT_ATTRIBUTE_OVERLAP", "A display word cannot receive two separate attribute blocks.", annotation.offset);
marks.push({ displayIndex, attributes: annotation.attributes });
}
return marks;
};
const parseMarkedDisplay = (
raw: string,
absoluteStart: number,
): { readonly display: string; readonly marks: readonly ParsedCaptionRegion["marks"][number][] } => {
const parts: string[] = [];
let cursor = 0;
const attributePositions: Array<{ position: number; offset: number; attributes: readonly CaptionWordAttribute[] }> = [];
for (let index = 0; index < raw.length; index += 1) {
if (raw[index] === "\\") {
index += 1;
continue;
}
if (raw[index] !== "{") continue;
const block = parseAttributeBlock(raw.slice(index), absoluteStart + index);
const before = raw.slice(0, index);
if (!before || /\s$/u.test(before)) {
fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must immediately follow a display token.", absoluteStart + index);
}
const cleanBefore = parts.join("") + literalString(raw.slice(cursor, index), absoluteStart + cursor, true);
parts.push(literalString(raw.slice(cursor, index), absoluteStart + cursor, true));
attributePositions.push({ position: cleanBefore.length, offset: absoluteStart + index, attributes: block.attributes });
cursor = index + block.length;
index += block.length - 1;
}
parts.push(literalString(raw.slice(cursor), absoluteStart + cursor, true));
const display = parts.join("");
return { display, marks: bindDisplayAttributes(display, attributePositions) };
};
const assertDualDisplayLiteral = (raw: string, absoluteStart: number): void => {
for (let index = 0; index < raw.length; index += 1) {
if (raw[index] === "\\") {
index += 1;
continue;
}
if (raw[index] === "@") {
fail("SCRIPT_DUAL_DISPLAY_MARKER", "Selection and Moment markers belong to spoken text, not the Dual display side; escape @ as \\@.", absoluteStart + index);
}
if (raw.startsWith("||", index)) {
fail("SCRIPT_CAPTION_BREAK_DUAL", "Caption Cue break cannot occur inside Dual Text; split the Dual Text into separate units.", absoluteStart + index);
}
}
};
const addCaptionRegion = (
displayValue: string,
segmentId: string,
tokenStart: number,
tokenEndExclusive: number,
kind: ParsedCaptionRegion["kind"],
range: ParsedCaptionRegion["range"],
marks: ParsedCaptionRegion["marks"] = [],
): void => {
if (kind === "hidden") {
if (tokenEndExclusive > tokenStart) {
captionRegions.push({
id: `caption-region:${captionRegions.length + 1}`,
display: "",
separatorBefore: "",
segmentId,
startToken: tokenStart,
endTokenExclusive: tokenEndExclusive,
kind,
marks: [],
range,
});
}
return;
}
// Dual Text edge whitespace is syntax padding; ordinary prose whitespace is content.
const raw = kind === "alias" ? cleanProjection(displayValue) : displayValue;
const value = (pendingCaptionText + raw).replace(/\s+/gu, " ");
const split = splitDisplayPrefix(value, precedingCaptionRegion !== undefined);
const previous = precedingCaptionRegion === undefined ? undefined : captionRegions[precedingCaptionRegion];
if (previous && split.previous.trim()) {
captionRegions[precedingCaptionRegion!] = {
...previous,
display: previous.display + split.previous.trimEnd(),
};
}
pendingCaptionText = /\s$/u.test(value) ? " " : "";
const display = (previous ? split.current : split.previous + split.current).trim();
if (tokenEndExclusive <= tokenStart) {
// A literal opener outside an explicit correspondence awaits that display,
// while closing punctuation has already been attached to the preceding one.
pendingCaptionText = previous ? split.current || pendingCaptionText : value;
return;
}
if (!display) return;
captionRegions.push({
id: `caption-region:${captionRegions.length + 1}`,
display,
separatorBefore: previous && /\s$/u.test(split.previous) ? " " : "",
segmentId,
startToken: tokenStart,
endTokenExclusive: tokenEndExclusive,
kind,
marks,
range,
});
precedingCaptionRegion = captionRegions.length - 1;
};
const addText = (
speech: string,
caption: string,
start: number,
_end: number,
captureCaptionRegion = true,
sourcePositions?: readonly number[],
): void => {
if (!current) {
if (speech.trim() || caption.trim()) fail("SCRIPT_TEXT_OUTSIDE_SEGMENT", "Natural-language text is only allowed inside a named Segment.", start);
return;
}
runCapture = captureCaptionRegion;
runText += speech;
runCaption += caption;
for (let index = 0; index < speech.length; index++) {
runStarts.push(sourceOffset + (sourcePositions?.[index] ?? start + index));
runEnds.push(sourceOffset + (sourcePositions?.[index + 1] ?? start + index + 1));
}
};
const consumeSpeechSide = (
raw: string,
absoluteStart: number,
caption: string | undefined,
marks: ParsedCaptionRegion["marks"] = [],
): void => {
const leading = /^\s*/u.exec(raw)![0].length;
raw = raw.trim();
absoluteStart += leading;
if (caption !== undefined) caption = caption.trim();
const startToken = tokens.length;
// An omitted speech side shares the written display source. Keep its original offsets:
// marker edits target these words, not a synthetic copy beyond the pipe.
const shared = caption === undefined;
const spokenParts: string[] = [];
let partStart = 0;
let index = 0;
let emittedCaption = false;
const addLiteral = (part: string, start: number): void => {
for (const piece of literalPieces(part, start, true)) {
spokenParts.push(piece.value);
addText(piece.value, shared ? piece.value : emittedCaption ? "" : caption!, piece.start, piece.end, false, piece.positions);
emittedCaption = true;
}
};
while (index < raw.length) {
if (raw[index] === "\\") {
index += 2;
continue;
}
if (raw.startsWith("||", index)) {
fail("SCRIPT_CAPTION_BREAK_DUAL", "Caption Cue break cannot occur inside Dual Text; split the Dual Text into separate units.", absoluteStart + index);
}
if (!shared && raw[index] === "{") fail("SCRIPT_DUAL_SPEECH_ATTRIBUTE", "Display attributes belong to the display side; escape literal braces in speech.", absoluteStart + index);
if (shared && raw[index] === "{") {
const before = raw.slice(partStart, index);
addLiteral(before, absoluteStart + partStart);
if (!runText || /\s$/u.test(runText)) {
fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must immediately follow a display token.", absoluteStart + index);
}
const block = parseAttributeBlock(raw.slice(index), absoluteStart + index);
runAttributes.push({ position: runText.length, offset: absoluteStart + index,
end: absoluteStart + index + block.length, attributes: block.attributes });
index += block.length;
partStart = index;
continue;
}
const marker = parseMarker(raw, index);
if (!marker) {
if (raw[index] === "@") fail("SCRIPT_MARKER", "Unescaped @ must begin a delimited @{...} marker.", absoluteStart + index);
index += 1;
continue;
}
addLiteral(raw.slice(partStart, index), absoluteStart + partStart);
addMarker(marker, absoluteStart + index);
index += marker.length;
partStart = index;
}
addLiteral(raw.slice(partStart), absoluteStart + partStart);
const display = caption ?? spokenParts.join("");
const sharedMarks = finishLexicalRun();
if (tokens.length === startToken) {
fail("SCRIPT_DUAL_EMPTY", shared
? "Dual Text with omitted speech must contain spoken text on its display side."
: "Dual Text speech side must contain a spoken word, not only markers or punctuation.", absoluteStart);
}
addCaptionRegion(
display,
current!.id,
startToken,
tokens.length,
cleanProjection(display) ? "alias" : "hidden",
{ start: sourceOffset + absoluteStart, end: sourceOffset + absoluteStart + raw.length },
shared ? sharedMarks : marks,
);
};
const closeCurrent = (end: number, selfClosing: boolean, contentEnd = end): void => {
const segment = current
?? fail("SCRIPT_SEGMENT_CLOSE", "Segment close has no matching open.", end);
finishLexicalRun();
segments.push({
id: segment.id,
index: segment.index,
startAnchorId: segmentAnchorId(segment.id, "start"),
endAnchorId: segmentAnchorId(segment.id, "end"),
tokenStart: segment.tokenStart,
tokenEndExclusive: tokens.length,
atoms: segment.atoms,
range: { start: segment.sourceStart, end: sourceOffset + end },
contentRange: { start: segment.contentStart, end: sourceOffset + contentEnd },
selfClosing,
});
current = undefined;
structuralPosition += 1;
};
while (offset < source.length) {
if (source.startsWith("<!--", offset)) {
const end = source.indexOf("-->", offset + 4);
if (end < 0) fail("SCRIPT_COMMENT", "Unclosed Script comment.", offset);
offset = end + 3;
continue;
}
if (!current && source[offset] === "<") {
const self = /^<([a-z][a-z0-9_-]{0,63})\s*\/>/u.exec(source.slice(offset));
const open = self ?? /^<([a-z][a-z0-9_-]{0,63})\s*>/u.exec(source.slice(offset));
if (open) {
const id = open[1]!;
if (!SEGMENT_ID.test(id) || RESERVED_SEGMENT_IDS.has(id)) {
fail("SCRIPT_SEGMENT_ID", `Invalid or reserved Segment id "${id}".`, offset);
}
if (segments.some((segment) => segment.id === id)) {
fail("SCRIPT_SEGMENT_DUPLICATE", `Duplicate Segment id "${id}".`, offset);
}
const contentStart = offset + open[0].length;
precedingCaptionRegion = undefined;
pendingCaptionText = "";
current = {
id,
index: segments.length,
atoms: [],
tokenStart: tokens.length,
sourceStart: sourceOffset + offset,
contentStart: sourceOffset + contentStart,
};
offset = contentStart;
if (self) closeCurrent(offset, true);
continue;
}
const close = /^<\/([^>]+)>/u.exec(source.slice(offset));
if (close) fail("SCRIPT_SEGMENT_CLOSE", `Unexpected Segment close </${close[1]}>.`, offset);
fail(
"SCRIPT_SEGMENT_OPEN",
"Script body expects a lower-case named Segment without attributes.",
offset,
);
}
if (current && source.startsWith("</", offset)) {
const close = /^<\/([a-z][a-z0-9_-]{0,63})\s*>/u.exec(source.slice(offset))
?? fail("SCRIPT_SEGMENT_CLOSE", "Malformed Segment close.", offset);
if (close[1] !== current.id) {
fail("SCRIPT_SEGMENT_MISMATCH", `Segment "${current.id}" was closed by "${close[1]}".`, offset);
}
const contentEnd = offset;
offset += close[0].length;
closeCurrent(offset, false, contentEnd);
continue;
}
const marker = parseMarker(source, offset);
if (marker) {
addMarker(marker, offset);
offset += marker.length;
continue;
}
if (source[offset] === "@") fail("SCRIPT_MARKER", "Unescaped @ must begin a delimited @{...} marker.", offset);
if (source[offset] === "<" && current) {
const end = findUnescaped(source, ">", offset + 1);
if (end < 0) fail("SCRIPT_ANGLE", "Unclosed Script angle construct.", offset);
const inside = source.slice(offset + 1, end);
const pipe = findUnescaped(inside, "|");
if (pipe >= 0) {
finishLexicalRun();
const speech = inside.slice(pipe + 1);
if (!speech.trim()) {
consumeSpeechSide(inside.slice(0, pipe), offset + 1, undefined);
offset = end + 1;
continue;
}
assertDualDisplayLiteral(inside.slice(0, pipe), offset + 1);
const markedDisplay = parseMarkedDisplay(inside.slice(0, pipe), offset + 1);
const display = markedDisplay.display;
consumeSpeechSide(speech, offset + pipe + 2, display, markedDisplay.marks);
offset = end + 1;
continue;
}
if (!ROLE_LABEL.test(inside)) fail("SCRIPT_ANGLE", `Unknown Script construct <${inside}>.`, offset);
finishLexicalRun();
precedingCaptionRegion = undefined;
pendingCaptionText = "";
current.atoms.push({
kind: "role",
label: inside,
range: { start: sourceOffset + offset, end: sourceOffset + end + 1 },
});
offset = end + 1;
continue;
}
const textStart = offset;
while (offset < source.length) {
if (
source.startsWith("<!--", offset)
|| parseMarker(source, offset)
|| source[offset] === "<"
|| source[offset] === "@"
|| source[offset] === "{"
|| source.startsWith("||", offset)
) break;
if (source[offset] === "\\") {
offset += Math.min(2, source.length - offset);
continue;
}
offset += 1;
}
const raw = source.slice(textStart, offset);
for (const piece of literalPieces(raw, textStart)) addText(piece.value, piece.value, piece.start, piece.end, true, piece.positions);
if (source[offset] === "{") {
const block = parseAttributeBlock(source.slice(offset), offset);
if (!runText || /\s$/u.test(runText)) {
fail("SCRIPT_ATTRIBUTE_TARGET", "A token attribute must immediately follow a display token.", offset);
}
runAttributes.push({ position: runText.length, offset, end: offset + block.length, attributes: block.attributes });
offset += block.length;
continue;
}
if (source.startsWith("||", offset)) {
if (!current) fail("SCRIPT_CAPTION_BREAK_EMPTY", "Caption Cue break belongs inside a Segment.", offset);
const value = { tokenIndex: tokens.length, range: { start: sourceOffset + offset, end: sourceOffset + offset + 2 } };
captionBreaks.push(value);
runBoundaries.push({ position: runText.length, offset, kind: "cue", value });
offset += 2;
}
}
if (current) fail("SCRIPT_SEGMENT_UNCLOSED", `Segment "${current.id}" is not closed.`);
const dangling = openSelections.keys().next().value as string | undefined;
if (dangling) fail("SCRIPT_SELECTION_UNCLOSED", `Selection "${dangling}" is not closed.`);
if (!segments.length) fail("SCRIPT_SEGMENT_CARDINALITY", "Script requires at least one Segment.");
const speechSegments: string[] = [];
const captionSegments: string[] = [];
const dialogueTurns: string[] = [];
const turns: ParsedTurn[] = [];
for (const segment of segments) {
const speechTurnBodies: string[] = [];
const captionTurnBodies: string[] = [];
let activeRole: { readonly label: string; readonly start: number } | undefined;
let sawRole = false;
let unownedSpeech = "";
let turnParts: string[] = [];
let captionTurnParts: string[] = [];
let turnTokenStart: number | undefined;
let turnTokenEndExclusive: number | undefined;
let turnEnd = segment.range.end;
const flushTurn = (): void => {
const body = joinProjection(turnParts);
if (activeRole && !body) {
fail("SCRIPT_ROLE_EMPTY", `Role Cue <${activeRole.label}> is not followed by spoken content.`, activeRole.start - sourceOffset);
}
if (body) {
dialogueTurns.push(activeRole ? `${activeRole.label}: ${body}` : body);
speechTurnBodies.push(body);
}
const captionBody = joinProjection(captionTurnParts);
if (captionBody) captionTurnBodies.push(captionBody);
if (turnTokenStart !== undefined && turnTokenEndExclusive !== undefined) {
const ordinal = turns.filter((turn) => turn.segmentId === segment.id).length + 1;
turns.push({
id: `segment:${segment.id}:turn:${ordinal}`,
segmentId: segment.id,
...(activeRole ? { role: activeRole.label } : {}),
tokenStart: turnTokenStart,
tokenEndExclusive: turnTokenEndExclusive,
range: { start: activeRole?.start ?? segment.range.start, end: turnEnd },
});
}
turnParts = [];
captionTurnParts = [];
turnTokenStart = undefined;
turnTokenEndExclusive = undefined;
};
for (const atom of segment.atoms) {
if (atom.kind === "role") {
if (!sawRole && cleanProjection(unownedSpeech)) {
fail("SCRIPT_ROLE_AFTER_TEXT", `Segment "${segment.id}" introduces a Role Cue after unowned speech.`, atom.range.start - sourceOffset);
}
flushTurn();
activeRole = { label: atom.label, start: atom.range.start };
sawRole = true;
continue;
}
if (!sawRole) unownedSpeech += atom.speech;
turnParts.push(atom.speech);
captionTurnParts.push(atom.caption);
if (atom.tokenEndExclusive > atom.tokenStart) {
turnTokenStart ??= atom.tokenStart;
turnTokenEndExclusive = atom.tokenEndExclusive;
turnEnd = atom.range.end;
}
}
flushTurn();
const speech = cleanProjection(speechTurnBodies.join(" "));
const caption = cleanProjection(captionTurnBodies.join(" "));
if (speech) speechSegments.push(speech);
if (caption) captionSegments.push(caption);
}
const segmentById = new Map(segments.map((segment) => [segment.id, segment]));
/**
* Resolve one marker to the exact `2m + 2n` anchor its affinity names. Token and
* Segment cuts are equal citizens: a marker with nothing to its left inside a
* Segment snaps to that Segment's own start, never across into the previous
* Segment, whose end may sit at a different time. The Script body's outer cuts
* are the first Segment start and last Segment end; Script owns no Timeline bounds.
*/
const anchorForBoundary = (boundary: RawMarkerBoundary, affinity: Affinity): string => {
if (segments.length === 0) fail("SCRIPT_ANCHOR_UNRESOLVED", "Script declares no Segment to anchor markers to.");
const inside = boundary.segmentId === undefined ? undefined : segmentById.get(boundary.segmentId);
if (inside) {
if (affinity === "left") {
return boundary.tokenIndex > inside.tokenStart
? tokens[boundary.tokenIndex - 1]!.endAnchorId
: inside.startAnchorId;
}
return boundary.tokenIndex < inside.tokenEndExclusive
? tokens[boundary.tokenIndex]!.startAnchorId
: inside.endAnchorId;
}
// Outside or between Segments: structuralPosition counts the Segments already closed.
if (affinity === "left") {
const previous = segments[boundary.structuralPosition - 1];
return previous ? previous.endAnchorId : segments[0]!.startAnchorId;
}
const next = segments[boundary.structuralPosition];
return next ? next.startAnchorId : segments.at(-1)!.endAnchorId;
};
const anchored = <T extends { readonly affinity: Affinity; readonly boundary: RawMarkerBoundary }>(
edge: T,
): Omit<T, "boundary"> & { readonly boundary: MarkerBoundary } => ({
...edge,
boundary: { ...edge.boundary, anchorId: anchorForBoundary(edge.boundary, edge.affinity) },
});
const anchors: SemanticAnchor[] = [
...segments.flatMap((segment) => [
{ id: segment.startAnchorId, kind: "segment-start" as const, segmentId: segment.id },
...tokens.slice(segment.tokenStart, segment.tokenEndExclusive).flatMap((token) => [
{
id: token.startAnchorId,
kind: "token-start" as const,
segmentId: segment.id,
tokenId: token.id,
},
{
id: token.endAnchorId,
kind: "token-end" as const,
segmentId: segment.id,
tokenId: token.id,
},
]),
{ id: segment.endAnchorId, kind: "segment-end" as const, segmentId: segment.id },
]),
];
if (anchors.length !== 2 * tokens.length + 2 * segments.length) {
fail("SCRIPT_ANCHOR_CARDINALITY", "Semantic anchor cardinality is not 2M + 2N.");
}
return {
sourceRange: { start: sourceOffset, end: sourceOffset + source.length },
segments,
tokens,
turns,
selections: [...selections].sort(([left], [right]) => left.localeCompare(right)).map(([, selection]) => {
const open = anchored(selection.open);
const close = anchored(selection.close);
return {
...selection,
startAnchorId: open.boundary.anchorId,
endAnchorId: close.boundary.anchorId,
open,
close,
};
}),
moments: [...moments].sort(([left], [right]) => left.localeCompare(right)).map(([, moment]) => {
const value = anchored(moment);
return { ...value, anchorId: value.boundary.anchorId };
}),
captionProjection: {
text: captionSegments.join("\n"),
regions: captionRegions,
breaks: captionBreaks,
},
anchors,
serializations: {
dialogue: dialogueTurns.join("\n"),
speech: speechSegments.join("\n"),
},
};
}