fix(cli): preserve literal WebVTT cue text (#5236)

This commit is contained in:
Abdullah M. Nazif
2026-10-08 21:16:36 +00:00
committed by GitHub
parent fe38e8fe12
commit 1fac5fb29d
2 changed files with 39 additions and 2 deletions
@@ -281,6 +281,33 @@ Short format
});
describe("caption formatting", () => {
it.each([
["R&D <config> next", "R&amp;D &lt;config&gt; next"],
["Literal &lt; &amp; references", "Literal &amp;lt; &amp;amp; references"],
["3 < 4 and 5 > 2", "3 &lt; 4 and 5 &gt; 2"],
["R&D --> next", "R&amp;D --&gt; next"],
])("preserves literal WebVTT text %j on export and reimport", (text, payload) => {
const words = [{ text, start: 1, end: 2, id: "w0" }];
const output = formatVtt(words);
expect(output).toBe(`WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n${payload}\n`);
expect(loadTranscript(tmpFile("literal.vtt", output)).words).toEqual(words);
});
it.each([
["R&amp;D &lt;config&gt; next", "R&D <config> next"],
["&quot;quoted&quot; &apos;text&apos;", "\"quoted\" 'text'"],
["&#38; &#x3c; &#60;", "& < <"],
["Literal &amp;lt; &amp;amp; references", "Literal &lt; &amp; references"],
["<b>R&amp;D</b> &lt;config&gt;", "R&D <config>"],
["A&nbsp;B&lrm;&rlm;", "A\u00a0B\u200e\u200f"],
["Keep &unknown;", "Keep &unknown;"],
])("decodes the WebVTT cue payload %j once", (payload, text) => {
const input = `WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n${payload}\n`;
expect(loadTranscript(tmpFile("entities.vtt", input)).words).toEqual([
{ text, start: 1, end: 2, id: "w0" },
]);
});
it("round-trips SRT cues through normalized words", () => {
const srt = `1
00:00:01,000 --> 00:00:03,500
+12 -2
View File
@@ -1,5 +1,6 @@
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { extname, join } from "node:path";
import { parseHTML } from "linkedom";
export interface Word {
/** Stable identifier for referencing this word in overrides and compositions.
@@ -217,6 +218,14 @@ function parseSrt(content: string): Word[] {
return words;
}
function decodeVttText(text: string): string {
if (!text.includes("&")) return text;
const { document } = parseHTML("<html><body></body></html>");
const element = document.createElement("span");
element.innerHTML = text.replace(/</g, "&lt;");
return element.textContent ?? "";
}
function parseVtt(content: string): Word[] {
const body = content.replace(/^WEBVTT[^\n]*\n/, "");
// VTT is structurally similar to SRT (without numeric indices)
@@ -239,7 +248,7 @@ function parseVtt(content: string): Word[] {
if (!text) continue;
words.push({
text,
text: decodeVttText(text),
start: parseVttTimestamp(startStr),
end: parseVttTimestamp(endStr),
});
@@ -468,7 +477,8 @@ export function formatVtt(words: Word[], opts?: WordsToCuesOptions): string {
"WEBVTT\n\n" +
cues
.map(
(cue) => `${formatVttTimestamp(cue.start)} --> ${formatVttTimestamp(cue.end)}\n${cue.text}`,
(cue) =>
`${formatVttTimestamp(cue.start)} --> ${formatVttTimestamp(cue.end)}\n${cue.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")}`,
)
.join("\n\n") +
"\n"