Home Reference Source

src/utils/vttparser.ts

  1. /*
  2. * Source: https://github.com/mozilla/vtt.js/blob/master/dist/vtt.js
  3. */
  4.  
  5. import VTTCue from './vttcue';
  6.  
  7. class StringDecoder {
  8. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  9. decode(data: string | any, options?: Object): string | never {
  10. if (!data) {
  11. return '';
  12. }
  13.  
  14. if (typeof data !== 'string') {
  15. throw new Error('Error - expected string data.');
  16. }
  17.  
  18. return decodeURIComponent(encodeURIComponent(data));
  19. }
  20. }
  21.  
  22. // Try to parse input as a time stamp.
  23. export function parseTimeStamp(input: string) {
  24. function computeSeconds(h, m, s, f) {
  25. return (h | 0) * 3600 + (m | 0) * 60 + (s | 0) + parseFloat(f || 0);
  26. }
  27.  
  28. const m = input.match(/^(?:(\d+):)?(\d{2}):(\d{2})(\.\d+)?/);
  29. if (!m) {
  30. return null;
  31. }
  32.  
  33. if (parseFloat(m[2]) > 59) {
  34. // Timestamp takes the form of [hours]:[minutes].[milliseconds]
  35. // First position is hours as it's over 59.
  36. return computeSeconds(m[2], m[3], 0, m[4]);
  37. }
  38. // Timestamp takes the form of [hours (optional)]:[minutes]:[seconds].[milliseconds]
  39. return computeSeconds(m[1], m[2], m[3], m[4]);
  40. }
  41.  
  42. // A settings object holds key/value pairs and will ignore anything but the first
  43. // assignment to a specific key.
  44. class Settings {
  45. private readonly values: { [key: string]: any } = Object.create(null);
  46.  
  47. // Only accept the first assignment to any key.
  48. set(k: string, v: any) {
  49. if (!this.get(k) && v !== '') {
  50. this.values[k] = v;
  51. }
  52. }
  53. // Return the value for a key, or a default value.
  54. // If 'defaultKey' is passed then 'dflt' is assumed to be an object with
  55. // a number of possible default values as properties where 'defaultKey' is
  56. // the key of the property that will be chosen; otherwise it's assumed to be
  57. // a single value.
  58. get(k: string, dflt?: any, defaultKey?: string): any {
  59. if (defaultKey) {
  60. return this.has(k) ? this.values[k] : dflt[defaultKey];
  61. }
  62.  
  63. return this.has(k) ? this.values[k] : dflt;
  64. }
  65. // Check whether we have a value for a key.
  66. has(k: string): boolean {
  67. return k in this.values;
  68. }
  69. // Accept a setting if its one of the given alternatives.
  70. alt(k: string, v: any, a: any[]) {
  71. for (let n = 0; n < a.length; ++n) {
  72. if (v === a[n]) {
  73. this.set(k, v);
  74. break;
  75. }
  76. }
  77. }
  78. // Accept a setting if its a valid (signed) integer.
  79. integer(k: string, v: any) {
  80. if (/^-?\d+$/.test(v)) {
  81. // integer
  82. this.set(k, parseInt(v, 10));
  83. }
  84. }
  85. // Accept a setting if its a valid percentage.
  86. percent(k: string, v: any): boolean {
  87. if (/^([\d]{1,3})(\.[\d]*)?%$/.test(v)) {
  88. const percent = parseFloat(v);
  89. if (percent >= 0 && percent <= 100) {
  90. this.set(k, percent);
  91. return true;
  92. }
  93. }
  94. return false;
  95. }
  96. }
  97.  
  98. // Helper function to parse input into groups separated by 'groupDelim', and
  99. // interpret each group as a key/value pair separated by 'keyValueDelim'.
  100. function parseOptions(
  101. input: string,
  102. callback: (k: string, v: any) => void,
  103. keyValueDelim: RegExp,
  104. groupDelim?: RegExp
  105. ) {
  106. const groups = groupDelim ? input.split(groupDelim) : [input];
  107. for (const i in groups) {
  108. if (typeof groups[i] !== 'string') {
  109. continue;
  110. }
  111.  
  112. const kv = groups[i].split(keyValueDelim);
  113. if (kv.length !== 2) {
  114. continue;
  115. }
  116.  
  117. const k = kv[0];
  118. const v = kv[1];
  119. callback(k, v);
  120. }
  121. }
  122.  
  123. const defaults = new VTTCue(0, 0, '');
  124. // 'middle' was changed to 'center' in the spec: https://github.com/w3c/webvtt/pull/244
  125. // Safari doesn't yet support this change, but FF and Chrome do.
  126. const center = (defaults.align as string) === 'middle' ? 'middle' : 'center';
  127.  
  128. function parseCue(input: string, cue: VTTCue, regionList: Region[]) {
  129. // Remember the original input if we need to throw an error.
  130. const oInput = input;
  131. // 4.1 WebVTT timestamp
  132. function consumeTimeStamp(): number | never {
  133. const ts = parseTimeStamp(input);
  134. if (ts === null) {
  135. throw new Error('Malformed timestamp: ' + oInput);
  136. }
  137.  
  138. // Remove time stamp from input.
  139. input = input.replace(/^[^\sa-zA-Z-]+/, '');
  140. return ts;
  141. }
  142.  
  143. // 4.4.2 WebVTT cue settings
  144. function consumeCueSettings(input: string, cue: VTTCue) {
  145. const settings = new Settings();
  146.  
  147. parseOptions(
  148. input,
  149. function (k, v) {
  150. let vals;
  151. switch (k) {
  152. case 'region':
  153. // Find the last region we parsed with the same region id.
  154. for (let i = regionList.length - 1; i >= 0; i--) {
  155. if (regionList[i].id === v) {
  156. settings.set(k, regionList[i].region);
  157. break;
  158. }
  159. }
  160. break;
  161. case 'vertical':
  162. settings.alt(k, v, ['rl', 'lr']);
  163. break;
  164. case 'line':
  165. vals = v.split(',');
  166. settings.integer(k, vals[0]);
  167. if (settings.percent(k, vals[0])) {
  168. settings.set('snapToLines', false);
  169. }
  170.  
  171. settings.alt(k, vals[0], ['auto']);
  172. if (vals.length === 2) {
  173. settings.alt('lineAlign', vals[1], ['start', center, 'end']);
  174. }
  175.  
  176. break;
  177. case 'position':
  178. vals = v.split(',');
  179. settings.percent(k, vals[0]);
  180. if (vals.length === 2) {
  181. settings.alt('positionAlign', vals[1], [
  182. 'start',
  183. center,
  184. 'end',
  185. 'line-left',
  186. 'line-right',
  187. 'auto',
  188. ]);
  189. }
  190.  
  191. break;
  192. case 'size':
  193. settings.percent(k, v);
  194. break;
  195. case 'align':
  196. settings.alt(k, v, ['start', center, 'end', 'left', 'right']);
  197. break;
  198. }
  199. },
  200. /:/,
  201. /\s/
  202. );
  203.  
  204. // Apply default values for any missing fields.
  205. cue.region = settings.get('region', null);
  206. cue.vertical = settings.get('vertical', '');
  207. let line = settings.get('line', 'auto');
  208. if (line === 'auto' && defaults.line === -1) {
  209. // set numeric line number for Safari
  210. line = -1;
  211. }
  212. cue.line = line;
  213. cue.lineAlign = settings.get('lineAlign', 'start');
  214. cue.snapToLines = settings.get('snapToLines', true);
  215. cue.size = settings.get('size', 100);
  216. cue.align = settings.get('align', center);
  217. let position = settings.get('position', 'auto');
  218. if (position === 'auto' && defaults.position === 50) {
  219. // set numeric position for Safari
  220. position =
  221. cue.align === 'start' || cue.align === 'left'
  222. ? 0
  223. : cue.align === 'end' || cue.align === 'right'
  224. ? 100
  225. : 50;
  226. }
  227. cue.position = position;
  228. }
  229.  
  230. function skipWhitespace() {
  231. input = input.replace(/^\s+/, '');
  232. }
  233.  
  234. // 4.1 WebVTT cue timings.
  235. skipWhitespace();
  236. cue.startTime = consumeTimeStamp(); // (1) collect cue start time
  237. skipWhitespace();
  238. if (input.substr(0, 3) !== '-->') {
  239. // (3) next characters must match '-->'
  240. throw new Error(
  241. "Malformed time stamp (time stamps must be separated by '-->'): " + oInput
  242. );
  243. }
  244. input = input.substr(3);
  245. skipWhitespace();
  246. cue.endTime = consumeTimeStamp(); // (5) collect cue end time
  247.  
  248. // 4.1 WebVTT cue settings list.
  249. skipWhitespace();
  250. consumeCueSettings(input, cue);
  251. }
  252.  
  253. export function fixLineBreaks(input: string): string {
  254. return input.replace(/<br(?: \/)?>/gi, '\n');
  255. }
  256.  
  257. type Region = {
  258. id: string;
  259. region: any;
  260. };
  261.  
  262. export class VTTParser {
  263. private state:
  264. | 'INITIAL'
  265. | 'HEADER'
  266. | 'ID'
  267. | 'CUE'
  268. | 'CUETEXT'
  269. | 'NOTE'
  270. | 'BADWEBVTT'
  271. | 'BADCUE' = 'INITIAL';
  272. private buffer: string = '';
  273. private decoder: StringDecoder = new StringDecoder();
  274. private regionList: Region[] = [];
  275. private cue: VTTCue | null = null;
  276. public oncue?: (cue: VTTCue) => void;
  277. public onparsingerror?: (error: Error) => void;
  278. public onflush?: () => void;
  279.  
  280. parse(data?: string): VTTParser {
  281. const _this = this;
  282.  
  283. // If there is no data then we won't decode it, but will just try to parse
  284. // whatever is in buffer already. This may occur in circumstances, for
  285. // example when flush() is called.
  286. if (data) {
  287. // Try to decode the data that we received.
  288. _this.buffer += _this.decoder.decode(data, { stream: true });
  289. }
  290.  
  291. function collectNextLine(): string {
  292. let buffer: string = _this.buffer;
  293. let pos = 0;
  294.  
  295. buffer = fixLineBreaks(buffer);
  296.  
  297. while (
  298. pos < buffer.length &&
  299. buffer[pos] !== '\r' &&
  300. buffer[pos] !== '\n'
  301. ) {
  302. ++pos;
  303. }
  304.  
  305. const line: string = buffer.substr(0, pos);
  306. // Advance the buffer early in case we fail below.
  307. if (buffer[pos] === '\r') {
  308. ++pos;
  309. }
  310.  
  311. if (buffer[pos] === '\n') {
  312. ++pos;
  313. }
  314.  
  315. _this.buffer = buffer.substr(pos);
  316. return line;
  317. }
  318.  
  319. // 3.2 WebVTT metadata header syntax
  320. function parseHeader(input) {
  321. parseOptions(
  322. input,
  323. function (k, v) {
  324. // switch (k) {
  325. // case 'region':
  326. // 3.3 WebVTT region metadata header syntax
  327. // console.log('parse region', v);
  328. // parseRegion(v);
  329. // break;
  330. // }
  331. },
  332. /:/
  333. );
  334. }
  335.  
  336. // 5.1 WebVTT file parsing.
  337. try {
  338. let line: string = '';
  339. if (_this.state === 'INITIAL') {
  340. // We can't start parsing until we have the first line.
  341. if (!/\r\n|\n/.test(_this.buffer)) {
  342. return this;
  343. }
  344.  
  345. line = collectNextLine();
  346. // strip of UTF-8 BOM if any
  347. // https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8
  348. const m = line.match(/^()?WEBVTT([ \t].*)?$/);
  349. if (!m || !m[0]) {
  350. throw new Error('Malformed WebVTT signature.');
  351. }
  352.  
  353. _this.state = 'HEADER';
  354. }
  355.  
  356. let alreadyCollectedLine = false;
  357. while (_this.buffer) {
  358. // We can't parse a line until we have the full line.
  359. if (!/\r\n|\n/.test(_this.buffer)) {
  360. return this;
  361. }
  362.  
  363. if (!alreadyCollectedLine) {
  364. line = collectNextLine();
  365. } else {
  366. alreadyCollectedLine = false;
  367. }
  368.  
  369. switch (_this.state) {
  370. case 'HEADER':
  371. // 13-18 - Allow a header (metadata) under the WEBVTT line.
  372. if (/:/.test(line)) {
  373. parseHeader(line);
  374. } else if (!line) {
  375. // An empty line terminates the header and starts the body (cues).
  376. _this.state = 'ID';
  377. }
  378. continue;
  379. case 'NOTE':
  380. // Ignore NOTE blocks.
  381. if (!line) {
  382. _this.state = 'ID';
  383. }
  384.  
  385. continue;
  386. case 'ID':
  387. // Check for the start of NOTE blocks.
  388. if (/^NOTE($|[ \t])/.test(line)) {
  389. _this.state = 'NOTE';
  390. break;
  391. }
  392. // 19-29 - Allow any number of line terminators, then initialize new cue values.
  393. if (!line) {
  394. continue;
  395. }
  396.  
  397. _this.cue = new VTTCue(0, 0, '');
  398. _this.state = 'CUE';
  399. // 30-39 - Check if self line contains an optional identifier or timing data.
  400. if (line.indexOf('-->') === -1) {
  401. _this.cue.id = line;
  402. continue;
  403. }
  404. // Process line as start of a cue.
  405. /* falls through */
  406. case 'CUE':
  407. // 40 - Collect cue timings and settings.
  408. if (!_this.cue) {
  409. _this.state = 'BADCUE';
  410. continue;
  411. }
  412. try {
  413. parseCue(line, _this.cue, _this.regionList);
  414. } catch (e) {
  415. // In case of an error ignore rest of the cue.
  416. _this.cue = null;
  417. _this.state = 'BADCUE';
  418. continue;
  419. }
  420. _this.state = 'CUETEXT';
  421. continue;
  422. case 'CUETEXT':
  423. {
  424. const hasSubstring = line.indexOf('-->') !== -1;
  425. // 34 - If we have an empty line then report the cue.
  426. // 35 - If we have the special substring '-->' then report the cue,
  427. // but do not collect the line as we need to process the current
  428. // one as a new cue.
  429. if (!line || (hasSubstring && (alreadyCollectedLine = true))) {
  430. // We are done parsing self cue.
  431. if (_this.oncue && _this.cue) {
  432. _this.oncue(_this.cue);
  433. }
  434.  
  435. _this.cue = null;
  436. _this.state = 'ID';
  437. continue;
  438. }
  439. if (_this.cue === null) {
  440. continue;
  441. }
  442.  
  443. if (_this.cue.text) {
  444. _this.cue.text += '\n';
  445. }
  446. _this.cue.text += line;
  447. }
  448. continue;
  449. case 'BADCUE':
  450. // 54-62 - Collect and discard the remaining cue.
  451. if (!line) {
  452. _this.state = 'ID';
  453. }
  454. }
  455. }
  456. } catch (e) {
  457. // If we are currently parsing a cue, report what we have.
  458. if (_this.state === 'CUETEXT' && _this.cue && _this.oncue) {
  459. _this.oncue(_this.cue);
  460. }
  461.  
  462. _this.cue = null;
  463. // Enter BADWEBVTT state if header was not parsed correctly otherwise
  464. // another exception occurred so enter BADCUE state.
  465. _this.state = _this.state === 'INITIAL' ? 'BADWEBVTT' : 'BADCUE';
  466. }
  467. return this;
  468. }
  469.  
  470. flush(): VTTParser {
  471. const _this = this;
  472. try {
  473. // Finish decoding the stream.
  474. // _this.buffer += _this.decoder.decode();
  475. // Synthesize the end of the current cue or region.
  476. if (_this.cue || _this.state === 'HEADER') {
  477. _this.buffer += '\n\n';
  478. _this.parse();
  479. }
  480. // If we've flushed, parsed, and we're still on the INITIAL state then
  481. // that means we don't have enough of the stream to parse the first
  482. // line.
  483. if (_this.state === 'INITIAL' || _this.state === 'BADWEBVTT') {
  484. throw new Error('Malformed WebVTT signature.');
  485. }
  486. } catch (e) {
  487. if (_this.onparsingerror) {
  488. _this.onparsingerror(e);
  489. }
  490. }
  491. if (_this.onflush) {
  492. _this.onflush();
  493. }
  494.  
  495. return this;
  496. }
  497. }