// Top-level orchestration for the independent Scandroid engine. Public API: // `scanTextNatively(rawLines)` takes ONLY the raw verse-line strings of a // poem (or a single line) and returns one rich, self-contained result per // line -- Hartman's own verdict, Hartman's own stress map, in Hartman's own // symbols and foot names. Nothing here reads anything Calliope has already // worked out about the same lines. import { footDict, AnapSubs, lineLengthName, FOOTDIV } from './constants.js'; import { invertFootDict } from './utilities.js'; import { ScansionMachine } from './machine.js'; import { deduceParametersForPoem, deduceParametersForSingleLine, type ScandroidParams, type Metron } from './deduceParameters.js'; const footDictInverse = invertFootDict(footDict); const anapSubsInverse = invertFootDict(AnapSubs); export interface ScandroidNativeFootResult { /** 'Corral the Weird' | 'Maximize the Normal' | 'Anapestic' */ algorithm: string; footlist: string[]; /** Feet joined by FOOTDIV ('|'), each foot's own STRESS/SLACK/PROMOTED marks. */ scanString: string; /** The same marks with no foot divisions -- one character per syllable. */ marksString: string; substitutions: number; /** false = FAIL: wrong foot count against a fixed line length, a pyrrhic * before a trochee, an amphibrach inside an anapestic line, etc. */ ok: boolean; failReason?: string; } export interface ScandroidNativeLineResult { metron: Metron; metronName: string; // 'IAMBIC' | 'ANAPESTIC', Hartman's own status-bar wording lineFeet: number; lineFeetSet: boolean; lineLengthName: string; // e.g. 'PENTAMETER', or 'VARIABLE' when lineFeetSet is false /** The ChooseAlgorithm / GetBestAnapLexes winner -- Scandroid's own verdict. */ verdict: ScandroidNativeFootResult | null; /** Iambic lines only: each algorithm's own independent best, for side-by-side display. */ corralTheWeird?: ScandroidNativeFootResult | null; maximizeTheNormal?: ScandroidNativeFootResult | null; /** 0-based syllable indices where a pyrrhic/slack-run was promoted to a beat. */ promotions: number[]; } // Display-only helper (not a port of anything in the 2005 source): walks the // footlist in pattern-length chunks to insert FOOTDIV between feet. On a // length-mismatch FAIL the footlist can run past the syllables actually // available (CleanUpRE/TestLengthAndDice keep assembling feet from whatever // state a wrong-length line leaves them in) -- stop once marks are exhausted // rather than emit a trail of empty '||||' segments. function renderFootDividedString(marks: string, footlist: string[], patternDict: Record): string { const parts: string[] = []; let pos = 0; for (const foot of footlist) { if (pos >= marks.length) break; const len = patternDict[foot]?.length ?? 0; parts.push(marks.slice(pos, pos + len)); pos += len; } return parts.join(FOOTDIV); } function buildFootResult( algorithm: string, footlist: string[], marks: string, substitutions: number, ok: boolean, patternDict: Record, failReason?: string ): ScandroidNativeFootResult { return { algorithm, footlist: footlist.slice(), scanString: renderFootDividedString(marks, footlist, patternDict), marksString: marks, substitutions, ok, failReason, }; } function runIndividualIambic( line: string, params: ScandroidParams, algorithm: 1 | 2, candidate: { marks: string; complexity: number }, algLabel: string ): ScandroidNativeFootResult { const m = new ScansionMachine(); m.SetLineFeet(params.lineFeet, params.lineFeetSet); m.ParseLine(line); // Mirror DoAlgorithm's own auto-length side effect (this candidate never ran // DoAlgorithm on THIS fresh machine, since we're going straight to the // step-by-step path) -- every candidate shares the same total syllable // count, so this is exactly what DoAlgorithm would have set LD.lfeet to. if (!params.lineFeetSet) m.LD.lfeet = Math.floor(candidate.marks.length / 2); const stepOk = m.runIambicStepByStep(algorithm, candidate.marks); const promote = m.PromotePyrrhics(); const howwedoing = m.HowWeDoing(); return buildFootResult(algLabel, m.LD.footlist, m.P.GetMarks(), howwedoing.substitutions, stepOk && promote.ok && howwedoing.ok, footDictInverse); } function lengthName(feet: number, feetSet: boolean): string { return feetSet ? lineLengthName[feet] ?? '' : 'VARIABLE'; } /** Scan one line under already-deduced poem-level (or single-line) parameters. */ export function scanLineNatively(line: string, params: ScandroidParams): ScandroidNativeLineResult { const metronName = params.metron === 'iambic' ? 'IAMBIC' : 'ANAPESTIC'; if (params.metron === 'iambic') { const machine = new ScansionMachine(); machine.SetLineFeet(params.lineFeet, params.lineFeetSet); machine.ParseLine(line); const chosen = machine.ChooseAlgorithm(); if (!chosen) { return { metron: 'iambic', metronName, lineFeet: params.lineFeet, lineFeetSet: params.lineFeetSet, lineLengthName: lengthName(params.lineFeet, params.lineFeetSet), verdict: null, corralTheWeird: null, maximizeTheNormal: null, promotions: [], }; } const promote = machine.PromotePyrrhics(); const howwedoing = machine.HowWeDoing(); const verdict = buildFootResult( chosen.verdictAlgorithm === 1 ? 'Corral the Weird' : 'Maximize the Normal', machine.LD.footlist, machine.P.GetMarks(), howwedoing.substitutions, chosen.verdictOk && promote.ok && howwedoing.ok, footDictInverse ); const corral = chosen.alg1Best ? runIndividualIambic(line, params, 1, chosen.alg1Best, 'Corral the Weird') : null; const maximize = chosen.alg2Best ? runIndividualIambic(line, params, 2, chosen.alg2Best, 'Maximize the Normal') : null; return { metron: 'iambic', metronName, lineFeet: machine.LD.lfeet, lineFeetSet: params.lineFeetSet, lineLengthName: lengthName(machine.LD.lfeet, params.lineFeetSet), verdict, corralTheWeird: corral, maximizeTheNormal: maximize, promotions: promote.promotions, }; } // Anapestic const machine = new ScansionMachine(); machine.SetLineFeet(params.lineFeet, params.lineFeetSet); machine.ParseLine(line); const best = machine.GetBestAnapLexes(); if (!best) { return { metron: 'anapestic', metronName, lineFeet: params.lineFeet, lineFeetSet: params.lineFeetSet, lineLengthName: lengthName(params.lineFeet, params.lineFeetSet), verdict: null, promotions: [], }; } const cleanup = machine.AnapCleanUpAndReport(); const verdict = buildFootResult( 'Anapestic', machine.LD.footlist, machine.P.GetMarks(), cleanup.substitutions, cleanup.ok, anapSubsInverse, cleanup.fail ); return { metron: 'anapestic', metronName, lineFeet: machine.LD.lfeet, lineFeetSet: params.lineFeetSet, lineLengthName: lengthName(machine.LD.lfeet, params.lineFeetSet), verdict, promotions: [], }; } /** Scan a whole poem (or a single line): deduces Metron/line-length exactly * once over the full raw-line list -- matching Scandroid's own "loaded * document" behaviour for 2+ lines, and its "typed single line" behaviour * (variable length, per-line Metron) for exactly one line -- then scans * every line under those shared parameters. `rawLines` should be every * non-blank line of the poem in reading order, spanning stanza breaks: the * original deduces Metron/length from up to a dozen lines of the whole * loaded document, not per stanza. */ export function scanTextNatively(rawLines: string[]): ScandroidNativeLineResult[] { if (rawLines.length === 0) return []; const params = rawLines.length < 2 ? deduceParametersForSingleLine(rawLines[0]) : deduceParametersForPoem(rawLines); return rawLines.map(line => scanLineNatively(line, params)); }