Sfoglia il codice sorgente

Improve Movie ASS subtitle rendering

Expand the Movie player's ASS renderer to better match libass behavior. This adds support for ScaleX/ScaleY, blur, animated `\move`, `\org`, vertical text, and vector drawing events, while tightening font metric handling so ASS font sizes and late-loaded embedded fonts render at the correct size and position.

The player overlay CSS also now isolates ASS cue stacking so subtitle layers no longer cover controls, and the scrub preview is raised above the center click flash.
Toby Chui 2 settimane fa
parent
commit
31163845e0
3 ha cambiato i file con 640 aggiunte e 92 eliminazioni
  1. 16 6
      src/web/Movie/embedded.html
  2. 16 6
      src/web/Movie/index.html
  3. 608 80
      src/web/Movie/script/ass.js

+ 16 - 6
src/web/Movie/embedded.html

@@ -127,11 +127,14 @@
                so they paint above the (unpositioned) video but below every later
                positioned sibling — scrub preview, controls, menus and modals all stay
                on top of the text instead of being covered by it. */
-
-            /* libass spaces lines by the font's own ascent+descent with no extra
-               leading; 1.2 would push stacked dual-language lines apart and break
-               the MarginV offsets releases use to sit them one above the other. */
-            line-height: 1.05;
+            /* ...but that only holds if the cues stay *inside* this box. The renderer
+               gives each cue an inline z-index so simultaneous lines stack in event
+               order (see ass.js boxCss), and a positioned element with z-index:auto is
+               not a stacking context — so those cues would otherwise be promoted into
+               the player's stacking context and outrank the controls, which are
+               themselves z-index:auto. Isolating creates the stacking context that keeps
+               cue ordering internal, without giving this layer a z-index of its own. */
+            isolation: isolate;
         }
         #ass-overlay.active { display: block; }
 
@@ -143,7 +146,10 @@
             margin-bottom: 12px;
             transform: translateX(-50%);
             pointer-events: none;
-            z-index: 8;
+            /* Above the centre play/pause flash, so scrubbing right after a click
+               does not put the thumbnail behind it. (ASS cues no longer need
+               outranking here — #ass-overlay isolates them.) */
+            z-index: 20;
             flex-direction: column;
             align-items: center;
             gap: 6px;
@@ -2077,6 +2083,10 @@ function ensureEmbeddedFontLoaded(family) {
         embeddedFontFaces.push(loaded);
         applySubtitleSettings();
         syncSettingsPreview();
+        // The renderer sizes every run off the face's own ascent/descent, so a
+        // face arriving late invalidates the measurements taken against the
+        // fallback it was standing in for.
+        if (typeof ASS !== 'undefined' && ASS.invalidateFontMetrics) { ASS.invalidateFontMetrics(); }
         if (assRenderer) { assRenderer.clear(); }   // redraw with the real face
     }).catch(function () {
         embeddedFontsLoaded[family] = false;

+ 16 - 6
src/web/Movie/index.html

@@ -547,11 +547,14 @@ body.always-show-volume #volume-slider { display: block; }
        so they paint above the (unpositioned) video but below every later
        positioned sibling — scrub preview, controls, menus and modals all stay
        on top of the text instead of being covered by it. */
-
-    /* libass spaces lines by the font's own ascent+descent with no extra
-       leading; 1.2 would push stacked dual-language lines apart and break the
-       MarginV offsets releases use to sit them one above the other. */
-    line-height: 1.05;
+    /* ...but that only holds if the cues stay *inside* this box. The renderer
+       gives each cue an inline z-index so simultaneous lines stack in event
+       order (see ass.js boxCss), and a positioned element with z-index:auto is
+       not a stacking context — so those cues would otherwise be promoted into
+       the player's stacking context and outrank the controls, which are
+       themselves z-index:auto. Isolating creates the stacking context that keeps
+       cue ordering internal, without giving this layer a z-index of its own. */
+    isolation: isolate;
 }
 #ass-overlay.active { display: block; }
 
@@ -563,7 +566,10 @@ body.always-show-volume #volume-slider { display: block; }
     margin-bottom: 12px;
     transform: translateX(-50%);
     pointer-events: none;
-    z-index: 8;
+    /* Above the centre play/pause flash, so scrubbing right after a click
+       does not put the thumbnail behind it. (ASS cues no longer need
+       outranking here — #ass-overlay isolates them.) */
+    z-index: 20;
     flex-direction: column;
     align-items: center;
     gap: 6px;
@@ -5112,6 +5118,10 @@ function ensureEmbeddedFontLoaded(family) {
         embeddedFontFaces.push(loaded);
         applySubtitleSettings();
         syncSettingsPreview();
+        // The renderer sizes every run off the face's own ascent/descent, so a
+        // face arriving late invalidates the measurements taken against the
+        // fallback it was standing in for.
+        if (typeof ASS !== 'undefined' && ASS.invalidateFontMetrics) { ASS.invalidateFontMetrics(); }
         if (assRenderer) { assRenderer.clear(); }   // redraw with the real face
     }).catch(function () {
         embeddedFontsLoaded[family] = false;

+ 608 - 80
src/web/Movie/script/ass.js

@@ -12,17 +12,34 @@
 
     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
+        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)
-      • 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
+      • 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';
@@ -98,7 +115,8 @@ function defaultStyle() {
         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,
+        scaleX: 100, scaleY: 100,
+        borderStyle: 1, outline: 2, shadow: 0, blur: 0,
         alignment: 2,
         marginL: 10, marginR: 10, marginV: 10
     };
@@ -122,6 +140,8 @@ function parseStyleLine(fields, values) {
             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;
@@ -149,6 +169,14 @@ function normaliseAlignment(value, isLegacy) {
     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,
@@ -214,7 +242,10 @@ function parse(text) {
     }
 
     if (!track.styles.Default) { track.styles.Default = defaultStyle(); }
-    // Stable ordering: by layer, then by start, so z-index follows the format's rules
+    // 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);
     });
@@ -240,12 +271,62 @@ function parseEventLine(fields, values) {
     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, fade: null, rotate: 0, drawing: false };
+    var block = { align: null, pos: null, move: null, org: null, fade: null, rotate: 0 };
     var runs = [];
     var current = cloneRunState(baseStyle);
     var buffer = '';
@@ -297,9 +378,13 @@ function cloneRunState(style) {
         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,
-        borderStyle: style.borderStyle
+        blur: style.blur || 0,
+        borderStyle: style.borderStyle,
+        drawScale: style.drawScale || 0
     };
 }
 
@@ -332,10 +417,18 @@ function applyOneOverride(tag, state, block, baseStyle, styles) {
     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(/^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;
@@ -344,14 +437,17 @@ function applyOneOverride(tag, state, block, baseStyle, styles) {
         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(/^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;
     }
 
@@ -362,8 +458,13 @@ function applyOneOverride(tag, state, block, baseStyle, styles) {
     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;
@@ -384,13 +485,137 @@ function applyOneOverride(tag, state, block, baseStyle, styles) {
     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
+    // 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) {
@@ -472,7 +697,7 @@ Renderer.prototype.setTime = function (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.
+    // 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('|');
@@ -485,7 +710,7 @@ Renderer.prototype.setTime = function (time) {
             var piece = this.renderEvent(active[j], meta.length);
             if (!piece) { continue; }
             html += piece.html;
-            meta.push({ ev: active[j], fade: piece.fade });
+            meta.push({ ev: active[j], fade: piece.fade, move: piece.move });
         }
         this.overlay.innerHTML = html;
         this._meta = meta;
@@ -493,7 +718,7 @@ Renderer.prototype.setTime = function (time) {
         this.resolveCollisions();
     }
 
-    this.applyFades(time);
+    this.applyDynamics(time);
 };
 
 // Nudge margin-positioned lines apart when they would overlap.
@@ -504,20 +729,44 @@ Renderer.prototype.setTime = function (time) {
 // 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) { return; }
+    if (!this._children || this._children.length < 2 || !this.overlay) { return; }
 
-    var placed = { top: [], bottom: [] };
+    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;
-        var bands = placed[anchor];
 
         // Repeatedly push past whatever it overlaps until the slot is clear
         var moved = true;
@@ -525,10 +774,11 @@ Renderer.prototype.resolveCollisions = function () {
         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;
-                }
+                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
@@ -536,72 +786,237 @@ Renderer.prototype.resolveCollisions = function () {
             offset = Math.max(0, limit - height);
         }
 
-        bands.push({ start: offset, end: offset + height });
+        bands.push({ start: offset, end: offset + height, left: left, right: right });
         el.style[anchor] = offset.toFixed(1) + 'px';
     }
 };
 
-Renderer.prototype.applyFades = function (time) {
+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 value = this._meta[i].fade
-            ? this.fadeOpacity(this._meta[i].fade, this._meta[i].ev, time)
-            : 1;
+        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); }
     }
 };
 
-// Returns {html, fade} or null when the event produces nothing to draw.
+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);
 
-    // Vector drawings would render as a stream of coordinates; skip them.
-    if (built.block.drawing) { return null; }
+    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); }
+    }
 
-    var body = '';
-    for (var i = 0; i < built.runs.length; i++) {
-        var run = built.runs[i];
-        if (run.lineBreak) { body += '<br>'; continue; }
-        if (!run.text) { continue; }
-        body += '<span style="' + this.runCss(run.state) + '">' + escapeHtml(run.text) + '</span>';
+    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; }
     }
-    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);
+    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 = '<span style="font-size:0;line-height:' +
+                 (style.fontsize * this.scale).toFixed(2) + 'px;">\u200b</span>';
+    var lineHasText = false;
+
+    for (i = 0; i < runs.length; i++) {
+        run = runs[i];
+        if (run.lineBreak) {
+            var lead = lineHasText ? '' : filler;
+            fill += lead + '<br>';
+            outlineLayer += lead + '<br>';
+            lineHasText = false;
+            continue;
+        }
+        if (!run.text) { continue; }
+        lineHasText = true;
+        var body = this.runBody(run.state, run.text, vertical);
+        fill += '<span style="' + this.runCss(run.state, run.text, 'fill') + '">' + body + '</span>';
+        if (needsOutlineLayer) {
+            outlineLayer += '<span style="' + this.runCss(run.state, run.text, 'outline') + '">' + body + '</span>';
+        }
+    }
+    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 = '<div style="position:absolute;left:0;top:0;width:100%;height:100%;">' +
+                    outlineLayer +
+                '</div><div data-fill="1" style="position:relative;">' + fill + '</div>';
+    }
 
     return {
-        html: '<div ' + box.attrs + ' style="' + box.css + '">' + body + '</div>',
-        fade: built.block.fade
+        html: '<div ' + box.attrs + ' style="' + box.css + '">' + inner + '</div>',
+        fade: block.fade,
+        move: box.move
     };
 };
 
-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));
+Renderer.prototype.renderDrawing = function (ev, style, block, runs, order) {
+    var s = this.scale;
+    var state = runs[0].state;
+    var text = '';
+    for (var i = 0; i < runs.length; i++) { text += runs[i].text; }
+
+    var path = parseDrawing(text, state.drawScale);
+    if (!path || (path.width <= 0 && path.height <= 0)) { return null; }
+
+    var align = block.align !== null ? block.align : style.alignment;
+    var f = alignFractions(align);
+
+    // libass places a drawing by treating its bounding box as a glyph box: the
+    // drawing's own origin lands at pos - (hf*width, vf*height), and the path
+    // is then painted in its raw coordinates. The visible box therefore sits at
+    // that origin plus the path's own minimum, which is *not* centred on \pos
+    // unless the path starts at 0,0.
+    var originX, originY;
+    if (block.pos) {
+        originX = block.pos.x - f.hf * path.width;
+        originY = block.pos.y - f.vf * path.height;
+    } else {
+        var marginL = (ev.marginL || style.marginL);
+        var marginR = (ev.marginR || style.marginR);
+        var marginV = (ev.marginV || style.marginV);
+        var availW = this.track.playResX - marginL - marginR;
+        originX = f.h === 0 ? marginL
+                : f.h === 2 ? (this.track.playResX - marginR - path.width)
+                : (marginL + (availW - path.width) / 2);
+        originY = f.v === 0 ? (this.track.playResY - marginV - path.height)
+                : f.v === 2 ? marginV
+                : ((this.track.playResY - path.height) / 2);
     }
-    if (outSec > 0 && time > ev.end - outSec) {
-        return Math.max(0, Math.min(1, (ev.end - time) / outSec));
+
+    var border = state.borderStyle === 3 ? 0 : state.outline;
+    var css = 'position:absolute;overflow:visible;';
+    css += 'left:' + ((originX + path.minX) * s).toFixed(1) + 'px;';
+    css += 'top:'  + ((originY + path.minY) * s).toFixed(1) + 'px;';
+    css += 'width:'  + Math.max(1, path.width * s).toFixed(1) + 'px;';
+    css += 'height:' + Math.max(1, path.height * s).toFixed(1) + 'px;';
+    css += 'z-index:' + (10 + order) + ';';
+
+    if (block.rotate) {
+        css += 'transform-origin:' + ((f.hf * path.width - path.minX) * s).toFixed(1) + 'px ' +
+               ((f.vf * path.height - path.minY) * s).toFixed(1) + 'px;';
+        css += 'transform:rotate(' + (-block.rotate) + 'deg);';
     }
-    return 1;
+
+    var filters = [];
+    if (state.shadow > 0) {
+        var d = (state.shadow * this.effectScale()).toFixed(2);
+        filters.push('drop-shadow(' + d + 'px ' + d + 'px 0 ' + colourToCss(state.backColour) + ')');
+    }
+    if (state.blur > 0) {
+        filters.push('blur(' + (state.blur * this.effectScale()).toFixed(2) + 'px)');
+    }
+    if (filters.length) { css += 'filter:' + filters.join(' ') + ';'; }
+
+    var svg = '<svg style="overflow:visible;display:block;" width="100%" height="100%" ' +
+              'viewBox="' + fmt(path.minX) + ' ' + fmt(path.minY) + ' ' +
+              fmt(Math.max(path.width, 0.01)) + ' ' + fmt(Math.max(path.height, 0.01)) + '" ' +
+              'preserveAspectRatio="none">' +
+              '<path d="' + path.d + '" fill="' + colourToCss(state.primary) + '"' +
+              (border > 0
+                  ? ' stroke="' + colourToCss(state.outlineColour) + '" stroke-width="' +
+                    fmt(border * 2) + '" paint-order="stroke"'
+                  : '') +
+              '/></svg>';
+
+    return {
+        html: '<div style="' + css + '">' + svg + '</div>',
+        fade: block.fade,
+        move: block.move
+            ? { x1: block.move.x1, y1: block.move.y1, x2: block.move.x2, y2: block.move.y2,
+                t1: block.move.t1, t2: block.move.t2,
+                dx: path.minX - f.hf * path.width, dy: path.minY - f.vf * path.height }
+            : null
+    };
+};
+
+// Border and shadow follow the picture only when ScaledBorderAndShadow is on;
+// otherwise the script means literal pixels at the script's own resolution.
+Renderer.prototype.effectScale = function () {
+    return (this.track && this.track.scaledBorderAndShadow === false) ? 1 : this.scale;
 };
 
 // Position the text block. Alignment uses the numpad layout, so 1-3 is the
 // bottom row, 4-6 the middle and 7-9 the top.
 //
-// Returns the inline CSS plus the data attributes the collision pass needs.
-Renderer.prototype.boxCss = function (ev, style, align, block, order) {
+// Returns the inline CSS, the data attributes the collision pass needs, and the
+// \move descriptor (with the offsets the per-frame update has to re-apply).
+Renderer.prototype.boxCss = function (ev, style, align, block, order, vertical) {
     var s = this.scale;
-    var horizontal = ((align - 1) % 3);   // 0 left, 1 centre, 2 right
-    var vertical = Math.floor((align - 1) / 3);   // 0 bottom, 1 middle, 2 top
+    var f = alignFractions(align);
 
     // A zero event margin means "inherit the style's", per the format
     var marginL = (ev.marginL || style.marginL) * s;
@@ -609,34 +1024,75 @@ Renderer.prototype.boxCss = function (ev, style, align, block, order) {
     var marginV = (ev.marginV || style.marginV) * s;
 
     var css = 'position:absolute;';
-    css += 'text-align:' + ['left', 'center', 'right'][horizontal] + ';';
+    css += 'text-align:' + ['left', 'center', 'right'][f.h] + ';';
+    css += 'font-size:0;line-height:0;';   // no strut — see renderText
     css += 'z-index:' + (10 + order) + ';';
-    css += 'white-space:' + (this.wrapsAutomatically() ? 'pre-wrap' : 'pre') + ';';
+
+    if (vertical) {
+        // An "@" face carries a `vert` feature that swaps brackets and dashes
+        // for pre-rotated forms; VSFilter applies it and then turns the whole
+        // line back upright, so the glyph the viewer sees is the *unsubstituted*
+        // one. Chrome applies `vert` on its own in vertical writing, which would
+        // leave those characters lying on their side — turn it off.
+        css += 'writing-mode:vertical-rl;text-orientation:upright;';
+        // Single quotes: this lands inside a style="..." attribute.
+        css += "font-feature-settings:'vert' 0,'vrt2' 0;";
+    }
 
     var attrs = '';
+    var move = null;
 
     if (block.pos) {
+        // A positioned line is typeset to an exact spot; letting the browser
+        // re-wrap it would move it. Only \N breaks such a line.
+        css += 'white-space:pre;';
         var x = block.pos.x * s;
         var y = block.pos.y * s;
-        var tx = ['0', '-50%', '-100%'][horizontal];
-        var ty = ['-100%', '-50%', '0'][vertical];
+        var tx, ty, ox, oy, angle;
+
+        if (vertical) {
+            // The vertical box is the horizontal one turned a quarter turn, so
+            // the anchor swaps axes: what was the bottom edge is now the left.
+            tx = -(1 - f.vf) * 100; ty = -f.hf * 100;
+            ox = (1 - f.vf) * 100;  oy = f.hf * 100;
+            angle = 270 - block.rotate;   // \frz270 is what stands the line up
+        } else {
+            tx = -f.hf * 100; ty = -f.vf * 100;
+            ox = f.hf * 100;  oy = f.vf * 100;
+            angle = -block.rotate;
+        }
+
         css += 'left:' + x.toFixed(1) + 'px;top:' + y.toFixed(1) + 'px;';
-        css += 'transform:translate(' + tx + ',' + ty + ')';
-        css += block.rotate ? ' rotate(' + (-block.rotate) + 'deg);' : ';';
+        css += 'transform-origin:' + ox + '% ' + oy + '%;';
+        css += 'transform:translate(' + tx + '%,' + ty + '%)';
+        css += angle ? ' rotate(' + angle.toFixed(3) + 'deg);' : ';';
+
+        if (block.move) {
+            move = {
+                x1: block.move.x1, y1: block.move.y1,
+                x2: block.move.x2, y2: block.move.y2,
+                t1: block.move.t1, t2: block.move.t2,
+                dx: 0, dy: 0
+            };
+        }
     } else {
+        css += 'white-space:' + (this.wrapsAutomatically() ? 'pre-wrap' : 'pre') + ';';
         css += 'left:' + marginL.toFixed(1) + 'px;right:' + marginR.toFixed(1) + 'px;';
-        if (vertical === 0) {
+        if (f.v === 0) {
             css += 'bottom:' + marginV.toFixed(1) + 'px;';
             attrs = 'data-anchor="bottom" data-offset="' + marginV.toFixed(1) + '"';
-        } else if (vertical === 2) {
+        } else if (f.v === 2) {
             css += 'top:' + marginV.toFixed(1) + 'px;';
             attrs = 'data-anchor="top" data-offset="' + marginV.toFixed(1) + '"';
         } else {
             css += 'top:50%;transform:translateY(-50%);';
         }
-        if (block.rotate) { css += 'rotate:' + (-block.rotate) + 'deg;'; }
+        if (block.rotate) {
+            css += 'transform-origin:' + (f.hf * 100) + '% ' + (f.vf * 100) + '%;';
+            css += 'rotate:' + (-block.rotate) + 'deg;';
+        }
     }
-    return { css: css, attrs: attrs };
+    return { css: css, attrs: attrs, move: move };
 };
 
 // WrapStyle 2 means the script is typeset to exact line lengths and must never
@@ -647,12 +1103,53 @@ Renderer.prototype.wrapsAutomatically = function () {
     return !this.track || this.track.wrapStyle !== 2;
 };
 
-Renderer.prototype.runCss = function (st) {
+/**
+ * The escaped text of one run.
+ *
+ * In vertical writing CSS gives every character a full em cell, spaces
+ * included. An "@" font is laid out horizontally and only then stood upright,
+ * so ASS gives a space its ordinary — much narrower — horizontal advance, and
+ * the difference accumulates: the two spaces a typesetter leaves as a slot for
+ * a number stretch the column and shift everything below them. Emit those gaps
+ * at their measured width instead.
+ */
+Renderer.prototype.runBody = function (st, text, vertical) {
+    if (!vertical || !/\s/.test(text)) { return escapeHtml(text); }
+
+    // letter-spacing is not applied after a replaced box, so \fsp has to be
+    // folded into the gap itself.
+    var metrics = fontMetrics(st, text);
+    var gap = metrics.space * st.fontsize * this.scale * metrics.scale + st.spacing * this.scale;
+    var spacer = '<span style="display:inline-block;width:0;height:' + gap.toFixed(2) + 'px;"></span>';
+    var out = '';
+    for (var i = 0; i < text.length; i++) {
+        var ch = text.charAt(i);
+        out += /\s/.test(ch) ? spacer : escapeHtml(ch);
+    }
+    return out;
+};
+
+/**
+ * Inline CSS for one run.
+ *
+ * `mode` is "fill" for the normal pass, or "outline" for the copy that sits
+ * behind it when a blurred border has to be drawn as its own layer: that copy
+ * paints the glyph *and* its border in the border colour, which is the dilated
+ * silhouette libass blurs.
+ */
+Renderer.prototype.runCss = function (st, sample, mode) {
     var s = this.scale;
+    var es = this.effectScale();
+    var metrics = fontMetrics(st, sample);
+    var lineHeight = st.fontsize * s;
     var css = '';
+
     css += 'font-family:' + cssFontStack(st.fontname) + ';';
-    css += 'font-size:' + (st.fontsize * s).toFixed(2) + 'px;';
-    css += 'color:' + colourToCss(st.primary) + ';';
+    // font-size is the em box; ASS sizes by ascent+descent — see the header.
+    css += 'font-size:' + (lineHeight * metrics.scale).toFixed(3) + 'px;';
+    // Matching line-height to the ASS size zeroes CSS half-leading, so the
+    // inline box is exactly the box the script was typeset against.
+    css += 'line-height:' + lineHeight.toFixed(2) + 'px;';
     if (st.bold)      { css += 'font-weight:bold;'; }
     if (st.italic)    { css += 'font-style:italic;'; }
     if (st.underline || st.strikeout) {
@@ -661,18 +1158,46 @@ Renderer.prototype.runCss = function (st) {
     }
     if (st.spacing)   { css += 'letter-spacing:' + (st.spacing * s).toFixed(2) + 'px;'; }
 
+    if (st.scaleX !== 100 || st.scaleY !== 100) {
+        // Scaling from the baseline keeps the run sitting on the same line.
+        css += 'display:inline-block;';
+        css += 'transform-origin:0 ' + (metrics.ascent * 100).toFixed(2) + '%;';
+        css += 'transform:scale(' + (st.scaleX / 100).toFixed(4) + ',' + (st.scaleY / 100).toFixed(4) + ');';
+    }
+
+    if (mode === 'outline') {
+        css += 'color:' + colourToCss(st.outlineColour) + ';';
+        css += '-webkit-text-stroke:' + (st.outline * 2 * es).toFixed(2) + 'px ' + colourToCss(st.outlineColour) + ';';
+        css += 'filter:blur(' + (st.blur * es).toFixed(2) + 'px);';
+        return css;
+    }
+
+    css += 'color:' + colourToCss(st.primary) + ';';
+
     if (st.borderStyle === 3) {
         // Opaque box instead of an outline
         css += 'background-color:' + colourToCss(st.outlineColour) + ';';
-        css += 'padding:0.05em 0.2em;';
+        css += 'box-decoration-break:clone;-webkit-box-decoration-break:clone;';
+        css += 'padding:0 ' + Math.max(1, st.outline * es).toFixed(2) + 'px;';
     } else if (st.outline > 0) {
-        // paint-order keeps the stroke behind the glyph so thick outlines do not
-        // eat into the letterforms.
-        css += '-webkit-text-stroke:' + (st.outline * s).toFixed(2) + 'px ' + colourToCss(st.outlineColour) + ';';
-        css += 'paint-order:stroke fill;';
+        // -webkit-text-stroke straddles the glyph edge, so a stroke of 2×bord
+        // puts bord outside it — which is where ASS draws the border. paint-order
+        // keeps the stroke behind the glyph so a thick one does not eat into the
+        // letterforms.
+        //
+        // When the border is blurred the stroke lives on its own layer instead
+        // (see renderText), and this run only paints the sharp fill.
+        if (!(st.blur > 0)) {
+            css += '-webkit-text-stroke:' + (st.outline * 2 * es).toFixed(2) + 'px ' + colourToCss(st.outlineColour) + ';';
+            css += 'paint-order:stroke fill;';
+        }
+    } else if (st.blur > 0) {
+        // No border to blur, so the glyph itself is what gets softened
+        css += 'filter:blur(' + (st.blur * es).toFixed(2) + 'px);';
     }
+
     if (st.shadow > 0) {
-        var d = (st.shadow * s).toFixed(2);
+        var d = (st.shadow * es).toFixed(2);
         css += 'text-shadow:' + d + 'px ' + d + 'px 0 ' + colourToCss(st.backColour) + ';';
     }
     return css;
@@ -717,11 +1242,14 @@ global.ASS = {
     parse: parse,
     Renderer: Renderer,
     referencedFonts: referencedFonts,
+    invalidateFontMetrics: invalidateFontMetrics,
     // exposed for tests
     _parseTime: parseTime,
     _parseColour: parseColour,
     _normaliseAlignment: normaliseAlignment,
-    _buildRuns: buildRuns
+    _alignFractions: alignFractions,
+    _buildRuns: buildRuns,
+    _parseDrawing: parseDrawing
 };
 
 })(window);