/*
ass.js — Advanced SubStation Alpha parser and DOM renderer
Renders ASS/SSA subtitle tracks with their original styling instead of
flattening them to plain text. That matters for releases which show two
languages at once: a Japanese line pinned near the top and a Chinese line
below it are two separate events with different styles, and any renderer
that only shows "the cue at time t" will silently drop one of them.
Written in-repo rather than pulling in libass/WASM, so there is no binary
blob to vendor and nothing to fetch at runtime.
Supported
• [V4+ Styles] and [V4 Styles]: font, size, colours, bold/italic/underline/
strikeout, outline, shadow, opaque box, alignment, margins, spacing
• Positioning: \an, \a, \pos, \move (start point), margins, PlayRes scaling
• Inline overrides: \b \i \u \s \fn \fs \fsp \c \1c \2c \3c \4c
\alpha \1a \2a \3a \4a \bord \shad \frz \fad \fade \r
• Line breaks (\N, \n, \h), layer ordering, simultaneous events
• Karaoke tags are stripped so the text still reads correctly
Not supported (degrades rather than breaks)
• Vector drawing (\p) — those events are skipped instead of drawn as text
• Animation (\t), clipping (\clip), 3D rotation (\frx, \fry), \org
• ScaleX/ScaleY, WrapStyle nuances beyond normal wrapping
*/
(function (global) {
'use strict';
// ─── Parsing ──────────────────────────────────────────────────────────────────
// "0:03:02.65" -> 182.65
function parseTime(value) {
var m = String(value).trim().match(/^(\d+):(\d+):(\d+)(?:[.,](\d+))?$/);
if (!m) { return 0; }
var frac = m[4] ? parseFloat('0.' + m[4]) : 0;
return parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10) + frac;
}
// ASS colours are &HAABBGGRR — alpha is inverted (00 = opaque)
function parseColour(value) {
var hex = String(value).trim().replace(/^&[Hh]/, '').replace(/&$/, '');
if (!/^[0-9a-fA-F]+$/.test(hex)) { return { r: 255, g: 255, b: 255, a: 1 }; }
var n = parseInt(hex.padStart(8, '0').slice(-8), 16);
return {
r: n & 0xff,
g: (n >> 8) & 0xff,
b: (n >> 16) & 0xff,
a: 1 - (((n >> 24) & 0xff) / 255)
};
}
function colourToCss(c) {
return 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + c.a.toFixed(3) + ')';
}
// ASS alpha overrides are &HAA& where 00 is opaque
function parseAlpha(value) {
var hex = String(value).trim().replace(/^&[Hh]/, '').replace(/&$/, '');
if (!/^[0-9a-fA-F]+$/.test(hex)) { return 1; }
return 1 - (parseInt(hex.slice(-2), 16) / 255);
}
function toNumber(value, fallback) {
var n = parseFloat(value);
return isNaN(n) ? fallback : n;
}
// Split a "Format:" line into trimmed field names
function parseFormat(line) {
return line.slice(line.indexOf(':') + 1).split(',').map(function (s) { return s.trim(); });
}
// Split a data line into values, keeping the final field (Text) intact even
// though it legitimately contains commas.
function splitFields(line, count) {
var body = line.slice(line.indexOf(':') + 1);
var parts = [];
var start = 0;
for (var i = 0; i < body.length && parts.length < count - 1; i++) {
if (body[i] === ',') {
parts.push(body.slice(start, i).trim());
start = i + 1;
}
}
parts.push(body.slice(start));
return parts;
}
function defaultStyle() {
return {
name: 'Default',
fontname: 'Arial',
fontsize: 48,
primary: { r: 255, g: 255, b: 255, a: 1 },
secondary: { r: 255, g: 0, b: 0, a: 1 },
outlineColour: { r: 0, g: 0, b: 0, a: 1 },
backColour: { r: 0, g: 0, b: 0, a: 1 },
bold: false, italic: false, underline: false, strikeout: false,
spacing: 0, angle: 0,
borderStyle: 1, outline: 2, shadow: 0,
alignment: 2,
marginL: 10, marginR: 10, marginV: 10
};
}
function parseStyleLine(fields, values) {
var s = defaultStyle();
for (var i = 0; i < fields.length && i < values.length; i++) {
var v = values[i];
switch (fields[i]) {
case 'Name': s.name = v; break;
case 'Fontname': s.fontname = v; break;
case 'Fontsize': s.fontsize = toNumber(v, 48); break;
case 'PrimaryColour': s.primary = parseColour(v); break;
case 'SecondaryColour': s.secondary = parseColour(v); break;
case 'OutlineColour':
case 'TertiaryColour': s.outlineColour = parseColour(v); break;
case 'BackColour': s.backColour = parseColour(v); break;
// ASS booleans are -1 for true
case 'Bold': s.bold = toNumber(v, 0) !== 0; break;
case 'Italic': s.italic = toNumber(v, 0) !== 0; break;
case 'Underline': s.underline = toNumber(v, 0) !== 0; break;
case 'StrikeOut': s.strikeout = toNumber(v, 0) !== 0; break;
case 'Spacing': s.spacing = toNumber(v, 0); break;
case 'Angle': s.angle = toNumber(v, 0); break;
case 'BorderStyle': s.borderStyle = toNumber(v, 1); break;
case 'Outline': s.outline = toNumber(v, 2); break;
case 'Shadow': s.shadow = toNumber(v, 0); break;
case 'Alignment': s.alignment = normaliseAlignment(toNumber(v, 2), false); break;
case 'MarginL': s.marginL = toNumber(v, 10); break;
case 'MarginR': s.marginR = toNumber(v, 10); break;
case 'MarginV': s.marginV = toNumber(v, 10); break;
}
}
return s;
}
// Legacy SSA uses a different alignment numbering (1-3 bottom, 5-7 top,
// 9-11 middle); ASS uses numpad layout. Normalise everything to numpad.
function normaliseAlignment(value, isLegacy) {
if (!isLegacy) {
return (value >= 1 && value <= 9) ? value : 2;
}
var horizontal = ((value - 1) % 4); // 0 left, 1 centre, 2 right
if (horizontal > 2) { horizontal = 1; }
if (value >= 9) { return 4 + horizontal; } // middle row
if (value >= 5) { return 7 + horizontal; } // top row
return 1 + horizontal; // bottom row
}
function parse(text) {
var track = {
playResX: 384, playResY: 288,
wrapStyle: 0,
scaledBorderAndShadow: true,
styles: {},
events: []
};
var lines = String(text).replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
var section = '';
var styleFormat = null;
var eventFormat = null;
var legacyStyles = false;
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line.charAt(0) === ';') { continue; }
if (line.charAt(0) === '[') {
section = line.replace(/^\[|\]$/g, '').toLowerCase();
legacyStyles = (section === 'v4 styles');
continue;
}
if (section === 'script info') {
var kv = line.split(':');
if (kv.length < 2) { continue; }
var key = kv[0].trim().toLowerCase();
var val = kv.slice(1).join(':').trim();
if (key === 'playresx') { track.playResX = toNumber(val, 384); }
else if (key === 'playresy') { track.playResY = toNumber(val, 288); }
else if (key === 'wrapstyle') { track.wrapStyle = toNumber(val, 0); }
else if (key === 'scaledborderandshadow') {
track.scaledBorderAndShadow = /^yes$/i.test(val);
}
continue;
}
if (section === 'v4+ styles' || section === 'v4 styles') {
if (/^Format\s*:/i.test(line)) { styleFormat = parseFormat(line); continue; }
if (/^Style\s*:/i.test(line) && styleFormat) {
var sv = splitFields(line, styleFormat.length);
var style = parseStyleLine(styleFormat, sv);
if (legacyStyles) {
// re-normalise using the legacy numbering
var rawAlign = sv[styleFormat.indexOf('Alignment')];
style.alignment = normaliseAlignment(toNumber(rawAlign, 2), true);
}
track.styles[style.name] = style;
}
continue;
}
if (section === 'events') {
if (/^Format\s*:/i.test(line)) { eventFormat = parseFormat(line); continue; }
if (/^Dialogue\s*:/i.test(line) && eventFormat) {
var ev = parseEventLine(eventFormat, splitFields(line, eventFormat.length));
if (ev) { track.events.push(ev); }
}
continue;
}
}
if (!track.styles.Default) { track.styles.Default = defaultStyle(); }
// Stable ordering: by layer, then by start, so z-index follows the format's rules
track.events.sort(function (a, b) {
return (a.layer - b.layer) || (a.start - b.start);
});
return track;
}
function parseEventLine(fields, values) {
var ev = { layer: 0, start: 0, end: 0, style: 'Default', marginL: 0, marginR: 0, marginV: 0, text: '' };
for (var i = 0; i < fields.length && i < values.length; i++) {
switch (fields[i]) {
case 'Layer':
case 'Marked': ev.layer = toNumber(String(values[i]).replace(/^Marked=/i, ''), 0); break;
case 'Start': ev.start = parseTime(values[i]); break;
case 'End': ev.end = parseTime(values[i]); break;
case 'Style': ev.style = String(values[i]).replace(/^\*+/, '') || 'Default'; break;
case 'MarginL': ev.marginL = toNumber(values[i], 0); break;
case 'MarginR': ev.marginR = toNumber(values[i], 0); break;
case 'MarginV': ev.marginV = toNumber(values[i], 0); break;
case 'Text': ev.text = values[i]; break;
}
}
if (ev.end <= ev.start) { return null; }
return ev;
}
// ─── Override tag handling ────────────────────────────────────────────────────
// Split event text into runs, each with its own formatting state. Block-level
// effects (position, alignment, fade) are collected onto the returned object.
function buildRuns(text, baseStyle, styles) {
var block = { align: null, pos: null, fade: null, rotate: 0, drawing: false };
var runs = [];
var current = cloneRunState(baseStyle);
var buffer = '';
function flush() {
if (buffer.length > 0) {
runs.push({ state: cloneRunState(current), text: buffer });
buffer = '';
}
}
var i = 0;
while (i < text.length) {
var ch = text.charAt(i);
if (ch === '\\' && i + 1 < text.length) {
var next = text.charAt(i + 1);
if (next === 'N') { flush(); runs.push({ lineBreak: true }); i += 2; continue; }
if (next === 'n') { flush(); runs.push({ lineBreak: true }); i += 2; continue; }
if (next === 'h') { buffer += '\u00a0'; i += 2; continue; }
}
if (ch === '{') {
var close = text.indexOf('}', i);
if (close === -1) { buffer += text.slice(i); break; }
flush();
applyOverrides(text.slice(i + 1, close), current, block, baseStyle, styles);
i = close + 1;
continue;
}
buffer += ch;
i++;
}
flush();
return { runs: runs, block: block };
}
function cloneRunState(style) {
return {
fontname: style.fontname,
fontsize: style.fontsize,
primary: style.primary,
outlineColour: style.outlineColour,
backColour: style.backColour,
bold: style.bold,
italic: style.italic,
underline: style.underline,
strikeout: style.strikeout,
spacing: style.spacing,
outline: style.outline,
shadow: style.shadow,
borderStyle: style.borderStyle
};
}
function applyOverrides(chunk, state, block, baseStyle, styles) {
// Each override starts with a backslash; arguments may contain commas and
// nested parentheses, so match the tag name then take everything up to the
// next backslash that is not inside parentheses.
var i = 0;
while (i < chunk.length) {
if (chunk.charAt(i) !== '\\') { i++; continue; }
var j = i + 1;
var depth = 0;
while (j < chunk.length) {
var c = chunk.charAt(j);
if (c === '(') { depth++; }
else if (c === ')') { depth--; }
else if (c === '\\' && depth <= 0) { break; }
j++;
}
applyOneOverride(chunk.slice(i + 1, j), state, block, baseStyle, styles);
i = j;
}
}
function applyOneOverride(tag, state, block, baseStyle, styles) {
var m;
if ((m = tag.match(/^an(\d+)$/i))) { block.align = normaliseAlignment(parseInt(m[1], 10), false); return; }
if ((m = tag.match(/^a(\d+)$/i))) { block.align = normaliseAlignment(parseInt(m[1], 10), true); return; }
if ((m = tag.match(/^pos\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)$/i))) {
block.pos = { x: parseFloat(m[1]), y: parseFloat(m[2]) }; return;
}
if ((m = tag.match(/^move\(\s*([-\d.]+)\s*,\s*([-\d.]+)/i))) {
// Animation is unsupported; anchor at the start point so the line at
// least appears in a sensible place.
block.pos = { x: parseFloat(m[1]), y: parseFloat(m[2]) }; return;
}
if ((m = tag.match(/^fad\(\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/i))) {
block.fade = { inMs: parseFloat(m[1]), outMs: parseFloat(m[2]) }; return;
}
if ((m = tag.match(/^fade\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i))) {
block.fade = { inMs: parseFloat(m[2]), outMs: parseFloat(m[3]) }; return;
}
if ((m = tag.match(/^frz?([-\d.]+)$/i))) { block.rotate = parseFloat(m[1]); return; }
if (/^p[1-9]\d*$/i.test(tag)) { block.drawing = true; return; }
if (/^p0$/i.test(tag)) { block.drawing = false; return; }
if ((m = tag.match(/^r(.*)$/i)) && !/^rnd/i.test(tag)) {
var target = m[1].trim();
var reset = (target && styles[target]) ? styles[target] : baseStyle;
var fresh = cloneRunState(reset);
for (var k in fresh) { if (Object.prototype.hasOwnProperty.call(fresh, k)) { state[k] = fresh[k]; } }
return;
}
if ((m = tag.match(/^b(\d+)$/i))) { state.bold = parseInt(m[1], 10) !== 0; return; }
if ((m = tag.match(/^i(\d)$/i))) { state.italic = m[1] !== '0'; return; }
if ((m = tag.match(/^u(\d)$/i))) { state.underline = m[1] !== '0'; return; }
if ((m = tag.match(/^s(\d)$/i))) { state.strikeout = m[1] !== '0'; return; }
if ((m = tag.match(/^fn(.+)$/i))) { state.fontname = m[1].trim(); return; }
if ((m = tag.match(/^fs([\d.]+)$/i))) { state.fontsize = parseFloat(m[1]); return; }
if ((m = tag.match(/^fsp([-\d.]+)$/i))) { state.spacing = parseFloat(m[1]); return; }
if ((m = tag.match(/^bord([\d.]+)$/i))) { state.outline = parseFloat(m[1]); return; }
if ((m = tag.match(/^shad([\d.]+)$/i))) { state.shadow = parseFloat(m[1]); return; }
if ((m = tag.match(/^(?:1?c|1c)&?[Hh]([0-9a-fA-F]+)&?$/))) {
state.primary = parseColour('&H' + m[1]); return;
}
if ((m = tag.match(/^3c&?[Hh]([0-9a-fA-F]+)&?$/))) {
state.outlineColour = parseColour('&H' + m[1]); return;
}
if ((m = tag.match(/^4c&?[Hh]([0-9a-fA-F]+)&?$/))) {
state.backColour = parseColour('&H' + m[1]); return;
}
if ((m = tag.match(/^(?:alpha|1a)&?[Hh]([0-9a-fA-F]+)&?$/i))) {
var a = parseAlpha(m[1]);
state.primary = withAlpha(state.primary, a); return;
}
if ((m = tag.match(/^3a&?[Hh]([0-9a-fA-F]+)&?$/i))) {
state.outlineColour = withAlpha(state.outlineColour, parseAlpha(m[1])); return;
}
if ((m = tag.match(/^4a&?[Hh]([0-9a-fA-F]+)&?$/i))) {
state.backColour = withAlpha(state.backColour, parseAlpha(m[1])); return;
}
// Everything else (\t, \clip, \k, \fr x/y, \org, \2c …) is ignored on purpose
}
function withAlpha(colour, a) {
return { r: colour.r, g: colour.g, b: colour.b, a: a };
}
// ─── Rendering ────────────────────────────────────────────────────────────────
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
/**
* Renderer draws events into an overlay element.
*
* The overlay must be positioned over the video's *displayed* rectangle; call
* resize(width, height) whenever that changes so PlayRes coordinates map onto
* real pixels.
*/
function Renderer(overlay) {
this.overlay = overlay;
this.track = null;
this.width = 0;
this.height = 0;
this.scale = 1;
this._lastKey = null;
this._suppressBefore = 0;
}
Renderer.prototype.setTrack = function (track) {
this.track = track;
this._lastKey = null;
this.clear();
};
Renderer.prototype.resize = function (width, height) {
this.width = width;
this.height = height;
if (this.track && this.track.playResY > 0) {
// Uniform scale off the vertical axis, matching how libass maps a script
// onto a frame of a different size.
this.scale = height / this.track.playResY;
}
this._lastKey = null; // force a redraw at the new geometry
};
Renderer.prototype.clear = function () {
if (this.overlay) { this.overlay.innerHTML = ''; }
this._lastKey = null;
};
/**
* Ignore any line that had already begun before `time`.
*
* Seeking into the middle of a line would otherwise drop the viewer into a
* half-shown caption. Everything already in flight at the landing point is
* skipped; the next line to *begin* after it plays normally.
*/
Renderer.prototype.setSuppressBefore = function (time) {
this._suppressBefore = Math.max(0, time || 0);
this._lastKey = null; // rebuild now so a suppressed line disappears at once
};
Renderer.prototype.activeEvents = function (time) {
var out = [];
if (!this.track) { return out; }
var floor = this._suppressBefore || 0;
var events = this.track.events;
for (var i = 0; i < events.length; i++) {
var ev = events[i];
if (time < ev.start || time > ev.end) { continue; }
if (ev.start < floor) { continue; } // was already on screen when we landed
out.push(ev);
}
return out;
};
Renderer.prototype.setTime = function (time) {
if (!this.overlay || !this.track) { return; }
var active = this.activeEvents(time);
// Rebuilding the DOM every frame would thrash layout — and most events in a
// typical release carry a fade, so keying off "has a fade" would rebuild
// almost constantly. Instead the DOM is rebuilt only when the visible set
// changes, and fades are applied by touching opacity on the existing nodes.
var key = active.map(function (e) {
return e.start + '/' + e.end + '/' + e.style + '/' + e.text.length;
}).join('|');
if (key !== this._lastKey) {
this._lastKey = key;
var html = '';
var meta = [];
for (var j = 0; j < active.length; j++) {
var piece = this.renderEvent(active[j], meta.length);
if (!piece) { continue; }
html += piece.html;
meta.push({ ev: active[j], fade: piece.fade });
}
this.overlay.innerHTML = html;
this._meta = meta;
this._children = Array.prototype.slice.call(this.overlay.children);
this.resolveCollisions();
}
this.applyFades(time);
};
// Nudge margin-positioned lines apart when they would overlap.
//
// Releases stack dual-language subtitles by giving each style a different
// MarginV, which assumes every line is exactly as tall as the author expected.
// One line wrapping — or a font whose metrics differ slightly from the one the
// script was typeset against — makes a block taller and it collides with its
// neighbour. libass resolves this the same way, by pushing the later event
// further from the anchored edge.
Renderer.prototype.resolveCollisions = function () {
if (!this._children || this._children.length < 2) { return; }
var placed = { top: [], bottom: [] };
var limit = this.height || 0;
for (var i = 0; i < this._children.length; i++) {
var el = this._children[i];
var anchor = el.getAttribute('data-anchor');
if (anchor !== 'top' && anchor !== 'bottom') { continue; } // \pos and middle are left alone
var offset = parseFloat(el.getAttribute('data-offset')) || 0;
var height = el.offsetHeight;
var bands = placed[anchor];
// Repeatedly push past whatever it overlaps until the slot is clear
var moved = true;
var guard = 0;
while (moved && guard++ < 32) {
moved = false;
for (var b = 0; b < bands.length; b++) {
if (offset < bands[b].end && bands[b].start < offset + height) {
offset = bands[b].end;
moved = true;
}
}
}
// Never push a line off the picture; overlapping is better than invisible
if (limit > 0 && offset + height > limit) {
offset = Math.max(0, limit - height);
}
bands.push({ start: offset, end: offset + height });
el.style[anchor] = offset.toFixed(1) + 'px';
}
};
Renderer.prototype.applyFades = function (time) {
if (!this._meta || !this._children) { return; }
for (var i = 0; i < this._meta.length; i++) {
var el = this._children[i];
if (!el) { continue; }
var value = this._meta[i].fade
? this.fadeOpacity(this._meta[i].fade, this._meta[i].ev, time)
: 1;
var next = value >= 1 ? '' : value.toFixed(3);
if (el.style.opacity !== next) { el.style.opacity = next; }
}
};
// Returns {html, fade} or null when the event produces nothing to draw.
Renderer.prototype.renderEvent = function (ev, order) {
var track = this.track;
var style = track.styles[ev.style] || track.styles.Default;
var built = buildRuns(ev.text, style, track.styles);
// Vector drawings would render as a stream of coordinates; skip them.
if (built.block.drawing) { return null; }
var body = '';
for (var i = 0; i < built.runs.length; i++) {
var run = built.runs[i];
if (run.lineBreak) { body += '
'; continue; }
if (!run.text) { continue; }
body += '' + escapeHtml(run.text) + '';
}
if (!body) { return null; }
var align = built.block.align !== null ? built.block.align : style.alignment;
var box = this.boxCss(ev, style, align, built.block, order);
return {
html: '