File size: 5,581 Bytes
ac6c361 8edf447 ac6c361 8edf447 bf1a1f0 f3fb7bc ac6c361 8edf447 ac6c361 f3fb7bc ac6c361 8edf447 ac6c361 f3fb7bc 8edf447 ac6c361 f3fb7bc ac6c361 f3fb7bc bf1a1f0 ac6c361 bf1a1f0 ac6c361 f3fb7bc ac6c361 f3fb7bc ac6c361 f3fb7bc bf1a1f0 ac6c361 f3fb7bc bf1a1f0 f3fb7bc bf1a1f0 f3fb7bc bf1a1f0 f3fb7bc 8edf447 ac6c361 bf1a1f0 f3fb7bc ac6c361 f3fb7bc 8edf447 ac6c361 8edf447 bf1a1f0 8edf447 bf1a1f0 f3fb7bc ac6c361 f3fb7bc ac6c361 8edf447 ac6c361 040f97b ac6c361 bf1a1f0 ac6c361 8edf447 040f97b ac6c361 040f97b 8edf447 bf1a1f0 8edf447 bf1a1f0 8edf447 bf1a1f0 8edf447 bf1a1f0 ac6c361 f3fb7bc ac6c361 f3fb7bc ac6c361 bf1a1f0 ac6c361 | 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 182 183 184 185 186 187 188 189 | """
subtitle_parser.py β Subtitle Parsing & Writing
Handles .srt / .vtt / .ass via pysubs2.
"""
import copy
import logging
import os
import re
import shutil
import tempfile
import pysubs2
logger = logging.getLogger(__name__)
WORKSPACE_DIR = os.path.join(tempfile.gettempdir(), "subsync_workspace")
os.makedirs(WORKSPACE_DIR, exist_ok=True)
# ASS styles that should NOT be translated (romaji, karaoke, etc.)
SKIP_STYLES = {
"EDR", "Romaji", "JP", "Karaoke", "KAR",
"Signs", "Lyrics_JP", "Romaji2", "OP_Romaji", "ED_Romaji",
}
_EXT_MAP = {".srt": "srt", ".vtt": "vtt", ".ass": "ass", ".ssa": "ass"}
_ASS_TAG_RE = re.compile(r"\{[^}]*\}")
_HTML_TAG_RE = re.compile(r"<[^>]+>")
_LEADING_TAGS_RE = re.compile(r"^(\{[^}]*\})+")
def is_skip_style(style_name: str) -> bool:
"""Return True if this ASS style should be skipped (not translated)."""
if not style_name:
return False
s = style_name.strip()
if s in SKIP_STYLES:
return True
sl = s.lower()
return any(k in sl for k in ["romaji", "karaoke", "kar_", "_kar", "furigana"])
def _detect_format(filename: str) -> str:
ext = os.path.splitext(filename.lower())[1]
fmt = _EXT_MAP.get(ext)
if not fmt:
raise ValueError(
f"'{ext}' format ααα― support ααα―ααΊαα« β .srt / .vtt / .ass αα¬ αααΊααΆαααΊ"
)
return fmt
def _strip_tags(text: str) -> str:
text = _ASS_TAG_RE.sub("", text)
text = _HTML_TAG_RE.sub("", text)
return text.strip()
def parse_subtitle(file_bytes: bytes, filename: str):
"""Parse subtitle bytes β (SSAFile, fmt_string)."""
fmt = _detect_format(filename)
text = None
for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"):
try:
text = file_bytes.decode(enc)
break
except (UnicodeDecodeError, LookupError):
continue
if text is None:
raise ValueError("ααα―ααΊ encoding αααΊα αααα«")
text = text.lstrip("\ufeff")
try:
subs = pysubs2.SSAFile.from_string(text)
logger.info("Parsed %d events from '%s' (%s)", len(subs), filename, fmt)
return subs, fmt
except Exception as e1:
logger.warning("from_string failed: %s", e1)
try:
suffix = os.path.splitext(filename)[1] or ("." + fmt)
fd, tmp = tempfile.mkstemp(suffix=suffix)
try:
os.write(fd, file_bytes)
finally:
os.close(fd)
subs = pysubs2.SSAFile.load(tmp)
os.unlink(tmp)
return subs, fmt
except Exception as e2:
raise ValueError(
f"Parse αααα«:\nAttempt 1: {e1}\nAttempt 2: {e2}"
) from e2
def get_plain_lines(ssafile) -> list:
"""
Return list[str] same length as ssafile.events.
- Comment events β "" (keep original, not displayed)
- Skip styles β "" (EDR/romaji/karaoke etc.)
- Normal events β clean text (tags stripped)
Empty string β replace_lines keeps original text unchanged.
"""
lines = []
for event in ssafile.events:
# Skip ASS Comment lines (editor notes, not shown to viewer)
if getattr(event, "is_comment", False):
lines.append("")
continue
# Skip romaji / karaoke / non-English style tracks
if is_skip_style(event.style):
lines.append("")
continue
raw = event.text.replace("\\N", "\n").replace("\\n", "\n")
clean = _strip_tags(raw)
lines.append(clean)
return lines
def replace_lines(ssafile, new_lines: list):
"""
Return new SSAFile with translated text. Timestamps never touched.
Leading ASS override tags {\\pos(...)\\be5} preserved from original.
Empty translation β keep original event.text.
"""
n_events = len(ssafile.events)
n_lines = len(new_lines)
if n_lines != n_events:
raise ValueError(
f"TIMESTAMP SAFETY: translated lines ({n_lines}) "
f"!= subtitle events ({n_events})"
)
result = copy.deepcopy(ssafile)
for i, event in enumerate(result.events):
translated = str(new_lines[i]).strip()
# Empty β keep original (skip-style or fallback)
if not translated:
continue
# Re-prepend leading inline tags from original
original_text = ssafile.events[i].text
leading_m = _LEADING_TAGS_RE.match(original_text)
tag_prefix = leading_m.group(0) if leading_m else ""
event.text = tag_prefix + translated.replace("\n", "\\N")
return result
def write_subtitle(ssafile, fmt: str) -> bytes:
try:
return ssafile.to_string(fmt).encode("utf-8")
except Exception as exc:
raise RuntimeError(f"Subtitle write αααα«: {exc}") from exc
def output_filename(original_name: str) -> str:
base, ext = os.path.splitext(original_name)
return f"{base}.my{ext}"
def clean_workspace() -> None:
try:
if os.path.exists(WORKSPACE_DIR):
shutil.rmtree(WORKSPACE_DIR)
os.makedirs(WORKSPACE_DIR, exist_ok=True)
except Exception as exc:
raise RuntimeError(f"Workspace ααΎααΊαΈαααα«: {exc}") from exc
def workspace_size_mb() -> float:
total = 0
for dirpath, _, filenames in os.walk(WORKSPACE_DIR):
for f in filenames:
try:
total += os.path.getsize(os.path.join(dirpath, f))
except OSError:
pass
return round(total / (1024 * 1024), 2)
|