Skip to content

Commit ef4e7e8

Browse files
authored
Merge pull request #721 from ac1982/fix/subtitle-cue-escaping
fix(subtitle): escape literal transcript text in WebVTT
2 parents 9b748de + 5a8e4aa commit ef4e7e8

2 files changed

Lines changed: 64 additions & 5 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Transcript text stays literal while generated karaoke markup remains active."""
2+
import json
3+
import shutil
4+
import subprocess
5+
6+
import pytest
7+
8+
from tools.subtitle.subtitle_gen import SubtitleGen
9+
10+
11+
@pytest.mark.parametrize("style", ["none", "word_by_word", "karaoke"])
12+
def test_transcript_markup_is_escaped(tmp_path, style):
13+
fmt = "vtt"
14+
output = tmp_path / f"captions.{fmt}"
15+
result = SubtitleGen().execute({
16+
"segments": [{"words": [
17+
{"word": "<b>tag</b>", "start": 0, "end": 1},
18+
{"word": "&amp;", "start": 1, "end": 2},
19+
]}],
20+
"format": fmt,
21+
"highlight_style": style,
22+
"output_path": str(output),
23+
})
24+
assert result.success, result.error
25+
content = output.read_text()
26+
assert "&lt;b&gt;tag&lt;/b&gt;" in content
27+
assert "&amp;amp;" in content
28+
if style == "karaoke":
29+
assert "<b>&lt;b&gt;tag&lt;/b&gt;</b>" in content
30+
else:
31+
assert "<b>" not in content
32+
33+
34+
def test_caption_json_keeps_raw_text(tmp_path):
35+
output = tmp_path / "captions.json"
36+
result = SubtitleGen().execute({
37+
"segments": [{"text": "<b>tag</b> &amp;", "start": 0, "end": 1}],
38+
"format": "json", "output_path": str(output),
39+
})
40+
assert result.success, result.error
41+
assert json.loads(output.read_text())["cues"][0]["text"] == "<b>tag</b> &amp;"
42+
43+
44+
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
45+
@pytest.mark.parametrize("style", ["none", "word_by_word", "karaoke"])
46+
def test_srt_remains_compatible_with_ffmpeg(tmp_path, style):
47+
source = tmp_path / "captions.srt"
48+
output = tmp_path / "captions.ass"
49+
result = SubtitleGen().execute({
50+
"segments": [{"words": [{"word": "AT&T", "start": 0, "end": 1}]}],
51+
"format": "srt", "highlight_style": style, "output_path": str(source),
52+
})
53+
assert result.success, result.error
54+
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(source), str(output)],
55+
check=True, capture_output=True, timeout=10)
56+
rendered = output.read_text()
57+
assert "AT&T" in rendered
58+
assert "&amp;" not in rendered

‎tools/subtitle/subtitle_gen.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import json
11+
from html import escape
1112
import time
1213
from pathlib import Path
1314
from typing import Any
@@ -279,14 +280,14 @@ def _render_vtt(self, cues: list[dict], highlight_style: str = "none") -> str:
279280
lines.append(
280281
f"{self._ts_vtt(word_info['start'])} --> {self._ts_vtt(word_info['end'])}"
281282
)
282-
lines.append(word_info["word"])
283+
lines.append(escape(word_info["word"], quote=False))
283284
lines.append("")
284285
elif highlight_style == "karaoke":
285286
for cue in cues:
286287
words = cue.get("words", [])
287288
if not words:
288289
lines.append(f"{self._ts_vtt(cue['start'])} --> {self._ts_vtt(cue['end'])}")
289-
lines.append(cue["text"])
290+
lines.append(escape(cue["text"], quote=False))
290291
lines.append("")
291292
continue
292293
for wi, word_info in enumerate(words):
@@ -296,15 +297,15 @@ def _render_vtt(self, cues: list[dict], highlight_style: str = "none") -> str:
296297
parts = []
297298
for wj, w in enumerate(words):
298299
if wj == wi:
299-
parts.append(f"<b>{w['word']}</b>")
300+
parts.append(f"<b>{escape(w['word'], quote=False)}</b>")
300301
else:
301-
parts.append(w["word"])
302+
parts.append(escape(w["word"], quote=False))
302303
lines.append(" ".join(parts))
303304
lines.append("")
304305
else:
305306
for cue in cues:
306307
lines.append(f"{self._ts_vtt(cue['start'])} --> {self._ts_vtt(cue['end'])}")
307-
lines.append(cue["text"])
308+
lines.append(escape(cue["text"], quote=False))
308309
lines.append("")
309310
return "\n".join(lines)
310311

0 commit comments

Comments
 (0)