The longform marker dialect (# heading / [voice:] / [pause] / SSML-lite) was
parsed by three independent code paths that already disagreed (client vs server
on [pause] units, [voice:] empty, H1-only chapters). This lands the single
canonical Python parser; the JS port + cross-impl test follow in slice B.
- New backend/services/longform_parser.py — parse_script_to_spans(text, *,
default_voice, default_speed) + _parse_chapter_body (the reusable voice→pause
→SSML layering the JS twin mirrors). Moves the H1/voice regexes verbatim from
audiobook.py (already CodeQL-cleared), reuses parse_pause_markers + ssml_lite
unchanged. Coerces None→"" and normalizes CRLF/CR→LF at entry (cross-platform
parity so Windows-authored scripts never carry a stray \r). Adds default_speed
plumbing (inline SSML speed overrides the per-line default).
- audiobook.py: parse_audiobook_script is now a thin wrapper that wraps the
canonical span dicts in Span/Chapter/AudiobookPlan — public return type and
.to_dict() shape unchanged, all four router call sites untouched. Deleted
_parse_spans / _HEADING_RE / _VOICE_RE and the now-dead `import re` +
parse_pause_markers import.
- tests/fixtures/longform_parser_cases.json — 78-case golden corpus (≥40
required) covering §A–I: H1-only chapters (H2–H6 + `# ` no-title → body), the
full pause dialect incl. the NO-MATCH boundary, banker's-rounding ties
([pause 0.5]→0, [pause 1.5]→2), [voice:] empty→default, [voice:[nested]]
literal, SSML nesting/spell/unknown-tag, speed override, CRLF, combined
precedence. Generated from actual parser output (the truth the JS port must
match).
- tests/test_longform_parser.py — parametrized over the corpus + None-input +
ReDoS-linearity (5000× repeats < 1 s).
130 passed (corpus + test_audiobook + test_pause_markers + test_ssml_lite all
green); CJK guard green.