/*
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,
ScaleX/ScaleY
• Positioning: \an, \a, \pos, \move (animated), \org, margins, PlayRes
scaling, \frz rotation about the alignment point
• Vector drawings (\p) — rendered as SVG paths, which is what typesetters
use to paint the opaque plates that cover on-screen Japanese signage
• Vertical text — an "@"-prefixed font name plus the customary \frz270
becomes real CSS vertical writing rather than a sideways line
• Inline overrides: \b \i \u \s \fn \fs \fsp \fscx \fscy \c \1c \2c \3c \4c
\alpha \1a \2a \3a \4a \bord \shad \blur \be \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)
• Animation (\t), clipping (\clip), 3D rotation (\frx, \fry)
• B-spline drawing commands (\p's s/p/c) are approximated by straight
segments; m/n/l/b are exact
• \fscx without a matching \fscy leaves the *layout* width unscaled, so a
horizontally squashed run can sit a few pixels off centre
Font sizing note — this is the one thing a naive renderer always gets wrong.
ASS `Fontsize` is not the em size. VSFilter asks FreeType for a face whose
ascender plus descender equals Fontsize, and libass copies that for
compatibility, so the em ends up at Fontsize × upem / (winAscent+winDescent).
CSS `font-size` is the em square. Rendering \fs60 as `font-size:60px` in a
typical CJK face therefore draws ~16% too large, and because the surplus
lands above the baseline the line also sits too high. Every run here is
measured (see fontMetrics) and scaled back down.
*/
(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,
scaleX: 100, scaleY: 100,
borderStyle: 1, outline: 2, shadow: 0, blur: 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 'ScaleX': s.scaleX = toNumber(v, 100); break;
case 'ScaleY': s.scaleY = toNumber(v, 100); 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
}
// Numpad alignment -> the fractions of the text block that sit at the anchor.
// hf 0/0.5/1 across, vf 1/0.5/0 down (bottom row anchors at the block's bottom).
function alignFractions(align) {
var h = ((align - 1) % 3);
var v = Math.floor((align - 1) / 3);
return { h: h, v: v, hf: h / 2, vf: [1, 0.5, 0][v] };
}
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. Array.prototype.sort is stable, which keeps events that share a
// layer and a start time in file order — that is what puts a typesetter's
// opaque plate behind the text meant to sit on it.
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;
}
// ─── Font metrics ─────────────────────────────────────────────────────────────
//
// See the header note: ASS Fontsize means "ascent + descent", CSS font-size
// means "em". Canvas TextMetrics reports the very same ascent/descent the
// layout engine will use for the inline box, so one probe per (face, weight,
// slant, script) yields both the em correction and where the baseline sits.
var PROBE_PX = 1024;
var metricsCanvas = null;
var metricsCache = {};
// Embedded fonts arrive after the first frames have already been drawn, so the
// player invalidates the cache once a face lands and lets the renderer redraw.
function invalidateFontMetrics() { metricsCache = {}; }
// Metrics follow the face actually chosen for the glyphs, so probe with a
// character out of the run itself rather than a fixed latin letter.
function probeGlyph(sample) {
var s = String(sample || '');
for (var i = 0; i < s.length; i++) {
if (!/[\s\u00a0]/.test(s.charAt(i))) { return s.charAt(i); }
}
return 'M';
}
function fontMetrics(state, sample) {
var stack = cssFontStack(state.fontname);
var head = (state.italic ? 'italic ' : '') + (state.bold ? 'bold ' : '');
var ch = probeGlyph(sample);
var key = head + stack + '\u0000' + ch;
if (metricsCache[key]) { return metricsCache[key]; }
var m = { scale: 1, ascent: 0.8, space: 0.25 }; // sane fallback if the probe fails
try {
if (!metricsCanvas) { metricsCanvas = document.createElement('canvas'); }
var ctx = metricsCanvas.getContext('2d');
ctx.font = head + PROBE_PX + 'px ' + stack;
var t = ctx.measureText(ch);
var asc = t.fontBoundingBoxAscent, desc = t.fontBoundingBoxDescent;
if (typeof asc === 'number' && typeof desc === 'number' && asc + desc > 0) {
m = {
scale: PROBE_PX / (asc + desc),
ascent: asc / (asc + desc),
space: ctx.measureText(' ').width / PROBE_PX
};
}
} catch (e) {}
metricsCache[key] = m;
return m;
}
// ─── 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, move: null, org: null, fade: null, rotate: 0 };
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,
scaleX: style.scaleX === undefined ? 100 : style.scaleX,
scaleY: style.scaleY === undefined ? 100 : style.scaleY,
outline: style.outline,
shadow: style.shadow,
blur: style.blur || 0,
borderStyle: style.borderStyle,
drawScale: style.drawScale || 0
};
}
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(/^org\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)$/i))) {
block.org = { x: parseFloat(m[1]), y: parseFloat(m[2]) }; return;
}
if ((m = tag.match(/^move\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*(?:,\s*([\d.]+)\s*,\s*([\d.]+)\s*)?\)$/i))) {
block.move = {
x1: parseFloat(m[1]), y1: parseFloat(m[2]),
x2: parseFloat(m[3]), y2: parseFloat(m[4]),
t1: m[5] !== undefined ? parseFloat(m[5]) / 1000 : null,
t2: m[6] !== undefined ? parseFloat(m[6]) / 1000 : null
};
block.pos = { x: block.move.x1, y: block.move.y1 };
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 ((m = tag.match(/^p([\d.]+)$/i)) && !/^pos/i.test(tag)) {
state.drawScale = Math.max(0, parseFloat(m[1])); 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 drawing = state.drawScale;
var fresh = cloneRunState(reset);
for (var k in fresh) { if (Object.prototype.hasOwnProperty.call(fresh, k)) { state[k] = fresh[k]; } }
state.drawScale = drawing; // \r restores the style, not the drawing mode
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(/^fscx([\d.]+)$/i))) { state.scaleX = parseFloat(m[1]); return; }
if ((m = tag.match(/^fscy([\d.]+)$/i))) { state.scaleY = 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(/^blur([\d.]+)$/i))) { state.blur = parseFloat(m[1]); return; }
// \be is an iterated box blur; one pass is roughly a one pixel gaussian
if ((m = tag.match(/^be([\d.]+)$/i))) { state.blur = 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, \frx, \fry, \2c …) is ignored on purpose
}
function withAlpha(colour, a) {
return { r: colour.r, g: colour.g, b: colour.b, a: a };
}
// ─── Vector drawings (\p) ─────────────────────────────────────────────────────
//
// Typesetters use these for the opaque plates that cover printed Japanese
// signage before the translation is written over it, so skipping them leaves
// the original text showing through under the subtitle.
//
// Commands: m/n move, l line, b cubic bézier, s/p/c b-spline (approximated by
// straight segments). Coordinates are divided by 2^(scale-1).
function parseDrawing(text, scaleExp) {
var div = Math.pow(2, Math.max(1, scaleExp || 1) - 1);
var tokens = String(text).replace(/([a-zA-Z])/g, ' $1 ').trim().split(/[\s,]+/);
var d = '';
var open = false;
var cmd = '';
var nums = [];
var cur = [0, 0];
var bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
function pt(x, y) {
if (x < bounds.minX) { bounds.minX = x; }
if (y < bounds.minY) { bounds.minY = y; }
if (x > bounds.maxX) { bounds.maxX = x; }
if (y > bounds.maxY) { bounds.maxY = y; }
}
function moveTo(x, y, close) {
if (open && close) { d += 'Z'; }
d += 'M' + fmt(x) + ' ' + fmt(y);
cur = [x, y]; open = true; pt(x, y);
}
function lineTo(x, y) {
if (!open) { moveTo(x, y, false); return; }
d += 'L' + fmt(x) + ' ' + fmt(y);
cur = [x, y]; pt(x, y);
}
function curveTo(x1, y1, x2, y2, x3, y3) {
if (!open) { moveTo(x1, y1, false); }
d += 'C' + fmt(x1) + ' ' + fmt(y1) + ' ' + fmt(x2) + ' ' + fmt(y2) + ' ' + fmt(x3) + ' ' + fmt(y3);
var b = cubicBounds(cur, [x1, y1], [x2, y2], [x3, y3]);
pt(b[0], b[1]); pt(b[2], b[3]);
cur = [x3, y3];
}
// Consume however many coordinate pairs the pending command takes.
function drain(final) {
if (!cmd) { nums = []; return; }
var i;
if (cmd === 'm' || cmd === 'n') {
for (i = 0; i + 1 < nums.length; i += 2) { moveTo(nums[i], nums[i + 1], cmd === 'm'); }
} else if (cmd === 'l' || cmd === 'p') {
for (i = 0; i + 1 < nums.length; i += 2) { lineTo(nums[i], nums[i + 1]); }
} else if (cmd === 'b') {
for (i = 0; i + 5 < nums.length; i += 6) {
curveTo(nums[i], nums[i + 1], nums[i + 2], nums[i + 3], nums[i + 4], nums[i + 5]);
}
} else if (cmd === 's') {
// B-spline approximated by its control polygon
for (i = 0; i + 1 < nums.length; i += 2) { lineTo(nums[i], nums[i + 1]); }
}
nums = [];
if (final && open) { d += 'Z'; }
}
for (var t = 0; t < tokens.length; t++) {
var tok = tokens[t];
if (!tok) { continue; }
if (/^[a-zA-Z]$/.test(tok)) {
drain(false);
var lower = tok.toLowerCase();
if (lower === 'c') { if (open) { d += 'Z'; open = false; } cmd = ''; continue; }
cmd = lower;
continue;
}
var n = parseFloat(tok);
if (!isNaN(n)) { nums.push(n / div); }
}
drain(true);
if (!d || bounds.minX === Infinity) { return null; }
return {
d: d,
minX: bounds.minX, minY: bounds.minY,
width: Math.max(0, bounds.maxX - bounds.minX),
height: Math.max(0, bounds.maxY - bounds.minY)
};
}
function fmt(n) {
return (Math.round(n * 100) / 100).toString();
}
// Tight bounding box of a cubic segment — the control hull would place a curved
// drawing several pixels off, because ASS anchors a drawing by its real extent.
function cubicBounds(p0, p1, p2, p3) {
var minX = Math.min(p0[0], p3[0]), maxX = Math.max(p0[0], p3[0]);
var minY = Math.min(p0[1], p3[1]), maxY = Math.max(p0[1], p3[1]);
for (var axis = 0; axis < 2; axis++) {
var a = -p0[axis] + 3 * p1[axis] - 3 * p2[axis] + p3[axis];
var b = 2 * (p0[axis] - 2 * p1[axis] + p2[axis]);
var c = p1[axis] - p0[axis];
var roots = [];
if (Math.abs(a) < 1e-9) {
if (Math.abs(b) > 1e-9) { roots.push(-c / b); }
} else {
var disc = b * b - 4 * a * c;
if (disc >= 0) {
var sq = Math.sqrt(disc);
roots.push((-b + sq) / (2 * a), (-b - sq) / (2 * a));
}
}
for (var i = 0; i < roots.length; i++) {
var u = roots[i];
if (!(u > 0 && u < 1)) { continue; }
var mt = 1 - u;
var v = mt * mt * mt * p0[axis] + 3 * mt * mt * u * p1[axis] +
3 * mt * u * u * p2[axis] + u * u * u * p3[axis];
if (axis === 0) { minX = Math.min(minX, v); maxX = Math.max(maxX, v); }
else { minY = Math.min(minY, v); maxY = Math.max(maxY, v); }
}
}
return [minX, minY, maxX, maxY];
}
// ─── 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 and \move are applied by touching 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, move: piece.move });
}
this.overlay.innerHTML = html;
this._meta = meta;
this._children = Array.prototype.slice.call(this.overlay.children);
this.resolveCollisions();
}
this.applyDynamics(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.
//
// Two limits matter, because without them the pass does more harm than good:
//
// • Events only collide within their own layer. Karaoke is typeset as two
// copies of the same line — a blurred glow on one layer and the sharp text
// on the next — which are meant to land exactly on top of each other. Pull
// them apart and every opening and ending shows its lyrics twice.
// • Events only collide when their text boxes overlap horizontally. A note
// pinned to the top-left corner and a centred line sit in the same band
// without touching, so neither should move.
Renderer.prototype.resolveCollisions = function () {
if (!this._children || this._children.length < 2 || !this.overlay) { return; }
var placed = {}; // "edge|layer" -> bands already occupied
var limit = this.height || 0;
var base = this.overlay.getBoundingClientRect();
var range = document.createRange();
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 layer = (this._meta && this._meta[i]) ? this._meta[i].ev.layer : 0;
var key = anchor + '|' + layer;
var bands = placed[key] || (placed[key] = []);
// The block spans the full margin box, but only the text inside it can
// collide, so measure the content rather than the element — and, where a
// blurred border added a full-bleed layer behind the text, only the text
// layer, or every line would look as wide as the picture.
range.selectNodeContents(el.querySelector('[data-fill]') || el);
var box = range.getBoundingClientRect();
var left = box.left - base.left;
var right = box.right - base.left;
var offset = parseFloat(el.getAttribute('data-offset')) || 0;
var height = el.offsetHeight;
// 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++) {
var band = bands[b];
if (offset >= band.end || band.start >= offset + height) { continue; }
if (left >= band.right || band.left >= right) { continue; }
offset = band.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, left: left, right: right });
el.style[anchor] = offset.toFixed(1) + 'px';
}
};
Renderer.prototype.applyDynamics = 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 entry = this._meta[i];
var value = entry.fade ? this.fadeOpacity(entry.fade, entry.ev, time) : 1;
var next = value >= 1 ? '' : value.toFixed(3);
if (el.style.opacity !== next) { el.style.opacity = next; }
if (entry.move) { this.applyMove(el, entry.move, entry.ev, time); }
}
};
Renderer.prototype.applyMove = function (el, move, ev, time) {
var s = this.scale;
var from = (move.t1 === null ? 0 : move.t1);
var to = (move.t2 === null || move.t2 <= from) ? (ev.end - ev.start) : move.t2;
var at = time - ev.start;
var k = to > from ? (at - from) / (to - from) : 1;
k = Math.max(0, Math.min(1, k));
var x = (move.x1 + (move.x2 - move.x1) * k + move.dx) * s;
var y = (move.y1 + (move.y2 - move.y1) * k + move.dy) * s;
el.style.left = x.toFixed(1) + 'px';
el.style.top = y.toFixed(1) + 'px';
};
Renderer.prototype.fadeOpacity = function (fade, ev, time) {
if (!fade) { return 1; }
var inSec = (fade.inMs || 0) / 1000;
var outSec = (fade.outMs || 0) / 1000;
if (inSec > 0 && time < ev.start + inSec) {
return Math.max(0, Math.min(1, (time - ev.start) / inSec));
}
if (outSec > 0 && time > ev.end - outSec) {
return Math.max(0, Math.min(1, (ev.end - time) / outSec));
}
return 1;
};
// Returns {html, fade, move} 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);
var drawRuns = [], textRuns = [], i, run;
for (i = 0; i < built.runs.length; i++) {
run = built.runs[i];
if (run.lineBreak) { textRuns.push(run); continue; }
if (run.state && run.state.drawScale > 0) { drawRuns.push(run); }
else { textRuns.push(run); }
}
if (drawRuns.length) { return this.renderDrawing(ev, style, built.block, drawRuns, order); }
return this.renderText(ev, style, built.block, textRuns, order);
};
Renderer.prototype.renderText = function (ev, style, block, runs, order) {
var i, run;
// An "@" prefixed family is Windows' vertical-writing variant: the glyphs
// are pre-rotated, and the script pairs it with \frz270 to stand the line
// up. CSS can do that natively, and doing it natively is the only way the
// characters stay upright instead of lying on their side.
var vertical = false;
for (i = 0; i < runs.length; i++) {
if (runs[i].state && /^@/.test(runs[i].state.fontname)) { vertical = true; break; }
}
var fill = '', outlineLayer = '';
var needsOutlineLayer = false;
for (i = 0; i < runs.length; i++) {
run = runs[i];
if (run.lineBreak) { continue; }
if (run.text && run.state.blur > 0 && run.state.outline > 0 && run.state.borderStyle !== 3) {
needsOutlineLayer = true;
}
}
// A CSS block contributes a "strut" — an invisible inline box as tall as
// its own line-height — to every line, so a style whose Fontsize is larger
// than the \fs the line actually uses would silently pad the line (and, in
// vertical writing, widen the column and push the text off its anchor).
// ASS has no such thing, so the strut is zeroed and blank lines get an
// explicit filler instead.
var filler = '\u200b';
var lineHasText = false;
for (i = 0; i < runs.length; i++) {
run = runs[i];
if (run.lineBreak) {
var lead = lineHasText ? '' : filler;
fill += lead + '
';
outlineLayer += lead + '
';
lineHasText = false;
continue;
}
if (!run.text) { continue; }
lineHasText = true;
var body = this.runBody(run.state, run.text, vertical);
fill += '' + body + '';
if (needsOutlineLayer) {
outlineLayer += '' + body + '';
}
}
if (!fill) { return null; }
var align = block.align !== null ? block.align : style.alignment;
var box = this.boxCss(ev, style, align, block, order, vertical);
var inner = fill;
if (needsOutlineLayer) {
// A blurred border has to be its own layer: libass blurs the dilated
// silhouette and then paints the sharp glyph on top of it, and there is
// no single-element CSS equivalent. Both layers hold identical text at
// identical metrics, so they line up exactly.
inner = '