File size: 7,901 Bytes
5ea8612
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// 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, string>): 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<string, string>,
  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));
}