├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── dist ├── ass-compiler.js ├── ass-compiler.min.js └── esm │ ├── ass-compiler.js │ └── ass-compiler.min.js ├── package.json ├── rollup.config.js ├── src ├── compiler │ ├── dialogues.js │ ├── drawing.js │ ├── index.js │ ├── styles.js │ ├── tag.js │ └── text.js ├── decompiler.js ├── index.js ├── parser │ ├── dialogue.js │ ├── drawing.js │ ├── effect.js │ ├── format.js │ ├── index.js │ ├── style.js │ ├── tag.js │ ├── tags.js │ ├── text.js │ └── time.js ├── stringifier.js └── utils.js ├── test ├── compiler │ ├── dialogues.js │ ├── drawing.js │ ├── index.js │ ├── styles.js │ ├── tag.js │ └── text.js ├── decompiler.js ├── fixtures │ ├── decompiler.js │ ├── index.js │ └── stringifier.js ├── index.js ├── parser │ ├── dialogue.js │ ├── drawing.js │ ├── effect.js │ ├── format.js │ ├── index.js │ ├── style.js │ ├── tag.js │ ├── tags.js │ ├── text.js │ └── time.js └── stringifier.js ├── types ├── index.d.ts └── tags.d.ts └── vitest.config.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "rules": { 4 | "no-bitwise": 0, 5 | "no-continue": 0, 6 | "no-mixed-operators": 0, 7 | "no-plusplus": 0, 8 | "object-curly-newline": ["error", { 9 | "ObjectExpression": { "multiline": true, "consistent": true }, 10 | "ObjectPattern": { "multiline": true, "consistent": true } 11 | }], 12 | "prefer-object-spread": 0, 13 | "import/extensions": ["error", "ignorePackages"], 14 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], 15 | "import/prefer-default-export": 0 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@main 12 | - name: Use Node.js 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 22 16 | - name: Install Dependencies 17 | run: npm i 18 | - name: Build 19 | run: npm run build 20 | - name: Test 21 | run: npm run cover 22 | - name: Upload coverage to Codecov 23 | uses: codecov/codecov-action@v4 24 | with: 25 | token: ${{ secrets.CODECOV_TOKEN }} 26 | file: ./coverage/clover.xml 27 | if: github.event_name == 'push' 28 | continue-on-error: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log* 2 | node_modules 3 | coverage 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Zhenye Wei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ass-compiler 2 | 3 | [![GitHub Action](https://img.shields.io/github/actions/workflow/status/weizhenye/ass-compiler/ci.yml?logo=github)](https://github.com/weizhenye/ass-compiler/actions) 4 | [![Codecov](https://img.shields.io/codecov/c/gh/weizhenye/ass-compiler?logo=codecov)](https://codecov.io/gh/weizhenye/ass-compiler) 5 | [![License](https://img.shields.io/npm/l/ass-compiler)](https://github.com/weizhenye/ass-compiler/blob/master/LICENSE) 6 | [![NPM Version](https://img.shields.io/npm/v/ass-compiler?logo=npm)](https://www.npmjs.com/package/ass-compiler) 7 | [![jsDelivr](https://img.shields.io/jsdelivr/npm/hm/ass-compiler?logo=jsdelivr)](https://www.jsdelivr.com/package/npm/ass-compiler) 8 | [![File size](https://img.shields.io/bundlejs/size/ass-compiler)](https://bundlephobia.com/result?p=ass-compiler) 9 | 10 | Parses and compiles ASS subtitle format to easy-to-use data structure. 11 | 12 | [Online Viewer](https://ass.js.org/ass-compiler/) 13 | 14 | ## Installation 15 | 16 | ```bash 17 | npm install ass-compiler 18 | ``` 19 | 20 | ## Usage 21 | 22 | You can use `parse` or `compile` as your need. 23 | 24 | ```js 25 | import { parse, stringify, compile, decompile } from 'ass-compiler'; 26 | 27 | // ASS file content 28 | const text = ` 29 | [Script Info] 30 | ; ... 31 | `; 32 | 33 | // parse just turn ASS text into JSON 34 | const parsedASS = parse(text); 35 | const stringifiedText = stringify(parsedASS); 36 | 37 | // compile will get rid of invalid tags, merge duplicated tags, transform drawings, etc. 38 | const compiledASS = compile(text, options); 39 | const decompiledText = decompile(compiledASS); 40 | ``` 41 | 42 | ### options 43 | 44 | ```js 45 | { 46 | // Used for default values if it's not in `[Script Info]` section. 47 | defaultInfo: { 48 | PlayResX: 1280, 49 | PlayResY: 720, 50 | }, 51 | // A Style named `Default` will be automatic generated by options.defaultStyle 52 | // if it is not exists in `[V4+ Style]` section. 53 | defaultStyle: { 54 | Name: 'Default', 55 | Fontname: 'Arial', 56 | Fontsize: '20', 57 | PrimaryColour: '&H00FFFFFF&', 58 | SecondaryColour: '&H000000FF&', 59 | OutlineColour: '&H00000000&', 60 | BackColour: '&H00000000&', 61 | Bold: '0', 62 | Italic: '0', 63 | Underline: '0', 64 | StrikeOut: '0', 65 | ScaleX: '100', 66 | ScaleY: '100', 67 | Spacing: '0', 68 | Angle: '0', 69 | BorderStyle: '1', 70 | Outline: '2', 71 | Shadow: '2', 72 | Alignment: '2', 73 | MarginL: '10', 74 | MarginR: '10', 75 | MarginV: '10', 76 | Encoding: '1', 77 | }, 78 | } 79 | ``` 80 | 81 | For details of data structure, please use the [online viewer](https://ass.js.org/ass-compiler/). 82 | -------------------------------------------------------------------------------- /dist/ass-compiler.min.js: -------------------------------------------------------------------------------- 1 | (function(t,r){typeof exports==="object"&&typeof module!=="undefined"?r(exports):typeof define==="function"&&define.amd?define(["exports"],r):(t=typeof globalThis!=="undefined"?globalThis:t||self,r(t.assCompiler={}))})(this,function(t){"use strict";function o(t){var r=t.toLowerCase().trim().split(/\s*;\s*/);if(r[0]==="banner"){return{name:r[0],delay:r[1]*1||0,leftToRight:r[2]*1||0,fadeAwayWidth:r[3]*1||0}}if(/^scroll\s/.test(r[0])){return{name:r[0],y1:Math.min(r[1]*1,r[2]*1),y2:Math.max(r[1]*1,r[2]*1),delay:r[3]*1||0,fadeAwayHeight:r[4]*1||0}}if(t!==""){return{name:t}}return null}function h(t){if(!t){return[]}return t.toLowerCase().replace(/([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/g," $1 ").replace(/([mnlbspc])/g," $1 ").trim().replace(/\s+/g," ").split(/\s(?=[mnlbspc])/).map(function(t){return t.split(" ").filter(function(t,r){return!(r&&isNaN(t*1))})})}var r=["b","i","u","s","fsp","k","K","kf","ko","kt","fe","q","p","pbo","a","an","fscx","fscy","fax","fay","frx","fry","frz","fr","be","blur","bord","xbord","ybord","shad","xshad","yshad"];var x=r.map(function(t){return{name:t,regex:new RegExp("^"+t+"-?\\d")}});function S(t){var r;var e={};for(var a=0;ar.length){var a=e.slice(r.length-1).join();e=e.slice(0,r.length-1);e.push(a)}var n={};for(var i=0;i-10?1+s/10:1)*e.fs:s*1}}if(r==="K"){return{kf:s}}if(r==="t"){var E=s.t1;var H=s.accel;var N=s.tags;var A=s.t2||(e.end-e.start)*1e3;var R={};N.forEach(function(t){var r=Object.keys(t)[0];if(~V.indexOf(r)&&!(r==="clip"&&!t[r].dots)){Object.assign(R,B(t,r,e))}});return{t:{t1:E,t2:A,accel:H,tag:R}}}return i={},i[r]=s,i}var T=[null,1,2,3,null,7,8,9,null,4,5,6];var D=["r","a","an","pos","org","move","fade","fad","clip"];function P(t){return JSON.parse(JSON.stringify(Object.assign({},t,{k:undefined,kf:undefined,ko:undefined,kt:undefined})))}function z(t){var r=t.styles;var e=t.style;var a=t.parsed;var n=t.start;var i=t.end;var s;var f={q:r[e].tag.q};var o;var l;var u;var c;var v;var p=[];var d={style:e,fragments:[]};var g={};for(var y=0;y=s.End){continue}if(!r[s.Style]){s.Style="Default"}var f=r[s.Style].style;var o=z({styles:r,style:s.Style,parsed:s.Text.parsed,start:s.Start,end:s.End});var l=o.alignment||f.Alignment;a=Math.min(a,s.Layer);n.push(Object.assign({layer:s.Layer,start:s.Start,end:s.End,style:s.Style,name:s.Name,margin:{left:s.MarginL||f.MarginL,right:s.MarginR||f.MarginR,vertical:s.MarginV||f.MarginV},effect:s.Effect},o,{alignment:l}))}for(var u=0;u `b\c\` 155 | // `a\b\c` -> `b\c\` 156 | var transText = text.split('\\').slice(1).concat('').join('\\'); 157 | for (var i = 0; i < transText.length; i++) { 158 | var x = transText[i]; 159 | if (x === '(') { depth++; } 160 | if (x === ')') { depth--; } 161 | if (depth < 0) { depth = 0; } 162 | if (!depth && x === '\\') { 163 | if (str) { 164 | tags.push(str); 165 | } 166 | str = ''; 167 | } else { 168 | str += x; 169 | } 170 | } 171 | return tags.map(parseTag); 172 | } 173 | 174 | function parseText(text) { 175 | var pairs = text.split(/{(.*?)}/); 176 | var parsed = []; 177 | if (pairs[0].length) { 178 | parsed.push({ tags: [], text: pairs[0], drawing: [] }); 179 | } 180 | for (var i = 1; i < pairs.length; i += 2) { 181 | var tags = parseTags(pairs[i]); 182 | var isDrawing = tags.reduce(function (v, tag) { return (tag.p === undefined ? v : !!tag.p); }, false); 183 | parsed.push({ 184 | tags: tags, 185 | text: isDrawing ? '' : pairs[i + 1], 186 | drawing: isDrawing ? parseDrawing(pairs[i + 1]) : [], 187 | }); 188 | } 189 | return { 190 | raw: text, 191 | combined: parsed.map(function (frag) { return frag.text; }).join(''), 192 | parsed: parsed, 193 | }; 194 | } 195 | 196 | function parseTime(time) { 197 | var t = time.split(':'); 198 | return t[0] * 3600 + t[1] * 60 + t[2] * 1; 199 | } 200 | 201 | function parseDialogue(text, format) { 202 | var fields = text.split(','); 203 | if (fields.length > format.length) { 204 | var textField = fields.slice(format.length - 1).join(); 205 | fields = fields.slice(0, format.length - 1); 206 | fields.push(textField); 207 | } 208 | 209 | var dia = {}; 210 | for (var i = 0; i < fields.length; i++) { 211 | var fmt = format[i]; 212 | var fld = fields[i].trim(); 213 | switch (fmt) { 214 | case 'Layer': 215 | case 'MarginL': 216 | case 'MarginR': 217 | case 'MarginV': 218 | dia[fmt] = fld * 1; 219 | break; 220 | case 'Start': 221 | case 'End': 222 | dia[fmt] = parseTime(fld); 223 | break; 224 | case 'Effect': 225 | dia[fmt] = parseEffect(fld); 226 | break; 227 | case 'Text': 228 | dia[fmt] = parseText(fld); 229 | break; 230 | default: 231 | dia[fmt] = fld; 232 | } 233 | } 234 | 235 | return dia; 236 | } 237 | 238 | var stylesFormat = ['Name', 'Fontname', 'Fontsize', 'PrimaryColour', 'SecondaryColour', 'OutlineColour', 'BackColour', 'Bold', 'Italic', 'Underline', 'StrikeOut', 'ScaleX', 'ScaleY', 'Spacing', 'Angle', 'BorderStyle', 'Outline', 'Shadow', 'Alignment', 'MarginL', 'MarginR', 'MarginV', 'Encoding']; 239 | var eventsFormat = ['Layer', 'Start', 'End', 'Style', 'Name', 'MarginL', 'MarginR', 'MarginV', 'Effect', 'Text']; 240 | 241 | function parseFormat(text) { 242 | var fields = stylesFormat.concat(eventsFormat); 243 | return text.match(/Format\s*:\s*(.*)/i)[1] 244 | .split(/\s*,\s*/) 245 | .map(function (field) { 246 | var caseField = fields.find(function (f) { return f.toLowerCase() === field.toLowerCase(); }); 247 | return caseField || field; 248 | }); 249 | } 250 | 251 | function parseStyle(text, format) { 252 | var values = text.match(/Style\s*:\s*(.*)/i)[1].split(/\s*,\s*/); 253 | return Object.assign.apply(Object, [ {} ].concat( format.map(function (fmt, idx) { 254 | var obj; 255 | 256 | return (( obj = {}, obj[fmt] = values[idx], obj )); 257 | }) )); 258 | } 259 | 260 | function parse(text) { 261 | var tree = { 262 | info: {}, 263 | styles: { format: [], style: [] }, 264 | events: { format: [], comment: [], dialogue: [] }, 265 | }; 266 | var lines = text.split(/\r?\n/); 267 | var state = 0; 268 | for (var i = 0; i < lines.length; i++) { 269 | var line = lines[i].trim(); 270 | if (/^;/.test(line)) { continue; } 271 | 272 | if (/^\[Script Info\]/i.test(line)) { state = 1; } 273 | else if (/^\[V4\+? Styles\]/i.test(line)) { state = 2; } 274 | else if (/^\[Events\]/i.test(line)) { state = 3; } 275 | else if (/^\[.*\]/.test(line)) { state = 0; } 276 | 277 | if (state === 0) { continue; } 278 | if (state === 1) { 279 | if (/:/.test(line)) { 280 | var ref = line.match(/(.*?)\s*:\s*(.*)/); 281 | var key = ref[1]; 282 | var value = ref[2]; 283 | tree.info[key] = value; 284 | } 285 | } 286 | if (state === 2) { 287 | if (/^Format\s*:/i.test(line)) { 288 | tree.styles.format = parseFormat(line); 289 | } 290 | if (/^Style\s*:/i.test(line)) { 291 | tree.styles.style.push(parseStyle(line, tree.styles.format)); 292 | } 293 | } 294 | if (state === 3) { 295 | if (/^Format\s*:/i.test(line)) { 296 | tree.events.format = parseFormat(line); 297 | } 298 | if (/^(?:Comment|Dialogue)\s*:/i.test(line)) { 299 | var ref$1 = line.match(/^(\w+?)\s*:\s*(.*)/i); 300 | var key$1 = ref$1[1]; 301 | var value$1 = ref$1[2]; 302 | tree.events[key$1.toLowerCase()].push(parseDialogue(value$1, tree.events.format)); 303 | } 304 | } 305 | } 306 | 307 | return tree; 308 | } 309 | 310 | function stringifyInfo(info) { 311 | return Object.keys(info) 312 | .filter(function (key) { return info[key] !== null; }) 313 | .map(function (key) { return (key + ": " + (info[key])); }) 314 | .join('\n'); 315 | } 316 | 317 | function pad00(n) { 318 | return ("00" + n).slice(-2); 319 | } 320 | 321 | function stringifyTime(tf) { 322 | var t = Number.parseFloat(tf.toFixed(2)); 323 | var ms = t.toFixed(2).slice(-2); 324 | var s = (t | 0) % 60; 325 | var m = (t / 60 | 0) % 60; 326 | var h = t / 3600 | 0; 327 | return (h + ":" + (pad00(m)) + ":" + (pad00(s)) + "." + ms); 328 | } 329 | 330 | function stringifyEffect(eff) { 331 | if (!eff) { return ''; } 332 | if (eff.name === 'banner') { 333 | return ("Banner;" + (eff.delay) + ";" + (eff.leftToRight) + ";" + (eff.fadeAwayWidth)); 334 | } 335 | if (/^scroll\s/.test(eff.name)) { 336 | return ((eff.name.replace(/^\w/, function (x) { return x.toUpperCase(); })) + ";" + (eff.y1) + ";" + (eff.y2) + ";" + (eff.delay) + ";" + (eff.fadeAwayHeight)); 337 | } 338 | return eff.name; 339 | } 340 | 341 | function stringifyDrawing(drawing) { 342 | return drawing.map(function (cmds) { return cmds.join(' '); }).join(' '); 343 | } 344 | 345 | function stringifyTag(tag) { 346 | var ref = Object.keys(tag); 347 | var key = ref[0]; 348 | if (!key) { return ''; } 349 | var _ = tag[key]; 350 | if (['pos', 'org', 'move', 'fad', 'fade'].some(function (ft) { return ft === key; })) { 351 | return ("\\" + key + "(" + _ + ")"); 352 | } 353 | if (/^[ac]\d$/.test(key)) { 354 | return ("\\" + (key[1]) + (key[0]) + "&H" + _ + "&"); 355 | } 356 | if (key === 'alpha') { 357 | return ("\\alpha&H" + _ + "&"); 358 | } 359 | if (key === 'clip') { 360 | return ("\\" + (_.inverse ? 'i' : '') + "clip(" + (_.dots || ("" + (_.scale === 1 ? '' : ((_.scale) + ",")) + (stringifyDrawing(_.drawing)))) + ")"); 361 | } 362 | if (key === 't') { 363 | return ("\\t(" + ([_.t1, _.t2, _.accel, _.tags.map(stringifyTag).join('')]) + ")"); 364 | } 365 | return ("\\" + key + _); 366 | } 367 | 368 | function stringifyText(Text) { 369 | return Text.parsed.map(function (ref) { 370 | var tags = ref.tags; 371 | var text = ref.text; 372 | var drawing = ref.drawing; 373 | 374 | var tagText = tags.map(stringifyTag).join(''); 375 | var content = drawing.length ? stringifyDrawing(drawing) : text; 376 | return ("" + (tagText ? ("{" + tagText + "}") : '') + content); 377 | }).join(''); 378 | } 379 | 380 | function stringifyEvent(event, format) { 381 | return format.map(function (fmt) { 382 | switch (fmt) { 383 | case 'Start': 384 | case 'End': 385 | return stringifyTime(event[fmt]); 386 | case 'MarginL': 387 | case 'MarginR': 388 | case 'MarginV': 389 | return event[fmt] || '0000'; 390 | case 'Effect': 391 | return stringifyEffect(event[fmt]); 392 | case 'Text': 393 | return stringifyText(event.Text); 394 | default: 395 | return event[fmt]; 396 | } 397 | }).join(); 398 | } 399 | 400 | function stringify(ref) { 401 | var ref$1; 402 | 403 | var info = ref.info; 404 | var styles = ref.styles; 405 | var events = ref.events; 406 | return [ 407 | '[Script Info]', 408 | stringifyInfo(info), 409 | '', 410 | '[V4+ Styles]', 411 | ("Format: " + (styles.format.join(', '))) ].concat( styles.style.map(function (style) { return ("Style: " + (styles.format.map(function (fmt) { return style[fmt]; }).join())); }), 412 | [''], 413 | ['[Events]'], 414 | [("Format: " + (events.format.join(', ')))], 415 | (ref$1 = []) 416 | .concat.apply(ref$1, ['Comment', 'Dialogue'].map(function (type) { return ( 417 | events[type.toLowerCase()].map(function (dia) { return ({ 418 | start: dia.Start, 419 | end: dia.End, 420 | string: (type + ": " + (stringifyEvent(dia, events.format))), 421 | }); }) 422 | ); })) 423 | .sort(function (a, b) { return (a.start - b.start) || (a.end - b.end); }) 424 | .map(function (x) { return x.string; }), 425 | [''] ).join('\n'); 426 | } 427 | 428 | function createCommand(arr) { 429 | var cmd = { 430 | type: null, 431 | prev: null, 432 | next: null, 433 | points: [], 434 | }; 435 | if (/[mnlbs]/.test(arr[0])) { 436 | cmd.type = arr[0] 437 | .toUpperCase() 438 | .replace('N', 'L') 439 | .replace('B', 'C'); 440 | } 441 | for (var len = arr.length - !(arr.length & 1), i = 1; i < len; i += 2) { 442 | cmd.points.push({ x: arr[i] * 1, y: arr[i + 1] * 1 }); 443 | } 444 | return cmd; 445 | } 446 | 447 | function isValid(cmd) { 448 | if (!cmd.points.length || !cmd.type) { 449 | return false; 450 | } 451 | if (/C|S/.test(cmd.type) && cmd.points.length < 3) { 452 | return false; 453 | } 454 | return true; 455 | } 456 | 457 | function getViewBox(commands) { 458 | var ref; 459 | 460 | var minX = Infinity; 461 | var minY = Infinity; 462 | var maxX = -Infinity; 463 | var maxY = -Infinity; 464 | (ref = []).concat.apply(ref, commands.map(function (ref) { 465 | var points = ref.points; 466 | 467 | return points; 468 | })).forEach(function (ref) { 469 | var x = ref.x; 470 | var y = ref.y; 471 | 472 | minX = Math.min(minX, x); 473 | minY = Math.min(minY, y); 474 | maxX = Math.max(maxX, x); 475 | maxY = Math.max(maxY, y); 476 | }); 477 | return { 478 | minX: minX, 479 | minY: minY, 480 | width: maxX - minX, 481 | height: maxY - minY, 482 | }; 483 | } 484 | 485 | /** 486 | * Convert S command to B command 487 | * Reference from https://github.com/d3/d3/blob/v3.5.17/src/svg/line.js#L259 488 | * @param {Array} points points 489 | * @param {String} prev type of previous command 490 | * @param {String} next type of next command 491 | * @return {Array} converted commands 492 | */ 493 | function s2b(points, prev, next) { 494 | var results = []; 495 | var bb1 = [0, 2 / 3, 1 / 3, 0]; 496 | var bb2 = [0, 1 / 3, 2 / 3, 0]; 497 | var bb3 = [0, 1 / 6, 2 / 3, 1 / 6]; 498 | var dot4 = function (a, b) { return (a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]); }; 499 | var px = [points[points.length - 1].x, points[0].x, points[1].x, points[2].x]; 500 | var py = [points[points.length - 1].y, points[0].y, points[1].y, points[2].y]; 501 | results.push({ 502 | type: prev === 'M' ? 'M' : 'L', 503 | points: [{ x: dot4(bb3, px), y: dot4(bb3, py) }], 504 | }); 505 | for (var i = 3; i < points.length; i++) { 506 | px = [points[i - 3].x, points[i - 2].x, points[i - 1].x, points[i].x]; 507 | py = [points[i - 3].y, points[i - 2].y, points[i - 1].y, points[i].y]; 508 | results.push({ 509 | type: 'C', 510 | points: [ 511 | { x: dot4(bb1, px), y: dot4(bb1, py) }, 512 | { x: dot4(bb2, px), y: dot4(bb2, py) }, 513 | { x: dot4(bb3, px), y: dot4(bb3, py) } ], 514 | }); 515 | } 516 | if (next === 'L' || next === 'C') { 517 | var last = points[points.length - 1]; 518 | results.push({ type: 'L', points: [{ x: last.x, y: last.y }] }); 519 | } 520 | return results; 521 | } 522 | 523 | function toSVGPath(instructions) { 524 | return instructions.map(function (ref) { 525 | var type = ref.type; 526 | var points = ref.points; 527 | 528 | return ( 529 | type + points.map(function (ref) { 530 | var x = ref.x; 531 | var y = ref.y; 532 | 533 | return (x + "," + y); 534 | }).join(',') 535 | ); 536 | }).join(''); 537 | } 538 | 539 | function compileDrawing(rawCommands) { 540 | var ref$1; 541 | 542 | var commands = []; 543 | var i = 0; 544 | while (i < rawCommands.length) { 545 | var arr = rawCommands[i]; 546 | var cmd = createCommand(arr); 547 | if (isValid(cmd)) { 548 | if (cmd.type === 'S') { 549 | var ref = (commands[i - 1] || { points: [{ x: 0, y: 0 }] }).points.slice(-1)[0]; 550 | var x = ref.x; 551 | var y = ref.y; 552 | cmd.points.unshift({ x: x, y: y }); 553 | } 554 | if (i) { 555 | cmd.prev = commands[i - 1].type; 556 | commands[i - 1].next = cmd.type; 557 | } 558 | commands.push(cmd); 559 | i++; 560 | } else { 561 | if (i && commands[i - 1].type === 'S') { 562 | var additionPoints = { 563 | p: cmd.points, 564 | c: commands[i - 1].points.slice(0, 3), 565 | }; 566 | commands[i - 1].points = commands[i - 1].points.concat( 567 | (additionPoints[arr[0]] || []).map(function (ref) { 568 | var x = ref.x; 569 | var y = ref.y; 570 | 571 | return ({ x: x, y: y }); 572 | }) 573 | ); 574 | } 575 | rawCommands.splice(i, 1); 576 | } 577 | } 578 | var instructions = (ref$1 = []).concat.apply( 579 | ref$1, commands.map(function (ref) { 580 | var type = ref.type; 581 | var points = ref.points; 582 | var prev = ref.prev; 583 | var next = ref.next; 584 | 585 | return ( 586 | type === 'S' 587 | ? s2b(points, prev, next) 588 | : { type: type, points: points } 589 | ); 590 | }) 591 | ); 592 | 593 | return Object.assign({ instructions: instructions, d: toSVGPath(instructions) }, getViewBox(commands)); 594 | } 595 | 596 | var tTags = [ 597 | 'fs', 'fsp', 'clip', 598 | 'c1', 'c2', 'c3', 'c4', 'a1', 'a2', 'a3', 'a4', 'alpha', 599 | 'fscx', 'fscy', 'fax', 'fay', 'frx', 'fry', 'frz', 'fr', 600 | 'be', 'blur', 'bord', 'xbord', 'ybord', 'shad', 'xshad', 'yshad' ]; 601 | 602 | function compileTag(tag, key, presets) { 603 | var obj, obj$1, obj$2; 604 | 605 | if ( presets === void 0 ) presets = {}; 606 | var value = tag[key]; 607 | if (value === undefined) { 608 | return null; 609 | } 610 | if (key === 'pos' || key === 'org') { 611 | return value.length === 2 ? ( obj = {}, obj[key] = { x: value[0], y: value[1] }, obj ) : null; 612 | } 613 | if (key === 'move') { 614 | var x1 = value[0]; 615 | var y1 = value[1]; 616 | var x2 = value[2]; 617 | var y2 = value[3]; 618 | var t1 = value[4]; if ( t1 === void 0 ) t1 = 0; 619 | var t2 = value[5]; if ( t2 === void 0 ) t2 = 0; 620 | return value.length === 4 || value.length === 6 621 | ? { move: { x1: x1, y1: y1, x2: x2, y2: y2, t1: t1, t2: t2 } } 622 | : null; 623 | } 624 | if (key === 'fad' || key === 'fade') { 625 | if (value.length === 2) { 626 | var t1$1 = value[0]; 627 | var t2$1 = value[1]; 628 | return { fade: { type: 'fad', t1: t1$1, t2: t2$1 } }; 629 | } 630 | if (value.length === 7) { 631 | var a1 = value[0]; 632 | var a2 = value[1]; 633 | var a3 = value[2]; 634 | var t1$2 = value[3]; 635 | var t2$2 = value[4]; 636 | var t3 = value[5]; 637 | var t4 = value[6]; 638 | return { fade: { type: 'fade', a1: a1, a2: a2, a3: a3, t1: t1$2, t2: t2$2, t3: t3, t4: t4 } }; 639 | } 640 | return null; 641 | } 642 | if (key === 'clip') { 643 | var inverse = value.inverse; 644 | var scale = value.scale; 645 | var drawing = value.drawing; 646 | var dots = value.dots; 647 | if (drawing) { 648 | return { clip: { inverse: inverse, scale: scale, drawing: compileDrawing(drawing), dots: dots } }; 649 | } 650 | if (dots) { 651 | var x1$1 = dots[0]; 652 | var y1$1 = dots[1]; 653 | var x2$1 = dots[2]; 654 | var y2$1 = dots[3]; 655 | return { clip: { inverse: inverse, scale: scale, drawing: drawing, dots: { x1: x1$1, y1: y1$1, x2: x2$1, y2: y2$1 } } }; 656 | } 657 | return null; 658 | } 659 | if (/^[xy]?(bord|shad)$/.test(key)) { 660 | value = Math.max(value, 0); 661 | } 662 | if (key === 'bord') { 663 | return { xbord: value, ybord: value }; 664 | } 665 | if (key === 'shad') { 666 | return { xshad: value, yshad: value }; 667 | } 668 | if (/^c\d$/.test(key)) { 669 | return ( obj$1 = {}, obj$1[key] = value || presets[key], obj$1 ); 670 | } 671 | if (key === 'alpha') { 672 | return { a1: value, a2: value, a3: value, a4: value }; 673 | } 674 | if (key === 'fr') { 675 | return { frz: value }; 676 | } 677 | if (key === 'fs') { 678 | return { 679 | fs: /^\+|-/.test(value) 680 | ? (value * 1 > -10 ? (1 + value / 10) : 1) * presets.fs 681 | : value * 1, 682 | }; 683 | } 684 | if (key === 'K') { 685 | return { kf: value }; 686 | } 687 | if (key === 't') { 688 | var t1$3 = value.t1; 689 | var accel = value.accel; 690 | var tags = value.tags; 691 | var t2$3 = value.t2 || (presets.end - presets.start) * 1e3; 692 | var compiledTag = {}; 693 | tags.forEach(function (t) { 694 | var k = Object.keys(t)[0]; 695 | if (~tTags.indexOf(k) && !(k === 'clip' && !t[k].dots)) { 696 | Object.assign(compiledTag, compileTag(t, k, presets)); 697 | } 698 | }); 699 | return { t: { t1: t1$3, t2: t2$3, accel: accel, tag: compiledTag } }; 700 | } 701 | return ( obj$2 = {}, obj$2[key] = value, obj$2 ); 702 | } 703 | 704 | var a2an = [ 705 | null, 1, 2, 3, 706 | null, 7, 8, 9, 707 | null, 4, 5, 6 ]; 708 | 709 | var globalTags = ['r', 'a', 'an', 'pos', 'org', 'move', 'fade', 'fad', 'clip']; 710 | 711 | function inheritTag(pTag) { 712 | return JSON.parse(JSON.stringify(Object.assign({}, pTag, { 713 | k: undefined, 714 | kf: undefined, 715 | ko: undefined, 716 | kt: undefined, 717 | }))); 718 | } 719 | 720 | function compileText(ref) { 721 | var styles = ref.styles; 722 | var style = ref.style; 723 | var parsed = ref.parsed; 724 | var start = ref.start; 725 | var end = ref.end; 726 | 727 | var alignment; 728 | var q = { q: styles[style].tag.q }; 729 | var pos; 730 | var org; 731 | var move; 732 | var fade; 733 | var clip; 734 | var slices = []; 735 | var slice = { style: style, fragments: [] }; 736 | var prevTag = {}; 737 | for (var i = 0; i < parsed.length; i++) { 738 | var ref$1 = parsed[i]; 739 | var tags = ref$1.tags; 740 | var text = ref$1.text; 741 | var drawing = ref$1.drawing; 742 | var reset = (void 0); 743 | for (var j = 0; j < tags.length; j++) { 744 | var tag = tags[j]; 745 | reset = tag.r === undefined ? reset : tag.r; 746 | } 747 | var fragment = { 748 | tag: reset === undefined ? inheritTag(prevTag) : {}, 749 | text: text, 750 | drawing: drawing.length ? compileDrawing(drawing) : null, 751 | }; 752 | for (var j$1 = 0; j$1 < tags.length; j$1++) { 753 | var tag$1 = tags[j$1]; 754 | alignment = alignment || a2an[tag$1.a || 0] || tag$1.an; 755 | q = compileTag(tag$1, 'q') || q; 756 | if (!move) { 757 | pos = pos || compileTag(tag$1, 'pos'); 758 | } 759 | org = org || compileTag(tag$1, 'org'); 760 | if (!pos) { 761 | move = move || compileTag(tag$1, 'move'); 762 | } 763 | fade = fade || compileTag(tag$1, 'fade') || compileTag(tag$1, 'fad'); 764 | clip = compileTag(tag$1, 'clip') || clip; 765 | var key = Object.keys(tag$1)[0]; 766 | if (key && !~globalTags.indexOf(key)) { 767 | var sliceTag = styles[style].tag; 768 | var c1 = sliceTag.c1; 769 | var c2 = sliceTag.c2; 770 | var c3 = sliceTag.c3; 771 | var c4 = sliceTag.c4; 772 | var fs = prevTag.fs || sliceTag.fs; 773 | var compiledTag = compileTag(tag$1, key, { start: start, end: end, c1: c1, c2: c2, c3: c3, c4: c4, fs: fs }); 774 | if (key === 't') { 775 | fragment.tag.t = fragment.tag.t || []; 776 | fragment.tag.t.push(compiledTag.t); 777 | } else { 778 | Object.assign(fragment.tag, compiledTag); 779 | } 780 | } 781 | } 782 | prevTag = fragment.tag; 783 | if (reset !== undefined) { 784 | slices.push(slice); 785 | slice = { style: styles[reset] ? reset : style, fragments: [] }; 786 | } 787 | if (fragment.text || fragment.drawing) { 788 | var prev = slice.fragments[slice.fragments.length - 1] || {}; 789 | if (prev.text && fragment.text && !Object.keys(fragment.tag).length) { 790 | // merge fragment to previous if its tag is empty 791 | prev.text += fragment.text; 792 | } else { 793 | slice.fragments.push(fragment); 794 | } 795 | } 796 | } 797 | slices.push(slice); 798 | 799 | return Object.assign({ alignment: alignment, slices: slices }, q, pos, org, move, fade, clip); 800 | } 801 | 802 | function compileDialogues(ref) { 803 | var styles = ref.styles; 804 | var dialogues = ref.dialogues; 805 | 806 | var minLayer = Infinity; 807 | var results = []; 808 | for (var i = 0; i < dialogues.length; i++) { 809 | var dia = dialogues[i]; 810 | if (dia.Start >= dia.End) { 811 | continue; 812 | } 813 | if (!styles[dia.Style]) { 814 | dia.Style = 'Default'; 815 | } 816 | var stl = styles[dia.Style].style; 817 | var compiledText = compileText({ 818 | styles: styles, 819 | style: dia.Style, 820 | parsed: dia.Text.parsed, 821 | start: dia.Start, 822 | end: dia.End, 823 | }); 824 | var alignment = compiledText.alignment || stl.Alignment; 825 | minLayer = Math.min(minLayer, dia.Layer); 826 | results.push(Object.assign({ 827 | layer: dia.Layer, 828 | start: dia.Start, 829 | end: dia.End, 830 | style: dia.Style, 831 | name: dia.Name, 832 | // reset style by `\r` will not effect margin and alignment 833 | margin: { 834 | left: dia.MarginL || stl.MarginL, 835 | right: dia.MarginR || stl.MarginR, 836 | vertical: dia.MarginV || stl.MarginV, 837 | }, 838 | effect: dia.Effect, 839 | }, compiledText, { alignment: alignment })); 840 | } 841 | for (var i$1 = 0; i$1 < results.length; i$1++) { 842 | results[i$1].layer -= minLayer; 843 | } 844 | return results.sort(function (a, b) { return a.start - b.start || a.end - b.end; }); 845 | } 846 | 847 | // same as Aegisub 848 | // https://github.com/Aegisub/Aegisub/blob/master/src/ass_style.h 849 | var DEFAULT_STYLE = { 850 | Name: 'Default', 851 | Fontname: 'Arial', 852 | Fontsize: '20', 853 | PrimaryColour: '&H00FFFFFF&', 854 | SecondaryColour: '&H000000FF&', 855 | OutlineColour: '&H00000000&', 856 | BackColour: '&H00000000&', 857 | Bold: '0', 858 | Italic: '0', 859 | Underline: '0', 860 | StrikeOut: '0', 861 | ScaleX: '100', 862 | ScaleY: '100', 863 | Spacing: '0', 864 | Angle: '0', 865 | BorderStyle: '1', 866 | Outline: '2', 867 | Shadow: '2', 868 | Alignment: '2', 869 | MarginL: '10', 870 | MarginR: '10', 871 | MarginV: '10', 872 | Encoding: '1', 873 | }; 874 | 875 | /** 876 | * @param {String} color 877 | * @returns {Array} [AA, BBGGRR] 878 | */ 879 | function parseStyleColor(color) { 880 | if (/^(&|H|&H)[0-9a-f]{6,}/i.test(color)) { 881 | var ref = color.match(/&?H?([0-9a-f]{2})?([0-9a-f]{6})/i); 882 | var a = ref[1]; 883 | var c = ref[2]; 884 | return [a || '00', c]; 885 | } 886 | var num = parseInt(color, 10); 887 | if (!isNaN(num)) { 888 | var min = -2147483648; 889 | var max = 2147483647; 890 | if (num < min) { 891 | return ['00', '000000']; 892 | } 893 | var aabbggrr = (min <= num && num <= max) 894 | ? ("00000000" + ((num < 0 ? num + 4294967296 : num).toString(16))).slice(-8) 895 | : String(num).slice(0, 8); 896 | return [aabbggrr.slice(0, 2), aabbggrr.slice(2)]; 897 | } 898 | return ['00', '000000']; 899 | } 900 | 901 | function compileStyles(ref) { 902 | var info = ref.info; 903 | var style = ref.style; 904 | var defaultStyle = ref.defaultStyle; 905 | 906 | var result = {}; 907 | var styles = [Object.assign({}, defaultStyle, { Name: 'Default' })].concat(style); 908 | var loop = function ( i ) { 909 | var s = Object.assign({}, DEFAULT_STYLE, styles[i]); 910 | // this behavior is same as Aegisub by black-box testing 911 | if (/^(\*+)Default$/.test(s.Name)) { 912 | s.Name = 'Default'; 913 | } 914 | Object.keys(s).forEach(function (key) { 915 | if (key !== 'Name' && key !== 'Fontname' && !/Colour/.test(key)) { 916 | s[key] *= 1; 917 | } 918 | }); 919 | var ref$1 = parseStyleColor(s.PrimaryColour); 920 | var a1 = ref$1[0]; 921 | var c1 = ref$1[1]; 922 | var ref$2 = parseStyleColor(s.SecondaryColour); 923 | var a2 = ref$2[0]; 924 | var c2 = ref$2[1]; 925 | var ref$3 = parseStyleColor(s.OutlineColour); 926 | var a3 = ref$3[0]; 927 | var c3 = ref$3[1]; 928 | var ref$4 = parseStyleColor(s.BackColour); 929 | var a4 = ref$4[0]; 930 | var c4 = ref$4[1]; 931 | var tag = { 932 | fn: s.Fontname, 933 | fs: s.Fontsize, 934 | c1: c1, 935 | a1: a1, 936 | c2: c2, 937 | a2: a2, 938 | c3: c3, 939 | a3: a3, 940 | c4: c4, 941 | a4: a4, 942 | b: Math.abs(s.Bold), 943 | i: Math.abs(s.Italic), 944 | u: Math.abs(s.Underline), 945 | s: Math.abs(s.StrikeOut), 946 | fscx: s.ScaleX, 947 | fscy: s.ScaleY, 948 | fsp: s.Spacing, 949 | frz: s.Angle, 950 | xbord: s.Outline, 951 | ybord: s.Outline, 952 | xshad: s.Shadow, 953 | yshad: s.Shadow, 954 | fe: s.Encoding, 955 | // TODO: [breaking change] remove `q` from style 956 | q: /^[0-3]$/.test(info.WrapStyle) ? info.WrapStyle * 1 : 2, 957 | }; 958 | result[s.Name] = { style: s, tag: tag }; 959 | }; 960 | 961 | for (var i = 0; i < styles.length; i++) loop( i ); 962 | return result; 963 | } 964 | 965 | function compile(text, options) { 966 | if ( options === void 0 ) options = {}; 967 | 968 | var tree = parse(text); 969 | var info = Object.assign(options.defaultInfo || {}, tree.info); 970 | var styles = compileStyles({ 971 | info: info, 972 | style: tree.styles.style, 973 | defaultStyle: options.defaultStyle || {}, 974 | }); 975 | return { 976 | info: info, 977 | width: info.PlayResX * 1 || null, 978 | height: info.PlayResY * 1 || null, 979 | wrapStyle: /^[0-3]$/.test(info.WrapStyle) ? info.WrapStyle * 1 : 2, 980 | collisions: info.Collisions || 'Normal', 981 | styles: styles, 982 | dialogues: compileDialogues({ 983 | styles: styles, 984 | dialogues: tree.events.dialogue, 985 | }), 986 | }; 987 | } 988 | 989 | function decompileStyle(ref) { 990 | var style = ref.style; 991 | var tag = ref.tag; 992 | 993 | var obj = Object.assign({}, style, { 994 | PrimaryColour: ("&H" + (tag.a1) + (tag.c1)), 995 | SecondaryColour: ("&H" + (tag.a2) + (tag.c2)), 996 | OutlineColour: ("&H" + (tag.a3) + (tag.c3)), 997 | BackColour: ("&H" + (tag.a4) + (tag.c4)), 998 | }); 999 | return ("Style: " + (stylesFormat.map(function (fmt) { return obj[fmt]; }).join())); 1000 | } 1001 | 1002 | var drawingInstructionMap = { 1003 | M: 'm', 1004 | L: 'l', 1005 | C: 'b', 1006 | }; 1007 | 1008 | function decompileDrawing(ref) { 1009 | var instructions = ref.instructions; 1010 | 1011 | return instructions.map(function (ref) { 1012 | var ref$1; 1013 | 1014 | var type = ref.type; 1015 | var points = ref.points; 1016 | return ( 1017 | (ref$1 = [drawingInstructionMap[type]]) 1018 | .concat.apply(ref$1, points.map(function (ref) { 1019 | var x = ref.x; 1020 | var y = ref.y; 1021 | 1022 | return [x, y]; 1023 | })) 1024 | .join(' ') 1025 | ); 1026 | }).join(' '); 1027 | } 1028 | 1029 | var ca = function (x) { return function (n) { return function (_) { return ("" + n + x + "&H" + _ + "&"); }; }; }; 1030 | var c = ca('c'); 1031 | var a = ca('a'); 1032 | 1033 | var tagDecompiler = { 1034 | c1: c(1), 1035 | c2: c(2), 1036 | c3: c(3), 1037 | c4: c(4), 1038 | a1: a(1), 1039 | a2: a(2), 1040 | a3: a(3), 1041 | a4: a(4), 1042 | pos: function (_) { return ("pos(" + ([_.x, _.y]) + ")"); }, 1043 | org: function (_) { return ("org(" + ([_.x, _.y]) + ")"); }, 1044 | move: function (_) { return ("move(" + ([_.x1, _.y1, _.x2, _.y2, _.t1, _.t2]) + ")"); }, 1045 | fade: function (_) { return ( 1046 | _.type === 'fad' 1047 | ? ("fad(" + ([_.t1, _.t2]) + ")") 1048 | : ("fade(" + ([_.a1, _.a2, _.a3, _.t1, _.t2, _.t3, _.t4]) + ")") 1049 | ); }, 1050 | clip: function (_) { return ((_.inverse ? 'i' : '') + "clip(" + (_.dots 1051 | ? ("" + ([_.dots.x1, _.dots.y1, _.dots.x2, _.dots.y2])) 1052 | : ("" + (_.scale === 1 ? '' : ((_.scale) + ",")) + (decompileDrawing(_.drawing)))) + ")"); }, 1053 | // eslint-disable-next-line no-use-before-define 1054 | t: function (arr) { return arr.map(function (_) { return ("t(" + ([_.t1, _.t2, _.accel, decompileTag(_.tag)]) + ")"); }).join('\\'); }, 1055 | }; 1056 | 1057 | function decompileTag(tag) { 1058 | return Object.keys(tag).map(function (key) { 1059 | var fn = tagDecompiler[key] || (function (_) { return ("" + key + _); }); 1060 | return ("\\" + (fn(tag[key]))); 1061 | }).join(''); 1062 | } 1063 | 1064 | function decompileSlice(slice) { 1065 | return slice.fragments.map(function (ref) { 1066 | var tag = ref.tag; 1067 | var text = ref.text; 1068 | var drawing = ref.drawing; 1069 | 1070 | var tagText = decompileTag(tag); 1071 | return ("" + (tagText ? ("{" + tagText + "}") : '') + (drawing ? decompileDrawing(drawing) : text)); 1072 | }).join(''); 1073 | } 1074 | 1075 | function decompileText(dia, style) { 1076 | return dia.slices 1077 | .filter(function (slice) { return slice.fragments.length; }) 1078 | .map(function (slice, idx) { 1079 | var sliceCopy = JSON.parse(JSON.stringify(slice)); 1080 | var tag = {}; 1081 | if (idx) { 1082 | tag.r = slice.style === dia.style ? '' : slice.style; 1083 | } else { 1084 | if (style.Alignment !== dia.alignment) { 1085 | tag.an = dia.alignment; 1086 | } 1087 | ['pos', 'org', 'move', 'fade', 'clip'].forEach(function (key) { 1088 | if (dia[key]) { 1089 | tag[key] = dia[key]; 1090 | } 1091 | }); 1092 | } 1093 | // make sure additional tags are first 1094 | sliceCopy.fragments[0].tag = Object.assign(tag, sliceCopy.fragments[0].tag); 1095 | return sliceCopy; 1096 | }) 1097 | .map(decompileSlice) 1098 | .join(''); 1099 | } 1100 | 1101 | function getMargin(margin, styleMargin) { 1102 | return margin === styleMargin ? '0000' : margin; 1103 | } 1104 | 1105 | function decompileDialogue(dia, style) { 1106 | return ("Dialogue: " + ([ 1107 | dia.layer, 1108 | stringifyTime(dia.start), 1109 | stringifyTime(dia.end), 1110 | dia.style, 1111 | dia.name, 1112 | getMargin(dia.margin.left, style.MarginL), 1113 | getMargin(dia.margin.right, style.MarginR), 1114 | getMargin(dia.margin.vertical, style.MarginV), 1115 | stringifyEffect(dia.effect), 1116 | decompileText(dia, style) ].join())); 1117 | } 1118 | 1119 | function decompile(ref) { 1120 | var info = ref.info; 1121 | var width = ref.width; 1122 | var height = ref.height; 1123 | var collisions = ref.collisions; 1124 | var styles = ref.styles; 1125 | var dialogues = ref.dialogues; 1126 | 1127 | return [ 1128 | '[Script Info]', 1129 | stringifyInfo(Object.assign({}, info, { 1130 | PlayResX: width, 1131 | PlayResY: height, 1132 | Collisions: collisions, 1133 | })), 1134 | '', 1135 | '[V4+ Styles]', 1136 | ("Format: " + (stylesFormat.join(', '))) ].concat( Object.keys(styles).map(function (name) { return decompileStyle(styles[name]); }), 1137 | [''], 1138 | ['[Events]'], 1139 | [("Format: " + (eventsFormat.join(', ')))], 1140 | dialogues 1141 | .sort(function (x, y) { return x.start - y.start || x.end - y.end; }) 1142 | .map(function (dia) { return decompileDialogue(dia, styles[dia.style].style); }), 1143 | [''] ).join('\n'); 1144 | } 1145 | 1146 | export { compile, decompile, parse, stringify }; 1147 | -------------------------------------------------------------------------------- /dist/esm/ass-compiler.min.js: -------------------------------------------------------------------------------- 1 | function parseEffect(t){var r=t.toLowerCase().trim().split(/\s*;\s*/);if(r[0]==="banner"){return{name:r[0],delay:r[1]*1||0,leftToRight:r[2]*1||0,fadeAwayWidth:r[3]*1||0}}if(/^scroll\s/.test(r[0])){return{name:r[0],y1:Math.min(r[1]*1,r[2]*1),y2:Math.max(r[1]*1,r[2]*1),delay:r[3]*1||0,fadeAwayHeight:r[4]*1||0}}if(t!==""){return{name:t}}return null}function parseDrawing(t){if(!t){return[]}return t.toLowerCase().replace(/([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/g," $1 ").replace(/([mnlbspc])/g," $1 ").trim().replace(/\s+/g," ").split(/\s(?=[mnlbspc])/).map(function(t){return t.split(" ").filter(function(t,r){return!(r&&isNaN(t*1))})})}var numTags=["b","i","u","s","fsp","k","K","kf","ko","kt","fe","q","p","pbo","a","an","fscx","fscy","fax","fay","frx","fry","frz","fr","be","blur","bord","xbord","ybord","shad","xshad","yshad"];var numRegexs=numTags.map(function(t){return{name:t,regex:new RegExp("^"+t+"-?\\d")}});function parseTag(t){var r;var e={};for(var a=0;ar.length){var a=e.slice(r.length-1).join();e=e.slice(0,r.length-1);e.push(a)}var n={};for(var i=0;i-10?1+s/10:1)*e.fs:s*1}}if(r==="K"){return{kf:s}}if(r==="t"){var D=s.t1;var k=s.accel;var L=s.tags;var H=s.t2||(e.end-e.start)*1e3;var N={};L.forEach(function(t){var r=Object.keys(t)[0];if(~tTags.indexOf(r)&&!(r==="clip"&&!t[r].dots)){Object.assign(N,compileTag(t,r,e))}});return{t:{t1:D,t2:H,accel:k,tag:N}}}return i={},i[r]=s,i}var a2an=[null,1,2,3,null,7,8,9,null,4,5,6];var globalTags=["r","a","an","pos","org","move","fade","fad","clip"];function inheritTag(t){return JSON.parse(JSON.stringify(Object.assign({},t,{k:undefined,kf:undefined,ko:undefined,kt:undefined})))}function compileText(t){var r=t.styles;var e=t.style;var a=t.parsed;var n=t.start;var i=t.end;var s;var o={q:r[e].tag.q};var l;var f;var c;var u;var v;var g=[];var p={style:e,fragments:[]};var m={};for(var y=0;y=s.End){continue}if(!r[s.Style]){s.Style="Default"}var o=r[s.Style].style;var l=compileText({styles:r,style:s.Style,parsed:s.Text.parsed,start:s.Start,end:s.End});var f=l.alignment||o.Alignment;a=Math.min(a,s.Layer);n.push(Object.assign({layer:s.Layer,start:s.Start,end:s.End,style:s.Style,name:s.Name,margin:{left:s.MarginL||o.MarginL,right:s.MarginR||o.MarginR,vertical:s.MarginV||o.MarginV},effect:s.Effect},l,{alignment:f}))}for(var c=0;c= dia.End) { 9 | continue; 10 | } 11 | if (!styles[dia.Style]) { 12 | dia.Style = 'Default'; 13 | } 14 | const stl = styles[dia.Style].style; 15 | const compiledText = compileText({ 16 | styles, 17 | style: dia.Style, 18 | parsed: dia.Text.parsed, 19 | start: dia.Start, 20 | end: dia.End, 21 | }); 22 | const alignment = compiledText.alignment || stl.Alignment; 23 | minLayer = Math.min(minLayer, dia.Layer); 24 | results.push(Object.assign({ 25 | layer: dia.Layer, 26 | start: dia.Start, 27 | end: dia.End, 28 | style: dia.Style, 29 | name: dia.Name, 30 | // reset style by `\r` will not effect margin and alignment 31 | margin: { 32 | left: dia.MarginL || stl.MarginL, 33 | right: dia.MarginR || stl.MarginR, 34 | vertical: dia.MarginV || stl.MarginV, 35 | }, 36 | effect: dia.Effect, 37 | }, compiledText, { alignment })); 38 | } 39 | for (let i = 0; i < results.length; i++) { 40 | results[i].layer -= minLayer; 41 | } 42 | return results.sort((a, b) => a.start - b.start || a.end - b.end); 43 | } 44 | -------------------------------------------------------------------------------- /src/compiler/drawing.js: -------------------------------------------------------------------------------- 1 | function createCommand(arr) { 2 | const cmd = { 3 | type: null, 4 | prev: null, 5 | next: null, 6 | points: [], 7 | }; 8 | if (/[mnlbs]/.test(arr[0])) { 9 | cmd.type = arr[0] 10 | .toUpperCase() 11 | .replace('N', 'L') 12 | .replace('B', 'C'); 13 | } 14 | for (let len = arr.length - !(arr.length & 1), i = 1; i < len; i += 2) { 15 | cmd.points.push({ x: arr[i] * 1, y: arr[i + 1] * 1 }); 16 | } 17 | return cmd; 18 | } 19 | 20 | function isValid(cmd) { 21 | if (!cmd.points.length || !cmd.type) { 22 | return false; 23 | } 24 | if (/C|S/.test(cmd.type) && cmd.points.length < 3) { 25 | return false; 26 | } 27 | return true; 28 | } 29 | 30 | function getViewBox(commands) { 31 | let minX = Infinity; 32 | let minY = Infinity; 33 | let maxX = -Infinity; 34 | let maxY = -Infinity; 35 | [].concat(...commands.map(({ points }) => points)).forEach(({ x, y }) => { 36 | minX = Math.min(minX, x); 37 | minY = Math.min(minY, y); 38 | maxX = Math.max(maxX, x); 39 | maxY = Math.max(maxY, y); 40 | }); 41 | return { 42 | minX, 43 | minY, 44 | width: maxX - minX, 45 | height: maxY - minY, 46 | }; 47 | } 48 | 49 | /** 50 | * Convert S command to B command 51 | * Reference from https://github.com/d3/d3/blob/v3.5.17/src/svg/line.js#L259 52 | * @param {Array} points points 53 | * @param {String} prev type of previous command 54 | * @param {String} next type of next command 55 | * @return {Array} converted commands 56 | */ 57 | export function s2b(points, prev, next) { 58 | const results = []; 59 | const bb1 = [0, 2 / 3, 1 / 3, 0]; 60 | const bb2 = [0, 1 / 3, 2 / 3, 0]; 61 | const bb3 = [0, 1 / 6, 2 / 3, 1 / 6]; 62 | const dot4 = (a, b) => (a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]); 63 | let px = [points[points.length - 1].x, points[0].x, points[1].x, points[2].x]; 64 | let py = [points[points.length - 1].y, points[0].y, points[1].y, points[2].y]; 65 | results.push({ 66 | type: prev === 'M' ? 'M' : 'L', 67 | points: [{ x: dot4(bb3, px), y: dot4(bb3, py) }], 68 | }); 69 | for (let i = 3; i < points.length; i++) { 70 | px = [points[i - 3].x, points[i - 2].x, points[i - 1].x, points[i].x]; 71 | py = [points[i - 3].y, points[i - 2].y, points[i - 1].y, points[i].y]; 72 | results.push({ 73 | type: 'C', 74 | points: [ 75 | { x: dot4(bb1, px), y: dot4(bb1, py) }, 76 | { x: dot4(bb2, px), y: dot4(bb2, py) }, 77 | { x: dot4(bb3, px), y: dot4(bb3, py) }, 78 | ], 79 | }); 80 | } 81 | if (next === 'L' || next === 'C') { 82 | const last = points[points.length - 1]; 83 | results.push({ type: 'L', points: [{ x: last.x, y: last.y }] }); 84 | } 85 | return results; 86 | } 87 | 88 | export function toSVGPath(instructions) { 89 | return instructions.map(({ type, points }) => ( 90 | type + points.map(({ x, y }) => `${x},${y}`).join(',') 91 | )).join(''); 92 | } 93 | 94 | export function compileDrawing(rawCommands) { 95 | const commands = []; 96 | let i = 0; 97 | while (i < rawCommands.length) { 98 | const arr = rawCommands[i]; 99 | const cmd = createCommand(arr); 100 | if (isValid(cmd)) { 101 | if (cmd.type === 'S') { 102 | const { x, y } = (commands[i - 1] || { points: [{ x: 0, y: 0 }] }).points.slice(-1)[0]; 103 | cmd.points.unshift({ x, y }); 104 | } 105 | if (i) { 106 | cmd.prev = commands[i - 1].type; 107 | commands[i - 1].next = cmd.type; 108 | } 109 | commands.push(cmd); 110 | i++; 111 | } else { 112 | if (i && commands[i - 1].type === 'S') { 113 | const additionPoints = { 114 | p: cmd.points, 115 | c: commands[i - 1].points.slice(0, 3), 116 | }; 117 | commands[i - 1].points = commands[i - 1].points.concat( 118 | (additionPoints[arr[0]] || []).map(({ x, y }) => ({ x, y })), 119 | ); 120 | } 121 | rawCommands.splice(i, 1); 122 | } 123 | } 124 | const instructions = [].concat( 125 | ...commands.map(({ type, points, prev, next }) => ( 126 | type === 'S' 127 | ? s2b(points, prev, next) 128 | : { type, points } 129 | )), 130 | ); 131 | 132 | return Object.assign({ instructions, d: toSVGPath(instructions) }, getViewBox(commands)); 133 | } 134 | -------------------------------------------------------------------------------- /src/compiler/index.js: -------------------------------------------------------------------------------- 1 | import { parse } from '../parser/index.js'; 2 | import { compileDialogues } from './dialogues.js'; 3 | import { compileStyles } from './styles.js'; 4 | 5 | export function compile(text, options = {}) { 6 | const tree = parse(text); 7 | const info = Object.assign(options.defaultInfo || {}, tree.info); 8 | const styles = compileStyles({ 9 | info, 10 | style: tree.styles.style, 11 | defaultStyle: options.defaultStyle || {}, 12 | }); 13 | return { 14 | info, 15 | width: info.PlayResX * 1 || null, 16 | height: info.PlayResY * 1 || null, 17 | wrapStyle: /^[0-3]$/.test(info.WrapStyle) ? info.WrapStyle * 1 : 2, 18 | collisions: info.Collisions || 'Normal', 19 | styles, 20 | dialogues: compileDialogues({ 21 | styles, 22 | dialogues: tree.events.dialogue, 23 | }), 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/compiler/styles.js: -------------------------------------------------------------------------------- 1 | // same as Aegisub 2 | // https://github.com/Aegisub/Aegisub/blob/master/src/ass_style.h 3 | const DEFAULT_STYLE = { 4 | Name: 'Default', 5 | Fontname: 'Arial', 6 | Fontsize: '20', 7 | PrimaryColour: '&H00FFFFFF&', 8 | SecondaryColour: '&H000000FF&', 9 | OutlineColour: '&H00000000&', 10 | BackColour: '&H00000000&', 11 | Bold: '0', 12 | Italic: '0', 13 | Underline: '0', 14 | StrikeOut: '0', 15 | ScaleX: '100', 16 | ScaleY: '100', 17 | Spacing: '0', 18 | Angle: '0', 19 | BorderStyle: '1', 20 | Outline: '2', 21 | Shadow: '2', 22 | Alignment: '2', 23 | MarginL: '10', 24 | MarginR: '10', 25 | MarginV: '10', 26 | Encoding: '1', 27 | }; 28 | 29 | /** 30 | * @param {String} color 31 | * @returns {Array} [AA, BBGGRR] 32 | */ 33 | export function parseStyleColor(color) { 34 | if (/^(&|H|&H)[0-9a-f]{6,}/i.test(color)) { 35 | const [, a, c] = color.match(/&?H?([0-9a-f]{2})?([0-9a-f]{6})/i); 36 | return [a || '00', c]; 37 | } 38 | const num = parseInt(color, 10); 39 | if (!Number.isNaN(num)) { 40 | const min = -2147483648; 41 | const max = 2147483647; 42 | if (num < min) { 43 | return ['00', '000000']; 44 | } 45 | const aabbggrr = (min <= num && num <= max) 46 | ? `00000000${(num < 0 ? num + 4294967296 : num).toString(16)}`.slice(-8) 47 | : String(num).slice(0, 8); 48 | return [aabbggrr.slice(0, 2), aabbggrr.slice(2)]; 49 | } 50 | return ['00', '000000']; 51 | } 52 | 53 | export function compileStyles({ info, style, defaultStyle }) { 54 | const result = {}; 55 | const styles = [Object.assign({}, defaultStyle, { Name: 'Default' })].concat(style); 56 | for (let i = 0; i < styles.length; i++) { 57 | const s = Object.assign({}, DEFAULT_STYLE, styles[i]); 58 | // this behavior is same as Aegisub by black-box testing 59 | if (/^(\*+)Default$/.test(s.Name)) { 60 | s.Name = 'Default'; 61 | } 62 | Object.keys(s).forEach((key) => { 63 | if (key !== 'Name' && key !== 'Fontname' && !/Colour/.test(key)) { 64 | s[key] *= 1; 65 | } 66 | }); 67 | const [a1, c1] = parseStyleColor(s.PrimaryColour); 68 | const [a2, c2] = parseStyleColor(s.SecondaryColour); 69 | const [a3, c3] = parseStyleColor(s.OutlineColour); 70 | const [a4, c4] = parseStyleColor(s.BackColour); 71 | const tag = { 72 | fn: s.Fontname, 73 | fs: s.Fontsize, 74 | c1, 75 | a1, 76 | c2, 77 | a2, 78 | c3, 79 | a3, 80 | c4, 81 | a4, 82 | b: Math.abs(s.Bold), 83 | i: Math.abs(s.Italic), 84 | u: Math.abs(s.Underline), 85 | s: Math.abs(s.StrikeOut), 86 | fscx: s.ScaleX, 87 | fscy: s.ScaleY, 88 | fsp: s.Spacing, 89 | frz: s.Angle, 90 | xbord: s.Outline, 91 | ybord: s.Outline, 92 | xshad: s.Shadow, 93 | yshad: s.Shadow, 94 | fe: s.Encoding, 95 | // TODO: [breaking change] remove `q` from style 96 | q: /^[0-3]$/.test(info.WrapStyle) ? info.WrapStyle * 1 : 2, 97 | }; 98 | result[s.Name] = { style: s, tag }; 99 | } 100 | return result; 101 | } 102 | -------------------------------------------------------------------------------- /src/compiler/tag.js: -------------------------------------------------------------------------------- 1 | import { compileDrawing } from './drawing.js'; 2 | 3 | const tTags = [ 4 | 'fs', 'fsp', 'clip', 5 | 'c1', 'c2', 'c3', 'c4', 'a1', 'a2', 'a3', 'a4', 'alpha', 6 | 'fscx', 'fscy', 'fax', 'fay', 'frx', 'fry', 'frz', 'fr', 7 | 'be', 'blur', 'bord', 'xbord', 'ybord', 'shad', 'xshad', 'yshad', 8 | ]; 9 | 10 | export function compileTag(tag, key, presets = {}) { 11 | let value = tag[key]; 12 | if (value === undefined) { 13 | return null; 14 | } 15 | if (key === 'pos' || key === 'org') { 16 | return value.length === 2 ? { [key]: { x: value[0], y: value[1] } } : null; 17 | } 18 | if (key === 'move') { 19 | const [x1, y1, x2, y2, t1 = 0, t2 = 0] = value; 20 | return value.length === 4 || value.length === 6 21 | ? { move: { x1, y1, x2, y2, t1, t2 } } 22 | : null; 23 | } 24 | if (key === 'fad' || key === 'fade') { 25 | if (value.length === 2) { 26 | const [t1, t2] = value; 27 | return { fade: { type: 'fad', t1, t2 } }; 28 | } 29 | if (value.length === 7) { 30 | const [a1, a2, a3, t1, t2, t3, t4] = value; 31 | return { fade: { type: 'fade', a1, a2, a3, t1, t2, t3, t4 } }; 32 | } 33 | return null; 34 | } 35 | if (key === 'clip') { 36 | const { inverse, scale, drawing, dots } = value; 37 | if (drawing) { 38 | return { clip: { inverse, scale, drawing: compileDrawing(drawing), dots } }; 39 | } 40 | if (dots) { 41 | const [x1, y1, x2, y2] = dots; 42 | return { clip: { inverse, scale, drawing, dots: { x1, y1, x2, y2 } } }; 43 | } 44 | return null; 45 | } 46 | if (/^[xy]?(bord|shad)$/.test(key)) { 47 | value = Math.max(value, 0); 48 | } 49 | if (key === 'bord') { 50 | return { xbord: value, ybord: value }; 51 | } 52 | if (key === 'shad') { 53 | return { xshad: value, yshad: value }; 54 | } 55 | if (/^c\d$/.test(key)) { 56 | return { [key]: value || presets[key] }; 57 | } 58 | if (key === 'alpha') { 59 | return { a1: value, a2: value, a3: value, a4: value }; 60 | } 61 | if (key === 'fr') { 62 | return { frz: value }; 63 | } 64 | if (key === 'fs') { 65 | return { 66 | fs: /^\+|-/.test(value) 67 | ? (value * 1 > -10 ? (1 + value / 10) : 1) * presets.fs 68 | : value * 1, 69 | }; 70 | } 71 | if (key === 'K') { 72 | return { kf: value }; 73 | } 74 | if (key === 't') { 75 | const { t1, accel, tags } = value; 76 | const t2 = value.t2 || (presets.end - presets.start) * 1e3; 77 | const compiledTag = {}; 78 | tags.forEach((t) => { 79 | const k = Object.keys(t)[0]; 80 | if (~tTags.indexOf(k) && !(k === 'clip' && !t[k].dots)) { 81 | Object.assign(compiledTag, compileTag(t, k, presets)); 82 | } 83 | }); 84 | return { t: { t1, t2, accel, tag: compiledTag } }; 85 | } 86 | return { [key]: value }; 87 | } 88 | -------------------------------------------------------------------------------- /src/compiler/text.js: -------------------------------------------------------------------------------- 1 | import { compileDrawing } from './drawing.js'; 2 | import { compileTag } from './tag.js'; 3 | 4 | const a2an = [ 5 | null, 1, 2, 3, 6 | null, 7, 8, 9, 7 | null, 4, 5, 6, 8 | ]; 9 | 10 | const globalTags = ['r', 'a', 'an', 'pos', 'org', 'move', 'fade', 'fad', 'clip']; 11 | 12 | function inheritTag(pTag) { 13 | return JSON.parse(JSON.stringify(Object.assign({}, pTag, { 14 | k: undefined, 15 | kf: undefined, 16 | ko: undefined, 17 | kt: undefined, 18 | }))); 19 | } 20 | 21 | export function compileText({ styles, style, parsed, start, end }) { 22 | let alignment; 23 | let q = { q: styles[style].tag.q }; 24 | let pos; 25 | let org; 26 | let move; 27 | let fade; 28 | let clip; 29 | const slices = []; 30 | let slice = { style, fragments: [] }; 31 | let prevTag = {}; 32 | for (let i = 0; i < parsed.length; i++) { 33 | const { tags, text, drawing } = parsed[i]; 34 | let reset; 35 | for (let j = 0; j < tags.length; j++) { 36 | const tag = tags[j]; 37 | reset = tag.r === undefined ? reset : tag.r; 38 | } 39 | const fragment = { 40 | tag: reset === undefined ? inheritTag(prevTag) : {}, 41 | text, 42 | drawing: drawing.length ? compileDrawing(drawing) : null, 43 | }; 44 | for (let j = 0; j < tags.length; j++) { 45 | const tag = tags[j]; 46 | alignment = alignment || a2an[tag.a || 0] || tag.an; 47 | q = compileTag(tag, 'q') || q; 48 | if (!move) { 49 | pos = pos || compileTag(tag, 'pos'); 50 | } 51 | org = org || compileTag(tag, 'org'); 52 | if (!pos) { 53 | move = move || compileTag(tag, 'move'); 54 | } 55 | fade = fade || compileTag(tag, 'fade') || compileTag(tag, 'fad'); 56 | clip = compileTag(tag, 'clip') || clip; 57 | const key = Object.keys(tag)[0]; 58 | if (key && !~globalTags.indexOf(key)) { 59 | const sliceTag = styles[style].tag; 60 | const { c1, c2, c3, c4 } = sliceTag; 61 | const fs = prevTag.fs || sliceTag.fs; 62 | const compiledTag = compileTag(tag, key, { start, end, c1, c2, c3, c4, fs }); 63 | if (key === 't') { 64 | fragment.tag.t = fragment.tag.t || []; 65 | fragment.tag.t.push(compiledTag.t); 66 | } else { 67 | Object.assign(fragment.tag, compiledTag); 68 | } 69 | } 70 | } 71 | prevTag = fragment.tag; 72 | if (reset !== undefined) { 73 | slices.push(slice); 74 | slice = { style: styles[reset] ? reset : style, fragments: [] }; 75 | } 76 | if (fragment.text || fragment.drawing) { 77 | const prev = slice.fragments[slice.fragments.length - 1] || {}; 78 | if (prev.text && fragment.text && !Object.keys(fragment.tag).length) { 79 | // merge fragment to previous if its tag is empty 80 | prev.text += fragment.text; 81 | } else { 82 | slice.fragments.push(fragment); 83 | } 84 | } 85 | } 86 | slices.push(slice); 87 | 88 | return Object.assign({ alignment, slices }, q, pos, org, move, fade, clip); 89 | } 90 | -------------------------------------------------------------------------------- /src/decompiler.js: -------------------------------------------------------------------------------- 1 | import { stringifyInfo, stringifyTime, stringifyEffect } from './stringifier.js'; 2 | import { stylesFormat, eventsFormat } from './utils.js'; 3 | 4 | export function decompileStyle({ style, tag }) { 5 | const obj = Object.assign({}, style, { 6 | PrimaryColour: `&H${tag.a1}${tag.c1}`, 7 | SecondaryColour: `&H${tag.a2}${tag.c2}`, 8 | OutlineColour: `&H${tag.a3}${tag.c3}`, 9 | BackColour: `&H${tag.a4}${tag.c4}`, 10 | }); 11 | return `Style: ${stylesFormat.map((fmt) => obj[fmt]).join()}`; 12 | } 13 | 14 | const drawingInstructionMap = { 15 | M: 'm', 16 | L: 'l', 17 | C: 'b', 18 | }; 19 | 20 | export function decompileDrawing({ instructions }) { 21 | return instructions.map(({ type, points }) => ( 22 | [drawingInstructionMap[type]] 23 | .concat(...points.map(({ x, y }) => [x, y])) 24 | .join(' ') 25 | )).join(' '); 26 | } 27 | 28 | const ca = (x) => (n) => (_) => `${n}${x}&H${_}&`; 29 | const c = ca('c'); 30 | const a = ca('a'); 31 | 32 | const tagDecompiler = { 33 | c1: c(1), 34 | c2: c(2), 35 | c3: c(3), 36 | c4: c(4), 37 | a1: a(1), 38 | a2: a(2), 39 | a3: a(3), 40 | a4: a(4), 41 | pos: (_) => `pos(${[_.x, _.y]})`, 42 | org: (_) => `org(${[_.x, _.y]})`, 43 | move: (_) => `move(${[_.x1, _.y1, _.x2, _.y2, _.t1, _.t2]})`, 44 | fade: (_) => ( 45 | _.type === 'fad' 46 | ? `fad(${[_.t1, _.t2]})` 47 | : `fade(${[_.a1, _.a2, _.a3, _.t1, _.t2, _.t3, _.t4]})` 48 | ), 49 | clip: (_) => `${_.inverse ? 'i' : ''}clip(${ 50 | _.dots 51 | ? `${[_.dots.x1, _.dots.y1, _.dots.x2, _.dots.y2]}` 52 | : `${_.scale === 1 ? '' : `${_.scale},`}${decompileDrawing(_.drawing)}` 53 | })`, 54 | // eslint-disable-next-line no-use-before-define 55 | t: (arr) => arr.map((_) => `t(${[_.t1, _.t2, _.accel, decompileTag(_.tag)]})`).join('\\'), 56 | }; 57 | 58 | export function decompileTag(tag) { 59 | return Object.keys(tag).map((key) => { 60 | const fn = tagDecompiler[key] || ((_) => `${key}${_}`); 61 | return `\\${fn(tag[key])}`; 62 | }).join(''); 63 | } 64 | 65 | export function decompileSlice(slice) { 66 | return slice.fragments.map(({ tag, text, drawing }) => { 67 | const tagText = decompileTag(tag); 68 | return `${tagText ? `{${tagText}}` : ''}${drawing ? decompileDrawing(drawing) : text}`; 69 | }).join(''); 70 | } 71 | 72 | export function decompileText(dia, style) { 73 | return dia.slices 74 | .filter((slice) => slice.fragments.length) 75 | .map((slice, idx) => { 76 | const sliceCopy = JSON.parse(JSON.stringify(slice)); 77 | const tag = {}; 78 | if (idx) { 79 | tag.r = slice.style === dia.style ? '' : slice.style; 80 | } else { 81 | if (style.Alignment !== dia.alignment) { 82 | tag.an = dia.alignment; 83 | } 84 | ['pos', 'org', 'move', 'fade', 'clip'].forEach((key) => { 85 | if (dia[key]) { 86 | tag[key] = dia[key]; 87 | } 88 | }); 89 | } 90 | // make sure additional tags are first 91 | sliceCopy.fragments[0].tag = Object.assign(tag, sliceCopy.fragments[0].tag); 92 | return sliceCopy; 93 | }) 94 | .map(decompileSlice) 95 | .join(''); 96 | } 97 | 98 | function getMargin(margin, styleMargin) { 99 | return margin === styleMargin ? '0000' : margin; 100 | } 101 | 102 | export function decompileDialogue(dia, style) { 103 | return `Dialogue: ${[ 104 | dia.layer, 105 | stringifyTime(dia.start), 106 | stringifyTime(dia.end), 107 | dia.style, 108 | dia.name, 109 | getMargin(dia.margin.left, style.MarginL), 110 | getMargin(dia.margin.right, style.MarginR), 111 | getMargin(dia.margin.vertical, style.MarginV), 112 | stringifyEffect(dia.effect), 113 | decompileText(dia, style), 114 | ].join()}`; 115 | } 116 | 117 | export function decompile({ info, width, height, collisions, styles, dialogues }) { 118 | return [ 119 | '[Script Info]', 120 | stringifyInfo(Object.assign({}, info, { 121 | PlayResX: width, 122 | PlayResY: height, 123 | Collisions: collisions, 124 | })), 125 | '', 126 | '[V4+ Styles]', 127 | `Format: ${stylesFormat.join(', ')}`, 128 | ...Object.keys(styles).map((name) => decompileStyle(styles[name])), 129 | '', 130 | '[Events]', 131 | `Format: ${eventsFormat.join(', ')}`, 132 | ...dialogues 133 | .sort((x, y) => x.start - y.start || x.end - y.end) 134 | .map((dia) => decompileDialogue(dia, styles[dia.style].style)), 135 | '', 136 | ].join('\n'); 137 | } 138 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { parse } from './parser/index.js'; 2 | export { stringify } from './stringifier.js'; 3 | export { compile } from './compiler/index.js'; 4 | export { decompile } from './decompiler.js'; 5 | -------------------------------------------------------------------------------- /src/parser/dialogue.js: -------------------------------------------------------------------------------- 1 | import { parseEffect } from './effect.js'; 2 | import { parseText } from './text.js'; 3 | import { parseTime } from './time.js'; 4 | 5 | export function parseDialogue(text, format) { 6 | let fields = text.split(','); 7 | if (fields.length > format.length) { 8 | const textField = fields.slice(format.length - 1).join(); 9 | fields = fields.slice(0, format.length - 1); 10 | fields.push(textField); 11 | } 12 | 13 | const dia = {}; 14 | for (let i = 0; i < fields.length; i++) { 15 | const fmt = format[i]; 16 | const fld = fields[i].trim(); 17 | switch (fmt) { 18 | case 'Layer': 19 | case 'MarginL': 20 | case 'MarginR': 21 | case 'MarginV': 22 | dia[fmt] = fld * 1; 23 | break; 24 | case 'Start': 25 | case 'End': 26 | dia[fmt] = parseTime(fld); 27 | break; 28 | case 'Effect': 29 | dia[fmt] = parseEffect(fld); 30 | break; 31 | case 'Text': 32 | dia[fmt] = parseText(fld); 33 | break; 34 | default: 35 | dia[fmt] = fld; 36 | } 37 | } 38 | 39 | return dia; 40 | } 41 | -------------------------------------------------------------------------------- /src/parser/drawing.js: -------------------------------------------------------------------------------- 1 | export function parseDrawing(text) { 2 | if (!text) return []; 3 | return text 4 | .toLowerCase() 5 | // numbers 6 | .replace(/([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)/g, ' $1 ') 7 | // commands 8 | .replace(/([mnlbspc])/g, ' $1 ') 9 | .trim() 10 | .replace(/\s+/g, ' ') 11 | .split(/\s(?=[mnlbspc])/) 12 | .map((cmd) => ( 13 | cmd.split(' ') 14 | .filter((x, i) => !(i && Number.isNaN(x * 1))) 15 | )); 16 | } 17 | -------------------------------------------------------------------------------- /src/parser/effect.js: -------------------------------------------------------------------------------- 1 | export function parseEffect(text) { 2 | const param = text 3 | .toLowerCase() 4 | .trim() 5 | .split(/\s*;\s*/); 6 | if (param[0] === 'banner') { 7 | return { 8 | name: param[0], 9 | delay: param[1] * 1 || 0, 10 | leftToRight: param[2] * 1 || 0, 11 | fadeAwayWidth: param[3] * 1 || 0, 12 | }; 13 | } 14 | if (/^scroll\s/.test(param[0])) { 15 | return { 16 | name: param[0], 17 | y1: Math.min(param[1] * 1, param[2] * 1), 18 | y2: Math.max(param[1] * 1, param[2] * 1), 19 | delay: param[3] * 1 || 0, 20 | fadeAwayHeight: param[4] * 1 || 0, 21 | }; 22 | } 23 | if (text !== '') { 24 | return { name: text }; 25 | } 26 | return null; 27 | } 28 | -------------------------------------------------------------------------------- /src/parser/format.js: -------------------------------------------------------------------------------- 1 | import { stylesFormat, eventsFormat } from '../utils.js'; 2 | 3 | export function parseFormat(text) { 4 | const fields = stylesFormat.concat(eventsFormat); 5 | return text.match(/Format\s*:\s*(.*)/i)[1] 6 | .split(/\s*,\s*/) 7 | .map((field) => { 8 | const caseField = fields.find((f) => f.toLowerCase() === field.toLowerCase()); 9 | return caseField || field; 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/parser/index.js: -------------------------------------------------------------------------------- 1 | import { parseDialogue } from './dialogue.js'; 2 | import { parseFormat } from './format.js'; 3 | import { parseStyle } from './style.js'; 4 | 5 | export function parse(text) { 6 | const tree = { 7 | info: {}, 8 | styles: { format: [], style: [] }, 9 | events: { format: [], comment: [], dialogue: [] }, 10 | }; 11 | const lines = text.split(/\r?\n/); 12 | let state = 0; 13 | for (let i = 0; i < lines.length; i++) { 14 | const line = lines[i].trim(); 15 | if (/^;/.test(line)) continue; 16 | 17 | if (/^\[Script Info\]/i.test(line)) state = 1; 18 | else if (/^\[V4\+? Styles\]/i.test(line)) state = 2; 19 | else if (/^\[Events\]/i.test(line)) state = 3; 20 | else if (/^\[.*\]/.test(line)) state = 0; 21 | 22 | if (state === 0) continue; 23 | if (state === 1) { 24 | if (/:/.test(line)) { 25 | const [, key, value] = line.match(/(.*?)\s*:\s*(.*)/); 26 | tree.info[key] = value; 27 | } 28 | } 29 | if (state === 2) { 30 | if (/^Format\s*:/i.test(line)) { 31 | tree.styles.format = parseFormat(line); 32 | } 33 | if (/^Style\s*:/i.test(line)) { 34 | tree.styles.style.push(parseStyle(line, tree.styles.format)); 35 | } 36 | } 37 | if (state === 3) { 38 | if (/^Format\s*:/i.test(line)) { 39 | tree.events.format = parseFormat(line); 40 | } 41 | if (/^(?:Comment|Dialogue)\s*:/i.test(line)) { 42 | const [, key, value] = line.match(/^(\w+?)\s*:\s*(.*)/i); 43 | tree.events[key.toLowerCase()].push(parseDialogue(value, tree.events.format)); 44 | } 45 | } 46 | } 47 | 48 | return tree; 49 | } 50 | -------------------------------------------------------------------------------- /src/parser/style.js: -------------------------------------------------------------------------------- 1 | export function parseStyle(text, format) { 2 | const values = text.match(/Style\s*:\s*(.*)/i)[1].split(/\s*,\s*/); 3 | return Object.assign({}, ...format.map((fmt, idx) => ({ [fmt]: values[idx] }))); 4 | } 5 | -------------------------------------------------------------------------------- /src/parser/tag.js: -------------------------------------------------------------------------------- 1 | import { parseDrawing } from './drawing.js'; 2 | 3 | const numTags = [ 4 | 'b', 'i', 'u', 's', 'fsp', 5 | 'k', 'K', 'kf', 'ko', 'kt', 6 | 'fe', 'q', 'p', 'pbo', 'a', 'an', 7 | 'fscx', 'fscy', 'fax', 'fay', 'frx', 'fry', 'frz', 'fr', 8 | 'be', 'blur', 'bord', 'xbord', 'ybord', 'shad', 'xshad', 'yshad', 9 | ]; 10 | 11 | const numRegexs = numTags.map((nt) => ({ name: nt, regex: new RegExp(`^${nt}-?\\d`) })); 12 | 13 | export function parseTag(text) { 14 | const tag = {}; 15 | for (let i = 0; i < numRegexs.length; i++) { 16 | const { name, regex } = numRegexs[i]; 17 | if (regex.test(text)) { 18 | tag[name] = text.slice(name.length) * 1; 19 | return tag; 20 | } 21 | } 22 | if (/^fn/.test(text)) { 23 | tag.fn = text.slice(2); 24 | } else if (/^r/.test(text)) { 25 | tag.r = text.slice(1); 26 | } else if (/^fs[\d+-]/.test(text)) { 27 | tag.fs = text.slice(2); 28 | } else if (/^\d?c&?H?[0-9a-fA-F]+|^\d?c$/.test(text)) { 29 | const [, num, color] = text.match(/^(\d?)c&?H?(\w*)/); 30 | tag[`c${num || 1}`] = color && `000000${color}`.slice(-6); 31 | } else if (/^\da&?H?[0-9a-fA-F]+/.test(text)) { 32 | const [, num, alpha] = text.match(/^(\d)a&?H?([0-9a-f]+)/i); 33 | tag[`a${num}`] = `00${alpha}`.slice(-2); 34 | } else if (/^alpha&?H?[0-9a-fA-F]+/.test(text)) { 35 | [, tag.alpha] = text.match(/^alpha&?H?([0-9a-f]+)/i); 36 | tag.alpha = `00${tag.alpha}`.slice(-2); 37 | } else if (/^(?:pos|org|move|fad|fade)\([^)]+/.test(text)) { 38 | const [, key, value] = text.match(/^(\w+)\((.*?)\)?$/); 39 | tag[key] = value 40 | .trim() 41 | .split(/\s*,\s*/) 42 | .map(Number); 43 | } else if (/^i?clip\([^)]+/.test(text)) { 44 | const p = text 45 | .match(/^i?clip\((.*?)\)?$/)[1] 46 | .trim() 47 | .split(/\s*,\s*/); 48 | tag.clip = { 49 | inverse: /iclip/.test(text), 50 | scale: 1, 51 | drawing: null, 52 | dots: null, 53 | }; 54 | if (p.length === 1) { 55 | tag.clip.drawing = parseDrawing(p[0]); 56 | } 57 | if (p.length === 2) { 58 | tag.clip.scale = p[0] * 1; 59 | tag.clip.drawing = parseDrawing(p[1]); 60 | } 61 | if (p.length === 4) { 62 | tag.clip.dots = p.map(Number); 63 | } 64 | } else if (/^t\(/.test(text)) { 65 | const p = text 66 | .match(/^t\((.*?)\)?$/)[1] 67 | .trim() 68 | .replace(/\\.*/, (x) => x.replace(/,/g, '\n')) 69 | .split(/\s*,\s*/); 70 | if (!p[0]) return tag; 71 | tag.t = { 72 | t1: 0, 73 | t2: 0, 74 | accel: 1, 75 | tags: p[p.length - 1] 76 | .replace(/\n/g, ',') 77 | .split('\\') 78 | .slice(1) 79 | .map(parseTag), 80 | }; 81 | if (p.length === 2) { 82 | tag.t.accel = p[0] * 1; 83 | } 84 | if (p.length === 3) { 85 | tag.t.t1 = p[0] * 1; 86 | tag.t.t2 = p[1] * 1; 87 | } 88 | if (p.length === 4) { 89 | tag.t.t1 = p[0] * 1; 90 | tag.t.t2 = p[1] * 1; 91 | tag.t.accel = p[2] * 1; 92 | } 93 | } 94 | 95 | return tag; 96 | } 97 | -------------------------------------------------------------------------------- /src/parser/tags.js: -------------------------------------------------------------------------------- 1 | import { parseTag } from './tag.js'; 2 | 3 | export function parseTags(text) { 4 | const tags = []; 5 | let depth = 0; 6 | let str = ''; 7 | // `\b\c` -> `b\c\` 8 | // `a\b\c` -> `b\c\` 9 | const transText = text.split('\\').slice(1).concat('').join('\\'); 10 | for (let i = 0; i < transText.length; i++) { 11 | const x = transText[i]; 12 | if (x === '(') depth++; 13 | if (x === ')') depth--; 14 | if (depth < 0) depth = 0; 15 | if (!depth && x === '\\') { 16 | if (str) { 17 | tags.push(str); 18 | } 19 | str = ''; 20 | } else { 21 | str += x; 22 | } 23 | } 24 | return tags.map(parseTag); 25 | } 26 | -------------------------------------------------------------------------------- /src/parser/text.js: -------------------------------------------------------------------------------- 1 | import { parseDrawing } from './drawing.js'; 2 | import { parseTags } from './tags.js'; 3 | 4 | export function parseText(text) { 5 | const pairs = text.split(/{(.*?)}/); 6 | const parsed = []; 7 | if (pairs[0].length) { 8 | parsed.push({ tags: [], text: pairs[0], drawing: [] }); 9 | } 10 | for (let i = 1; i < pairs.length; i += 2) { 11 | const tags = parseTags(pairs[i]); 12 | const isDrawing = tags.reduce((v, tag) => (tag.p === undefined ? v : !!tag.p), false); 13 | parsed.push({ 14 | tags, 15 | text: isDrawing ? '' : pairs[i + 1], 16 | drawing: isDrawing ? parseDrawing(pairs[i + 1]) : [], 17 | }); 18 | } 19 | return { 20 | raw: text, 21 | combined: parsed.map((frag) => frag.text).join(''), 22 | parsed, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/parser/time.js: -------------------------------------------------------------------------------- 1 | export function parseTime(time) { 2 | const t = time.split(':'); 3 | return t[0] * 3600 + t[1] * 60 + t[2] * 1; 4 | } 5 | -------------------------------------------------------------------------------- /src/stringifier.js: -------------------------------------------------------------------------------- 1 | export function stringifyInfo(info) { 2 | return Object.keys(info) 3 | .filter((key) => info[key] !== null) 4 | .map((key) => `${key}: ${info[key]}`) 5 | .join('\n'); 6 | } 7 | 8 | function pad00(n) { 9 | return `00${n}`.slice(-2); 10 | } 11 | 12 | export function stringifyTime(tf) { 13 | const t = Number.parseFloat(tf.toFixed(2)); 14 | const ms = t.toFixed(2).slice(-2); 15 | const s = (t | 0) % 60; 16 | const m = (t / 60 | 0) % 60; 17 | const h = t / 3600 | 0; 18 | return `${h}:${pad00(m)}:${pad00(s)}.${ms}`; 19 | } 20 | 21 | export function stringifyEffect(eff) { 22 | if (!eff) return ''; 23 | if (eff.name === 'banner') { 24 | return `Banner;${eff.delay};${eff.leftToRight};${eff.fadeAwayWidth}`; 25 | } 26 | if (/^scroll\s/.test(eff.name)) { 27 | return `${eff.name.replace(/^\w/, (x) => x.toUpperCase())};${eff.y1};${eff.y2};${eff.delay};${eff.fadeAwayHeight}`; 28 | } 29 | return eff.name; 30 | } 31 | 32 | export function stringifyDrawing(drawing) { 33 | return drawing.map((cmds) => cmds.join(' ')).join(' '); 34 | } 35 | 36 | export function stringifyTag(tag) { 37 | const [key] = Object.keys(tag); 38 | if (!key) return ''; 39 | const _ = tag[key]; 40 | if (['pos', 'org', 'move', 'fad', 'fade'].some((ft) => ft === key)) { 41 | return `\\${key}(${_})`; 42 | } 43 | if (/^[ac]\d$/.test(key)) { 44 | return `\\${key[1]}${key[0]}&H${_}&`; 45 | } 46 | if (key === 'alpha') { 47 | return `\\alpha&H${_}&`; 48 | } 49 | if (key === 'clip') { 50 | return `\\${_.inverse ? 'i' : ''}clip(${ 51 | _.dots || `${_.scale === 1 ? '' : `${_.scale},`}${stringifyDrawing(_.drawing)}` 52 | })`; 53 | } 54 | if (key === 't') { 55 | return `\\t(${[_.t1, _.t2, _.accel, _.tags.map(stringifyTag).join('')]})`; 56 | } 57 | return `\\${key}${_}`; 58 | } 59 | 60 | export function stringifyText(Text) { 61 | return Text.parsed.map(({ tags, text, drawing }) => { 62 | const tagText = tags.map(stringifyTag).join(''); 63 | const content = drawing.length ? stringifyDrawing(drawing) : text; 64 | return `${tagText ? `{${tagText}}` : ''}${content}`; 65 | }).join(''); 66 | } 67 | 68 | export function stringifyEvent(event, format) { 69 | return format.map((fmt) => { 70 | switch (fmt) { 71 | case 'Start': 72 | case 'End': 73 | return stringifyTime(event[fmt]); 74 | case 'MarginL': 75 | case 'MarginR': 76 | case 'MarginV': 77 | return event[fmt] || '0000'; 78 | case 'Effect': 79 | return stringifyEffect(event[fmt]); 80 | case 'Text': 81 | return stringifyText(event.Text); 82 | default: 83 | return event[fmt]; 84 | } 85 | }).join(); 86 | } 87 | 88 | export function stringify({ info, styles, events }) { 89 | return [ 90 | '[Script Info]', 91 | stringifyInfo(info), 92 | '', 93 | '[V4+ Styles]', 94 | `Format: ${styles.format.join(', ')}`, 95 | ...styles.style.map((style) => `Style: ${styles.format.map((fmt) => style[fmt]).join()}`), 96 | '', 97 | '[Events]', 98 | `Format: ${events.format.join(', ')}`, 99 | ...[] 100 | .concat(...['Comment', 'Dialogue'].map((type) => ( 101 | events[type.toLowerCase()].map((dia) => ({ 102 | start: dia.Start, 103 | end: dia.End, 104 | string: `${type}: ${stringifyEvent(dia, events.format)}`, 105 | })) 106 | ))) 107 | .sort((a, b) => (a.start - b.start) || (a.end - b.end)) 108 | .map((x) => x.string), 109 | '', 110 | ].join('\n'); 111 | } 112 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const stylesFormat = ['Name', 'Fontname', 'Fontsize', 'PrimaryColour', 'SecondaryColour', 'OutlineColour', 'BackColour', 'Bold', 'Italic', 'Underline', 'StrikeOut', 'ScaleX', 'ScaleY', 'Spacing', 'Angle', 'BorderStyle', 'Outline', 'Shadow', 'Alignment', 'MarginL', 'MarginR', 'MarginV', 'Encoding']; 2 | export const eventsFormat = ['Layer', 'Start', 'End', 'Style', 'Name', 'MarginL', 'MarginR', 'MarginV', 'Effect', 'Text']; 3 | -------------------------------------------------------------------------------- /test/compiler/dialogues.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseDialogue } from '../../src/parser/dialogue.js'; 3 | import { compileDialogues } from '../../src/compiler/dialogues.js'; 4 | import { compileStyles } from '../../src/compiler/styles.js'; 5 | import { eventsFormat } from '../../src/utils.js'; 6 | 7 | describe('dialogues compiler', () => { 8 | const style = [ 9 | { 10 | Name: 'Default', 11 | Fontname: 'Arial', 12 | Fontsize: '20', 13 | PrimaryColour: '&H00FFFFFF', 14 | SecondaryColour: '&H000000FF', 15 | OutlineColour: '&H000000', 16 | BackColour: '&H00000000', 17 | Bold: '-1', 18 | Italic: '0', 19 | Underline: '0', 20 | StrikeOut: '0', 21 | ScaleX: '100', 22 | ScaleY: '100', 23 | Spacing: '0', 24 | Angle: '0', 25 | BorderStyle: '1', 26 | Outline: '2', 27 | Shadow: '2', 28 | Alignment: '2', 29 | MarginL: '10', 30 | MarginR: '10', 31 | MarginV: '10', 32 | Encoding: '0', 33 | }, 34 | { 35 | Name: 'alt', 36 | Fontname: 'Arial', 37 | Fontsize: '24', 38 | PrimaryColour: '&H00FFFFFF', 39 | SecondaryColour: '&H000000FF', 40 | OutlineColour: '&H000000', 41 | BackColour: '&H00000000', 42 | Bold: '-1', 43 | Italic: '0', 44 | Underline: '0', 45 | StrikeOut: '0', 46 | ScaleX: '100', 47 | ScaleY: '100', 48 | Spacing: '0', 49 | Angle: '0', 50 | BorderStyle: '3', 51 | Outline: '2', 52 | Shadow: '2', 53 | Alignment: '2', 54 | MarginL: '20', 55 | MarginR: '20', 56 | MarginV: '20', 57 | Encoding: '0', 58 | }, 59 | ]; 60 | const styles = compileStyles({ info: { WrapStyle: 0 }, style }); 61 | 62 | it('should compile dialogue', () => { 63 | const dialogue = parseDialogue('0,0:00:00.00,0:00:05.00,Default,,0,0,0,,text', eventsFormat); 64 | expect(compileDialogues({ styles, dialogues: [dialogue] })[0]).to.deep.equal({ 65 | layer: 0, 66 | start: 0, 67 | end: 5, 68 | style: 'Default', 69 | name: '', 70 | margin: { 71 | left: 10, 72 | right: 10, 73 | vertical: 10, 74 | }, 75 | effect: null, 76 | alignment: 2, 77 | q: 0, 78 | slices: [{ 79 | style: 'Default', 80 | fragments: [{ 81 | tag: {}, 82 | text: 'text', 83 | drawing: null, 84 | }], 85 | }], 86 | }); 87 | }); 88 | 89 | it('should sort dialogues with start time and end time', () => { 90 | const dialogues = [ 91 | '2,0:00:05.00,0:00:07.00,Default,,0,0,0,,text2', 92 | '1,0:00:00.00,0:00:05.00,Default,,0,0,0,,text1', 93 | '0,0:00:00.00,0:00:03.00,Default,,0,0,0,,text0', 94 | ].map((dialogue) => parseDialogue(dialogue, eventsFormat)); 95 | const layers = compileDialogues({ styles, dialogues }).map((dia) => dia.layer); 96 | expect(layers).to.deep.equal([0, 1, 2]); 97 | }); 98 | 99 | it('should ignore dialogues when end > start', () => { 100 | const dialogues = [ 101 | '0,0:00:00.00,0:00:05.00,Default,,0,0,0,,text1', 102 | '0,0:07:00.00,0:00:05.00,Default,,0,0,0,,text2', 103 | ].map((dialogue) => parseDialogue(dialogue, eventsFormat)); 104 | expect(compileDialogues({ styles, dialogues })).to.have.lengthOf(1); 105 | }); 106 | 107 | it('should make layer be a non-negative number', () => { 108 | const dialogues = [ 109 | '-1,0:00:00.00,0:00:03.00,Default,,0,0,0,,text-1', 110 | '1,0:00:00.00,0:00:05.00,Default,,0,0,0,,text1', 111 | '2,0:00:05.00,0:00:07.00,Default,,0,0,0,,text2', 112 | ].map((dialogue) => parseDialogue(dialogue, eventsFormat)); 113 | const layers = compileDialogues({ styles, dialogues }).map((dia) => dia.layer); 114 | expect(layers).to.deep.equal([0, 2, 3]); 115 | }); 116 | 117 | it('should use Default Style when style name is not found', () => { 118 | const dialogue = parseDialogue('0,0:00:00.00,0:00:05.00,Unknown,,0,0,0,,text', eventsFormat); 119 | const { margin } = compileDialogues({ styles, dialogues: [dialogue] })[0]; 120 | expect(margin.left).to.equal(10); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /test/compiler/drawing.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseDrawing } from '../../src/parser/drawing.js'; 3 | import { s2b, toSVGPath, compileDrawing } from '../../src/compiler/drawing.js'; 4 | 5 | describe('drawing compiler', () => { 6 | it('should convert instructions to SVG Path', () => { 7 | const instructions = [ 8 | { type: 'M', points: [{ x: 0, y: 0 }] }, 9 | { type: 'L', points: [{ x: 1, y: 0 }, { x: 1, y: 1 }] }, 10 | { type: 'L', points: [{ x: 0, y: 1 }] }, 11 | ]; 12 | expect(toSVGPath(instructions)).to.equal('M0,0L1,0,1,1L0,1'); 13 | }); 14 | 15 | it('should convert S command to B command', () => { 16 | const points = [ 17 | { x: 0, y: 0 }, 18 | { x: 150, y: 60 }, 19 | { x: 150, y: 150 }, 20 | { x: 60, y: 150 }, 21 | { x: 120, y: 120 }, 22 | { x: 90, y: 90 }, 23 | ]; 24 | expect(toSVGPath(s2b(points, 'L', 'S'))).to.equal( 25 | 'L125,65C150,90,150,120,135,135C120,150,90,150,85,145C80,140,100,130,105,120', 26 | ); 27 | expect(toSVGPath(s2b(points, 'M', 'S'))).to.equal( 28 | 'M125,65C150,90,150,120,135,135C120,150,90,150,85,145C80,140,100,130,105,120', 29 | ); 30 | expect(toSVGPath(s2b(points, 'M', 'C'))).to.equal( 31 | 'M125,65C150,90,150,120,135,135C120,150,90,150,85,145C80,140,100,130,105,120L90,90', 32 | ); 33 | expect(toSVGPath(s2b(points, 'M', 'L'))).to.equal( 34 | 'M125,65C150,90,150,120,135,135C120,150,90,150,85,145C80,140,100,130,105,120L90,90', 35 | ); 36 | }); 37 | 38 | it('should deal with C command', () => { 39 | const rawCommands = parseDrawing('m 0 0 s 150 60 150 150 60 150 c'); 40 | expect(compileDrawing(rawCommands).d).to.equal( 41 | 'M0,0M125,65C150,90,150,120,135,135C120,150,90,150,65,125C40,100,20,50,35,35C50,20,100,40,125,65', 42 | ); 43 | }); 44 | 45 | it('should deal with P command', () => { 46 | const rawCommands = parseDrawing('m 0 0 s 150 60 150 150 60 150 p 120 120'); 47 | expect(compileDrawing(rawCommands).d).to.equal( 48 | 'M0,0M125,65C150,90,150,120,135,135C120,150,90,150,85,145', 49 | ); 50 | }); 51 | 52 | it('should ignore illegal commands', () => { 53 | let rawCommands = null; 54 | rawCommands = [['m', '0', '0'], ['l']]; 55 | expect(compileDrawing(rawCommands).d).to.equal('M0,0'); 56 | rawCommands = [['l'], ['m', '0', '0']]; 57 | expect(compileDrawing(rawCommands).d).to.equal('M0,0'); 58 | rawCommands = [['m', '0', '0'], ['l', '1', '1', '1']]; 59 | expect(compileDrawing(rawCommands).d).to.equal('M0,0L1,1'); 60 | rawCommands = [['m', '0', '0'], ['b', '1', '0', '1', '1']]; 61 | expect(compileDrawing(rawCommands).d).to.equal('M0,0'); 62 | rawCommands = [['m', '0', '0'], ['s', '1', '1']]; 63 | expect(compileDrawing(rawCommands).d).to.equal('M0,0'); 64 | rawCommands = [['s', '1', '1']]; 65 | expect(compileDrawing(rawCommands).d).to.equal(''); 66 | rawCommands = [['s', '150', '60', '150', '150', '60', '150'], ['l', '120']]; 67 | expect(compileDrawing(rawCommands).d).to.equal('L125,65C150,90,150,120,135,135'); 68 | }); 69 | 70 | it('should compile drawing commands', () => { 71 | const rawCommands = parseDrawing('m 0 0 s 150 60 150 150 60 150 120 120 90 90 b 60 90 90 120 60 120'); 72 | const { minX, minY, width, height, d } = compileDrawing(rawCommands); 73 | expect(minX).to.equal(0); 74 | expect(minY).to.equal(0); 75 | expect(width).to.equal(150); 76 | expect(height).to.equal(150); 77 | expect(d).to.equal('M0,0M125,65C150,90,150,120,135,135C120,150,90,150,85,145C80,140,100,130,105,120L90,90C60,90,90,120,60,120'); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/compiler/index.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { compile } from '../../src/compiler/index.js'; 3 | import { text } from '../fixtures/index.js'; 4 | 5 | describe('ASS compiler', () => { 6 | it('should compile ASS', () => { 7 | const { info, width, height, collisions, styles, dialogues } = compile(text); 8 | expect(info).to.deep.equal({ 9 | Title: 'Default Aegisub file', 10 | ScriptType: 'v4.00+', 11 | WrapStyle: '0', 12 | ScaledBorderAndShadow: 'yes', 13 | 'YCbCr Matrix': 'None', 14 | }); 15 | expect(width).to.equal(null); 16 | expect(height).to.equal(null); 17 | expect(collisions).to.equal('Normal'); 18 | expect(styles).to.have.property('Default'); 19 | expect(dialogues).to.be.lengthOf(1); 20 | }); 21 | 22 | it('should support options', () => { 23 | const { info, width, height } = compile(text, { 24 | defaultInfo: { 25 | PlayResX: 1280, 26 | PlayResY: 720, 27 | }, 28 | }); 29 | expect(info.PlayResX).to.equal(1280); 30 | expect(info.PlayResY).to.equal(720); 31 | expect(width).to.equal(1280); 32 | expect(height).to.equal(720); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/compiler/styles.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseStyle } from '../../src/parser/style.js'; 3 | import { parseStyleColor, compileStyles } from '../../src/compiler/styles.js'; 4 | import { stylesFormat } from '../../src/utils.js'; 5 | 6 | describe('styles compiler', () => { 7 | const styleString = 'Style:Default,Arial,20,&H00FFFFFF,&H000000FF,&H000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,0'; 8 | 9 | it('should compile styles', () => { 10 | const result = compileStyles({ 11 | info: { WrapStyle: 0 }, 12 | style: [parseStyle(styleString, stylesFormat)], 13 | }); 14 | expect(result.Default.style).to.deep.equal({ 15 | Name: 'Default', 16 | Fontname: 'Arial', 17 | Fontsize: 20, 18 | PrimaryColour: '&H00FFFFFF', 19 | SecondaryColour: '&H000000FF', 20 | OutlineColour: '&H000000', 21 | BackColour: '&H00000000', 22 | Bold: -1, 23 | Italic: 0, 24 | Underline: 0, 25 | StrikeOut: 0, 26 | ScaleX: 100, 27 | ScaleY: 100, 28 | Spacing: 0, 29 | Angle: 0, 30 | BorderStyle: 1, 31 | Outline: 2, 32 | Shadow: 2, 33 | Alignment: 2, 34 | MarginL: 10, 35 | MarginR: 10, 36 | MarginV: 10, 37 | Encoding: 0, 38 | }); 39 | expect(result.Default.tag).to.deep.equal({ 40 | fn: 'Arial', 41 | fs: 20, 42 | c1: 'FFFFFF', 43 | a1: '00', 44 | c2: '0000FF', 45 | a2: '00', 46 | c3: '000000', 47 | a3: '00', 48 | c4: '000000', 49 | a4: '00', 50 | b: 1, 51 | i: 0, 52 | u: 0, 53 | s: 0, 54 | fscx: 100, 55 | fscy: 100, 56 | fsp: 0, 57 | frz: 0, 58 | xbord: 2, 59 | ybord: 2, 60 | xshad: 2, 61 | yshad: 2, 62 | fe: 0, 63 | q: 0, 64 | }); 65 | }); 66 | 67 | it('should set WrapStyle default to 2', () => { 68 | const result = compileStyles({ 69 | info: {}, 70 | style: [parseStyle(styleString, stylesFormat)], 71 | }); 72 | expect(result.Default.tag.q).to.equal(2); 73 | }); 74 | 75 | it('should handle `*Default` as `Default`', () => { 76 | const result = compileStyles({ 77 | info: { WrapStyle: 0 }, 78 | style: [ 79 | parseStyle('Style:*Default,Arial,21,&H00FFFFFF,&H000000FF,&H000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,0', stylesFormat), 80 | parseStyle('Style:**Default,Arial,22,&H00FFFFFF,&H000000FF,&H000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,2,2,10,10,10,0', stylesFormat), 81 | ], 82 | }); 83 | expect(result['*Default']).to.equal(undefined); 84 | expect(result['**Default']).to.equal(undefined); 85 | expect(result.Default.tag.fs).to.equal(22); 86 | }); 87 | 88 | it('should parse color in (AA)BBGGRR format', () => { 89 | expect(parseStyleColor('&H12345678')).to.deep.equal(['12', '345678']); 90 | expect(parseStyleColor('&12345678')).to.deep.equal(['12', '345678']); 91 | expect(parseStyleColor('H12345678')).to.deep.equal(['12', '345678']); 92 | expect(parseStyleColor('&H123456')).to.deep.equal(['00', '123456']); 93 | expect(parseStyleColor('&H12345678xx')).to.deep.equal(['12', '345678']); 94 | expect(parseStyleColor('&H123456xx')).to.deep.equal(['00', '123456']); 95 | }); 96 | 97 | it('should support color present in long integer', () => { 98 | expect(parseStyleColor('65535')).to.deep.equal(['00', '00ffff']); 99 | expect(parseStyleColor('-2147483640')).to.deep.equal(['80', '000008']); 100 | expect(parseStyleColor('15724527')).to.deep.equal(['00', 'efefef']); 101 | expect(parseStyleColor('986895')).to.deep.equal(['00', '0f0f0f']); 102 | expect(parseStyleColor('2147483646')).to.deep.equal(['7f', 'fffffe']); 103 | expect(parseStyleColor('2147483647')).to.deep.equal(['7f', 'ffffff']); 104 | expect(parseStyleColor('2147483648')).to.deep.equal(['21', '474836']); 105 | expect(parseStyleColor('2147483649')).to.deep.equal(['21', '474836']); 106 | expect(parseStyleColor('-2147483647')).to.deep.equal(['80', '000001']); 107 | expect(parseStyleColor('-2147483648')).to.deep.equal(['80', '000000']); 108 | expect(parseStyleColor('-2147483649')).to.deep.equal(['00', '000000']); 109 | expect(parseStyleColor('-2147483650')).to.deep.equal(['00', '000000']); 110 | expect(parseStyleColor('999999999')).to.deep.equal(['3b', '9ac9ff']); 111 | expect(parseStyleColor('9999999999')).to.deep.equal(['99', '999999']); 112 | expect(parseStyleColor('255xx')).to.deep.equal(['00', '0000ff']); 113 | expect(parseStyleColor('2147483648xx')).to.deep.equal(['21', '474836']); 114 | expect(parseStyleColor('-2147483649xx')).to.deep.equal(['00', '000000']); 115 | expect(parseStyleColor('999999999xx')).to.deep.equal(['3b', '9ac9ff']); 116 | expect(parseStyleColor('999999999xx9')).to.deep.equal(['3b', '9ac9ff']); 117 | }); 118 | 119 | it('should ignore invalid color format', () => { 120 | const defaultVal = ['00', '000000']; 121 | expect(parseStyleColor('&Hxx12345678')).to.deep.equal(defaultVal); 122 | expect(parseStyleColor('&H12xx345678')).to.deep.equal(defaultVal); 123 | expect(parseStyleColor('&H12345')).to.deep.equal(defaultVal); 124 | expect(parseStyleColor('xx255')).to.deep.equal(defaultVal); 125 | expect(parseStyleColor('xx-255')).to.deep.equal(defaultVal); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /test/compiler/tag.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { compileTag } from '../../src/compiler/tag.js'; 3 | 4 | describe('tag compiler', () => { 5 | it('should ignore empty tag', () => { 6 | expect(compileTag({}, 'b')).to.deep.equal(null); 7 | }); 8 | 9 | it('should compile pos,org', () => { 10 | expect(compileTag({ pos: [1, 2] }, 'pos')).to.deep.equal({ pos: { x: 1, y: 2 } }); 11 | expect(compileTag({ org: [1, 2] }, 'org')).to.deep.equal({ org: { x: 1, y: 2 } }); 12 | expect(compileTag({ pos: [1, 2, 3] }, 'pos')).to.deep.equal(null); 13 | }); 14 | 15 | it('should compile move', () => { 16 | expect(compileTag({ move: [1, 2, 3, 4] }, 'move')).to.deep.equal({ 17 | move: { x1: 1, y1: 2, x2: 3, y2: 4, t1: 0, t2: 0 }, 18 | }); 19 | expect(compileTag({ move: [1, 2, 3, 4, 5, 6] }, 'move')).to.deep.equal({ 20 | move: { x1: 1, y1: 2, x2: 3, y2: 4, t1: 5, t2: 6 }, 21 | }); 22 | expect(compileTag({ move: [1, 2, 3, 4, 5] }, 'move')).to.deep.equal(null); 23 | }); 24 | 25 | it('should compile fad,fade', () => { 26 | expect(compileTag({ fad: [1, 2] }, 'fad')).to.deep.equal({ 27 | fade: { type: 'fad', t1: 1, t2: 2 }, 28 | }); 29 | expect(compileTag({ fade: [1, 2] }, 'fade')).to.deep.equal({ 30 | fade: { type: 'fad', t1: 1, t2: 2 }, 31 | }); 32 | expect(compileTag({ fad: [1, 2, 3, 4, 5, 6, 7] }, 'fad')).to.deep.equal({ 33 | fade: { type: 'fade', a1: 1, a2: 2, a3: 3, t1: 4, t2: 5, t3: 6, t4: 7 }, 34 | }); 35 | expect(compileTag({ fade: [1, 2, 3, 4, 5, 6, 7] }, 'fade')).to.deep.equal({ 36 | fade: { type: 'fade', a1: 1, a2: 2, a3: 3, t1: 4, t2: 5, t3: 6, t4: 7 }, 37 | }); 38 | expect(compileTag({ fad: [1, 2, 3] }, 'fad')).to.deep.equal(null); 39 | }); 40 | 41 | it('should compile clip', () => { 42 | let clip; 43 | clip = { 44 | inverse: false, 45 | scale: 1, 46 | drawing: null, 47 | dots: [1, 2, 3, 4], 48 | }; 49 | expect(compileTag({ clip }, 'clip')).to.deep.equal({ 50 | clip: { 51 | inverse: false, 52 | scale: 1, 53 | drawing: null, 54 | dots: { x1: 1, y1: 2, x2: 3, y2: 4 }, 55 | }, 56 | }); 57 | clip = { 58 | inverse: false, 59 | scale: 1, 60 | drawing: [ 61 | ['m', '0', '0'], 62 | ['l', '1', '0', '1', '1'], 63 | ], 64 | dots: null, 65 | }; 66 | expect(compileTag({ clip }, 'clip')).to.deep.equal({ 67 | clip: { 68 | inverse: false, 69 | scale: 1, 70 | drawing: { 71 | minX: 0, 72 | minY: 0, 73 | width: 1, 74 | height: 1, 75 | instructions: [ 76 | { type: 'M', points: [{ x: 0, y: 0 }] }, 77 | { type: 'L', points: [{ x: 1, y: 0 }, { x: 1, y: 1 }] }, 78 | ], 79 | d: 'M0,0L1,0,1,1', 80 | }, 81 | dots: null, 82 | }, 83 | }); 84 | clip = { inverse: false, scale: 1, drawing: null, dots: null }; 85 | expect(compileTag({ clip }, 'clip')).to.deep.equal(null); 86 | }); 87 | 88 | it('should compile bord,xbord,ybord,shad,xshad,yshad', () => { 89 | expect(compileTag({ bord: 1 }, 'bord')).to.deep.equal({ xbord: 1, ybord: 1 }); 90 | expect(compileTag({ shad: 1 }, 'shad')).to.deep.equal({ xshad: 1, yshad: 1 }); 91 | expect(compileTag({ xbord: -1 }, 'xbord')).to.deep.equal({ xbord: 0 }); 92 | }); 93 | 94 | it('should compile c', () => { 95 | expect(compileTag({ c1: '000000' }, 'c1', { c1: 'FFFFFF' })).to.deep.equal({ c1: '000000' }); 96 | expect(compileTag({ c1: '' }, 'c1', { c1: 'FFFFFF' })).to.deep.equal({ c1: 'FFFFFF' }); 97 | }); 98 | 99 | it('should compile alpha', () => { 100 | expect(compileTag({ alpha: 'FF' }, 'alpha')).to.deep.equal({ a1: 'FF', a2: 'FF', a3: 'FF', a4: 'FF' }); 101 | }); 102 | 103 | it('should compile fr', () => { 104 | expect(compileTag({ fr: 30 }, 'fr')).to.deep.equal({ frz: 30 }); 105 | }); 106 | 107 | it('should compile fs', () => { 108 | expect(compileTag({ fs: '20' }, 'fs', { fs: 20 })).to.deep.equal({ fs: 20 }); 109 | expect(compileTag({ fs: '+2' }, 'fs', { fs: 20 })).to.deep.equal({ fs: 24 }); 110 | expect(compileTag({ fs: '-3' }, 'fs', { fs: 20 })).to.deep.equal({ fs: 14 }); 111 | expect(compileTag({ fs: '-10' }, 'fs', { fs: 20 })).to.deep.equal({ fs: 20 }); 112 | }); 113 | 114 | it('should compile t', () => { 115 | const t = { 116 | t1: 0, 117 | t2: 1000, 118 | accel: 1, 119 | tags: [ 120 | { b: 1 }, 121 | { fr: 30 }, 122 | { fsp: 4 }, 123 | { 124 | clip: { 125 | inverse: false, 126 | scale: 1, 127 | drawing: [ 128 | ['m', '0', '0'], 129 | ['l', '1', '0', '1', '1'], 130 | ], 131 | dots: null, 132 | }, 133 | }, 134 | ], 135 | }; 136 | expect(compileTag({ t }, 't')).to.deep.equal({ 137 | t: { t1: 0, t2: 1000, accel: 1, tag: { frz: 30, fsp: 4 } }, 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /test/compiler/text.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseText } from '../../src/parser/text.js'; 3 | import { compileStyles } from '../../src/compiler/styles.js'; 4 | import { compileText } from '../../src/compiler/text.js'; 5 | 6 | describe('text compiler', () => { 7 | const styles = compileStyles({ 8 | info: { WrapStyle: 0 }, 9 | style: [ 10 | { 11 | Name: 'Default', 12 | Fontname: 'Arial', 13 | Fontsize: '20', 14 | PrimaryColour: '&H00FFFFFF', 15 | SecondaryColour: '&H000000FF', 16 | OutlineColour: '&H000000', 17 | BackColour: '&H00000000', 18 | Bold: '-1', 19 | Italic: '0', 20 | Underline: '0', 21 | StrikeOut: '0', 22 | ScaleX: '100', 23 | ScaleY: '100', 24 | Spacing: '0', 25 | Angle: '0', 26 | BorderStyle: '1', 27 | Outline: '2', 28 | Shadow: '2', 29 | Alignment: '2', 30 | MarginL: '10', 31 | MarginR: '10', 32 | MarginV: '10', 33 | Encoding: '0', 34 | }, 35 | { 36 | Name: 'alt', 37 | Fontname: 'Arial', 38 | Fontsize: '24', 39 | PrimaryColour: '&H00FFFFFF', 40 | SecondaryColour: '&H000000FF', 41 | OutlineColour: '&H000000', 42 | BackColour: '&H00000000', 43 | Bold: '-1', 44 | Italic: '0', 45 | Underline: '0', 46 | StrikeOut: '0', 47 | ScaleX: '100', 48 | ScaleY: '100', 49 | Spacing: '0', 50 | Angle: '0', 51 | BorderStyle: '3', 52 | Outline: '2', 53 | Shadow: '2', 54 | Alignment: '2', 55 | MarginL: '10', 56 | MarginR: '10', 57 | MarginV: '10', 58 | Encoding: '0', 59 | }, 60 | ], 61 | }); 62 | const style = 'Default'; 63 | 64 | it('should compile text with drawing', () => { 65 | const { parsed } = parseText('{\\p1}m 0 0 l 1 0 1 1'); 66 | const { slices } = compileText({ styles, style, parsed }); 67 | expect(slices[0].fragments[0]).to.deep.equal({ 68 | tag: { p: 1 }, 69 | text: '', 70 | drawing: { 71 | minX: 0, 72 | minY: 0, 73 | width: 1, 74 | height: 1, 75 | instructions: [ 76 | { type: 'M', points: [{ x: 0, y: 0 }] }, 77 | { type: 'L', points: [{ x: 1, y: 0 }, { x: 1, y: 1 }] }, 78 | ], 79 | d: 'M0,0L1,0,1,1', 80 | }, 81 | }); 82 | }); 83 | 84 | it('should compile global tags', () => { 85 | const { parsed } = parseText('{\\an1\\a7\\pos(1,2)\\org(1,2)\\move(1,2,3,4)\\fade(1,2)\\fad(3,4)\\clip(1,2,3,4)}bla bla'); 86 | const { alignment, pos, org, move, fade, clip } = compileText({ styles, style, parsed }); 87 | expect(alignment).to.equal(1); 88 | expect(pos).to.deep.equal({ x: 1, y: 2 }); 89 | expect(org).to.deep.equal({ x: 1, y: 2 }); 90 | expect(move).to.deep.equal(undefined); 91 | expect(fade).to.deep.equal({ type: 'fad', t1: 1, t2: 2 }); 92 | expect(clip).to.deep.equal({ 93 | inverse: false, 94 | scale: 1, 95 | drawing: null, 96 | dots: { x1: 1, y1: 2, x2: 3, y2: 4 }, 97 | }); 98 | const test1 = compileText({ styles, style, parsed: parseText('bla bla').parsed }); 99 | expect(test1.q).to.equal(0); 100 | const test2 = compileText({ styles, style, parsed: parseText('{\\q1}bla bla').parsed }); 101 | expect(test2.q).to.equal(1); 102 | const test3 = compileText({ styles, style, parsed: parseText('{\\q1}bla {\\q2}bla').parsed }); 103 | expect(test3.q).to.equal(2); 104 | }); 105 | 106 | it('should only respect the first \\pos or \\move tag', () => { 107 | const { parsed } = parseText('{\\move(1,2,3,4)\\pos(5,6)}bla'); 108 | const { pos, move } = compileText({ styles, style, parsed }); 109 | expect(move).to.deep.equal({ x1: 1, y1: 2, x2: 3, y2: 4, t1: 0, t2: 0 }); 110 | expect(pos).to.deep.equal(undefined); 111 | }); 112 | 113 | it('should compile text with \\r', () => { 114 | const { parsed } = parseText('{\\fr30}a{\\r}b{\\fr60}c{\\ralt}d'); 115 | const { slices } = compileText({ styles, style, parsed }); 116 | expect(slices).to.deep.equal([ 117 | { 118 | style: 'Default', 119 | fragments: [{ tag: { frz: 30 }, text: 'a', drawing: null }], 120 | }, 121 | { 122 | style: 'Default', 123 | fragments: [ 124 | { tag: {}, text: 'b', drawing: null }, 125 | { tag: { frz: 60 }, text: 'c', drawing: null }, 126 | ], 127 | }, 128 | { 129 | style: 'alt', 130 | fragments: [{ tag: {}, text: 'd', drawing: null }], 131 | }, 132 | ]); 133 | }); 134 | 135 | it('should compile text with \\t', () => { 136 | const { parsed } = parseText('{\\t(\\frx30)\\t(0,500,\\fry60)\\t(\\frz90)\\t(0,500,1,\\frz60)}foo'); 137 | const { slices } = compileText({ styles, style, parsed, start: 0, end: 1 }); 138 | expect(slices[0].fragments[0].tag).to.deep.equal({ 139 | t: [ 140 | { t1: 0, t2: 1000, accel: 1, tag: { frx: 30 } }, 141 | { t1: 0, t2: 500, accel: 1, tag: { fry: 60 } }, 142 | { t1: 0, t2: 1000, accel: 1, tag: { frz: 90 } }, 143 | { t1: 0, t2: 500, accel: 1, tag: { frz: 60 } }, 144 | ], 145 | }); 146 | }); 147 | 148 | it('should inherit tag from previous fragment', () => { 149 | const { parsed } = parseText('{\\frx30}a{\\fry60}b{\\frx150\\frz120}c{\\r\\t(\\frx30)}d{\\t(\\fry60)}e'); 150 | const { slices } = compileText({ styles, style, parsed, start: 0, end: 1 }); 151 | expect(slices[0].fragments).to.deep.equal([ 152 | { tag: { frx: 30 }, text: 'a', drawing: null }, 153 | { tag: { frx: 30, fry: 60 }, text: 'b', drawing: null }, 154 | { tag: { frx: 150, fry: 60, frz: 120 }, text: 'c', drawing: null }, 155 | ]); 156 | expect(slices[1].fragments[0].tag.t).to.deep.equal([ 157 | { t1: 0, t2: 1000, accel: 1, tag: { frx: 30 } }, 158 | ]); 159 | expect(slices[1].fragments[1].tag.t).to.deep.equal([ 160 | { t1: 0, t2: 1000, accel: 1, tag: { frx: 30 } }, 161 | { t1: 0, t2: 1000, accel: 1, tag: { fry: 60 } }, 162 | ]); 163 | }); 164 | 165 | it('should not inherit karaoke tags from previous fragment', () => { 166 | const { parsed } = parseText('{\\k10}And {\\k5}now {\\k20}for {\\kf50}ka{\\kf20}ra{\\K70}o{\\K10}ke{\\k0}!{\\kt100\\k30}!!'); 167 | const { slices } = compileText({ styles, style, parsed, start: 0, end: 1 }); 168 | expect(slices[0].fragments).to.deep.equal([ 169 | { drawing: null, text: 'And ', tag: { k: 10 } }, 170 | { drawing: null, text: 'now ', tag: { k: 5 } }, 171 | { drawing: null, text: 'for ', tag: { k: 20 } }, 172 | { drawing: null, text: 'ka', tag: { kf: 50 } }, 173 | { drawing: null, text: 'ra', tag: { kf: 20 } }, 174 | { drawing: null, text: 'o', tag: { kf: 70 } }, 175 | { drawing: null, text: 'ke', tag: { kf: 10 } }, 176 | { drawing: null, text: '!', tag: { k: 0 } }, 177 | { drawing: null, text: '!!', tag: { kt: 100, k: 30 } }, 178 | ]); 179 | }); 180 | 181 | it('should not create fragment without text and drawing', () => { 182 | const { parsed } = parseText('{\\b1}{\\p1}m 0 0 l 1 0 1 1{\\p0}'); 183 | const { slices } = compileText({ styles, style, parsed }); 184 | expect(slices[0].fragments[0].tag).to.deep.equal({ p: 1, b: 1 }); 185 | expect(slices[0].fragments.length).to.equal(1); 186 | }); 187 | 188 | it('should merge two fragments if the latter has no tag', () => { 189 | const { parsed } = parseText('foo{\\a1}bar{\\an2}baz'); 190 | const { slices } = compileText({ styles, style, parsed }); 191 | expect(slices[0].fragments).to.deep.equal([ 192 | { tag: {}, text: 'foobarbaz', drawing: null }, 193 | ]); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /test/decompiler.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { decompile, decompileDrawing, decompileTag, decompileText } from '../src/decompiler.js'; 3 | import { compiled, decompiled, compiled2, decompiled2 } from './fixtures/decompiler.js'; 4 | 5 | describe('ASS decompiler', () => { 6 | it('should decompile ASS', () => { 7 | expect(decompile(compiled)).to.equal(decompiled); 8 | }); 9 | 10 | it('should add default value', () => { 11 | expect(decompile(compiled2)).to.equal(decompiled2); 12 | }); 13 | 14 | it('should decompile drawing', () => { 15 | expect(decompileDrawing({ 16 | instructions: [ 17 | { type: 'M', points: [{ x: 0, y: 0 }] }, 18 | { type: 'L', points: [{ x: 1, y: 0 }, { x: 1, y: 1 }] }, 19 | { type: 'L', points: [{ x: 0, y: 1 }] }, 20 | { type: 'C', points: [{ x: 0, y: 0 }, { x: 150, y: 60 }, { x: 150, y: 150 }, { x: 60, y: 150 }, { x: 120, y: 120 }, { x: 90, y: 90 }] }, 21 | ], 22 | })).to.equal('m 0 0 l 1 0 1 1 l 0 1 b 0 0 150 60 150 150 60 150 120 120 90 90'); 23 | }); 24 | 25 | describe('tag decompiler', () => { 26 | it('should decompile tag 1c,2c,3c,4c,1a,2a,3a,4a', () => { 27 | expect(decompileTag({ 28 | c1: '111111', 29 | c2: '222222', 30 | c3: '333333', 31 | c4: '444444', 32 | a1: '11', 33 | a2: '22', 34 | a3: '33', 35 | a4: '44', 36 | })).to.deep.equal('\\1c&H111111&\\2c&H222222&\\3c&H333333&\\4c&H444444&\\1a&H11&\\2a&H22&\\3a&H33&\\4a&H44&'); 37 | }); 38 | 39 | it('should decompile tag pos,org,move', () => { 40 | expect(decompileTag({ 41 | pos: { x: 1, y: 2 }, 42 | org: { x: 3, y: 4 }, 43 | move: { x1: 11, y1: 21, x2: 12, y2: 22, t1: 31, t2: 32 }, 44 | })).to.deep.equal('\\pos(1,2)\\org(3,4)\\move(11,21,12,22,31,32)'); 45 | }); 46 | 47 | it('should decompile tag fade', () => { 48 | expect(decompileTag({ 49 | fade: { type: 'fad', t1: 1000, t2: 2000 }, 50 | })).to.deep.equal('\\fad(1000,2000)'); 51 | expect(decompileTag({ 52 | fade: { type: 'fade', t1: 1, t2: 2, t3: 3, t4: 4, a1: 11, a2: 12, a3: 13 }, 53 | })).to.deep.equal('\\fade(11,12,13,1,2,3,4)'); 54 | }); 55 | 56 | it('should decompile clip,iclip', () => { 57 | expect(decompileTag({ 58 | clip: { 59 | inverse: false, 60 | scale: 1, 61 | drawing: null, 62 | dots: { x1: 11, y1: 21, x2: 12, y2: 22 }, 63 | }, 64 | })).to.deep.equal('\\clip(11,21,12,22)'); 65 | expect(decompileTag({ 66 | clip: { 67 | inverse: true, 68 | scale: 1, 69 | drawing: null, 70 | dots: { x1: 11, y1: 21, x2: 12, y2: 22 }, 71 | }, 72 | })).to.deep.equal('\\iclip(11,21,12,22)'); 73 | expect(decompileTag({ 74 | clip: { 75 | inverse: false, 76 | scale: 1, 77 | drawing: { 78 | instructions: [ 79 | { type: 'M', points: [{ x: 1, y: 2 }] }, 80 | { type: 'L', points: [{ x: 3, y: 4 }] }, 81 | ], 82 | }, 83 | dots: null, 84 | }, 85 | })).to.deep.equal('\\clip(m 1 2 l 3 4)'); 86 | expect(decompileTag({ 87 | clip: { 88 | inverse: true, 89 | scale: 2, 90 | drawing: { 91 | instructions: [ 92 | { type: 'M', points: [{ x: 5, y: 6 }] }, 93 | { type: 'L', points: [{ x: 7, y: 8 }] }, 94 | ], 95 | }, 96 | dots: null, 97 | }, 98 | })).to.deep.equal('\\iclip(2,m 5 6 l 7 8)'); 99 | }); 100 | 101 | it('should decompile tag t', () => { 102 | expect(decompileTag({ 103 | t: [ 104 | { 105 | t1: 1, 106 | t2: 2, 107 | accel: 3, 108 | tag: { 109 | clip: { 110 | inverse: false, 111 | scale: 1, 112 | drawing: null, 113 | dots: { x1: 11, y1: 21, x2: 12, y2: 22 }, 114 | }, 115 | }, 116 | }, 117 | { 118 | t1: 4, 119 | t2: 5, 120 | accel: 6, 121 | tag: { 122 | b: 1, 123 | fr: 30, 124 | }, 125 | }, 126 | ], 127 | })).to.deep.equal('\\t(1,2,3,\\clip(11,21,12,22))\\t(4,5,6,\\b1\\fr30)'); 128 | }); 129 | }); 130 | describe('text decompiler', () => { 131 | it('should put \\r to the beginning of tags', () => { 132 | expect(decompileText({ 133 | alignment: 1, 134 | slices: [{ 135 | style: 'Default', 136 | fragments: [{ 137 | text: 'Hello', 138 | tag: { t: [{ t1: 0, t2: 1000, accel: 1, tag: { c1: 'FF' } }] }, 139 | }], 140 | }, { 141 | style: 'Nondefault', 142 | fragments: [{ 143 | text: 'World', 144 | tag: { t: [{ t1: 1000, t2: 2000, accel: 1, tag: { c1: 'FFFF' } }] }, 145 | }], 146 | }], 147 | }, { 148 | Alignment: 1, 149 | })).to.equal('{\\t(0,1000,1,\\1c&HFF&)}Hello{\\rNondefault\\t(1000,2000,1,\\1c&HFFFF&)}World'); 150 | }); 151 | }); 152 | }); 153 | -------------------------------------------------------------------------------- /test/fixtures/decompiler.js: -------------------------------------------------------------------------------- 1 | export const compiled = { 2 | info: { 3 | Title: 'Default Aegisub file', 4 | ScriptType: 'v4.00+', 5 | WrapStyle: '0', 6 | PlayResX: '640', 7 | PlayResY: '480', 8 | ScaledBorderAndShadow: 'yes', 9 | }, 10 | width: 640, 11 | height: 480, 12 | collisions: 'Normal', 13 | styles: { 14 | Default: { 15 | style: { 16 | Name: 'Default', 17 | Fontname: 'Arial', 18 | Fontsize: 20, 19 | PrimaryColour: '&H00FFFFFF', 20 | SecondaryColour: '&H000000FF', 21 | OutlineColour: '&H00000000', 22 | BackColour: '&H00000000', 23 | Bold: 0, 24 | Italic: 0, 25 | Underline: 0, 26 | StrikeOut: 0, 27 | ScaleX: 100, 28 | ScaleY: 100, 29 | Spacing: 0, 30 | Angle: 0, 31 | BorderStyle: 1, 32 | Outline: 2, 33 | Shadow: 2, 34 | Alignment: 2, 35 | MarginL: 10, 36 | MarginR: 10, 37 | MarginV: 10, 38 | Encoding: 0, 39 | }, 40 | tag: { 41 | fn: 'Arial', 42 | fs: 20, 43 | c1: 'FFFFFF', 44 | a1: '00', 45 | c2: '0000FF', 46 | a2: '00', 47 | c3: '000000', 48 | a3: '00', 49 | c4: '000000', 50 | a4: '00', 51 | b: 0, 52 | i: 0, 53 | u: 0, 54 | s: 0, 55 | fscx: 100, 56 | fscy: 100, 57 | fsp: 0, 58 | frz: 0, 59 | xbord: 2, 60 | ybord: 2, 61 | xshad: 2, 62 | yshad: 2, 63 | q: 0, 64 | }, 65 | }, 66 | Alt: { 67 | style: { 68 | Name: 'Alt', 69 | Fontname: 'Times New Roman', 70 | Fontsize: 40, 71 | PrimaryColour: '&H00FFFFFF', 72 | SecondaryColour: '&H000000FF', 73 | OutlineColour: '&H00000000', 74 | BackColour: '&H00000000', 75 | Bold: 0, 76 | Italic: 0, 77 | Underline: 0, 78 | StrikeOut: 0, 79 | ScaleX: 100, 80 | ScaleY: 100, 81 | Spacing: 0, 82 | Angle: 0, 83 | BorderStyle: 1, 84 | Outline: 2, 85 | Shadow: 2, 86 | Alignment: 8, 87 | MarginL: 10, 88 | MarginR: 10, 89 | MarginV: 10, 90 | Encoding: 0, 91 | }, 92 | tag: { 93 | fn: 'Times New Roman', 94 | fs: 40, 95 | c1: 'FFFFFF', 96 | a1: '00', 97 | c2: '0000FF', 98 | a2: '00', 99 | c3: '000000', 100 | a3: '00', 101 | c4: '000000', 102 | a4: '00', 103 | b: 0, 104 | i: 0, 105 | u: 0, 106 | s: 0, 107 | fscx: 100, 108 | fscy: 100, 109 | fsp: 0, 110 | frz: 0, 111 | xbord: 2, 112 | ybord: 2, 113 | xshad: 2, 114 | yshad: 2, 115 | q: 0, 116 | }, 117 | }, 118 | }, 119 | dialogues: [ 120 | { 121 | layer: 0, 122 | start: 0, 123 | end: 4, 124 | style: 'Default', 125 | name: '', 126 | margin: { 127 | left: 1, 128 | right: 2, 129 | vertical: 3, 130 | }, 131 | effect: null, 132 | alignment: 2, 133 | slices: [ 134 | { 135 | style: 'Default', 136 | fragments: [ 137 | { 138 | tag: {}, 139 | text: 'This is a test of the ASS format and some basic features in it.', 140 | drawing: null, 141 | }, 142 | ], 143 | }, 144 | ], 145 | }, 146 | { 147 | layer: 0, 148 | start: 0, 149 | end: 4, 150 | style: 'Default', 151 | name: '', 152 | margin: { 153 | left: 1, 154 | right: 2, 155 | vertical: 3, 156 | }, 157 | effect: null, 158 | alignment: 2, 159 | slices: [ 160 | { 161 | style: 'Default', 162 | fragments: [], 163 | }, 164 | ], 165 | }, 166 | { 167 | layer: 0, 168 | start: 0, 169 | end: 5, 170 | style: 'Default', 171 | name: '', 172 | margin: { 173 | left: 10, 174 | right: 10, 175 | vertical: 10, 176 | }, 177 | effect: null, 178 | alignment: 2, 179 | slices: [ 180 | { 181 | style: 'Default', 182 | fragments: [ 183 | { 184 | tag: {}, 185 | text: 'This is a test of the ASS format and some basic features in it.', 186 | drawing: null, 187 | }, 188 | ], 189 | }, 190 | ], 191 | }, 192 | { 193 | layer: 0, 194 | start: 11, 195 | end: 13, 196 | style: 'Default', 197 | name: '', 198 | margin: { 199 | left: 10, 200 | right: 10, 201 | vertical: 10, 202 | }, 203 | effect: null, 204 | alignment: 9, 205 | slices: [ 206 | { 207 | style: 'Default', 208 | fragments: [ 209 | { 210 | tag: {}, 211 | text: 'Upper right', 212 | drawing: null, 213 | }, 214 | ], 215 | }, 216 | ], 217 | }, 218 | { 219 | layer: 0, 220 | start: 24, 221 | end: 26, 222 | style: 'Default', 223 | name: '', 224 | margin: { 225 | left: 10, 226 | right: 10, 227 | vertical: 10, 228 | }, 229 | effect: null, 230 | alignment: 2, 231 | slices: [ 232 | { 233 | style: 'Default', 234 | fragments: [ 235 | { 236 | tag: {}, 237 | text: 'Also ', 238 | drawing: null, 239 | }, 240 | ], 241 | }, 242 | { 243 | style: 'Alt', 244 | fragments: [ 245 | { 246 | tag: {}, 247 | text: 'switching to a different style ', 248 | drawing: null, 249 | }, 250 | ], 251 | }, 252 | { 253 | style: 'Default', 254 | fragments: [ 255 | { 256 | tag: {}, 257 | text: 'inline', 258 | drawing: null, 259 | }, 260 | ], 261 | }, 262 | ], 263 | }, 264 | { 265 | layer: 0, 266 | start: 26, 267 | end: 28, 268 | style: 'Default', 269 | name: '', 270 | margin: { 271 | left: 10, 272 | right: 10, 273 | vertical: 10, 274 | }, 275 | effect: null, 276 | alignment: 5, 277 | slices: [ 278 | { 279 | style: 'Default', 280 | fragments: [ 281 | { 282 | tag: {}, 283 | text: 'Positioning... this line should be in an odd place', 284 | drawing: null, 285 | }, 286 | ], 287 | }, 288 | ], 289 | pos: { 290 | x: 258, 291 | y: 131, 292 | }, 293 | }, 294 | { 295 | layer: 0, 296 | start: 38, 297 | end: 40, 298 | style: 'Default', 299 | name: '', 300 | margin: { 301 | left: 10, 302 | right: 10, 303 | vertical: 10, 304 | }, 305 | effect: null, 306 | alignment: 2, 307 | slices: [ 308 | { 309 | style: 'Default', 310 | fragments: [ 311 | { 312 | tag: { p: 1 }, 313 | text: '', 314 | drawing: { 315 | instructions: [ 316 | { type: 'M', points: [{ x: 0, y: 0 }] }, 317 | { type: 'L', points: [{ x: 1, y: 1 }] }, 318 | ], 319 | }, 320 | }, 321 | ], 322 | }, 323 | ], 324 | }, 325 | ], 326 | }; 327 | 328 | export const decompiled = `[Script Info] 329 | Title: Default Aegisub file 330 | ScriptType: v4.00+ 331 | WrapStyle: 0 332 | PlayResX: 640 333 | PlayResY: 480 334 | ScaledBorderAndShadow: yes 335 | Collisions: Normal 336 | 337 | [V4+ Styles] 338 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 339 | Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,0 340 | Style: Alt,Times New Roman,40,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,8,10,10,10,0 341 | 342 | [Events] 343 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 344 | Dialogue: 0,0:00:00.00,0:00:04.00,Default,,1,2,3,,This is a test of the ASS format and some basic features in it. 345 | Dialogue: 0,0:00:00.00,0:00:04.00,Default,,1,2,3,, 346 | Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0000,0000,0000,,This is a test of the ASS format and some basic features in it. 347 | Dialogue: 0,0:00:11.00,0:00:13.00,Default,,0000,0000,0000,,{\\an9}Upper right 348 | Dialogue: 0,0:00:24.00,0:00:26.00,Default,,0000,0000,0000,,Also {\\rAlt}switching to a different style {\\r}inline 349 | Dialogue: 0,0:00:26.00,0:00:28.00,Default,,0000,0000,0000,,{\\an5\\pos(258,131)}Positioning... this line should be in an odd place 350 | Dialogue: 0,0:00:38.00,0:00:40.00,Default,,0000,0000,0000,,{\\p1}m 0 0 l 1 1 351 | `; 352 | 353 | export const compiled2 = { 354 | info: { 355 | Title: 'Default Aegisub file', 356 | ScriptType: 'v4.00+', 357 | }, 358 | width: 640, 359 | height: 480, 360 | collisions: 'Normal', 361 | styles: { 362 | Default: { 363 | style: { 364 | Name: 'Default', 365 | Fontname: 'Arial', 366 | Fontsize: 16, 367 | PrimaryColour: '&Hffffff', 368 | SecondaryColour: '&Hffffff', 369 | OutlineColour: '&H0', 370 | BackColour: '&H0', 371 | Bold: 0, 372 | Italic: 0, 373 | Underline: 0, 374 | StrikeOut: 0, 375 | ScaleX: 100, 376 | ScaleY: 100, 377 | Spacing: 0, 378 | Angle: 0, 379 | BorderStyle: 1, 380 | Outline: 1, 381 | Shadow: 0, 382 | Alignment: 2, 383 | MarginL: 10, 384 | MarginR: 10, 385 | MarginV: 10, 386 | Encoding: 0, 387 | AlphaLevel: 0, 388 | }, 389 | tag: { 390 | fn: 'Arial', 391 | fs: 16, 392 | c1: 'ffffff', 393 | a1: '00', 394 | c2: 'ffffff', 395 | a2: '00', 396 | c3: '000000', 397 | a3: '00', 398 | c4: '000000', 399 | a4: '00', 400 | b: 0, 401 | i: 0, 402 | u: 0, 403 | s: 0, 404 | fscx: 100, 405 | fscy: 100, 406 | fsp: 0, 407 | frz: 0, 408 | xbord: 1, 409 | ybord: 1, 410 | xshad: 0, 411 | yshad: 0, 412 | fe: 0, 413 | q: 2, 414 | }, 415 | }, 416 | }, 417 | dialogues: [], 418 | }; 419 | 420 | export const decompiled2 = `[Script Info] 421 | Title: Default Aegisub file 422 | ScriptType: v4.00+ 423 | PlayResX: 640 424 | PlayResY: 480 425 | Collisions: Normal 426 | 427 | [V4+ Styles] 428 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 429 | Style: Default,Arial,16,&H00ffffff,&H00ffffff,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,10,10,10,0 430 | 431 | [Events] 432 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 433 | `; 434 | -------------------------------------------------------------------------------- /test/fixtures/index.js: -------------------------------------------------------------------------------- 1 | export const text = ` 2 | [Script Info] 3 | ; Script generated by Aegisub 3.2.2 4 | ; http://www.aegisub.org/ 5 | Title: Default Aegisub file 6 | ScriptType: v4.00+ 7 | WrapStyle: 0 8 | ScaledBorderAndShadow: yes 9 | YCbCr Matrix: None 10 | 11 | [Aegisub Project Garbage] 12 | 13 | [V4+ Styles] 14 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 15 | Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 16 | 17 | [Events] 18 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 19 | Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,text 20 | `; 21 | -------------------------------------------------------------------------------- /test/fixtures/stringifier.js: -------------------------------------------------------------------------------- 1 | import { stylesFormat, eventsFormat } from '../../src/utils.js'; 2 | 3 | export const parsed = { 4 | info: { 5 | Title: 'Default Aegisub file', 6 | ScriptType: 'v4.00+', 7 | WrapStyle: '0', 8 | ScaledBorderAndShadow: 'yes', 9 | 'YCbCr Matrix': 'None', 10 | }, 11 | styles: { 12 | format: stylesFormat, 13 | style: [{ 14 | Name: 'Default', 15 | Fontname: 'Arial', 16 | Fontsize: '20', 17 | PrimaryColour: '&H00FFFFFF', 18 | SecondaryColour: '&H000000FF', 19 | OutlineColour: '&H00000000', 20 | BackColour: '&H00000000', 21 | Bold: '0', 22 | Italic: '0', 23 | Underline: '0', 24 | StrikeOut: '0', 25 | ScaleX: '100', 26 | ScaleY: '100', 27 | Spacing: '0', 28 | Angle: '0', 29 | BorderStyle: '1', 30 | Outline: '2', 31 | Shadow: '2', 32 | Alignment: '2', 33 | MarginL: '10', 34 | MarginR: '10', 35 | MarginV: '10', 36 | Encoding: '1', 37 | }], 38 | }, 39 | events: { 40 | format: eventsFormat, 41 | comment: [{ 42 | Layer: 0, 43 | Start: 0, 44 | End: 4, 45 | Style: 'Default', 46 | Name: '', 47 | MarginL: 0, 48 | MarginR: 0, 49 | MarginV: 0, 50 | Effect: { 51 | name: 'banner', 52 | delay: 5, 53 | leftToRight: 1, 54 | fadeAwayWidth: 80, 55 | }, 56 | Text: { 57 | raw: 'text', 58 | combined: 'text', 59 | parsed: [{ tags: [], text: 'text', drawing: [] }], 60 | }, 61 | }], 62 | dialogue: [ 63 | { 64 | Layer: 0, 65 | Start: 0, 66 | End: 5, 67 | Style: 'Default', 68 | Name: '', 69 | MarginL: 0, 70 | MarginR: 8, 71 | MarginV: 0, 72 | Effect: null, 73 | Text: { 74 | raw: 'text', 75 | combined: 'text', 76 | parsed: [{ tags: [], text: 'text', drawing: [] }], 77 | }, 78 | }, 79 | { 80 | Layer: 0, 81 | Start: 0, 82 | End: 5, 83 | Style: 'Default', 84 | Name: '', 85 | MarginL: 0, 86 | MarginR: 8, 87 | MarginV: 0, 88 | Effect: null, 89 | Text: { 90 | raw: '{\\p1}m 0 0 l 1 1', 91 | combined: '', 92 | parsed: [{ 93 | tags: [{ p: 1 }], 94 | text: '', 95 | drawing: [['m', '0', '0'], ['l', '1', '1']], 96 | }], 97 | }, 98 | }, 99 | ], 100 | }, 101 | }; 102 | 103 | export const stringified = `[Script Info] 104 | Title: Default Aegisub file 105 | ScriptType: v4.00+ 106 | WrapStyle: 0 107 | ScaledBorderAndShadow: yes 108 | YCbCr Matrix: None 109 | 110 | [V4+ Styles] 111 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 112 | Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 113 | 114 | [Events] 115 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 116 | Comment: 0,0:00:00.00,0:00:04.00,Default,,0000,0000,0000,Banner;5;1;80,text 117 | Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0000,8,0000,,text 118 | Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0000,8,0000,,{\\p1}m 0 0 l 1 1 119 | `; 120 | 121 | export const parsed2 = { 122 | info: { 123 | Title: 'Title', 124 | ScriptType: 'v4.00+', 125 | }, 126 | styles: { 127 | format: ['Name', 'Fontname', 'Fontsize', 'Unknown'], 128 | style: [{ 129 | Name: 'Default', 130 | Fontname: 'Arial', 131 | Fontsize: '20', 132 | Unknown: '1', 133 | }], 134 | }, 135 | events: { 136 | format: ['Layer', 'Start', 'End', 'Style', 'Text'], 137 | comment: [], 138 | dialogue: [{ 139 | Layer: 0, 140 | Start: 0, 141 | End: 5, 142 | Style: 'Default', 143 | Text: { 144 | raw: 'text', 145 | combined: 'text', 146 | parsed: [{ tags: [], text: 'text', drawing: [] }], 147 | }, 148 | }], 149 | }, 150 | }; 151 | 152 | export const stringified2 = `[Script Info] 153 | Title: Title 154 | ScriptType: v4.00+ 155 | 156 | [V4+ Styles] 157 | Format: Name, Fontname, Fontsize, Unknown 158 | Style: Default,Arial,20,1 159 | 160 | [Events] 161 | Format: Layer, Start, End, Style, Text 162 | Dialogue: 0,0:00:00.00,0:00:05.00,Default,text 163 | `; 164 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parse, stringify, compile, decompile } from '../src/index.js'; 3 | 4 | describe('ass-compiler', () => { 5 | it('should provide parse function and compile function', () => { 6 | expect(parse).to.be.a('function'); 7 | expect(stringify).to.be.a('function'); 8 | expect(compile).to.be.a('function'); 9 | expect(decompile).to.be.a('function'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/parser/dialogue.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseDialogue } from '../../src/parser/dialogue.js'; 3 | import { eventsFormat } from '../../src/utils.js'; 4 | 5 | describe('dialogue parser', () => { 6 | let text = ''; 7 | 8 | it('should parse dialogue', () => { 9 | text = '0,0:00:00.00,0:00:05.00,Default,,0,0,0,,text'; 10 | expect(parseDialogue(text, eventsFormat)).to.deep.equal({ 11 | Layer: 0, 12 | Start: 0, 13 | End: 5, 14 | Style: 'Default', 15 | Name: '', 16 | MarginL: 0, 17 | MarginR: 0, 18 | MarginV: 0, 19 | Effect: null, 20 | Text: { 21 | raw: 'text', 22 | combined: 'text', 23 | parsed: [{ tags: [], text: 'text', drawing: [] }], 24 | }, 25 | }); 26 | text = '0,0:00:00.00,0:00:05.00,Default,,0,0,0,,text,with,comma'; 27 | expect(parseDialogue(text, eventsFormat).Text).to.deep.equal({ 28 | raw: 'text,with,comma', 29 | combined: 'text,with,comma', 30 | parsed: [{ tags: [], text: 'text,with,comma', drawing: [] }], 31 | }); 32 | }); 33 | 34 | it('should parse dialogue without standard events format', () => { 35 | text = '0,0:00:01.00,0:00:02.00,Default,text'; 36 | const format = ['Layer', 'Start', 'End', 'Style', 'Text']; 37 | expect(parseDialogue(text, format)).to.deep.equal({ 38 | Layer: 0, 39 | Start: 1, 40 | End: 2, 41 | Style: 'Default', 42 | Text: { 43 | raw: 'text', 44 | combined: 'text', 45 | parsed: [{ tags: [], text: 'text', drawing: [] }], 46 | }, 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/parser/drawing.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseDrawing } from '../../src/parser/drawing.js'; 3 | 4 | describe('drawing parser', () => { 5 | it('should parse drawing', () => { 6 | expect(parseDrawing('')).to.deep.equal([]); 7 | expect(parseDrawing('m0 0l 1 0 n 2 2')).to.deep.equal([ 8 | ['m', '0', '0'], 9 | ['l', '1', '0'], 10 | ['n', '2', '2'], 11 | ]); 12 | expect(parseDrawing('m 0 0 s 1 0 1 1 0 1 c')).to.deep.equal([ 13 | ['m', '0', '0'], 14 | ['s', '1', '0', '1', '1', '0', '1'], 15 | ['c'], 16 | ]); 17 | expect(parseDrawing('m 0 0 b 1 0 1 1 0 1 p 2 2')).to.deep.equal([ 18 | ['m', '0', '0'], 19 | ['b', '1', '0', '1', '1', '0', '1'], 20 | ['p', '2', '2'], 21 | ]); 22 | }); 23 | 24 | it('should ignore unknown character', () => { 25 | expect(parseDrawing('m0 _ 0\\Nl 1_ _0 e 2\\h2')).to.deep.equal([ 26 | ['m', '0', '0'], 27 | ['n'], 28 | ['l', '1', '0', '2', '2'], 29 | ]); 30 | }); 31 | 32 | it('should support numbers in scientific notation', () => { 33 | expect(parseDrawing('l _+1.0e+0_ _-.0_ _.2e1_ _20e-1_ _1.2e3.4_')).to.deep.equal([ 34 | ['l', '+1.0e+0', '-.0', '.2e1', '20e-1', '1.2e3', '.4'], 35 | ]); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/parser/effect.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseEffect } from '../../src/parser/effect.js'; 3 | 4 | describe('effect parser', () => { 5 | it('should parse Scroll up/down', () => { 6 | expect(parseEffect('Scroll up;40;320;5;80')).to.deep.equal({ 7 | name: 'scroll up', 8 | y1: 40, 9 | y2: 320, 10 | delay: 5, 11 | fadeAwayHeight: 80, 12 | }); 13 | expect(parseEffect('Scroll down;40;320;5;80')).to.deep.equal({ 14 | name: 'scroll down', 15 | y1: 40, 16 | y2: 320, 17 | delay: 5, 18 | fadeAwayHeight: 80, 19 | }); 20 | }); 21 | 22 | it('should parse Banner', () => { 23 | expect(parseEffect('Banner;5;1;80')).to.deep.equal({ 24 | name: 'banner', 25 | delay: 5, 26 | leftToRight: 1, 27 | fadeAwayWidth: 80, 28 | }); 29 | }); 30 | 31 | it('should let y1 < y2', () => { 32 | expect(parseEffect('Scroll up;320;40')).to.deep.equal({ 33 | name: 'scroll up', 34 | y1: 40, 35 | y2: 320, 36 | delay: 0, 37 | fadeAwayHeight: 0, 38 | }); 39 | }); 40 | 41 | it('should default parmas to 0', () => { 42 | expect(parseEffect('Scroll up;40;320')).to.deep.equal({ 43 | name: 'scroll up', 44 | y1: 40, 45 | y2: 320, 46 | delay: 0, 47 | fadeAwayHeight: 0, 48 | }); 49 | expect(parseEffect('Scroll up;40;320;5')).to.deep.equal({ 50 | name: 'scroll up', 51 | y1: 40, 52 | y2: 320, 53 | delay: 5, 54 | fadeAwayHeight: 0, 55 | }); 56 | expect(parseEffect('Banner')).to.deep.equal({ 57 | name: 'banner', 58 | delay: 0, 59 | leftToRight: 0, 60 | fadeAwayWidth: 0, 61 | }); 62 | expect(parseEffect('Banner;5')).to.deep.equal({ 63 | name: 'banner', 64 | delay: 5, 65 | leftToRight: 0, 66 | fadeAwayWidth: 0, 67 | }); 68 | expect(parseEffect('Banner;5;1')).to.deep.equal({ 69 | name: 'banner', 70 | delay: 5, 71 | leftToRight: 1, 72 | fadeAwayWidth: 0, 73 | }); 74 | }); 75 | 76 | it('should return EffectUnknown if non-standard, but non empty', () => { 77 | expect(parseEffect('unknown')).to.deep.equal({ name: 'unknown' }); 78 | }); 79 | 80 | it('should return null if empty', () => { 81 | expect(parseEffect('')).to.equal(null); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /test/parser/format.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseFormat } from '../../src/parser/format.js'; 3 | import { stylesFormat, eventsFormat } from '../../src/utils.js'; 4 | 5 | describe('format parser', () => { 6 | let text = ''; 7 | 8 | it('should parse format', () => { 9 | text = 'Format: Layer, Start,End ,Style , Name, MarginL , MarginR, MarginV, Effect, Text'; 10 | expect(parseFormat(text)).to.deep.equal(eventsFormat); 11 | text = 'Format:Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding'; 12 | expect(parseFormat(text)).to.deep.equal(stylesFormat); 13 | }); 14 | 15 | it('should correct upper or lower cases', () => { 16 | text = 'Format: Name, FontName, FontSize, PrImArYcOlOuR, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, Strikeout, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding'; 17 | expect(parseFormat(text)).to.deep.equal(stylesFormat); 18 | text = 'Format: uNkNoWn, Layer, Start, End, Style, Name, Text'; 19 | expect(parseFormat(text)).to.deep.equal(['uNkNoWn', 'Layer', 'Start', 'End', 'Style', 'Name', 'Text']); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/parser/index.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parse } from '../../src/parser/index.js'; 3 | import { stylesFormat, eventsFormat } from '../../src/utils.js'; 4 | import { text } from '../fixtures/index.js'; 5 | 6 | describe('ASS parser', () => { 7 | it('should parse ASS', () => { 8 | expect(parse(text)).to.deep.equal({ 9 | info: { 10 | Title: 'Default Aegisub file', 11 | ScriptType: 'v4.00+', 12 | WrapStyle: '0', 13 | ScaledBorderAndShadow: 'yes', 14 | 'YCbCr Matrix': 'None', 15 | }, 16 | styles: { 17 | format: stylesFormat, 18 | style: [{ 19 | Name: 'Default', 20 | Fontname: 'Arial', 21 | Fontsize: '20', 22 | PrimaryColour: '&H00FFFFFF', 23 | SecondaryColour: '&H000000FF', 24 | OutlineColour: '&H00000000', 25 | BackColour: '&H00000000', 26 | Bold: '0', 27 | Italic: '0', 28 | Underline: '0', 29 | StrikeOut: '0', 30 | ScaleX: '100', 31 | ScaleY: '100', 32 | Spacing: '0', 33 | Angle: '0', 34 | BorderStyle: '1', 35 | Outline: '2', 36 | Shadow: '2', 37 | Alignment: '2', 38 | MarginL: '10', 39 | MarginR: '10', 40 | MarginV: '10', 41 | Encoding: '1', 42 | }], 43 | }, 44 | events: { 45 | format: eventsFormat, 46 | comment: [], 47 | dialogue: [{ 48 | Layer: 0, 49 | Start: 0, 50 | End: 5, 51 | Style: 'Default', 52 | Name: '', 53 | MarginL: 0, 54 | MarginR: 0, 55 | MarginV: 0, 56 | Effect: null, 57 | Text: { 58 | raw: 'text', 59 | combined: 'text', 60 | parsed: [{ tags: [], text: 'text', drawing: [] }], 61 | }, 62 | }], 63 | }, 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/parser/style.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseStyle } from '../../src/parser/style.js'; 3 | import { stylesFormat } from '../../src/utils.js'; 4 | 5 | describe('style parser', () => { 6 | let text = ''; 7 | let result = []; 8 | 9 | it('should parse style', () => { 10 | text = 'Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,0'; 11 | result = { 12 | Name: 'Default', 13 | Fontname: 'Arial', 14 | Fontsize: '20', 15 | PrimaryColour: '&H00FFFFFF', 16 | SecondaryColour: '&H000000FF', 17 | OutlineColour: '&H00000000', 18 | BackColour: '&H00000000', 19 | Bold: '0', 20 | Italic: '0', 21 | Underline: '0', 22 | StrikeOut: '0', 23 | ScaleX: '100', 24 | ScaleY: '100', 25 | Spacing: '0', 26 | Angle: '0', 27 | BorderStyle: '1', 28 | Outline: '2', 29 | Shadow: '2', 30 | Alignment: '2', 31 | MarginL: '10', 32 | MarginR: '10', 33 | MarginV: '10', 34 | Encoding: '0', 35 | }; 36 | expect(parseStyle(text, stylesFormat)).to.deep.equal(result); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/parser/tag.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseTag } from '../../src/parser/tag.js'; 3 | 4 | describe('tag parser', () => { 5 | it('should parse b,i,u,s', () => { 6 | ['b', 'i', 'u', 's'].forEach((tag) => { 7 | expect(parseTag(`${tag}0`)).to.deep.equal({ [tag]: 0 }); 8 | expect(parseTag(`${tag}1`)).to.deep.equal({ [tag]: 1 }); 9 | }); 10 | }); 11 | 12 | it('should parse fn', () => { 13 | expect(parseTag('fnArial')).to.deep.equal({ fn: 'Arial' }); 14 | expect(parseTag('fnNoto Sans')).to.deep.equal({ fn: 'Noto Sans' }); 15 | expect(parseTag('fn黑体')).to.deep.equal({ fn: '黑体' }); 16 | expect(parseTag('fn@微软雅黑')).to.deep.equal({ fn: '@微软雅黑' }); 17 | }); 18 | 19 | it('should parse fe', () => { 20 | expect(parseTag('fe0')).to.deep.equal({ fe: 0 }); 21 | expect(parseTag('fe134')).to.deep.equal({ fe: 134 }); 22 | }); 23 | 24 | it('should parse k,K,kf,ko,kt', () => { 25 | ['k', 'K', 'kf', 'ko', 'kt'].forEach((tag) => { 26 | expect(parseTag(`${tag}0`)).to.deep.equal({ [tag]: 0 }); 27 | expect(parseTag(`${tag}100`)).to.deep.equal({ [tag]: 100 }); 28 | }); 29 | }); 30 | 31 | it('should parse q', () => { 32 | expect(parseTag('q0')).to.deep.equal({ q: 0 }); 33 | expect(parseTag('q1')).to.deep.equal({ q: 1 }); 34 | expect(parseTag('q2')).to.deep.equal({ q: 2 }); 35 | expect(parseTag('q3')).to.deep.equal({ q: 3 }); 36 | }); 37 | 38 | it('should parse p', () => { 39 | expect(parseTag('p0')).to.deep.equal({ p: 0 }); 40 | expect(parseTag('p1')).to.deep.equal({ p: 1 }); 41 | }); 42 | 43 | it('should parse pbo', () => { 44 | expect(parseTag('pbo0')).to.deep.equal({ pbo: 0 }); 45 | expect(parseTag('pbo10')).to.deep.equal({ pbo: 10 }); 46 | }); 47 | 48 | it('should parse an,a', () => { 49 | for (let i = 1; i <= 11; i++) { 50 | expect(parseTag(`an${i}`)).to.deep.equal({ an: i }); 51 | expect(parseTag(`a${i}`)).to.deep.equal({ a: i }); 52 | } 53 | }); 54 | 55 | it('should parse r', () => { 56 | expect(parseTag('r')).to.deep.equal({ r: '' }); 57 | expect(parseTag('rDefault')).to.deep.equal({ r: 'Default' }); 58 | }); 59 | 60 | it('should parse be,blur', () => { 61 | expect(parseTag('be1')).to.deep.equal({ be: 1 }); 62 | expect(parseTag('be2.33')).to.deep.equal({ be: 2.33 }); 63 | expect(parseTag('blur1')).to.deep.equal({ blur: 1 }); 64 | expect(parseTag('blur2.33')).to.deep.equal({ blur: 2.33 }); 65 | }); 66 | 67 | it('should parse fs', () => { 68 | expect(parseTag('fs15')).to.deep.equal({ fs: '15' }); 69 | expect(parseTag('fs+6')).to.deep.equal({ fs: '+6' }); 70 | expect(parseTag('fs-6')).to.deep.equal({ fs: '-6' }); 71 | }); 72 | 73 | it('should parse fsp', () => { 74 | expect(parseTag('fsp0')).to.deep.equal({ fsp: 0 }); 75 | expect(parseTag('fsp5')).to.deep.equal({ fsp: 5 }); 76 | }); 77 | 78 | it('should parse fscx,fscy,fax,fay,frx,fry,frz,fr', () => { 79 | ['fscx', 'fscy', 'fax', 'fay', 'frx', 'fry', 'frz', 'fr'].forEach((tag) => { 80 | expect(parseTag(`${tag}0`)).to.deep.equal({ [tag]: 0 }); 81 | expect(parseTag(`${tag}2.33`)).to.deep.equal({ [tag]: 2.33 }); 82 | expect(parseTag(`${tag}-30`)).to.deep.equal({ [tag]: -30 }); 83 | }); 84 | }); 85 | 86 | it('should parse bord,xbord,ybord,shad,xshad,yshad', () => { 87 | ['bord', 'xbord', 'ybord', 'shad', 'xshad', 'yshad'].forEach((tag) => { 88 | expect(parseTag(`${tag}0`)).to.deep.equal({ [tag]: 0 }); 89 | expect(parseTag(`${tag}2.33`)).to.deep.equal({ [tag]: 2.33 }); 90 | expect(parseTag(`${tag}-3`)).to.deep.equal({ [tag]: -3 }); 91 | }); 92 | }); 93 | 94 | it('should parse c', () => { 95 | expect(parseTag('1c&HFFFFFF&')).to.deep.equal({ c1: 'FFFFFF' }); 96 | expect(parseTag('2c&HFFFFFF&')).to.deep.equal({ c2: 'FFFFFF' }); 97 | expect(parseTag('3c&HFFFFFF&')).to.deep.equal({ c3: 'FFFFFF' }); 98 | expect(parseTag('4c&HFFFFFF&')).to.deep.equal({ c4: 'FFFFFF' }); 99 | expect(parseTag('c&HFFFFFF&')).to.deep.equal({ c1: 'FFFFFF' }); 100 | expect(parseTag('1c&HFFF&')).to.deep.equal({ c1: '000FFF' }); 101 | expect(parseTag('1c&HFFFFFF')).to.deep.equal({ c1: 'FFFFFF' }); 102 | expect(parseTag('1c&FFFFFF')).to.deep.equal({ c1: 'FFFFFF' }); 103 | expect(parseTag('1cFFFFFF')).to.deep.equal({ c1: 'FFFFFF' }); 104 | expect(parseTag('1cHFFFFFF')).to.deep.equal({ c1: 'FFFFFF' }); 105 | expect(parseTag('1c')).to.deep.equal({ c1: '' }); 106 | }); 107 | 108 | it('should parse alpha', () => { 109 | expect(parseTag('1a&HFF&')).to.deep.equal({ a1: 'FF' }); 110 | expect(parseTag('2a&HFF&')).to.deep.equal({ a2: 'FF' }); 111 | expect(parseTag('3a&HFF&')).to.deep.equal({ a3: 'FF' }); 112 | expect(parseTag('4a&HFF&')).to.deep.equal({ a4: 'FF' }); 113 | expect(parseTag('a&HFF&')).to.deep.equal({}); 114 | expect(parseTag('1a&HFF')).to.deep.equal({ a1: 'FF' }); 115 | expect(parseTag('1a&FF')).to.deep.equal({ a1: 'FF' }); 116 | expect(parseTag('1aFF')).to.deep.equal({ a1: 'FF' }); 117 | expect(parseTag('1aHFF')).to.deep.equal({ a1: 'FF' }); 118 | expect(parseTag('1a5')).to.deep.equal({ a1: '05' }); 119 | expect(parseTag('alphaFF')).to.deep.equal({ alpha: 'FF' }); 120 | expect(parseTag('alpha&HFF&')).to.deep.equal({ alpha: 'FF' }); 121 | expect(parseTag('alpha&HFF')).to.deep.equal({ alpha: 'FF' }); 122 | expect(parseTag('alpha&FF')).to.deep.equal({ alpha: 'FF' }); 123 | expect(parseTag('alphaHFF')).to.deep.equal({ alpha: 'FF' }); 124 | expect(parseTag('alpha&HF')).to.deep.equal({ alpha: '0F' }); 125 | expect(parseTag('alpha&H1234')).to.deep.equal({ alpha: '34' }); 126 | expect(parseTag('alpha&H12X34')).to.deep.equal({ alpha: '12' }); 127 | }); 128 | 129 | it('should ignore upper case tags', () => { 130 | expect(parseTag('C')).to.deep.equal({}); 131 | expect(parseTag('1A&HFF&')).to.deep.equal({}); 132 | expect(parseTag('aLpHaFF')).to.deep.equal({}); 133 | }); 134 | 135 | it('should parse pos,org,move,fad,fade', () => { 136 | ['pos', 'org', 'move', 'fad', 'fade'].forEach((tag) => { 137 | expect(parseTag(`${tag}(0,1 ,2, 3)`)).to.deep.equal({ 138 | [tag]: [0, 1, 2, 3], 139 | }); 140 | expect(parseTag(`${tag}( 233,-42 )`)).to.deep.equal({ 141 | [tag]: [233, -42], 142 | }); 143 | }); 144 | }); 145 | 146 | it('should parse clip,iclip', () => { 147 | expect(parseTag('clip(0,1,2,3)')).to.deep.equal({ 148 | clip: { 149 | inverse: false, 150 | scale: 1, 151 | drawing: null, 152 | dots: [0, 1, 2, 3], 153 | }, 154 | }); 155 | expect(parseTag('iclip(0,1,2,3)')).to.deep.equal({ 156 | clip: { 157 | inverse: true, 158 | scale: 1, 159 | drawing: null, 160 | dots: [0, 1, 2, 3], 161 | }, 162 | }); 163 | expect(parseTag('clip(m 0 0 l 1 0 1 1 0 1)')).to.deep.equal({ 164 | clip: { 165 | inverse: false, 166 | scale: 1, 167 | drawing: [ 168 | ['m', '0', '0'], 169 | ['l', '1', '0', '1', '1', '0', '1'], 170 | ], 171 | dots: null, 172 | }, 173 | }); 174 | expect(parseTag('iclip(2, m 0 0 l 1 0 1 1 0 1)')).to.deep.equal({ 175 | clip: { 176 | inverse: true, 177 | scale: 2, 178 | drawing: [ 179 | ['m', '0', '0'], 180 | ['l', '1', '0', '1', '1', '0', '1'], 181 | ], 182 | dots: null, 183 | }, 184 | }); 185 | }); 186 | 187 | it('should parse t', () => { 188 | expect(parseTag('t()')).to.deep.equal({}); 189 | expect(parseTag('t(\\fs20)')).to.deep.equal({ 190 | t: { t1: 0, t2: 0, accel: 1, tags: [{ fs: '20' }] }, 191 | }); 192 | expect(parseTag('t(\\frx30\\fry60)')).to.deep.equal({ 193 | t: { t1: 0, t2: 0, accel: 1, tags: [{ frx: 30 }, { fry: 60 }] }, 194 | }); 195 | expect(parseTag('t(2,\\fs20 )')).to.deep.equal({ 196 | t: { t1: 0, t2: 0, accel: 2, tags: [{ fs: '20' }] }, 197 | }); 198 | expect(parseTag('t( 0,1000,\\fs20)')).to.deep.equal({ 199 | t: { t1: 0, t2: 1000, accel: 1, tags: [{ fs: '20' }] }, 200 | }); 201 | expect(parseTag('t(0, 1000 ,2,\\fs20)')).to.deep.equal({ 202 | t: { t1: 0, t2: 1000, accel: 2, tags: [{ fs: '20' }] }, 203 | }); 204 | expect(parseTag('t(\\clip(0,1,2,3)\\fs20)')).to.deep.equal({ 205 | t: { 206 | t1: 0, 207 | t2: 0, 208 | accel: 1, 209 | tags: [ 210 | { 211 | clip: { 212 | inverse: false, 213 | scale: 1, 214 | drawing: null, 215 | dots: [0, 1, 2, 3], 216 | }, 217 | }, 218 | { fs: '20' }, 219 | ], 220 | }, 221 | }); 222 | }); 223 | 224 | it('should support ignoring closing parentheses', () => { 225 | ['pos', 'org', 'move', 'fad', 'fade'].forEach((tag) => { 226 | expect(parseTag(`${tag}(0,1,2,3`)).to.deep.equal({ 227 | [tag]: [0, 1, 2, 3], 228 | }); 229 | }); 230 | const clip = { 231 | inverse: false, 232 | scale: 1, 233 | drawing: null, 234 | dots: [0, 1, 2, 3], 235 | }; 236 | expect(parseTag('clip(0,1,2,3')).to.deep.equal({ clip }); 237 | expect(parseTag('clip(m 0 0 l 1 0 1 1 0 1')).to.deep.equal({ 238 | clip: { 239 | inverse: false, 240 | scale: 1, 241 | drawing: [ 242 | ['m', '0', '0'], 243 | ['l', '1', '0', '1', '1', '0', '1'], 244 | ], 245 | dots: null, 246 | }, 247 | }); 248 | expect(parseTag('t(2,\\fs20')).to.deep.equal({ 249 | t: { t1: 0, t2: 0, accel: 2, tags: [{ fs: '20' }] }, 250 | }); 251 | expect(parseTag('t(\\clip(0,1,2,3\\fs20').t.tags).to.deep.equal([ 252 | { clip }, 253 | { fs: '20' }, 254 | ]); 255 | expect(parseTag('t(\\fs20\\clip(0,1,2,3').t.tags).to.deep.equal([ 256 | { fs: '20' }, 257 | { clip }, 258 | ]); 259 | }); 260 | 261 | it('should ignore tags without content', () => { 262 | ['pos', 'org', 'move', 'fad', 'fade', 'clip', 'iclip', 't'].forEach((tag) => { 263 | expect(parseTag(`${tag}`)).to.deep.equal({}); 264 | expect(parseTag(`${tag}(`)).to.deep.equal({}); 265 | expect(parseTag(`${tag}()`)).to.deep.equal({}); 266 | }); 267 | }); 268 | }); 269 | -------------------------------------------------------------------------------- /test/parser/tags.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseTags } from '../../src/parser/tags.js'; 3 | 4 | describe('tags parser', () => { 5 | it('should parse tags', () => { 6 | expect(parseTags('\\frx30\\fry60\\frz90')).to.deep.equal([ 7 | { frx: 30 }, 8 | { fry: 60 }, 9 | { frz: 90 }, 10 | ]); 11 | expect(parseTags('\\frx30\\t(\\frx120)')).to.deep.equal([ 12 | { frx: 30 }, 13 | { t: { t1: 0, t2: 0, accel: 1, tags: [{ frx: 120 }] } }, 14 | ]); 15 | expect(parseTags('\\clip(0,0,10,10)\\t(\\clip(5,5,15,15))')).to.deep.equal([ 16 | { 17 | clip: { 18 | inverse: false, scale: 1, drawing: null, dots: [0, 0, 10, 10], 19 | }, 20 | }, 21 | { 22 | t: { 23 | t1: 0, 24 | t2: 0, 25 | accel: 1, 26 | tags: [{ 27 | clip: { 28 | inverse: false, scale: 1, drawing: null, dots: [5, 5, 15, 15], 29 | }, 30 | }], 31 | }, 32 | }, 33 | ]); 34 | }); 35 | 36 | it('should avoid ReDoS', () => { 37 | expect(parseTags('\\foo(11111111111111111111111111111(2(3)2)1)x)')).to.deep.equal([{}]); 38 | }); 39 | 40 | it('should ignore tags not starts with `\\`', () => { 41 | expect(parseTags('Cell phone display')).to.deep.equal([]); 42 | expect(parseTags('ignored\\an5')).to.deep.equal([{ an: 5 }]); 43 | expect(parseTags('\\an5\\')).to.deep.equal([{ an: 5 }]); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/parser/text.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseText } from '../../src/parser/text.js'; 3 | 4 | describe('text parser', () => { 5 | it('should parse text', () => { 6 | expect(parseText('text')).to.deep.equal({ 7 | raw: 'text', 8 | combined: 'text', 9 | parsed: [{ tags: [], text: 'text', drawing: [] }], 10 | }); 11 | }); 12 | 13 | it('should parse text with tags', () => { 14 | expect(parseText('{\\b1}a')).to.deep.equal({ 15 | raw: '{\\b1}a', 16 | combined: 'a', 17 | parsed: [{ tags: [{ b: 1 }], text: 'a', drawing: [] }], 18 | }); 19 | expect(parseText('a{\\i1}b')).to.deep.equal({ 20 | raw: 'a{\\i1}b', 21 | combined: 'ab', 22 | parsed: [ 23 | { tags: [], text: 'a', drawing: [] }, 24 | { tags: [{ i: 1 }], text: 'b', drawing: [] }, 25 | ], 26 | }); 27 | expect(parseText('{\\b1}a{\\i1}b')).to.deep.equal({ 28 | raw: '{\\b1}a{\\i1}b', 29 | combined: 'ab', 30 | parsed: [ 31 | { tags: [{ b: 1 }], text: 'a', drawing: [] }, 32 | { tags: [{ i: 1 }], text: 'b', drawing: [] }, 33 | ], 34 | }); 35 | expect(parseText('{\\b1}a{\\i1}{\\s1}b')).to.deep.equal({ 36 | raw: '{\\b1}a{\\i1}{\\s1}b', 37 | combined: 'ab', 38 | parsed: [ 39 | { tags: [{ b: 1 }], text: 'a', drawing: [] }, 40 | { tags: [{ i: 1 }], text: '', drawing: [] }, 41 | { tags: [{ s: 1 }], text: 'b', drawing: [] }, 42 | ], 43 | }); 44 | }); 45 | 46 | it('should parse text with drawing', () => { 47 | expect(parseText('{\\p1}m 0 0 l 1 0 1 1 0 1{\\p0}')).to.deep.equal({ 48 | raw: '{\\p1}m 0 0 l 1 0 1 1 0 1{\\p0}', 49 | combined: '', 50 | parsed: [ 51 | { 52 | tags: [{ p: 1 }], 53 | text: '', 54 | drawing: [ 55 | ['m', '0', '0'], 56 | ['l', '1', '0', '1', '1', '0', '1'], 57 | ], 58 | }, 59 | { tags: [{ p: 0 }], text: '', drawing: [] }, 60 | ], 61 | }); 62 | }); 63 | 64 | it('should detect whether it is drawing', () => { 65 | expect(parseText('{\\p1\\p0}m 0 0 l 1 0').parsed[0].drawing).to.deep.equal([]); 66 | }); 67 | 68 | it('should handle mismatched brackets', () => { 69 | expect(parseText('{ a { b }c')).to.deep.equal({ 70 | raw: '{ a { b }c', 71 | combined: 'c', 72 | parsed: [{ tags: [], text: 'c', drawing: [] }], 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /test/parser/time.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { parseTime } from '../../src/parser/time.js'; 3 | 4 | describe('time parser', () => { 5 | it('should parse time', () => { 6 | expect(parseTime('0:00:00.00')).to.equal(0); 7 | expect(parseTime('1:23:45.67')).to.equal(5025.67); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/stringifier.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { stringifyTime, stringifyEffect, stringifyEvent, stringifyTag, stringify } from '../src/stringifier.js'; 3 | import { eventsFormat } from '../src/utils.js'; 4 | import { parsed, stringified, parsed2, stringified2 } from './fixtures/stringifier.js'; 5 | 6 | describe('ASS stringifier', () => { 7 | it('should stringify time', () => { 8 | expect(stringifyTime(0)).to.equal('0:00:00.00'); 9 | expect(stringifyTime(15.999)).to.equal('0:00:16.00'); 10 | expect(stringifyTime(5025.67)).to.equal('1:23:45.67'); 11 | }); 12 | 13 | it('should stringify effect', () => { 14 | expect(stringifyEffect({ 15 | name: 'scroll up', 16 | y1: 40, 17 | y2: 320, 18 | delay: 5, 19 | fadeAwayHeight: 80, 20 | })).to.equal('Scroll up;40;320;5;80'); 21 | expect(stringifyEffect({ 22 | name: 'scroll down', 23 | y1: 40, 24 | y2: 320, 25 | delay: 5, 26 | fadeAwayHeight: 80, 27 | })).to.equal('Scroll down;40;320;5;80'); 28 | expect(stringifyEffect({ 29 | name: 'banner', 30 | delay: 5, 31 | leftToRight: 1, 32 | fadeAwayWidth: 80, 33 | })).to.equal('Banner;5;1;80'); 34 | expect(stringifyEffect({ 35 | name: 'unknown', 36 | })).to.equal('unknown'); 37 | }); 38 | 39 | it('should stringify event', () => { 40 | expect(stringifyEvent({ 41 | Layer: 0, 42 | Start: 0, 43 | End: 5, 44 | Style: 'Default', 45 | Name: '', 46 | MarginL: 0, 47 | MarginR: 0, 48 | MarginV: 0, 49 | Effect: null, 50 | Text: { 51 | raw: 'text', 52 | combined: 'text', 53 | parsed: [{ tags: [], text: 'text', drawing: [] }], 54 | }, 55 | }, eventsFormat)).to.equal('0,0:00:00.00,0:00:05.00,Default,,0000,0000,0000,,text'); 56 | }); 57 | 58 | it('should stringify ASS', () => { 59 | expect(stringify(parsed)).to.equal(stringified); 60 | }); 61 | 62 | it('should stringify style format from source', () => { 63 | expect(stringify(parsed2)).to.equal(stringified2); 64 | }); 65 | 66 | describe('tag stringifier', () => { 67 | it('should stringify tag pos,org,move,fad,fade', () => { 68 | expect(stringifyTag({ pos: [1, 2] })).to.deep.equal('\\pos(1,2)'); 69 | expect(stringifyTag({ org: [3, 4] })).to.deep.equal('\\org(3,4)'); 70 | expect(stringifyTag({ move: [1, 2, 3, 4] })).to.deep.equal('\\move(1,2,3,4)'); 71 | expect(stringifyTag({ fad: [1, 2] })).to.deep.equal('\\fad(1,2)'); 72 | expect(stringifyTag({ fade: [1, 2, 3, 4, 5, 6, 7] })).to.deep.equal('\\fade(1,2,3,4,5,6,7)'); 73 | }); 74 | 75 | it('should stringify tag 1c,2c,3c,4c,1a,2a,3a,4a,alpha', () => { 76 | expect(stringifyTag({ c1: '111111' })).to.deep.equal('\\1c&H111111&'); 77 | expect(stringifyTag({ c2: '222222' })).to.deep.equal('\\2c&H222222&'); 78 | expect(stringifyTag({ c3: '333333' })).to.deep.equal('\\3c&H333333&'); 79 | expect(stringifyTag({ c4: '444444' })).to.deep.equal('\\4c&H444444&'); 80 | expect(stringifyTag({ a1: '11' })).to.deep.equal('\\1a&H11&'); 81 | expect(stringifyTag({ a2: '22' })).to.deep.equal('\\2a&H22&'); 82 | expect(stringifyTag({ a3: '33' })).to.deep.equal('\\3a&H33&'); 83 | expect(stringifyTag({ a4: '44' })).to.deep.equal('\\4a&H44&'); 84 | expect(stringifyTag({ alpha: '55' })).to.deep.equal('\\alpha&H55&'); 85 | }); 86 | 87 | it('should stringify clip,iclip', () => { 88 | expect(stringifyTag({ 89 | clip: { 90 | inverse: false, 91 | scale: 1, 92 | drawing: null, 93 | dots: [1, 2, 3, 4], 94 | }, 95 | })).to.deep.equal('\\clip(1,2,3,4)'); 96 | expect(stringifyTag({ 97 | clip: { 98 | inverse: true, 99 | scale: 1, 100 | drawing: null, 101 | dots: [1, 2, 3, 4], 102 | }, 103 | })).to.deep.equal('\\iclip(1,2,3,4)'); 104 | expect(stringifyTag({ 105 | clip: { 106 | inverse: false, 107 | scale: 1, 108 | drawing: [['m', '0', '0'], ['l', '1', '1']], 109 | dots: null, 110 | }, 111 | })).to.deep.equal('\\clip(m 0 0 l 1 1)'); 112 | expect(stringifyTag({ 113 | clip: { 114 | inverse: true, 115 | scale: 2, 116 | drawing: [['m', '0', '0'], ['l', '1', '1']], 117 | dots: null, 118 | }, 119 | })).to.deep.equal('\\iclip(2,m 0 0 l 1 1)'); 120 | }); 121 | 122 | it('should stringify t', () => { 123 | expect(stringifyTag({ 124 | t: { 125 | t1: 1, 126 | t2: 2, 127 | accel: 3, 128 | tags: [ 129 | { fs: 20 }, 130 | { 131 | clip: { 132 | inverse: false, 133 | scale: 1, 134 | drawing: null, 135 | dots: [1, 2, 3, 4], 136 | }, 137 | }, 138 | ], 139 | }, 140 | })).to.deep.equal('\\t(1,2,3,\\fs20\\clip(1,2,3,4))'); 141 | }); 142 | 143 | it('should ignore invalid tag', () => { 144 | expect(stringifyTag({})).to.deep.equal(''); 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ParsedTag, CompiledTag } from './tags'; 2 | 3 | interface ScriptInfo { 4 | Title: string; 5 | ScriptType: 'V4.00' | 'V4.00+' | string; 6 | WrapStyle: '0' | '1' | '2' | '3'; 7 | PlayResX: string; 8 | PlayResY: string; 9 | ScaledBorderAndShadow: 'yes' | 'no'; 10 | Collisions: 'Normal' | 'Reverse'; 11 | [name: string]: string; 12 | } 13 | 14 | interface ParsedASSStyles { 15 | format: string[]; 16 | style: { 17 | Name: string; 18 | Fontname: string; 19 | Fontsize: string; 20 | PrimaryColour: string; 21 | SecondaryColour: string; 22 | OutlineColour: string; 23 | BackColour: string; 24 | Bold: string; 25 | Italic: string; 26 | Underline: string; 27 | StrikeOut: string; 28 | ScaleX: string; 29 | ScaleY: string; 30 | Spacing: string; 31 | Angle: string; 32 | BorderStyle: string; 33 | Outline: string; 34 | Shadow: string; 35 | Alignment: string; 36 | MarginL: string; 37 | MarginR: string; 38 | MarginV: string; 39 | Encoding: string; 40 | }[]; 41 | } 42 | 43 | interface ParsedASSEventTextParsed { 44 | tags: { [K in keyof ParsedTag]: ParsedTag[K]; }[]; 45 | text: string; 46 | drawing: string[][]; 47 | } 48 | 49 | interface EffectBanner { 50 | name: 'banner'; 51 | delay: number; 52 | leftToRight: number; 53 | fadeAwayWidth: number; 54 | } 55 | 56 | interface EffectScroll { 57 | name: 'scroll up' | 'scroll down'; 58 | y1: number; 59 | y2: number; 60 | delay: number; 61 | fadeAwayHeight: number; 62 | } 63 | 64 | interface EffectUnknown { 65 | name: string; 66 | } 67 | 68 | interface ParsedASSEventText { 69 | raw: string; 70 | combined: string; 71 | parsed: ParsedASSEventTextParsed[]; 72 | } 73 | 74 | interface ParsedASSEvent { 75 | Layer: number; 76 | Start: number; 77 | End: number; 78 | Style: string; 79 | Name: string; 80 | MarginL: number; 81 | MarginR: number; 82 | MarginV: number; 83 | Effect?: EffectBanner | EffectScroll | EffectUnknown; 84 | Text: ParsedASSEventText; 85 | } 86 | 87 | interface ParsedASSEvents { 88 | format: string[]; 89 | comment: ParsedASSEvent[]; 90 | dialogue: ParsedASSEvent[]; 91 | } 92 | 93 | export interface ParsedASS { 94 | info: ScriptInfo; 95 | styles: ParsedASSStyles; 96 | events: ParsedASSEvents; 97 | } 98 | 99 | /** 100 | * Parse ASS string. 101 | * @param text 102 | */ 103 | export function parse(text: string): ParsedASS; 104 | 105 | export function stringify(obj: ParsedASS): string; 106 | 107 | export interface CompiledASSStyleTag { 108 | fn: string; 109 | fs: number; 110 | c1: string; 111 | a1: string; 112 | c2: string; 113 | a2: string; 114 | c3: string; 115 | a3: string; 116 | c4: string; 117 | a4: string; 118 | b: 0 | 1; 119 | i: 0 | 1; 120 | u: 0 | 1; 121 | s: 0 | 1; 122 | fscx: number; 123 | fscy: number; 124 | fsp: number; 125 | frz: number; 126 | xbord: number; 127 | ybord: number; 128 | xshad: number; 129 | yshad: number; 130 | fe: number; 131 | // TODO: [breaking change] delete `q` 132 | q: 0 | 1 | 2 | 3; 133 | } 134 | 135 | export interface CompiledASSStyle { 136 | style: { 137 | Name: string; 138 | Fontname: string; 139 | Fontsize: number; 140 | PrimaryColour: string; 141 | SecondaryColour: string; 142 | OutlineColour: string; 143 | BackColour: string; 144 | Bold: -1 | 0; 145 | Italic: -1 | 0; 146 | Underline: -1 | 0; 147 | StrikeOut: -1 | 0; 148 | ScaleX: number; 149 | ScaleY: number; 150 | Spacing: number; 151 | Angle: number; 152 | BorderStyle: 1 | 3; 153 | Outline: number; 154 | Shadow: number; 155 | Alignment: number; 156 | MarginL: number; 157 | MarginR: number; 158 | MarginV: number; 159 | Encoding: number; 160 | } 161 | tag: CompiledASSStyleTag; 162 | } 163 | 164 | export interface DialogueDrawingInstruction { 165 | type: 'M' | 'L' | 'C'; 166 | points: { 167 | x: number; 168 | y: number; 169 | }[]; 170 | } 171 | 172 | export interface DialogueDrawing { 173 | instructions: DialogueDrawingInstruction[]; 174 | d: string; 175 | minX: number; 176 | minY: number; 177 | width: number; 178 | height: number; 179 | } 180 | 181 | export interface DialogueFragment { 182 | tag: CompiledTag; 183 | text: string; 184 | drawing?: DialogueDrawing; 185 | } 186 | 187 | export interface DialogueSlice { 188 | style: string; 189 | fragments: DialogueFragment[]; 190 | } 191 | 192 | export interface Dialogue { 193 | layer: number; 194 | start: number; 195 | end: number; 196 | style: string; 197 | name: string; 198 | margin: { 199 | left: number; 200 | right: number; 201 | vertical: number; 202 | } 203 | effect?: EffectBanner | EffectScroll | EffectUnknown; 204 | alignment: number; 205 | q: 0 | 1 | 2 | 3; 206 | slices: DialogueSlice[]; 207 | pos?: { 208 | x: number; 209 | y: number; 210 | }; 211 | org?: { 212 | x: number; 213 | y: number; 214 | }; 215 | move?: { 216 | x1: number; 217 | y1: number; 218 | x2: number; 219 | y2: number; 220 | t1: number; 221 | t2: number; 222 | }; 223 | fade?: { 224 | type: 'fad'; 225 | t1: number; 226 | t2: number; 227 | } | { 228 | type: 'fade'; 229 | a1: number; 230 | a2: number; 231 | a3: number; 232 | t1: number; 233 | t2: number; 234 | t3: number; 235 | t4: number; 236 | }; 237 | clip?: { 238 | inverse: boolean; 239 | scale: number; 240 | drawing?: DialogueDrawing; 241 | dots?: { 242 | x1: number; 243 | y1: number; 244 | x2: number; 245 | y2: number; 246 | } 247 | } 248 | } 249 | 250 | export interface CompiledASS { 251 | info: ScriptInfo; 252 | width: number; 253 | height: number; 254 | collisions: 'Normal' | 'Reverse'; 255 | wrapStyle: 0 | 1 | 2 | 3; 256 | styles: { [styleName: string]: CompiledASSStyle }; 257 | dialogues: Dialogue[]; 258 | } 259 | 260 | export function compile(text: string, options: object): CompiledASS; 261 | 262 | export function decompile(obj: CompiledASS): string; 263 | -------------------------------------------------------------------------------- /types/tags.d.ts: -------------------------------------------------------------------------------- 1 | export interface ParsedTag { 2 | a?: 0 | 1 | 2 | 3 | 5 | 6 | 7 | 9 | 10 | 11; 3 | a1?: string; 4 | a2?: string; 5 | a3?: string; 6 | a4?: string; 7 | alpha?: string; 8 | an?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; 9 | b?: 0 | 1; 10 | be?: number; 11 | blur?: number; 12 | bord?: number; 13 | c1?: string; 14 | c2?: string; 15 | c3?: string; 16 | c4?: string; 17 | clip?: { 18 | inverse: boolean; 19 | scale: number; 20 | drawing?: string[][]; 21 | dots?: [number, number, number, number]; 22 | } 23 | fad?: [number, number]; 24 | fade?: [number, number, number, number, number, number]; 25 | fax?: number; 26 | fay?: number; 27 | fe?: number; 28 | fn?: string; 29 | fr?: number; 30 | frx?: number; 31 | fry?: number; 32 | frz?: number; 33 | fs?: string; 34 | fscx?: number; 35 | fscy?: number; 36 | fsp?: number; 37 | i?: 0 | 1; 38 | k?: number; 39 | kf?: number; 40 | ko?: number; 41 | kt?: number; 42 | K?: number; 43 | move?: [number, number, number, number] | [number, number, number, number, number, number]; 44 | org?: [number, number]; 45 | p?: number; 46 | pbo?: number; 47 | pos?: [number, number]; 48 | q?: 0 | 1 | 2 | 3; 49 | r?: string; 50 | s?: 0 | 1; 51 | shad?: number; 52 | t?: { 53 | t1: number; 54 | t2: number; 55 | accel: number; 56 | tags: { [K in keyof ParsedTag]: ParsedTag[K]; }[]; 57 | } 58 | u?: 0 | 1; 59 | xbord?: number; 60 | xshad?: number; 61 | ybord?: number; 62 | yshad?: number; 63 | } 64 | 65 | export interface CompiledTag { 66 | a1?: string; 67 | a2?: string; 68 | a3?: string; 69 | a4?: string; 70 | an?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; 71 | b?: 0 | 1; 72 | be?: number; 73 | blur?: number; 74 | c1?: string; 75 | c2?: string; 76 | c3?: string; 77 | c4?: string; 78 | fax?: number; 79 | fay?: number; 80 | fe?: number; 81 | fn?: string; 82 | frx?: number; 83 | fry?: number; 84 | frz?: number; 85 | fs?: number; 86 | fscx?: number; 87 | fscy?: number; 88 | fsp?: number; 89 | i?: 0 | 1; 90 | k?: number; 91 | kf?: number; 92 | ko?: number; 93 | kt?: number; 94 | p?: number; 95 | pbo?: number; 96 | q?: 0 | 1 | 2 | 3; 97 | s?: 0 | 1; 98 | t?: { 99 | t1: number; 100 | t2: number; 101 | accel: number; 102 | tag: CompiledTag; 103 | }[]; 104 | u?: 0 | 1; 105 | xbord?: number; 106 | xshad?: number; 107 | ybord?: number; 108 | yshad?: number; 109 | } 110 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: 'test/**/*.js', 6 | exclude: 'test/fixtures/*.js', 7 | }, 8 | }); 9 | --------------------------------------------------------------------------------