"""Parse a DOCX dub script laid out as a table. Expected columns (matched by header name, any order): Sr. No. | Start Time | End Time | Character | Dialogues Timecodes are frame-based HH:MM:SS:FF; fps is inferred from the frames seen unless passed explicitly. No python-docx dependency — we read the raw XML. """ from __future__ import annotations import re import zipfile from pathlib import Path from xml.etree import ElementTree as ET from .base import ScriptDoc, ScriptSegment, infer_fps_from_frames, normalize_character, parse_timecode _W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" _TC_RE = re.compile(r"^\d{1,2}:\d{2}:\d{2}[:;]\d{1,3}$") def _cell_text(tc) -> str: return "".join(t.text or "" for t in tc.iter(_W + "t")).strip() def _find_columns(header: list[str]) -> dict[str, int]: """Map logical column -> index by fuzzy header match.""" idx: dict[str, int] = {} for i, h in enumerate(header): hl = h.lower() if "start" in hl and "start" not in idx_keys(idx): idx["start"] = i elif "end" in hl: idx["end"] = i elif "character" in hl or "speaker" in hl or "char" in hl: idx["character"] = i elif "dialog" in hl or "dialogue" in hl or "line" in hl or "text" in hl: idx["text"] = i return idx def idx_keys(d: dict) -> set: return set(d.keys()) def parse(path: Path, fps: float | None = None) -> ScriptDoc: root = ET.fromstring(zipfile.ZipFile(path).read("word/document.xml")) rows: list[list[str]] = [] for tr in root.iter(_W + "tr"): rows.append([_cell_text(tc) for tc in tr.findall(_W + "tc")]) if not rows: raise ValueError("No table rows found in DOCX") # Locate header (row that names Character + a time column); fall back to row 0. header_i = 0 for i, r in enumerate(rows[:5]): joined = " ".join(r).lower() if "character" in joined and ("start" in joined or "time" in joined): header_i = i break cols = _find_columns(rows[header_i]) # Detect max frame value to infer fps if not given. max_frame = 0 for r in rows[header_i + 1:]: for cell in r: if _TC_RE.match(cell): max_frame = max(max_frame, int(re.split(r"[:;]", cell)[-1])) use_fps = fps or infer_fps_from_frames(max_frame) segments: list[ScriptSegment] = [] n = 0 for r in rows[header_i + 1:]: # Resolve start/end either by header columns or by "two timecode cells". if {"start", "end", "character"} <= idx_keys(cols) and len(r) > max(cols["start"], cols["end"], cols["character"]): start_cell, end_cell = r[cols["start"]], r[cols["end"]] char_cell = r[cols["character"]] text_cell = r[cols["text"]] if "text" in cols and len(r) > cols["text"] else "" else: tc_idx = [i for i, c in enumerate(r) if _TC_RE.match(c)] if len(tc_idx) < 2 or tc_idx[1] + 1 >= len(r): continue start_cell, end_cell = r[tc_idx[0]], r[tc_idx[1]] char_cell = r[tc_idx[1] + 1] text_cell = r[tc_idx[1] + 2] if tc_idx[1] + 2 < len(r) else "" if not (_TC_RE.match(start_cell) and _TC_RE.match(end_cell)) or not char_cell: continue try: start_s = parse_timecode(start_cell, use_fps) end_s = parse_timecode(end_cell, use_fps) except ValueError: continue if end_s <= start_s: continue segments.append(ScriptSegment( index=n, start_s=round(start_s, 3), end_s=round(end_s, 3), character_raw=char_cell, characters=normalize_character(char_cell), text=text_cell, )) n += 1 if not segments: raise ValueError("No timecoded character rows parsed from DOCX") return ScriptDoc(source_format="docx", fps=use_fps, segments=segments)