← Files SRT Subtitle TranslatorARCHIVED FILE

skills/translate-srt/scripts/srt_tool.py

7.76 KB · Sep 30, 2026 · 23:18 UTC

↓ Download file

"""Inspect and rebuild SRT subtitles without translating or using the network."""
import argparse
import codecs
import hashlib
import json
import re
import sys
from pathlib import Path

TIME = r"\d{2,}:\d{2}:\d{2},\d{3}"
TIMING = re.compile(rf"^({TIME})[ \t]+-->[ \t]+({TIME})(?:[ \t]+[^\r\n]+)?[ \t]*$")
TOKENS = re.compile(r"<[^>\r\n]+>|\{\\[^}\r\n]*\}|\\[Nnh]")


def ms(value):
    h, m, rest = value.split(":")
    s, milli = rest.split(",")
    if int(m) > 59 or int(s) > 59:
        raise ValueError(f"Invalid timestamp: {value}")
    return ((int(h) * 60 + int(m)) * 60 + int(s)) * 1000 + int(milli)


def parse(text):
    normalized = text.replace("\r\n", "\n").replace("\r", "\n")
    blocks = re.split(r"\n[ \t]*\n(?:[ \t]*\n)*", normalized.strip("\n"))
    cues = []
    for position, block in enumerate(blocks):
        lines = block.split("\n")
        if len(lines) < 3 or not re.fullmatch(r"[ \t]*[0-9]+[ \t]*", lines[0]):
            raise ValueError(f"Malformed cue at position {position}: expected number, timing, and text")
        match = TIMING.fullmatch(lines[1])
        if not match:
            raise ValueError(f"Malformed timestamp at position {position}")
        start, end = ms(match[1]), ms(match[2])
        if end < start:
            raise ValueError(f"Cue ends before it starts at position {position}")
        body = "\n".join(lines[2:])
        if not body.strip():
            raise ValueError(f"Empty source cue at position {position}")
        cues.append(dict(position=position, number=lines[0], timestamp=lines[1], text=body,
                         start_ms=start, end_ms=end))
    if not cues:
        raise ValueError("No SRT cues found")
    return cues


def load_source(path, encoding=None):
    raw = Path(path).read_bytes()
    if encoding:
        codec = encoding
    elif raw.startswith(codecs.BOM_UTF16_LE) or raw.startswith(codecs.BOM_UTF16_BE):
        codec = "utf-16"
    else:
        codec = "utf-8-sig"
    try:
        text = raw.decode(codec, errors="strict").lstrip("\ufeff")
    except (UnicodeError, LookupError) as exc:
        raise ValueError("Cannot decode source safely; supply the correct --encoding") from exc
    crlf = text.count("\r\n")
    lone_lf = text.count("\n") - crlf
    lone_cr = text.count("\r") - crlf
    newline = max(((crlf, "\r\n"), (lone_lf, "\n"), (lone_cr, "\r")), key=lambda item: item[0])[1]
    if not crlf and not lone_lf and not lone_cr:
        newline = "\n"
    cues = parse(text)
    warnings = [f"Source cue at position {b['position']} starts before the previous cue ends; timings preserved."
                for a, b in zip(cues, cues[1:]) if b["start_ms"] < a["end_ms"]]
    return raw, cues, newline, raw.startswith(codecs.BOM_UTF8), codec, warnings


def write_new(path, data, protected):
    target = Path(path)
    if target.resolve() in {Path(p).resolve() for p in protected}:
        raise ValueError("Output must not overwrite a source or another task artifact")
    if target.exists():
        raise ValueError(f"Output already exists; choose a new filename: {target.name}")
    target.parent.mkdir(parents=True, exist_ok=True)
    with target.open("xb") as handle:
        handle.write(data)


def json_bytes(data):
    return (json.dumps(data, ensure_ascii=False, indent=2) + "\n").encode("utf-8")


def build(source, translations, output, report_path=None, encoding=None, bilingual=False):
    raw, cues, newline, bom, codec, warnings = load_source(source, encoding)
    items = json.loads(Path(translations).read_text(encoding="utf-8-sig"))
    if not isinstance(items, list):
        raise ValueError("Translations must be a JSON array")
    translated = {}
    for item in items:
        if not isinstance(item, dict) or type(item.get("position")) is not int or not isinstance(item.get("text"), str):
            raise ValueError("Every translation requires integer position and string text")
        position = item["position"]
        if position in translated:
            raise ValueError(f"Duplicate translation position: {position}")
        body = item["text"].replace("\r\n", "\n").replace("\r", "\n")
        if not body.strip() or body != body.strip("\n") or re.search(r"\n[ \t]*\n", body) or "\x00" in body:
            raise ValueError(f"Blank or structurally invalid translation at position {position}")
        translated[position] = body
    if set(translated) != set(range(len(cues))):
        missing = sorted(set(range(len(cues))) - set(translated))
        extra = sorted(set(translated) - set(range(len(cues))))
        raise ValueError(f"Incomplete coverage: missing={missing[:20]}, extra={extra[:20]}")
    blocks = []
    for cue in cues:
        body = translated[cue["position"]]
        if TOKENS.findall(body) != TOKENS.findall(cue["text"]):
            raise ValueError(f"Formatting/control tokens changed at position {cue['position']}")
        if bilingual:
            body = cue["text"] + "\n" + body
        blocks.append(cue["number"] + "\n" + cue["timestamp"] + "\n" + body)
    result = "\n\n".join(blocks) + "\n"
    verified = parse(result)
    checks = {
        "cue_count_unchanged": len(verified) == len(cues),
        "number_lines_unchanged": [c["number"] for c in verified] == [c["number"] for c in cues],
        "timestamp_lines_unchanged": [c["timestamp"] for c in verified] == [c["timestamp"] for c in cues],
        "all_positions_translated_once": len(translated) == len(cues),
        "protected_tokens_preserved": True,
    }
    if not all(checks.values()):
        raise ValueError("Generated file failed structural validation")
    data = (codecs.BOM_UTF8 if bom else b"") + result.replace("\n", newline).encode("utf-8")
    report = dict(status="passed", cue_count=len(cues), mode="bilingual" if bilingual else "translation",
                  source_encoding=codec, output_encoding="utf-8-sig" if bom else "utf-8",
                  source_sha256=hashlib.sha256(raw).hexdigest(), output_sha256=hashlib.sha256(data).hexdigest(),
                  checks=checks, warnings=warnings)
    protected = [source, translations]
    if report_path:
        if Path(report_path).resolve() in {Path(p).resolve() for p in protected + [output]} or Path(report_path).exists():
            raise ValueError("Report path must be new and distinct from all input/output files")
    write_new(output, data, protected)
    if report_path:
        write_new(report_path, json_bytes(report), protected + [output])
    return report


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    sub = parser.add_subparsers(dest="command", required=True)
    inspect = sub.add_parser("inspect")
    inspect.add_argument("source")
    inspect.add_argument("--output", required=True)
    inspect.add_argument("--encoding")
    create = sub.add_parser("build")
    create.add_argument("source")
    create.add_argument("translations")
    create.add_argument("--output", required=True)
    create.add_argument("--report")
    create.add_argument("--encoding")
    create.add_argument("--bilingual", action="store_true")
    args = parser.parse_args()
    try:
        if args.command == "inspect":
            raw, cues, newline, bom, codec, warnings = load_source(args.source, args.encoding)
            manifest = dict(cue_count=len(cues), source_encoding=codec, warnings=warnings, cues=cues)
            write_new(args.output, json_bytes(manifest), [args.source])
            print(json.dumps(dict(status="inspected", cue_count=len(cues), warnings=warnings)))
        else:
            print(json.dumps(build(args.source, args.translations, args.output, args.report,
                                   args.encoding, args.bilingual), ensure_ascii=False))
    except (ValueError, OSError) as exc:
        print(json.dumps(dict(status="error", error=str(exc)), ensure_ascii=False), file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

SHA-256: bebeb31ac19a69fc8fdbb2aff3474fb64d5643e8c90d4995cdd9c062a3e55afd