diff --git a/README.md b/README.md
index c873dd8..b300aa9 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,7 @@ ASS.js uses many Web APIs to render subtitles, some features will be disabled if
| Animations
(`\t`, `\move`, `\fade`, Effect) | [Web Animations API](https://caniuse.com/web-animation) | 36 | 33 | 13.1 |
| Animations
(`\t`, `\move`, `\fade`, Effect) | [registerProperty()](https://caniuse.com/mdn-api_css_registerproperty_static) | 78 | 128 | 16.4 |
| `\q0` | [text-wrap: balance](https://caniuse.com/css-text-wrap-balance) | 114 | 121 | 17.5 |
+| `\bord0` when BorderStyle=3 | [@container](https://caniuse.com/mdn-css_at-rules_container_style_queries_for_custom_properties) | 111 | - | 18.0 |
## TODO
diff --git a/src/global.css b/src/global.css
index c7bf678..08e6236 100644
--- a/src/global.css
+++ b/src/global.css
@@ -1,4 +1,5 @@
.ASS-box {
+ font-family: Arial;
overflow: hidden;
pointer-events: none;
position: absolute;
@@ -8,32 +9,93 @@
position: absolute;
z-index: 0;
}
-.ASS-dialogue [data-stroke] {
+.ASS-dialogue span {
+ display: inline-block;
+}
+.ASS-dialogue [data-text] {
+ display: inline-block;
+ color: var(--ass-fill-color);
+ font-size: calc(var(--ass-scale) * var(--ass-real-fs) * 1px);
+ line-height: calc(var(--ass-scale) * var(--ass-tag-fs) * 1px);
+ letter-spacing: calc(var(--ass-scale) * var(--ass-tag-fsp) * 1px);
+}
+.ASS-dialogue [data-wrap-style="0"],
+.ASS-dialogue [data-wrap-style="3"] {
+ text-wrap: balance;
+}
+.ASS-dialogue [data-wrap-style="1"] {
+ word-break: break-word;
+ white-space: normal;
+}
+.ASS-dialogue [data-wrap-style="2"] {
+ word-break: normal;
+ white-space: nowrap;
+}
+.ASS-dialogue [data-border-style="1"] {
position: relative;
}
-.ASS-dialogue [data-stroke]::before,
-.ASS-dialogue [data-stroke]::after {
- content: attr(data-stroke);
+.ASS-dialogue [data-border-style="1"]::before,
+.ASS-dialogue [data-border-style="1"]::after {
+ content: attr(data-text);
position: absolute;
top: 0;
left: 0;
z-index: -1;
- filter: var(--ass-blur);
+ filter: blur(calc(var(--ass-tag-blur) * 1px));
}
-.ASS-dialogue [data-stroke]::before {
+.ASS-dialogue [data-border-style="1"]::before {
color: var(--ass-shadow-color);
- transform: translate(var(--ass-shadow-offset));
+ transform: translate(
+ calc(var(--ass-scale-stroke) * var(--ass-tag-xshad) * 1px),
+ calc(var(--ass-scale-stroke) * var(--ass-tag-yshad) * 1px)
+ );
-webkit-text-stroke: var(--ass-border-width) var(--ass-shadow-color);
text-shadow: var(--ass-shadow-delta);
opacity: var(--ass-shadow-opacity);
}
-.ASS-dialogue [data-stroke]::after {
+.ASS-dialogue [data-border-style="1"]::after {
color: transparent;
-webkit-text-stroke: var(--ass-border-width) var(--ass-border-color);
text-shadow: var(--ass-border-delta);
opacity: var(--ass-border-opacity);
}
+.ASS-dialogue [data-border-style="3"] {
+ padding:
+ calc(var(--ass-scale-stroke) * var(--ass-tag-xbord) * 1px)
+ calc(var(--ass-scale-stroke) * var(--ass-tag-ybord) * 1px);
+ position: relative;
+ filter: blur(calc(var(--ass-tag-blur) * 1px));
+}
+.ASS-dialogue [data-border-style="3"]::before,
+.ASS-dialogue [data-border-style="3"]::after {
+ content: "";
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ z-index: -1;
+}
+.ASS-dialogue [data-border-style="3"]::before {
+ background-color: var(--ass-shadow-color);
+ left: calc(var(--ass-scale-stroke) * var(--ass-tag-xshad) * 1px);
+ top: calc(var(--ass-scale-stroke) * var(--ass-tag-yshad) * 1px);
+}
+.ASS-dialogue [data-border-style="3"]::after {
+ background-color: var(--ass-border-color);
+ left: 0;
+ top: 0;
+}
+@container style(--ass-tag-xbord: 0) and style(--ass-tag-ybord: 0) {
+ .ASS-dialogue [data-border-style="3"]::after {
+ background-color: transparent;
+ }
+}
+@container style(--ass-tag-xshad: 0) and style(--ass-tag-yshad: 0) {
+ .ASS-dialogue [data-border-style="3"]::before {
+ background-color: transparent;
+ }
+}
.ASS-dialogue [data-rotate] {
+ /* TODO: {\an5\fs80\bord0\shad60\frx30\frz30\fry30}1234567890 */
/* https://github.com/libass/libass/issues/805 */
transform: perspective(312.5px)
rotateY(calc(var(--ass-tag-fry) * 1deg))
diff --git a/src/index.js b/src/index.js
index 552695f..2492aaa 100644
--- a/src/index.js
+++ b/src/index.js
@@ -46,8 +46,8 @@ export default class ASS {
resampledRes: {},
/** current index of dialogues to match currentTime */
index: 0,
- /** @type {import('ass-compiler').ScriptInfo} */
- info: {},
+ /** @type {boolean} ScaledBorderAndShadow */
+ sbas: true,
/** @type {import('ass-compiler').CompiledASSStyle} */
styles: {},
/** @type {import('ass-compiler').Dialogue[]} */
@@ -106,7 +106,7 @@ export default class ASS {
if (!container) throw new Error('Missing container.');
const { info, width, height, styles, dialogues } = compile(content);
- this.#store.info = info;
+ this.#store.sbas = /yes/i.test(info.ScaledBorderAndShadow);
this.#store.layoutRes = {
width: info.LayoutResX * 1 || video.videoWidth || video.clientWidth,
height: info.LayoutResY * 1 || video.videoHeight || video.clientHeight,
@@ -153,7 +153,7 @@ export default class ASS {
this.resampling = resampling;
dialogues.forEach((dialogue) => {
- setKeyframes(dialogue, styles);
+ setKeyframes(dialogue, this.#store);
});
const observer = new ResizeObserver(this.#resize);
diff --git a/src/internal.js b/src/internal.js
index 7c8ab92..7f205ab 100644
--- a/src/internal.js
+++ b/src/internal.js
@@ -118,14 +118,10 @@ export function createResize(that, store) {
store.height = bh;
store.resampledRes = { width: rw, height: rh };
- const cssText = (
- `width:${bw}px;`
- + `height:${bh}px;`
- + `top:${(ch - bh) / 2}px;`
- + `left:${(cw - bw) / 2}px;`
- );
+ const cssText = `width:${bw}px;height:${bh}px;top:${(ch - bh) / 2}px;left:${(cw - bw) / 2}px;`;
box.style.cssText = cssText;
box.style.setProperty('--ass-scale', store.scale);
+ box.style.setProperty('--ass-scale-stroke', store.sbas ? store.scale : 1);
svg.style.cssText = cssText;
createSeek(store)();
diff --git a/src/renderer/animation.js b/src/renderer/animation.js
index fcdc0a5..fdac96e 100644
--- a/src/renderer/animation.js
+++ b/src/renderer/animation.js
@@ -1,5 +1,6 @@
import { color2rgba } from '../utils.js';
import { getRealFontSize } from './font-size.js';
+import { createCSSStroke } from './stroke.js';
import { createTransform } from './transform.js';
// TODO: multi \t can't be merged directly
@@ -98,8 +99,36 @@ function createTransformKeyframes({ fromTag, tag, fragment }) {
return Object.fromEntries(createTransform(toTag));
}
+export function createAnimatableVars(tag) {
+ return [
+ ['real-fs', getRealFontSize(tag.fn, tag.fs)],
+ ['tag-fs', tag.fs],
+ ['tag-fsp', tag.fsp],
+ ['fill-color', color2rgba(tag.a1 + tag.c1)],
+ ]
+ .filter(([, v]) => v)
+ .map(([k, v]) => [`--ass-${k}`, v]);
+}
+
+if (window.CSS.registerProperty) {
+ ['real-fs', 'tag-fs', 'tag-fsp'].forEach((k) => {
+ window.CSS.registerProperty({
+ name: `--ass-${k}`,
+ syntax: '',
+ inherits: true,
+ initialValue: '0',
+ });
+ });
+ window.CSS.registerProperty({
+ name: '--ass-fill-color',
+ syntax: '',
+ inherits: true,
+ initialValue: 'transparent',
+ });
+}
+
// TODO: accel is not implemented yet, maybe it can be simulated by cubic-bezier?
-export function setKeyframes(dialogue, styles) {
+export function setKeyframes(dialogue, store) {
const { start, end, effect, move, fade, slices } = dialogue;
const duration = (end - start) * 1000;
const keyframes = [
@@ -111,7 +140,7 @@ export function setKeyframes(dialogue, styles) {
Object.assign(dialogue, { keyframes });
}
slices.forEach((slice) => {
- const sliceTag = styles[slice.style].tag;
+ const sliceTag = store.styles[slice.style].tag;
slice.fragments.forEach((fragment) => {
if (!fragment.tag.t || fragment.tag.t.length === 0) {
return;
@@ -128,25 +157,19 @@ export function setKeyframes(dialogue, styles) {
return tag;
}, {});
const fDuration = Math.max(duration, ...tTags.map(({ t2 }) => t2));
- const kfs = tTags.map(({ t2, tag }) => {
- const hasAlpha = (
- tag.a1 !== undefined
- && tag.a1 === tag.a2
- && tag.a2 === tag.a3
- && tag.a3 === tag.a4
- );
- // TODO: border and shadow, should animate CSS vars
- return {
- offset: t2 / fDuration,
- ...(tag.fs && { 'font-size': `calc(calc(var(--ass-scale) * ${getRealFontSize(tag.fn, tag.fs)}px)` }),
- ...(tag.fsp && { 'letter-spacing': `calc(calc(var(--ass-scale) * ${tag.fsp}px)` }),
- ...((tag.c1 || (tag.a1 && !hasAlpha)) && {
- color: color2rgba((tag.a1 || fromTag.a1) + (tag.c1 || fromTag.c1)),
- }),
- ...(hasAlpha && { opacity: 1 - Number.parseInt(tag.a1, 16) / 255 }),
- ...createTransformKeyframes({ fromTag, tag, fragment }),
- };
- }).sort((a, b) => a.offset - b.offset);
+ const kfs = tTags.map(({ t2, tag }) => ({
+ offset: t2 / fDuration,
+ ...Object.fromEntries(createAnimatableVars({
+ ...tag,
+ a1: tag.a1 || fromTag.a1,
+ c1: tag.c1 || fromTag.c1,
+ })),
+ ...Object.fromEntries(createCSSStroke(
+ { ...fromTag, ...tag },
+ store.sbas ? store.scale : 1,
+ )),
+ ...createTransformKeyframes({ fromTag, tag, fragment }),
+ })).sort((a, b) => a.offset - b.offset);
if (kfs.length > 0) {
Object.assign(fragment, { keyframes: kfs, duration: fDuration });
}
diff --git a/src/renderer/dom.js b/src/renderer/dom.js
index ecbedf8..2c408a1 100644
--- a/src/renderer/dom.js
+++ b/src/renderer/dom.js
@@ -1,6 +1,6 @@
-import { color2rgba, initAnimation } from '../utils.js';
+import { initAnimation } from '../utils.js';
import { createDrawing } from './drawing.js';
-import { getRealFontSize } from './font-size.js';
+import { createAnimatableVars } from './animation.js';
import { createCSSStroke } from './stroke.js';
import { rotateTags, scaleTags, skewTags, createTransform } from './transform.js';
@@ -12,11 +12,17 @@ function encodeText(text, q) {
}
export function createDialogue(dialogue, store) {
- const { video, styles, info } = store;
+ const { video, styles } = store;
const $div = document.createElement('div');
$div.className = 'ASS-dialogue';
const df = document.createDocumentFragment();
const { align, slices, start, end } = dialogue;
+ [
+ ['--ass-align-h', ['left', 'center', 'right'][align.h]],
+ ['--ass-align-v', ['bottom', 'center', 'top'][align.v]],
+ ].forEach(([k, v]) => {
+ $div.style.setProperty(k, v);
+ });
const animationOptions = {
duration: (end - start) * 1000,
delay: Math.min(0, start - (video.currentTime - store.delay)) * 1000,
@@ -29,50 +35,17 @@ export function createDialogue(dialogue, store) {
slice.fragments.forEach((fragment) => {
const { text, drawing } = fragment;
const tag = { ...sliceTag, ...fragment.tag };
- let cssText = 'display:inline-block;';
- const cssVars = [
- ['--ass-align-h', ['left', 'center', 'right'][align.h]],
- ['--ass-align-v', ['bottom', 'center', 'top'][align.v]],
- ];
+ let cssText = '';
+ const cssVars = [];
if (!drawing) {
- cssText += `font-family:"${tag.fn}",Arial;`;
- cssText += `font-size:calc(var(--ass-scale) * ${getRealFontSize(tag.fn, tag.fs)}px);`;
- cssText += `line-height:calc(var(--ass-scale) * ${tag.fs}px);`;
- cssText += `color:${color2rgba(tag.a1 + tag.c1)};`;
- const scale = /yes/i.test(info.ScaledBorderAndShadow) ? store.scale : 1;
- if (borderStyle === 1) {
- cssVars.push(...createCSSStroke(tag, scale));
- }
- if (borderStyle === 3) {
- // TODO: \bord0\shad16
- const bc = color2rgba(tag.a3 + tag.c3);
- const bx = tag.xbord * scale;
- const by = tag.ybord * scale;
- const sc = color2rgba(tag.a4 + tag.c4);
- const sx = tag.xshad * scale;
- const sy = tag.yshad * scale;
- cssText += (
- `${bx || by ? `background-color:${bc};` : ''}`
- + `border:0 solid ${bc};`
- + `border-width:${bx}px ${by}px;`
- + `margin:${-bx}px ${-by}px;`
- + `box-shadow:${sx}px ${sy}px ${sc};`
- );
- }
+ cssVars.push(...createAnimatableVars(tag));
+ const scale = store.sbas ? store.scale : 1;
+ cssVars.push(...createCSSStroke(tag, scale));
+
+ cssText += `font-family:"${tag.fn}";`;
cssText += tag.b ? `font-weight:${tag.b === 1 ? 'bold' : tag.b};` : '';
cssText += tag.i ? 'font-style:italic;' : '';
cssText += (tag.u || tag.s) ? `text-decoration:${tag.u ? 'underline' : ''} ${tag.s ? 'line-through' : ''};` : '';
- cssText += tag.fsp ? `letter-spacing:calc(var(--ass-scale) * ${tag.fsp}px);` : '';
- // TODO: q0 and q3 is same for now, at least better than nothing.
- if (tag.q === 0 || tag.q === 3) {
- cssText += 'text-wrap:balance;';
- }
- if (tag.q === 1) {
- cssText += 'word-break:break-word;white-space:normal;';
- }
- if (tag.q === 2) {
- cssText += 'word-break:normal;white-space:nowrap;';
- }
}
if (drawing && tag.pbo) {
const pbo = -tag.pbo * (tag.fscy || 100) / 100;
@@ -80,12 +53,16 @@ export function createDialogue(dialogue, store) {
}
cssVars.push(...createTransform(tag));
- const hasRotate = rotateTags.some((x) => tag[x] || tag.t?.[x]);
- const hasScale = scaleTags.some((x) => tag[x] !== 100 || tag.t?.[x] !== 100);
- const hasSkew = skewTags.some((x) => tag[x] || tag.t?.[x]);
+ const tags = [tag, ...(tag.t || []).map((t) => t.tag)];
+ const hasRotate = rotateTags.some((x) => tags.some((t) => t[x]));
+ const hasScale = scaleTags.some((x) => tags.some((t) => t[x] !== undefined && t[x] !== 100));
+ const hasSkew = skewTags.some((x) => tags.some((t) => t[x]));
+
encodeText(text, tag.q).split('\n').forEach((content, idx) => {
const $span = document.createElement('span');
const $ssspan = document.createElement('span');
+ $span.dataset.wrapStyle = tag.q;
+ $span.dataset.borderStyle = borderStyle;
if (hasScale || hasSkew) {
if (hasScale) {
$ssspan.dataset.scale = '';
@@ -115,11 +92,11 @@ export function createDialogue(dialogue, store) {
} else {
$span.textContent = content;
}
+ const el = hasScale || hasSkew ? $ssspan : $span;
if (tag.xbord || tag.ybord || tag.xshad || tag.yshad) {
- $span.dataset.stroke = content;
+ el.dataset.text = content;
}
}
- // TODO: maybe it can be optimized
$span.style.cssText += cssText;
cssVars.forEach(([k, v]) => {
$span.style.setProperty(k, v);
diff --git a/src/renderer/drawing.js b/src/renderer/drawing.js
index f51dc04..af711cf 100644
--- a/src/renderer/drawing.js
+++ b/src/renderer/drawing.js
@@ -3,10 +3,9 @@ import { createSVGStroke } from './stroke.js';
export function createDrawing(fragment, styleTag, store) {
if (!fragment.drawing.d) return null;
- const { scale, info } = store;
const tag = { ...styleTag, ...fragment.tag };
const { minX, minY, width, height } = fragment.drawing;
- const baseScale = scale / (1 << (tag.p - 1));
+ const baseScale = store.scale / (1 << (tag.p - 1));
const scaleX = (tag.fscx ? tag.fscx / 100 : 1) * baseScale;
const scaleY = (tag.fscy ? tag.fscy / 100 : 1) * baseScale;
const blur = tag.blur || tag.be || 0;
@@ -19,7 +18,7 @@ export function createDrawing(fragment, styleTag, store) {
['height', vbh],
['viewBox', `${-vbx} ${-vby} ${vbw} ${vbh}`],
]);
- const strokeScale = /yes/i.test(info.ScaledBorderAndShadow) ? scale : 1;
+ const strokeScale = store.sbas ? store.scale : 1;
const filterId = `ASS-${uuid()}`;
const $defs = createSVGEl('defs');
$defs.append(createSVGStroke(tag, filterId, strokeScale));
diff --git a/src/renderer/stroke.js b/src/renderer/stroke.js
index 155ab11..8838954 100644
--- a/src/renderer/stroke.js
+++ b/src/renderer/stroke.js
@@ -128,19 +128,54 @@ export function createCSSStroke(tag, scale) {
const bx = tag.xbord * scale;
const by = tag.ybord * scale;
const sc = color2rgba(`00${tag.c4}`);
- const sx = tag.xshad * scale;
- const sy = tag.yshad * scale;
const blur = tag.blur || tag.be || 0;
+ // TODO: is there any way to remove this hack?
const deltaOffsets = getOffsets(bx, by);
return [
['border-width', `${Math.min(bx, by) * 2}px`],
['border-color', bc],
['border-opacity', alpha2opacity(tag.a3)],
['border-delta', deltaOffsets.map(([x, y]) => `${x}px ${y}px ${bc}`).join(',')],
- ['shadow-offset', `${sx}px, ${sy}px`],
['shadow-color', sc],
['shadow-opacity', alpha2opacity(tag.a4)],
['shadow-delta', deltaOffsets.map(([x, y]) => `${x}px ${y}px ${sc}`).join(',')],
- ['blur', `blur(${blur}px)`],
+ ['tag-blur', blur],
+ ['tag-xbord', tag.xbord],
+ ['tag-ybord', tag.ybord],
+ ['tag-xshad', tag.xshad],
+ ['tag-yshad', tag.yshad],
].map(([k, v]) => [`--ass-${k}`, v]);
}
+
+if (window.CSS.registerProperty) {
+ window.CSS.registerProperty({
+ name: '--ass-border-width',
+ syntax: '',
+ inherits: true,
+ initialValue: '0px',
+ });
+ ['border-color', 'shadow-color'].forEach((k) => {
+ window.CSS.registerProperty({
+ name: `--ass-${k}`,
+ syntax: '',
+ inherits: true,
+ initialValue: 'transparent',
+ });
+ });
+ ['border-opacity', 'shadow-opacity'].forEach((k) => {
+ window.CSS.registerProperty({
+ name: `--ass-${k}`,
+ syntax: '',
+ inherits: true,
+ initialValue: '1',
+ });
+ });
+ ['blur', 'xbord', 'ybord', 'xshad', 'yshad'].forEach((k) => {
+ window.CSS.registerProperty({
+ name: `--ass-tag-${k}`,
+ syntax: '',
+ inherits: true,
+ initialValue: '0',
+ });
+ });
+}