├── .deepsource.toml
├── .eslintignore
├── .eslintrc
├── .github
└── workflows
│ ├── deploy-docs.yml
│ ├── lint-and-test.yml
│ └── release-please.yml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .vscode
└── settings.json
├── .whitesource
├── CHANGELOG.md
├── LICENSE.txt
├── README.md
├── app.js
├── bin
└── subsrt.js
├── jest.config.cjs
├── lib
├── format
│ ├── ass.ts
│ ├── index.ts
│ ├── json.ts
│ ├── lrc.ts
│ ├── sbv.ts
│ ├── smi.ts
│ ├── srt.ts
│ ├── ssa.ts
│ ├── sub.ts
│ ├── types
│ │ ├── smi.ts
│ │ └── sub.ts
│ └── vtt.ts
├── handler.ts
├── subsrt.ts
└── types
│ ├── handler.ts
│ └── subsrt.ts
├── package.json
├── pnpm-lock.yaml
├── renovate.json
├── subsrt.bat
├── test
├── build.test.ts
├── convert.test.ts
├── customFormat.test.ts
├── detect.test.ts
├── fixtures
│ ├── sample.ass
│ ├── sample.json
│ ├── sample.lrc
│ ├── sample.sbv
│ ├── sample.smi
│ ├── sample.srt
│ ├── sample.ssa
│ ├── sample.sub
│ └── sample.vtt
├── parse.test.ts
├── resync.test.ts
└── time.test.ts
├── tsconfig.eslint.json
└── tsconfig.json
/.deepsource.toml:
--------------------------------------------------------------------------------
1 | version = 1
2 |
3 | [[analyzers]]
4 | name = "test-coverage"
5 |
6 | [[analyzers]]
7 | name = "secrets"
8 |
9 | [[analyzers]]
10 | name = "javascript"
11 |
12 | [analyzers.meta]
13 | environment = ["nodejs"]
14 |
15 | [[transformers]]
16 | name = "prettier"
17 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 | pnpm-lock.yaml
5 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "es2017": true,
4 | "es2020": true,
5 | "es6": true,
6 | "node": true
7 | },
8 | "extends": ["eslint:recommended", "plugin:import/recommended", "plugin:regexp/recommended", "prettier"],
9 | "overrides": [
10 | {
11 | "env": {
12 | "jest/globals": true
13 | },
14 | "extends": ["plugin:jest/recommended", "plugin:jest/style"],
15 | "files": ["test/*.test.ts"],
16 | "plugins": ["jest"]
17 | },
18 | {
19 | "extends": [
20 | "plugin:@typescript-eslint/recommended-type-checked",
21 | "plugin:@typescript-eslint/stylistic-type-checked",
22 | "plugin:import/typescript"
23 | ],
24 | "files": ["**/*.ts"]
25 | }
26 | ],
27 | "parser": "@typescript-eslint/parser",
28 | "parserOptions": {
29 | "ecmaVersion": "latest",
30 | "project": "./tsconfig.eslint.json",
31 | "sourceType": "module"
32 | },
33 | "plugins": ["@typescript-eslint", "regexp", "eslint-plugin-tsdoc"],
34 | "rules": {
35 | "@typescript-eslint/no-unused-vars": [
36 | "error",
37 | {
38 | "argsIgnorePattern": "^_",
39 | "varsIgnorePattern": "^_"
40 | }
41 | ],
42 | "curly": "error",
43 | "dot-notation": "error",
44 | "eqeqeq": "error",
45 | "import/order": [
46 | "error",
47 | {
48 | "alphabetize": {
49 | "caseInsensitive": true,
50 | "order": "asc"
51 | },
52 | "groups": ["builtin", "external", "internal", "parent", "sibling", "index"],
53 | "newlines-between": "always"
54 | }
55 | ],
56 | "no-else-return": "error",
57 | "no-empty": [
58 | "error",
59 | {
60 | "allowEmptyCatch": true
61 | }
62 | ],
63 | "no-extra-bind": "error",
64 | "no-labels": "error",
65 | "no-lone-blocks": "error",
66 | "no-loop-func": "error",
67 | "no-new-func": "error",
68 | "no-new-object": "error",
69 | "no-new-wrappers": "error",
70 | "no-param-reassign": "error",
71 | "no-redeclare": "error",
72 | "no-template-curly-in-string": "error",
73 | "no-unreachable": "error",
74 | "no-useless-constructor": "error",
75 | "prefer-arrow-callback": "error",
76 | "prefer-exponentiation-operator": "error",
77 | "prefer-template": "error",
78 | "quotes": [
79 | "error",
80 | "double",
81 | {
82 | "avoidEscape": true
83 | }
84 | ],
85 | "regexp/no-super-linear-backtracking": "warn",
86 | "require-atomic-updates": "error",
87 | "require-await": "error",
88 | "sort-imports": [
89 | "error",
90 | {
91 | "ignoreCase": true,
92 | "ignoreDeclarationSort": true
93 | }
94 | ],
95 | "tsdoc/syntax": "warn"
96 | },
97 | "settings": {
98 | "import/parsers": {
99 | "@typescript-eslint/parser": [".ts", ".tsx"]
100 | },
101 | "import/resolver": {
102 | "typescript": {
103 | "alwaysTryTypes": true
104 | }
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-docs.yml:
--------------------------------------------------------------------------------
1 | name: Build and deploy docs
2 | on:
3 | push:
4 | branches:
5 | - main
6 | paths:
7 | - "**.js"
8 | - "**.ts"
9 |
10 | permissions:
11 | pages: write
12 | id-token: write
13 |
14 | concurrency:
15 | group: "pages"
16 | cancel-in-progress: false
17 |
18 | jobs:
19 | build:
20 | name: Build docs
21 | runs-on: ubuntu-latest
22 | steps:
23 | - name: Check out Git repository
24 | uses: actions/checkout@v4
25 |
26 | - name: Set up pnpm
27 | uses: pnpm/action-setup@v2
28 | with:
29 | version: latest
30 |
31 | - name: Set up Node.js
32 | uses: actions/setup-node@v4
33 | with:
34 | node-version: latest
35 | cache: "pnpm"
36 |
37 | - name: Setup Pages
38 | uses: actions/configure-pages@v5
39 |
40 | - name: Install Node.js dependencies
41 | run: pnpm install --frozen-lockfile
42 |
43 | - name: Build docs
44 | run: pnpm run docs
45 |
46 | - name: Upload artifact
47 | uses: actions/upload-pages-artifact@v3
48 | with:
49 | path: docs/
50 |
51 | deploy:
52 | name: Deploy docs
53 | environment:
54 | name: github-pages
55 | url: ${{ steps.deployment.outputs.page_url }}
56 | runs-on: ubuntu-latest
57 | needs: build
58 | steps:
59 | - name: Deploy to GitHub Pages
60 | id: deployment
61 | uses: actions/deploy-pages@v4
62 |
--------------------------------------------------------------------------------
/.github/workflows/lint-and-test.yml:
--------------------------------------------------------------------------------
1 | name: Lint and test
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 | run-linters-and-tests:
9 | name: Run linters and tests
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Check out Git repository
14 | uses: actions/checkout@v4
15 |
16 | - name: Set up pnpm
17 | uses: pnpm/action-setup@v2
18 | with:
19 | version: latest
20 |
21 | - name: Set up Node.js
22 | uses: actions/setup-node@v4
23 | with:
24 | node-version: latest
25 | cache: "pnpm"
26 |
27 | - name: Install Node.js dependencies
28 | run: pnpm install --frozen-lockfile
29 |
30 | - name: Run linters
31 | uses: wearerequired/lint-action@v2
32 | with:
33 | continue_on_error: false
34 | eslint: true
35 | eslint_extensions: js,ts
36 | prettier: true
37 |
38 | - name: Run tests
39 | run: pnpm run testCoverage
40 |
41 | - name: Report test coverage to DeepSource
42 | run: |
43 | curl https://deepsource.io/cli | sh
44 |
45 | ./bin/deepsource report --analyzer test-coverage --key javascript --value-file ./coverage/cobertura-coverage.xml
46 | env:
47 | DEEPSOURCE_DSN: ${{ secrets.DEEPSOURCE_DSN }}
48 |
--------------------------------------------------------------------------------
/.github/workflows/release-please.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - main
5 |
6 | permissions:
7 | contents: write
8 | pull-requests: write
9 |
10 | name: Release Please
11 |
12 | jobs:
13 | release-please:
14 | name: Release Please
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Run Release Please
18 | id: release
19 | uses: google-github-actions/release-please-action@v3
20 | with:
21 | release-type: node
22 | package-name: subsrt-ts
23 |
24 | # The logic below handles the npm publication:
25 | - name: Check out Git repository
26 | uses: actions/checkout@v4
27 | # these if statements ensure that a publication only occurs when
28 | # a new release is created:
29 | if: ${{ steps.release.outputs.release_created }}
30 |
31 | - name: Set up pnpm
32 | uses: pnpm/action-setup@v2
33 | with:
34 | version: latest
35 |
36 | - name: Set up Node.js
37 | uses: actions/setup-node@v4
38 | with:
39 | node-version: latest
40 | registry-url: "https://registry.npmjs.org"
41 | cache: "pnpm"
42 | if: ${{ steps.release.outputs.release_created }}
43 |
44 | - name: Install Node.js dependencies
45 | run: pnpm install --frozen-lockfile
46 | if: ${{ steps.release.outputs.release_created }}
47 |
48 | - name: Publish to npm
49 | run: pnpm publish
50 | env:
51 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
52 | if: ${{ steps.release.outputs.release_created }}
53 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
2 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node
3 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,node
4 |
5 | ### macOS ###
6 | # General
7 | .DS_Store
8 | .AppleDouble
9 | .LSOverride
10 |
11 | # Icon must end with two \r
12 | Icon
13 |
14 |
15 | # Thumbnails
16 | ._*
17 |
18 | # Files that might appear in the root of a volume
19 | .DocumentRevisions-V100
20 | .fseventsd
21 | .Spotlight-V100
22 | .TemporaryItems
23 | .Trashes
24 | .VolumeIcon.icns
25 | .com.apple.timemachine.donotpresent
26 |
27 | # Directories potentially created on remote AFP share
28 | .AppleDB
29 | .AppleDesktop
30 | Network Trash Folder
31 | Temporary Items
32 | .apdisk
33 |
34 | ### macOS Patch ###
35 | # iCloud generated files
36 | *.icloud
37 |
38 | ### Node ###
39 | # Logs
40 | logs
41 | *.log
42 | npm-debug.log*
43 | yarn-debug.log*
44 | yarn-error.log*
45 | lerna-debug.log*
46 | .pnpm-debug.log*
47 |
48 | # Diagnostic reports (https://nodejs.org/api/report.html)
49 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
50 |
51 | # Runtime data
52 | pids
53 | *.pid
54 | *.seed
55 | *.pid.lock
56 |
57 | # Directory for instrumented libs generated by jscoverage/JSCover
58 | lib-cov
59 |
60 | # Coverage directory used by tools like istanbul
61 | coverage
62 | *.lcov
63 |
64 | # nyc test coverage
65 | .nyc_output
66 |
67 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
68 | .grunt
69 |
70 | # Bower dependency directory (https://bower.io/)
71 | bower_components
72 |
73 | # node-waf configuration
74 | .lock-wscript
75 |
76 | # Compiled binary addons (https://nodejs.org/api/addons.html)
77 | build/Release
78 |
79 | # Dependency directories
80 | node_modules/
81 | jspm_packages/
82 |
83 | # Snowpack dependency directory (https://snowpack.dev/)
84 | web_modules/
85 |
86 | # TypeScript cache
87 | *.tsbuildinfo
88 |
89 | # Optional npm cache directory
90 | .npm
91 |
92 | # Optional eslint cache
93 | .eslintcache
94 |
95 | # Optional stylelint cache
96 | .stylelintcache
97 |
98 | # Microbundle cache
99 | .rpt2_cache/
100 | .rts2_cache_cjs/
101 | .rts2_cache_es/
102 | .rts2_cache_umd/
103 |
104 | # Optional REPL history
105 | .node_repl_history
106 |
107 | # Output of 'npm pack'
108 | *.tgz
109 |
110 | # Yarn Integrity file
111 | .yarn-integrity
112 |
113 | # dotenv environment variable files
114 | .env
115 | .env.development.local
116 | .env.test.local
117 | .env.production.local
118 | .env.local
119 |
120 | # parcel-bundler cache (https://parceljs.org/)
121 | .cache
122 | .parcel-cache
123 |
124 | # Next.js build output
125 | .next
126 | out
127 |
128 | # Nuxt.js build / generate output
129 | .nuxt
130 | dist
131 |
132 | # Gatsby files
133 | .cache/
134 | # Comment in the public line in if your project uses Gatsby and not Next.js
135 | # https://nextjs.org/blog/next-9-1#public-directory-support
136 | # public
137 |
138 | # vuepress build output
139 | .vuepress/dist
140 |
141 | # vuepress v2.x temp and cache directory
142 | .temp
143 |
144 | # Docusaurus cache and generated files
145 | .docusaurus
146 |
147 | # Serverless directories
148 | .serverless/
149 |
150 | # FuseBox cache
151 | .fusebox/
152 |
153 | # DynamoDB Local files
154 | .dynamodb/
155 |
156 | # TernJS port file
157 | .tern-port
158 |
159 | # Stores VSCode versions used for testing VSCode extensions
160 | .vscode-test
161 |
162 | # yarn v2
163 | .yarn/cache
164 | .yarn/unplugged
165 | .yarn/build-state.yml
166 | .yarn/install-state.gz
167 | .pnp.*
168 |
169 | ### Node Patch ###
170 | # Serverless Webpack directories
171 | .webpack/
172 |
173 | # Optional stylelint cache
174 |
175 | # SvelteKit build / generate output
176 | .svelte-kit
177 |
178 | ### VisualStudioCode ###
179 | .vscode/*
180 | !.vscode/settings.json
181 | !.vscode/tasks.json
182 | !.vscode/launch.json
183 | !.vscode/extensions.json
184 | !.vscode/*.code-snippets
185 |
186 | # Local History for Visual Studio Code
187 | .history/
188 |
189 | # Built Visual Studio Code Extensions
190 | *.vsix
191 |
192 | ### VisualStudioCode Patch ###
193 | # Ignore all local history of files
194 | .history
195 | .ionide
196 |
197 | # Support for Project snippet scope
198 | .vscode/*.code-snippets
199 |
200 | # Ignore code-workspaces
201 | *.code-workspace
202 |
203 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node
204 |
205 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
206 |
207 | # Logs
208 | logs
209 | *.log
210 | npm-debug.log*
211 |
212 | # Runtime data
213 | pids
214 | *.pid
215 | *.seed
216 |
217 | # Directory for instrumented libs generated by jscoverage/JSCover
218 | lib-cov
219 |
220 | # Coverage directory used by tools like istanbul
221 | coverage
222 |
223 | # nyc test coverage
224 | .nyc_output
225 |
226 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
227 | .grunt
228 |
229 | # node-waf configuration
230 | .lock-wscript
231 |
232 | # Compiled binary addons (http://nodejs.org/api/addons.html)
233 | build/Release
234 |
235 | # Dependency directories
236 | node_modules
237 | jspm_packages
238 |
239 | # npm
240 | package-lock.json
241 |
242 | # Optional npm cache directory
243 | .npm
244 |
245 | # Optional REPL history
246 | .node_repl_history
247 |
248 | # Notes
249 | .docs
250 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /dist
4 | pnpm-lock.yaml
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 150,
3 | "tabWidth": 4,
4 | "trailingComma": "all",
5 | "overrides": [
6 | {
7 | "files": ".*rc",
8 | "options": {
9 | "trailingComma": "none"
10 | }
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[json]": {
3 | "editor.defaultFormatter": "esbenp.prettier-vscode"
4 | },
5 | "[jsonc]": {
6 | "editor.defaultFormatter": "esbenp.prettier-vscode"
7 | },
8 | "[markdown]": {
9 | "editor.defaultFormatter": "esbenp.prettier-vscode"
10 | },
11 | "editor.codeActionsOnSave": ["source.fixAll.eslint"],
12 | "editor.defaultFormatter": "esbenp.prettier-vscode",
13 | "editor.formatOnSave": true,
14 | "markdownlint.config": {
15 | "MD007": {
16 | "indent": 4
17 | },
18 | "MD030": {
19 | "ul_multi": 3,
20 | "ul_single": 3
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/.whitesource:
--------------------------------------------------------------------------------
1 | {
2 | "scanSettings": {
3 | "baseBranches": []
4 | },
5 | "checkRunSettings": {
6 | "vulnerableCheckRunConclusionLevel": "failure",
7 | "displayMode": "diff",
8 | "useMendCheckNames": true
9 | },
10 | "issueSettings": {
11 | "minSeverityLevel": "LOW",
12 | "issueType": "DEPENDENCY"
13 | }
14 | }
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [2.1.2](https://github.com/rakuzen25/subsrt-ts/compare/v2.1.1...v2.1.2) (2023-10-17)
4 |
5 | ### Bug Fixes
6 |
7 | - apply new eslint recommendations ([3b2fb33](https://github.com/rakuzen25/subsrt-ts/commit/3b2fb338d8d59652702721305bd4aa2ca97bdca9))
8 |
9 | ## [2.1.1](https://github.com/rakuzen25/subsrt-ts/compare/2.1.0...v2.1.1) (2023-04-27)
10 |
11 | ### Bug Fixes
12 |
13 | - **vtt:** multiline support ([#3](https://github.com/rakuzen25/subsrt-ts/issues/3)) ([702c8a4](https://github.com/rakuzen25/subsrt-ts/commit/702c8a4c9284bf8d5a6d30f926858948a80d5ae4))
14 | - **vtt:** remove replacement of new lines in caption content ([#5](https://github.com/rakuzen25/subsrt-ts/issues/5)) ([7cdf968](https://github.com/rakuzen25/subsrt-ts/commit/7cdf968b28f0c6f72e296c48b579e1906b8f8ad8))
15 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017
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 |
2 |
3 | # subsrt-ts
4 |
5 |
6 |
7 | [](https://www.npmjs.com/package/subsrt-ts)
8 | [](https://www.npmjs.com/package/subsrt-ts)
9 | [](https://github.com/rakuzen25/subsrt-ts/actions/workflows/lint-and-test.yml)
10 | [](https://app.deepsource.com/gh/rakuzen25/subsrt-ts/?ref=repository-badge)
11 |
12 |
13 | Docs
14 | ·
15 | npm package
16 |
17 |
18 | Subtitle JavaScript library and command line tool with no dependencies.
19 |
20 | This is a rewrite of the original [subsrt](https://www.npmjs.com/package/subsrt) package in TypeScript and using ESM syntax.
21 |
22 |
23 | Table of Contents
24 |
25 | - Getting started
26 | - Supported subtitle formats
27 | - Command line arguments
28 | -
29 | Using in JavaScript
30 |
40 |
41 | - Source code
42 | - License
43 |
44 |
45 |
46 | ## Getting started
47 |
48 | Install the module
49 |
50 | ```console
51 | npm install -g subsrt
52 | ```
53 |
54 | Command line
55 |
56 | ```console
57 | subsrt --help
58 | subsrt convert sample.sub sample.srt
59 | ```
60 |
61 | Using as Node.js library
62 |
63 | ```javascript
64 | import subsrt from "subsrt";
65 |
66 | // MicroDVD (.sub) content
67 | const sub = "{14975}{104000}Hi, my name is...";
68 |
69 | // Convert to SubRip (.srt) content
70 | const srt = subsrt.convert(sub, { format: "srt", fps: 25 });
71 | ```
72 |
73 | (back to top)
74 |
75 | ## Supported subtitle formats
76 |
77 | - [MicroDVD SUB](https://en.wikipedia.org/wiki/MicroDVD) (.sub)
78 | - [SubRip](https://en.wikipedia.org/wiki/SubRip) (.srt)
79 | - [SubViewer](https://en.wikipedia.org/wiki/SubViewer) (.sbv)
80 | - [WebVTT](https://w3c.github.io/webvtt/) (.vtt)
81 | - [SubStation Alpha](https://en.wikipedia.org/wiki/SubStation_Alpha) (.ssa and .ass)
82 | - [SAMI](https://en.wikipedia.org/wiki/SAMI) (.smi) aka Synchronized Accessible Media Interchange
83 | - [LRC](https://en.wikipedia.org/wiki/LRC_%28file_format%29) (.lrc) aka LyRiCs
84 | - JSON (.json)
85 |
86 | (back to top)
87 |
88 | ## Command line arguments
89 |
90 | ```text
91 | Usage:
92 | subsrt [command] [options]
93 |
94 | Commands:
95 | list List supported formats
96 | parse [src] [json] Parse a subtitle file
97 | build [json] [dst] Create a subtitle file from captions
98 | detect [src] Detect subtitle file format, if supported
99 | resync [src] [dst] Resync FPS or shift time (+/- offset)
100 | convert [src] [dst] Converts a subtitle format
101 |
102 | Options:
103 | --help Print this message
104 | --eol [chars] End of line chars, e.g. \r\n
105 | --fps [fps] Frames per second for .sub format
106 | --offset [time] Resync time shift offset in ms
107 | --format [ext] Subtitle format to convert/build/parse
108 | --verbose Enable detailed logging
109 | --version Print version number
110 |
111 | Examples:
112 | subsrt parse sample.sbv
113 | subsrt parse sample.srt output.json
114 | subsrt parse sample.sub --fps 30
115 | subsrt build input.json output.vtt
116 | subsrt build input.json --format sbv
117 | subsrt detect unknown.txt
118 | subsrt convert sample.srt sample.vtt
119 | subsrt convert --offset -250 sample.srt sample.ssa
120 | subsrt resync --offset +3000 input.srt output.srt
121 | subsrt resync --fps 25-30 input.sub output.sub
122 | ```
123 |
124 | (back to top)
125 |
126 | ## Using in JavaScript
127 |
128 | The Node.js library supports converting, parsing and building subtitle file formats.
129 | Subtitles can also be resynced by shifting time offset, extending the duration or changing FPS.
130 |
131 | ### List supported formats
132 |
133 | ```javascript
134 | import subsrt from "subsrt";
135 |
136 | const list = subsrt.list();
137 |
138 | console.log(list.join(", "));
139 | // vtt, lrc, smi, ssa, ass, sub, srt, sbv, json
140 | ```
141 |
142 | Format name is used in conversion options, e.g. `{ format: "srt" }`
143 |
144 | Use `subsrt.format.name` to access functions directly
145 |
146 | ```javascript
147 | import subsrt from "subsrt";
148 | const handler = subsrt.format.srt;
149 | // handler = { name: 'srt', helper: [object], parse: [function], build: [function] }
150 | ```
151 |
152 | To implement a new subtitle format handler do the following
153 |
154 | ```javascript
155 | import subsrt from "subsrt";
156 | subsrt.format.my = {
157 | // "my" is the format name
158 | name: "my",
159 | parse: (content, options) => {
160 | const captions = [];
161 | // ...
162 | return captions;
163 | },
164 | build: (captions, options) => {
165 | const content = "";
166 | // ...
167 | return content;
168 | },
169 | detect: (content) => {
170 | if (content.indexOf("my") > 0) {
171 | return true; // Recognized
172 | }
173 | },
174 | };
175 | ```
176 |
177 | (back to top)
178 |
179 | ### Detect
180 |
181 | Recognizes format by content
182 |
183 | ```javascript
184 | import subsrt from "subsrt";
185 |
186 | let content = "";
187 | content += "5" + "\r\n";
188 | content += "00:00:16,700 --> 00:00:21,480" + "\r\n";
189 | content += "Okay, so we have all the ingredients laid out here" + "\r\n";
190 |
191 | const format = subsrt.detect(content);
192 | // format = "srt"
193 | ```
194 |
195 | (back to top)
196 |
197 | ### Parse
198 |
199 | Parse a subtitle file
200 |
201 | ```javascript
202 | import { readFileSync } from "fs";
203 |
204 | import subsrt from "subsrt";
205 |
206 | // Read a .srt file
207 | const content = readFileSync("sample.srt", "utf8");
208 |
209 | // Parse the content
210 | const options = { verbose: true };
211 | const captions = subsrt.parse(content, options);
212 |
213 | // Output to console
214 | console.log(captions);
215 | ```
216 |
217 | Example of output
218 |
219 | ```json
220 | [
221 | {
222 | "type": "caption", // "caption" or "meta"
223 | "index": 1, // Caption id, usually a sequential number
224 | "start": 599, // Time to show caption in milliseconds
225 | "end": 4160, // Time to hide caption in milliseconds
226 | "duration": 3561, // Calculated caption duration
227 | "content": ">> ALICE: Hi, my name is Alice Miller and this is John Brown", // Formatted content
228 | "text": "Hi, my name is Alice Miller and this is John Brown" // Plain text content
229 | },
230 | {
231 | "type": "caption",
232 | "index": 2,
233 | "start": 4160,
234 | "end": 6770,
235 | "duration": 2610,
236 | "content": ">> JOHN: and we're the owners of Miller Bakery.",
237 | "text": "and we're the owners of Miller Bakery."
238 | }
239 | // ...
240 | ]
241 | ```
242 |
243 | List of options
244 |
245 | - `format`: explicitly select a parser, values: `sub`, `srt`, `sbv`, `vtt`, `lrc`, `smi`, `ssa`, `ass`, `json`, default is undefined to auto detect
246 | - `verbose`: set to true for extra messages, console only, default: `false`
247 | - `eol`: end of line character(s), default: `\r\n`
248 | - `fps`: frames per second, `sub` format only
249 | - `preserveSpaces`: keep white space lines, `smi` format only
250 |
251 | (back to top)
252 |
253 | ### Build
254 |
255 | Build a subtitle file
256 |
257 | ```javascript
258 | import { writeFileSync } from "fs";
259 |
260 | import subsrt from "subsrt";
261 |
262 | // Sample captions
263 | const captions = [
264 | {
265 | start: 599, // Time to show caption in milliseconds
266 | end: 4160, // Time to hide caption in milliseconds
267 | text: "Hi, my name is Alice Miller and this is John Brown", // Plain text content
268 | },
269 | {
270 | start: 4160,
271 | end: 6770,
272 | text: "and we're the owners of Miller Bakery.",
273 | },
274 | ];
275 |
276 | // Build the WebVTT content
277 | const options = { format: "vtt" };
278 | const content = subsrt.build(captions, options);
279 |
280 | // Write content to .vtt file
281 | writeFileSync("generated.vtt", content);
282 | ```
283 |
284 | List of options
285 |
286 | - `format`: required, output subtitle format, values: `sub`, `srt`, `sbv`, `vtt`, `lrc`, `smi`, `ssa`, `ass`, `json`, default: `srt`
287 | - `verbose`: set to true for extra messages, console only, default: `false`
288 | - `fps`: frames per second, `sub` format only
289 | - `closeTags`: set to true to close tags, `smi` format only
290 |
291 | (back to top)
292 |
293 | ### Convert
294 |
295 | Using a single action to convert from one to another subtitle format
296 |
297 | ```javascript
298 | import { readFileSync, writeFileSync } from "fs";
299 |
300 | import subsrt from "subsrt";
301 |
302 | // Read a .srt file
303 | const srt = readFileSync("sample.srt", "utf8");
304 |
305 | // Convert .srt to .sbv
306 | const sbv = subsrt.convert(srt, { format: "sbv" });
307 |
308 | // Write content to .sbv file
309 | writeFileSync("converted.sbv", sbv);
310 | ```
311 |
312 | List of options
313 |
314 | - `format`: required, output subtitle format, values: `sub`, `srt`, `sbv`, `vtt`, `lrc`, `smi`, `ssa`, `ass`, `json`, default: `srt`
315 | - `verbose`: set to true for extra messages, console only, default: `false`
316 | - `eol`: end of line character(s), default: `\r\n`
317 | - `fps`: frames per second, `sub` format only
318 | - `resync`: resync options, see below
319 |
320 | (back to top)
321 |
322 | ### Timeshift (+/- offset)
323 |
324 | An example to make an extra 3-second delay
325 |
326 | ```javascript
327 | import { readFileSync } from "fs";
328 |
329 | import subsrt from "subsrt";
330 |
331 | // Read a .srt file
332 | const content = readFileSync("sample.srt", "utf8");
333 | const captions = subsrt.parse(content);
334 |
335 | // Returns updated captions
336 | const resynced = subsrt.resync(captions, { offset: 3000 });
337 | ```
338 |
339 | Use minus sign to display captions earlier
340 |
341 | ```javascript
342 | const resynced = subsrt.resync(captions, { offset: -3000 });
343 | ```
344 |
345 | (back to top)
346 |
347 | ### Change FPS
348 |
349 | The .sub format has captions saved in frame units. To shift from 25 FPS to 30 FPS do the following
350 |
351 | ```javascript
352 | import { readFileSync } from "fs";
353 |
354 | import subsrt from "subsrt";
355 |
356 | // Read a .sub file
357 | const content = readFileSync("sample.sub", "utf8");
358 | const captions = subsrt.parse(content, { fps: 25 }); // The .sub file content is saved in 25 FPS units
359 |
360 | // Convert to 30 FPS, make sure to set 'frame' to true to convert frames instead of time
361 | const resynced = subsrt.resync(content, { ratio: 30 / 25, frame: true });
362 | ```
363 |
364 | (back to top)
365 |
366 | ### Advanced resync options
367 |
368 | Extend caption duration by 500 ms
369 |
370 | ```javascript
371 | // Argument 'a' is an array with two elements: [ start, end ]
372 | // Return shifted [ start, end ] values
373 | const resynced = subsrt.resync(content, (a) => [a[0], a[1] + 500]);
374 | ```
375 |
376 | (back to top)
377 |
378 | ## Source code
379 |
380 | Download the source code from the GitHub repository.
381 |
382 | Install required packages if any
383 |
384 | ```console
385 | pnpm install
386 | ```
387 |
388 | Run the unit tests
389 |
390 | ```console
391 | pnpm test
392 | ```
393 |
394 | (back to top)
395 |
396 | ## License
397 |
398 | Distributed under the MIT License. See [LICENSE.txt](LICENSE.txt) for more information.
399 |
400 | (back to top)
401 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | import { readFileSync, writeFileSync } from "fs";
2 | import { extname } from "path";
3 |
4 | // eslint-disable-next-line import/no-unresolved
5 | import { build as _build, convert as _convert, detect as _detect, list as _list, parse as _parse } from "./dist/subsrt.js";
6 |
7 | const config = {
8 | verbose: process.env.NODE_VERBOSE === "true" || process.env.NODE_VERBOSE === "1",
9 | };
10 |
11 | // Prints help message
12 | const help = () => {
13 | console.log("Usage:");
14 | console.log(" subsrt [command] [options]");
15 | console.log("");
16 | console.log("Commands:");
17 | console.log(" list List supported formats");
18 | console.log(" parse [src] [json] Parse a subtitle file");
19 | console.log(" build [json] [dst] Create a subtitle file from captions");
20 | console.log(" detect [src] Detect subtitle file format, if supported");
21 | console.log(" resync [src] [dst] Resync FPS or shift time (+/- offset)");
22 | console.log(" convert [src] [dst] Converts a subtitle format");
23 | console.log("");
24 | console.log("Options:");
25 | console.log(" --help Print this message");
26 | console.log(" --eol [chars] End of line chars, e.g. \\r\\n");
27 | console.log(" --fps [fps] Frames per second for .sub format");
28 | console.log(" --offset [time] Resync time shift offset in ms");
29 | console.log(" --format [ext] Subtitle format to convert/build/parse");
30 | console.log(" --verbose Enable detailed logging");
31 | console.log(" --version Print version number");
32 | console.log("");
33 | console.log("Examples:");
34 | console.log(" subsrt parse sample.sbv");
35 | console.log(" subsrt parse sample.srt output.json");
36 | console.log(" subsrt parse sample.sub --fps 30");
37 | console.log(" subsrt build input.json output.vtt");
38 | console.log(" subsrt build input.json --format sbv");
39 | console.log(" subsrt detect unknown.txt");
40 | console.log(" subsrt convert sample.srt sample.vtt");
41 | console.log(" subsrt convert --offset -250 sample.srt sample.ssa");
42 | console.log(" subsrt resync --offset +3000 input.srt output.srt");
43 | console.log(" subsrt resync --fps 25-30 input.sub output.sub");
44 | };
45 |
46 | // Command line arguments
47 | const args = process.argv.slice(2);
48 | for (let i = 0; i < args.length; i++) {
49 | switch (args[i]) {
50 | case "list":
51 | case "parse":
52 | case "build":
53 | case "detect":
54 | case "resync":
55 | case "convert":
56 | if (config.command) {
57 | throw new Error(`Cannot run more than one command: ${args[i]}`);
58 | }
59 | config.command = args[i];
60 | break;
61 |
62 | case "--eol":
63 | config.eol = args[++i];
64 | if (config.eol) {
65 | config.eol = config.eol.replace(/\\r/g, "\r").replace(/\\n/g, "\n");
66 | }
67 | break;
68 |
69 | case "--fps": {
70 | let fps = args[++i];
71 | if (fps.indexOf("-") > 0) {
72 | fps = fps.split("-");
73 | config.fpsFrom = parseFloat(fps[0]);
74 | config.fpsTo = parseFloat(fps[1]);
75 | } else {
76 | config.fps = parseFloat(fps);
77 | }
78 | break;
79 | }
80 |
81 | case "--offset":
82 | config.offset = parseInt(args[++i], 10);
83 | break;
84 |
85 | case "--format":
86 | config.format = args[++i];
87 | break;
88 |
89 | case "--help":
90 | help();
91 | config.exit = true;
92 | break;
93 |
94 | case "--verbose":
95 | config.verbose = true;
96 | break;
97 |
98 | case "--version":
99 | console.log((await import("./package.json")).version);
100 | config.exit = true;
101 | break;
102 |
103 | default:
104 | if (!config.src) {
105 | config.src = args[i];
106 | continue;
107 | }
108 | if (!config.dst) {
109 | config.dst = args[i];
110 | continue;
111 | }
112 | throw new Error(`Unknown command line argument: ${args[i]}`);
113 | }
114 | }
115 |
116 | const commands = {
117 | list: () => {
118 | console.log(_list().join(", "));
119 | },
120 | parse: () => {
121 | const content = readFileSync(config.src, "utf8");
122 |
123 | const options = {
124 | verbose: config.verbose,
125 | };
126 | if (config.fps) {
127 | options.fps = config.fps;
128 | }
129 |
130 | const captions = _parse(content, options);
131 | const json = JSON.stringify(captions, " ", 2);
132 | if (config.dst) {
133 | writeFileSync(config.dst, json);
134 | } else {
135 | console.log(json);
136 | }
137 | },
138 | build: () => {
139 | const json = readFileSync(config.src, "utf8");
140 | const captions = JSON.parse(json);
141 | if (!config.format && config.dst) {
142 | const ext = extname(config.dst);
143 | config.format = ext.replace(/\./, "").toLowerCase();
144 | }
145 |
146 | const options = {
147 | verbose: config.verbose,
148 | format: config.format,
149 | };
150 | if (config.fps) {
151 | options.fps = config.fps;
152 | }
153 | if (config.eol) {
154 | options.eol = config.eol;
155 | }
156 |
157 | const content = _build(captions, options);
158 | if (config.dst) {
159 | writeFileSync(config.dst, content);
160 | } else {
161 | console.log(content);
162 | }
163 | },
164 | detect: () => {
165 | const content = readFileSync(config.src, "utf8");
166 | const format = _detect(content);
167 | console.log(format ?? "unknown");
168 | },
169 | resync: () => {
170 | const options = {};
171 | if (config.offset) {
172 | options.offset = config.offset;
173 | }
174 | if (config.fpsFrom && config.fpsTo) {
175 | options.ratio = config.fpsTo / config.fpsFrom;
176 | options.frame = true;
177 | }
178 | if (config.fps) {
179 | options.fps = config.fps;
180 | }
181 | if (config.fpsFrom) {
182 | options.fps = config.fpsFrom;
183 | options.frame = true;
184 | }
185 | config.resync = options;
186 | commands.convert();
187 | },
188 | convert: () => {
189 | const content = readFileSync(config.src, "utf8");
190 | if (!config.format && config.dst) {
191 | const ext = extname(config.dst);
192 | config.format = ext.replace(/\./, "").toLowerCase();
193 | }
194 |
195 | const options = {
196 | verbose: config.verbose,
197 | format: config.format,
198 | };
199 | if (config.fps) {
200 | options.fps = config.fps;
201 | }
202 | if (config.eol) {
203 | options.eol = config.eol;
204 | }
205 | if (config.resync) {
206 | options.resync = config.resync;
207 | }
208 |
209 | const converted = _convert(content, options);
210 | if (config.dst) {
211 | writeFileSync(config.dst, converted);
212 | } else {
213 | console.log(converted);
214 | }
215 | },
216 | };
217 |
218 | if (!config.exit) {
219 | const func = commands[config.command];
220 | if (typeof func === "function") {
221 | func();
222 | } else {
223 | help();
224 | }
225 | }
226 |
--------------------------------------------------------------------------------
/bin/subsrt.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | import "../app.js";
3 |
--------------------------------------------------------------------------------
/jest.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import("ts-jest").JestConfigWithTsJest} */
2 | module.exports = {
3 | // [...]
4 | preset: "ts-jest/presets/default-esm", // or other ESM presets
5 | moduleNameMapper: {
6 | "^(\\.{1,2}/.*)\\.js$": "$1",
7 | },
8 | transform: {
9 | // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest`
10 | // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest`
11 | "^.+\\.tsx?$": [
12 | "ts-jest",
13 | {
14 | useESM: true,
15 | },
16 | ],
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/lib/format/ass.ts:
--------------------------------------------------------------------------------
1 | import { buildHandler } from "../handler.js";
2 |
3 | const FORMAT_NAME = "ass";
4 |
5 | // Compatible format
6 | import { build, detect, helper, parse } from "./ssa.js";
7 |
8 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse });
9 | export { FORMAT_NAME as name, build, detect, helper, parse };
10 |
--------------------------------------------------------------------------------
/lib/format/index.ts:
--------------------------------------------------------------------------------
1 | import { SubsrtFormats } from "../types/subsrt.js";
2 |
3 | import ass from "./ass.js";
4 | import json from "./json.js";
5 | import lrc from "./lrc.js";
6 | import sbv from "./sbv.js";
7 | import smi from "./smi.js";
8 | import srt from "./srt.js";
9 | import ssa from "./ssa.js";
10 | import sub from "./sub.js";
11 | import vtt from "./vtt.js";
12 |
13 | const formats = {
14 | vtt,
15 | lrc,
16 | smi,
17 | ssa,
18 | ass,
19 | sub,
20 | srt,
21 | sbv,
22 | json,
23 | } as SubsrtFormats;
24 |
25 | export default formats;
26 |
--------------------------------------------------------------------------------
/lib/format/json.ts:
--------------------------------------------------------------------------------
1 | import { buildHandler } from "../handler.js";
2 | import { BuildOptions, Caption, ParseOptions } from "../types/handler.js";
3 |
4 | const FORMAT_NAME = "json";
5 |
6 | /**
7 | * Parses captions in JSON format.
8 | * @param content - The subtitle content
9 | * @param _options - Parse options
10 | * @returns Parsed captions
11 | */
12 | const parse = (content: string, _options: ParseOptions) => JSON.parse(content) as Caption[];
13 |
14 | /**
15 | * Builds captions in JSON format.
16 | * @param captions - The captions to build
17 | * @param _options - Build options
18 | * @returns The built captions string in JSON format
19 | */
20 | const build = (captions: Caption[], _options: BuildOptions) => JSON.stringify(captions, undefined, 2);
21 |
22 | /**
23 | * Detects whether the content is in JSON format.
24 | * @param content - The subtitle content
25 | * @returns Whether the content is in JSON format
26 | */
27 | const detect = (content: string) => {
28 | /*
29 | [
30 | { ... }
31 | ]
32 | */
33 | // return /^\[[\s\r\n]*\{[\s\S]*\}[\s\r\n]*\]$/g.test(content);
34 | try {
35 | const res = JSON.parse(content) as unknown;
36 | return Array.isArray(res) && res.length > 0 && typeof res[0] === "object";
37 | } catch (e) {
38 | return false;
39 | }
40 | };
41 |
42 | export default buildHandler({ name: FORMAT_NAME, build, detect, parse });
43 | export { FORMAT_NAME as name, build, detect, parse };
44 |
--------------------------------------------------------------------------------
/lib/format/lrc.ts:
--------------------------------------------------------------------------------
1 | import { buildHandler } from "../handler.js";
2 | import { BuildOptions, Caption, ContentCaption, MetaCaption, ParseOptions } from "../types/handler.js";
3 |
4 | const FORMAT_NAME = "lrc";
5 |
6 | const helper = {
7 | /**
8 | * Converts a time string in format of mm:ss.ff or mm:ss,ff to milliseconds.
9 | * @param s - The time string to convert
10 | * @throws TypeError If the time string is invalid
11 | * @returns Milliseconds
12 | */
13 | toMilliseconds: (s: string) => {
14 | const match = /^\s*(\d+):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s);
15 | if (!match) {
16 | throw new TypeError(`Invalid time format: ${s}`);
17 | }
18 | const mm = parseInt(match[1], 10);
19 | const ss = parseInt(match[2], 10);
20 | const ff = match[3] ? parseInt(match[3], 10) : 0;
21 | const ms = mm * 60 * 1000 + ss * 1000 + ff * 10;
22 | return ms;
23 | },
24 | /**
25 | * Converts milliseconds to a time string in format of mm:ss.ff.
26 | * @param ms - Milliseconds
27 | * @returns Time string in format of mm:ss.ff
28 | */
29 | toTimeString: (ms: number) => {
30 | const mm = Math.floor(ms / 1000 / 60);
31 | const ss = Math.floor((ms / 1000) % 60);
32 | const ff = Math.floor(ms % 1000);
33 | const time = `${(mm < 10 ? "0" : "") + mm}:${ss < 10 ? "0" : ""}${ss}.${ff < 100 ? "0" : ""}${ff < 10 ? "0" : Math.floor(ff / 10)}`;
34 | return time;
35 | },
36 | };
37 |
38 | /**
39 | * Parses captions in LRC format.
40 | * @param content - The subtitle content
41 | * @param options - Parse options
42 | * @returns Parsed captions
43 | * @see https://en.wikipedia.org/wiki/LRC_%28file_format%29
44 | */
45 | const parse = (content: string, options: ParseOptions) => {
46 | let prev = null;
47 | const captions = [];
48 | // const eol = options.eol || "\r\n";
49 | const parts = content.split(/\r?\n/);
50 | for (const part of parts) {
51 | if (!part || part.trim().length === 0) {
52 | continue;
53 | }
54 |
55 | // LRC content
56 | const regex = /^\[(\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\](.*)(?:\r?\n)*$/;
57 | const match = regex.exec(part);
58 | if (match) {
59 | const caption = {} as ContentCaption;
60 | caption.type = "caption";
61 | caption.start = helper.toMilliseconds(match[1]);
62 | caption.end = caption.start + 2000;
63 | caption.duration = caption.end - caption.start;
64 | caption.content = match[2];
65 | caption.text = caption.content;
66 | captions.push(caption);
67 |
68 | // Update previous
69 | if (prev) {
70 | prev.end = caption.start;
71 | prev.duration = prev.end - prev.start;
72 | }
73 | prev = caption;
74 | continue;
75 | }
76 |
77 | // LRC meta
78 | const meta = /^\[(\w+):([^\]]*)\](?:\r?\n)*$/.exec(part);
79 | if (meta) {
80 | const caption = {} as MetaCaption;
81 | caption.type = "meta";
82 | caption.tag = meta[1];
83 | if (meta[2]) {
84 | caption.data = meta[2];
85 | }
86 | captions.push(caption);
87 | continue;
88 | }
89 |
90 | if (options.verbose) {
91 | console.warn("Unknown part", part);
92 | }
93 | }
94 | return captions;
95 | };
96 |
97 | /**
98 | * Builds captions in LRC format.
99 | * @param captions - The captions to build
100 | * @param options - Build options
101 | * @returns The built captions string in LRC format
102 | * @see https://en.wikipedia.org/wiki/LRC_%28file_format%29
103 | */
104 | const build = (captions: Caption[], options: BuildOptions) => {
105 | let content = "";
106 | let lyrics = false;
107 | const eol = options.eol ?? "\r\n";
108 | for (const caption of captions) {
109 | if (caption.type === "meta") {
110 | if (caption.tag && caption.data && typeof caption.data === "string") {
111 | content += `[${caption.tag}:${caption.data.replace(/[\r\n]+/g, " ")}]${eol}`;
112 | }
113 | continue;
114 | }
115 |
116 | if (!caption.type || caption.type === "caption") {
117 | if (!lyrics) {
118 | content += eol; //New line when lyrics start
119 | lyrics = true;
120 | }
121 | content += `[${helper.toTimeString(caption.start)}]${caption.text}${eol}`;
122 | continue;
123 | }
124 |
125 | if (options.verbose) {
126 | console.log("SKIP:", caption);
127 | }
128 | }
129 |
130 | return content;
131 | };
132 |
133 | /**
134 | * Detects whether the content is in LRC format.
135 | * @param content - The subtitle content
136 | * @returns Format name if detected, or null if not detected
137 | */
138 | const detect = (content: string) => {
139 | /*
140 | [04:48.28]Sister, perfume?
141 | */
142 | return /\r?\n\[\d+:\d{1,2}(?:[.,]\d{1,3})?\].*\r?\n/.test(content);
143 | };
144 |
145 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse });
146 | export { FORMAT_NAME as name, build, detect, helper, parse };
147 |
--------------------------------------------------------------------------------
/lib/format/sbv.ts:
--------------------------------------------------------------------------------
1 | import { buildHandler } from "../handler.js";
2 | import { BuildOptions, Caption, ContentCaption, ParseOptions } from "../types/handler.js";
3 |
4 | const FORMAT_NAME = "sbv";
5 |
6 | const helper = {
7 | /**
8 | * Converts a time string in format of hh:mm:ss.sss or hh:mm:ss,sss to milliseconds.
9 | * @param s - The time string to convert
10 | * @throws TypeError If the time string is invalid
11 | * @returns Milliseconds
12 | */
13 | toMilliseconds: (s: string) => {
14 | const match = /^\s*(\d{1,2}):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s);
15 | if (!match) {
16 | throw new TypeError(`Invalid time format: ${s}`);
17 | }
18 | const hh = parseInt(match[1], 10);
19 | const mm = parseInt(match[2], 10);
20 | const ss = parseInt(match[3], 10);
21 | const ff = match[4] ? parseInt(match[4], 10) : 0;
22 | const ms = hh * 3600 * 1000 + mm * 60 * 1000 + ss * 1000 + ff;
23 | return ms;
24 | },
25 |
26 | /**
27 | * Converts milliseconds to a time string in format of hh:mm:ss.sss.
28 | * @param ms - Milliseconds
29 | * @returns Time string in format of hh:mm:ss.sss
30 | */
31 | toTimeString: (ms: number) => {
32 | const hh = Math.floor(ms / 1000 / 3600);
33 | const mm = Math.floor((ms / 1000 / 60) % 60);
34 | const ss = Math.floor((ms / 1000) % 60);
35 | const ff = Math.floor(ms % 1000);
36 | const time = `${(hh < 10 ? "0" : "") + hh}:${mm < 10 ? "0" : ""}${mm}:${ss < 10 ? "0" : ""}${ss}.${ff < 100 ? "0" : ""}${
37 | ff < 10 ? "0" : ""
38 | }${ff}`;
39 | return time;
40 | },
41 | };
42 |
43 | /**
44 | * Parses captions in SubViewer format (.sbv).
45 | * @param content - The subtitle content
46 | * @param options - Parse options
47 | * @returns Parsed captions
48 | */
49 | const parse = (content: string, options: ParseOptions) => {
50 | const captions = [];
51 | const eol = options.eol ?? "\r\n";
52 | const parts = content.split(/\r?\n\s*\n/);
53 | for (const part of parts) {
54 | const regex = /^(\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\s*[,;]\s*(\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\r?\n([\s\S]*)$/;
55 | const match = regex.exec(part);
56 | if (match) {
57 | const caption = {} as ContentCaption;
58 | caption.type = "caption";
59 | caption.start = helper.toMilliseconds(match[1]);
60 | caption.end = helper.toMilliseconds(match[2]);
61 | caption.duration = caption.end - caption.start;
62 | const lines = match[3].split(/\[br\]|\r?\n/i);
63 | caption.content = lines.join(eol);
64 | caption.text = caption.content.replace(/>>[^:]+:\s*/g, ""); // >> SPEAKER NAME:
65 | captions.push(caption);
66 | continue;
67 | }
68 |
69 | if (options.verbose) {
70 | console.warn("Unknown part", part);
71 | }
72 | }
73 | return captions;
74 | };
75 |
76 | /**
77 | * Builds captions in SubViewer format (.sbv).
78 | * @param captions - The captions to build
79 | * @param options - Build options
80 | * @returns The built captions string in SubViewer format
81 | */
82 | const build = (captions: Caption[], options: BuildOptions) => {
83 | let content = "";
84 | const eol = options.eol ?? "\r\n";
85 | for (const caption of captions) {
86 | if (!caption.type || caption.type === "caption") {
87 | content += `${helper.toTimeString(caption.start)},${helper.toTimeString(caption.end)}${eol}`;
88 | content += caption.text + eol;
89 | content += eol;
90 | continue;
91 | }
92 | if (options.verbose) {
93 | console.log("SKIP:", caption);
94 | }
95 | }
96 |
97 | return content;
98 | };
99 |
100 | /**
101 | * Detects whether the content is in SubViewer format.
102 | * @param content - The subtitle content
103 | * @returns Whether the subtitle format is SubViewer
104 | */
105 | const detect = (content: string) => {
106 | /*
107 | 00:04:48.280,00:04:50.510
108 | Sister, perfume?
109 | */
110 | return /\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?\s*[,;]\s*\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?/.test(content);
111 | };
112 |
113 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse });
114 | export { FORMAT_NAME as name, build, detect, helper, parse };
115 |
--------------------------------------------------------------------------------
/lib/format/smi.ts:
--------------------------------------------------------------------------------
1 | import { buildHandler } from "../handler.js";
2 | import { Caption, ContentCaption, MetaCaption } from "../types/handler.js";
3 |
4 | import { SMIBuildOptions, SMIParseOptions } from "./types/smi.js";
5 |
6 | const FORMAT_NAME = "smi";
7 |
8 | const helper = {
9 | /**
10 | * Encodes a string to be used in XML.
11 | * @param text - The text to be encoded
12 | * @returns The HTML-encoded string
13 | */
14 | htmlEncode: (text: string) =>
15 | text
16 | .replace(/&/g, "&")
17 | .replace(/"/g, """)
18 | .replace(/'/g, "'")
19 | .replace(//g, ">")
21 | //.replace(/\s/g, ' ')
22 | .replace(/\r?\n/g, "
"),
23 | /**
24 | * Decodes a string that has been HTML-encoded.
25 | * @param html - The HTML-encoded string to decode
26 | * @param eol - The end-of-line character to use
27 | * @returns The decoded string
28 | */
29 | htmlDecode: (html: string, eol: string) =>
30 | html
31 | .replace(/
/gi, eol ?? "\r\n")
32 | .replace(/ /g, " ")
33 | .replace(/"/g, '"')
34 | .replace(/'/g, "'")
35 | .replace(/</g, "<")
36 | .replace(/>/g, ">")
37 | .replace(/&/g, "&"),
38 | };
39 |
40 | /**
41 | * Parses captions in SAMI format (.smi).
42 | * @param content - The subtitle content
43 | * @param options - Parse options
44 | * @throws TypeError When the format is not supported
45 | * @returns Parsed captions
46 | */
47 | const parse = (content: string, options: SMIParseOptions) => {
48 | const captions = [];
49 | const eol = options.eol ?? "\r\n";
50 |
51 | const title = /]*>([\s\S]*)<\/TITLE>/i.exec(content);
52 | if (title) {
53 | const caption = {} as MetaCaption;
54 | caption.type = "meta";
55 | caption.name = "title";
56 | caption.data = title[1].replace(/^\s*/g, "").replace(/\s*$/g, "");
57 | captions.push(caption);
58 | }
59 |
60 | const style = /${eol}`;
149 | content += `${eol}`;
150 | content += `${eol}`;
151 |
152 | for (const caption of captions) {
153 | if (caption.type === "meta") {
154 | continue;
155 | }
156 |
157 | if (!caption.type || caption.type === "caption") {
158 | // Start of caption
159 | content += `${eol}`;
160 | content += ` ${helper.htmlEncode(caption.text ?? "")}${options.closeTags ? "
" : ""}${eol}`;
161 | if (options.closeTags) {
162 | content += `${eol}`;
163 | }
164 |
165 | // Blank line indicates the end of caption
166 | content += `${eol}`;
167 | content += ` ${options.closeTags ? "
" : ""}${eol}`;
168 | if (options.closeTags) {
169 | content += `${eol}`;
170 | }
171 |
172 | continue;
173 | }
174 |
175 | if (options.verbose) {
176 | console.log("SKIP:", caption);
177 | }
178 | }
179 |
180 | content += `${eol}`;
181 | content += `${eol}`;
182 |
183 | return content;
184 | };
185 |
186 | /**
187 | * Detects whether the content is in SAMI format.
188 | * @param content - The content to be detected
189 | * @returns Whether the subtitle format is SAMI
190 | */
191 | const detect = (content: string) => {
192 | /*
193 |
194 |
195 |
198 |
199 | */
200 | return /]*>[\s\S]*]*>/.test(content);
201 | };
202 |
203 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse });
204 | export { FORMAT_NAME as name, build, detect, helper, parse };
205 |
--------------------------------------------------------------------------------
/lib/format/srt.ts:
--------------------------------------------------------------------------------
1 | import { buildHandler } from "../handler.js";
2 | import { BuildOptions, Caption, ContentCaption, ParseOptions } from "../types/handler.js";
3 |
4 | const FORMAT_NAME = "srt";
5 |
6 | const helper = {
7 | /**
8 | * Converts a time string in format of hh:mm:ss, hh:mm:ss.sss or hh:mm:ss,sss to milliseconds.
9 | * @param s - The time string to convert
10 | * @throws TypeError If the time string is invalid
11 | * @returns Milliseconds
12 | */
13 | toMilliseconds: (s: string) => {
14 | const match = /^\s*(\d{1,2}):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s);
15 | if (!match) {
16 | throw new TypeError(`Invalid time format: ${s}`);
17 | }
18 | const hh = parseInt(match[1], 10);
19 | const mm = parseInt(match[2], 10);
20 | const ss = parseInt(match[3], 10);
21 | const ff = match[4] ? parseInt(match[4], 10) : 0;
22 | const ms = hh * 3600 * 1000 + mm * 60 * 1000 + ss * 1000 + ff;
23 | return ms;
24 | },
25 | /**
26 | * Converts milliseconds to a time string in format of hh:mm:ss,sss.
27 | * @param ms - Milliseconds
28 | * @returns Time string in format of hh:mm:ss,sss
29 | */
30 | toTimeString: (ms: number) => {
31 | const hh = Math.floor(ms / 1000 / 3600);
32 | const mm = Math.floor((ms / 1000 / 60) % 60);
33 | const ss = Math.floor((ms / 1000) % 60);
34 | const ff = Math.floor(ms % 1000);
35 | const time = `${(hh < 10 ? "0" : "") + hh}:${mm < 10 ? "0" : ""}${mm}:${ss < 10 ? "0" : ""}${ss},${ff < 100 ? "0" : ""}${
36 | ff < 10 ? "0" : ""
37 | }${ff}`;
38 | return time;
39 | },
40 | };
41 |
42 | /**
43 | * Parses captions in SubRip format (.srt).
44 | * @param content - The subtitle content
45 | * @param options - Parse options
46 | * @returns Parsed captions
47 | */
48 | const parse = (content: string, options: ParseOptions) => {
49 | const captions = [];
50 | const eol = options.eol ?? "\r\n";
51 | const parts = content.split(/\r?\n\s*\n/);
52 | for (const part of parts) {
53 | const regex = /^(\d+)\r?\n(\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\s*-->\s*(\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\r?\n([\s\S]*)$/;
54 | const match = regex.exec(part);
55 | if (match) {
56 | const caption = {} as ContentCaption;
57 | caption.type = "caption";
58 | caption.index = parseInt(match[1], 10);
59 | caption.start = helper.toMilliseconds(match[2]);
60 | caption.end = helper.toMilliseconds(match[3]);
61 | caption.duration = caption.end - caption.start;
62 | const lines = match[4].split(/\r?\n/);
63 | caption.content = lines.join(eol);
64 | caption.text = caption.content
65 | .replace(/<[^>]+>/g, "") // bold or italic
66 | .replace(/\{[^}]+\}/g, "") // {b}bold{/b} or {i}italic{/i}
67 | .replace(/>>[^:]*:\s*/g, ""); // >> SPEAKER NAME:
68 | captions.push(caption);
69 | continue;
70 | }
71 |
72 | if (options.verbose) {
73 | console.warn("Unknown part", part);
74 | }
75 | }
76 | return captions;
77 | };
78 |
79 | /**
80 | * Builds captions in SubRip format (.srt).
81 | * @param captions - The captions to build
82 | * @param options - Build options
83 | * @returns The built captions string in SubRip format
84 | */
85 | const build = (captions: Caption[], options: BuildOptions) => {
86 | let srt = "";
87 | const eol = options.eol ?? "\r\n";
88 | for (let i = 0; i < captions.length; i++) {
89 | const caption = captions[i];
90 | if (!caption.type || caption.type === "caption") {
91 | srt += (i + 1).toString() + eol;
92 | srt += `${helper.toTimeString(caption.start)} --> ${helper.toTimeString(caption.end)}${eol}`;
93 | srt += caption.text + eol;
94 | srt += eol;
95 | continue;
96 | }
97 | if (options.verbose) {
98 | console.log("SKIP:", caption);
99 | }
100 | }
101 |
102 | return srt;
103 | };
104 |
105 | /**
106 | * Detects whether the content is in SubRip format.
107 | * @param content - The subtitle content
108 | * @returns Whether the content is in SubRip format
109 | */
110 | const detect = (content: string) => {
111 | /*
112 | 3
113 | 00:04:48,280 --> 00:04:50,510
114 | Sister, perfume?
115 | */
116 | return /\d+\r?\n\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?\s*-->\s*\d{1,2}:\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?/.test(content);
117 | };
118 |
119 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse });
120 | export { FORMAT_NAME as name, build, detect, helper, parse };
121 |
--------------------------------------------------------------------------------
/lib/format/ssa.ts:
--------------------------------------------------------------------------------
1 | import { buildHandler } from "../handler.js";
2 | import { BuildOptions, Caption, ContentCaption, MetaCaption, ParseOptions, StyleCaption } from "../types/handler.js";
3 |
4 | const FORMAT_NAME = "ssa";
5 |
6 | const helper = {
7 | /**
8 | * Converts a time string in format of hh:mm:ss.fff or hh:mm:ss,fff to milliseconds.
9 | * @param s - The time string to convert
10 | * @throws TypeError If the time string is invalid
11 | * @returns Milliseconds
12 | */
13 | toMilliseconds: (s: string) => {
14 | const match = /^\s*(\d+:)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s);
15 | if (!match) {
16 | throw new TypeError(`Invalid time format: ${s}`);
17 | }
18 | const hh = match[1] ? parseInt(match[1].replace(":", "")) : 0;
19 | const mm = parseInt(match[2], 10);
20 | const ss = parseInt(match[3], 10);
21 | const ff = match[4] ? parseInt(match[4], 10) : 0;
22 | const ms = hh * 3600 * 1000 + mm * 60 * 1000 + ss * 1000 + ff * 10;
23 | return ms;
24 | },
25 | /**
26 | * Converts milliseconds to a time string in format of hh:mm:ss.fff.
27 | * @param ms - Milliseconds
28 | * @returns Time string in format of hh:mm:ss.fff
29 | */
30 | toTimeString: (ms: number) => {
31 | const hh = Math.floor(ms / 1000 / 3600);
32 | const mm = Math.floor((ms / 1000 / 60) % 60);
33 | const ss = Math.floor((ms / 1000) % 60);
34 | const ff = Math.floor((ms % 1000) / 10); // 2 digits
35 | const time = `${hh}:${mm < 10 ? "0" : ""}${mm}:${ss < 10 ? "0" : ""}${ss}.${ff < 10 ? "0" : ""}${ff}`;
36 | return time;
37 | },
38 | };
39 |
40 | /**
41 | * Internal helper function for building caption data.
42 | * @param columns - Columns
43 | * @param values - Values
44 | * @returns Caption data
45 | * @internal
46 | */
47 | const _buildCaptionData = (columns: string[], values: string[]) => {
48 | const data: Record = {};
49 | for (let c = 0; c < columns.length && c < values.length; c++) {
50 | data[columns[c]] = values[c];
51 | }
52 | return data;
53 | };
54 |
55 | /**
56 | * Parses captions in SubStation Alpha format (.ssa).
57 | * @param content - The subtitle content
58 | * @param options - Parse options
59 | * @throws TypeError If the meta data is in invalid format
60 | * @returns Parsed captions
61 | */
62 | const parse = (content: string, options: ParseOptions) => {
63 | let meta;
64 | let columns = null;
65 | const captions = [];
66 | const eol = options.eol ?? "\r\n";
67 | const parts = content.split(/\r?\n\s*\n/);
68 | for (const part of parts) {
69 | const regex = /^\s*\[([^\]]+)\]\r?\n([\s\S]*)$/;
70 | const match = regex.exec(part);
71 | if (!match) {
72 | if (options.verbose) {
73 | console.warn("Unknown part", part);
74 | }
75 | continue;
76 | }
77 |
78 | const tag = match[1];
79 | const lines = match[2].split(/\r?\n/);
80 | for (const line of lines) {
81 | if (/^\s*;/.test(line)) {
82 | continue; // Skip comment
83 | }
84 | // FIXME: prevent backtracking
85 | // eslint-disable-next-line regexp/no-super-linear-backtracking
86 | const lineMatch = /^\s*([^\s:]+):\s*(.*)$/.exec(line);
87 | if (!lineMatch) {
88 | continue;
89 | }
90 | if (tag === "Script Info") {
91 | if (!meta) {
92 | meta = {} as MetaCaption;
93 | meta.type = "meta";
94 | meta.data = {};
95 | captions.push(meta);
96 | }
97 | if (typeof meta.data === "object") {
98 | const name = lineMatch[1].trim();
99 | const value = lineMatch[2].trim();
100 | meta.data[name] = value;
101 | }
102 | } else if (tag === "V4 Styles" || tag === "V4+ Styles") {
103 | const name = lineMatch[1].trim();
104 | const value = lineMatch[2].trim();
105 | if (name === "Format") {
106 | columns = value.split(/\s*,\s*/);
107 | } else if (name === "Style" && columns) {
108 | const values = value.split(/\s*,\s*/);
109 | const caption = {} as StyleCaption;
110 | caption.type = "style";
111 | caption.data = _buildCaptionData(columns, values);
112 | captions.push(caption);
113 | }
114 | } else if (tag === "Events") {
115 | const name = lineMatch[1].trim();
116 | const value = lineMatch[2].trim();
117 | if (name === "Format") {
118 | columns = value.split(/\s*,\s*/);
119 | } else if (name === "Dialogue" && columns) {
120 | const values = value.split(/\s*,\s*/);
121 | const caption = {} as ContentCaption;
122 | caption.type = "caption";
123 | caption.data = _buildCaptionData(columns, values);
124 | caption.start = helper.toMilliseconds(caption.data.Start);
125 | caption.end = helper.toMilliseconds(caption.data.End);
126 | caption.duration = caption.end - caption.start;
127 | caption.content = caption.data.Text;
128 |
129 | // Work-around for missing text (when the text contains ',' char)
130 | const indexOfText = value.split(",", columns.length - 1).join(",").length + 1 + 1;
131 | caption.content = value.substring(indexOfText);
132 | caption.data.Text = caption.content;
133 |
134 | caption.text = caption.content
135 | .replace(/\\N/g, eol) // "\N" for new line
136 | .replace(/\{[^}]+\}/g, ""); // {\pos(400,570)}
137 | captions.push(caption);
138 | }
139 | }
140 | }
141 | }
142 | return captions;
143 | };
144 |
145 | /**
146 | * Builds captions in SubStation Alpha format (.ssa).
147 | * @param captions - The captions to build
148 | * @param options - Build options
149 | * @returns The built captions string in SubStation Alpha format
150 | */
151 | const build = (captions: Caption[], options: BuildOptions) => {
152 | const eol = options.eol ?? "\r\n";
153 | const ass = options.format === "ass";
154 |
155 | let content = "";
156 | content += `[Script Info]${eol}`;
157 | content += `; Script generated by subsrt ${eol}`;
158 | content += `ScriptType: v4.00${ass ? "+" : ""}${eol}`;
159 | content += `Collisions: Normal${eol}`;
160 | content += eol;
161 | if (ass) {
162 | content += `[V4+ Styles]${eol}`;
163 | content += `Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding${eol}`;
164 | content += `Style: DefaultVCD, Arial,28,&H00B4FCFC,&H00B4FCFC,&H00000008,&H80000008,-1,0,0,0,100,100,0.00,0.00,1,1.00,2.00,2,30,30,30,0${eol}`;
165 | } else {
166 | content += `[V4 Styles]${eol}`;
167 | content += `Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding${eol}`;
168 | content += `Style: DefaultVCD, Arial,28,11861244,11861244,11861244,-2147483640,-1,0,1,1,2,2,30,30,30,0,0${eol}`;
169 | }
170 | content += eol;
171 | content += `[Events]${eol}`;
172 | content += `Format: ${ass ? "Layer" : "Marked"}, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text${eol}`;
173 |
174 | for (const caption of captions) {
175 | if (caption.type === "meta") {
176 | continue;
177 | }
178 |
179 | if (!caption.type || caption.type === "caption") {
180 | content += `Dialogue: ${ass ? "0" : "Marked=0"},${helper.toTimeString(caption.start)},${helper.toTimeString(
181 | caption.end,
182 | )},DefaultVCD, NTP,0000,0000,0000,,${caption.text.replace(/\r?\n/g, "\\N")}${eol}`;
183 | continue;
184 | }
185 |
186 | if (options.verbose) {
187 | console.log("SKIP:", caption);
188 | }
189 | }
190 |
191 | return content;
192 | };
193 |
194 | /**
195 | * Detects whether the content is in ASS or SSA format.
196 | * @param content - The subtitle content
197 | * @returns Whether the content is in "ass", "ssa" or neither
198 | */
199 | const detect = (content: string) => {
200 | if (/^\s*\[Script Info\]\r?\n/.test(content) && /\s*\[Events\]\r?\n/.test(content)) {
201 | /*
202 | [Script Info]
203 | ...
204 | [Events]
205 | */
206 | // Advanced (V4+) styles for ASS format
207 | return content.indexOf("[V4+ Styles]") > 0 ? "ass" : "ssa";
208 | }
209 | return false;
210 | };
211 |
212 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse });
213 | export { FORMAT_NAME as name, build, detect, helper, parse };
214 |
--------------------------------------------------------------------------------
/lib/format/sub.ts:
--------------------------------------------------------------------------------
1 | import { buildHandler } from "../handler.js";
2 | import { Caption, ContentCaption } from "../types/handler.js";
3 |
4 | import { SUBBuildOptions, SUBParseOptions } from "./types/sub.js";
5 |
6 | const FORMAT_NAME = "sub";
7 | const DEFAULT_FPS = 25;
8 |
9 | /**
10 | * Parses captions in MicroDVD format.
11 | * @param content - The subtitle content
12 | * @param options - Parse options
13 | * @returns Parsed captions
14 | * @see https://en.wikipedia.org/wiki/MicroDVD
15 | */
16 | const parse = (content: string, options: SUBParseOptions) => {
17 | options.fps ||= DEFAULT_FPS;
18 | const fps = options.fps > 0 ? options.fps : DEFAULT_FPS;
19 | const captions = [];
20 | const eol = options.eol ?? "\r\n";
21 | const parts = content.split(/\r?\n/);
22 | for (let i = 0; i < parts.length; i++) {
23 | const regex = /^\{(\d+)\}\{(\d+)\}(.*)$/;
24 | const match = regex.exec(parts[i]);
25 | if (match) {
26 | const caption = {} as ContentCaption;
27 | caption.type = "caption";
28 | caption.index = i + 1;
29 | caption.frame = {
30 | start: parseInt(match[1], 10),
31 | end: parseInt(match[2], 10),
32 | count: parseInt(match[2]) - parseInt(match[1], 10),
33 | };
34 | caption.start = Math.round(caption.frame.start / fps);
35 | caption.end = Math.round(caption.frame.end / fps);
36 | caption.duration = caption.end - caption.start;
37 | const lines = match[3].split(/\|/);
38 | caption.content = lines.join(eol);
39 | caption.text = caption.content.replace(/\{[^}]+\}/g, ""); // {0}{25}{c:$0000ff}{y:b,u}{f:DeJaVuSans}{s:12}Hello!
40 | captions.push(caption);
41 | continue;
42 | }
43 |
44 | if (options.verbose) {
45 | console.warn("Unknown part", parts[i]);
46 | }
47 | }
48 | return captions;
49 | };
50 |
51 | /**
52 | * Builds captions in MicroDVD format.
53 | * @param captions - The captions to build
54 | * @param options - Build options
55 | * @returns The built captions string in MicroDVD format
56 | * @see https://en.wikipedia.org/wiki/MicroDVD
57 | */
58 | const build = (captions: Caption[], options: SUBBuildOptions) => {
59 | const fps = options.fps && options.fps > 0 ? options.fps : DEFAULT_FPS;
60 |
61 | let sub = "";
62 | const eol = options.eol ?? "\r\n";
63 | for (const caption of captions) {
64 | if (!caption.type || caption.type === "caption") {
65 | const startFrame = typeof caption.frame === "object" && caption.frame.start >= 0 ? caption.frame.start : caption.start * fps;
66 | const endFrame = typeof caption.frame === "object" && caption.frame.end >= 0 ? caption.frame.end : caption.end * fps;
67 | const text = caption.text.replace(/\r?\n/, "|");
68 | sub += `{${startFrame}}{${endFrame}}${text}${eol}`;
69 | continue;
70 | }
71 |
72 | if (options.verbose) {
73 | console.log("SKIP:", caption);
74 | }
75 | }
76 |
77 | return sub;
78 | };
79 |
80 | /**
81 | * Detects whether the content is in MicroDVD format.
82 | * @param content - The subtitle content
83 | * @returns Whether it's MicroDVD format
84 | */
85 | const detect = (content: string) => {
86 | /*
87 | {7207}{7262}Sister, perfume?
88 | */
89 | return /^\{\d+\}\{\d+\}.*/.test(content);
90 | };
91 |
92 | export default buildHandler({ name: FORMAT_NAME, build, detect, parse });
93 | export { FORMAT_NAME as name, build, detect, parse };
94 |
--------------------------------------------------------------------------------
/lib/format/types/smi.ts:
--------------------------------------------------------------------------------
1 | import { BuildOptions, ParseOptions } from "../../types/handler.js";
2 |
3 | export interface SMIBuildOptions extends BuildOptions {
4 | title?: string;
5 | langName?: string;
6 | langCode?: string;
7 | closeTags?: boolean;
8 | }
9 |
10 | export interface SMIParseOptions extends ParseOptions {
11 | preserveSpaces?: boolean;
12 | }
13 |
--------------------------------------------------------------------------------
/lib/format/types/sub.ts:
--------------------------------------------------------------------------------
1 | import { BuildOptions, ParseOptions } from "../../types/handler.js";
2 |
3 | export interface SUBBuildOptions extends BuildOptions {
4 | fps?: number;
5 | }
6 |
7 | export interface SUBParseOptions extends ParseOptions {
8 | fps?: number;
9 | }
10 |
--------------------------------------------------------------------------------
/lib/format/vtt.ts:
--------------------------------------------------------------------------------
1 | import { buildHandler } from "../handler.js";
2 | import { BuildOptions, Caption, ContentCaption, MetaCaption, ParseOptions } from "../types/handler.js";
3 |
4 | const FORMAT_NAME = "vtt";
5 |
6 | const helper = {
7 | /**
8 | * Converts a time string in format of hh:mm:ss.fff or hh:mm:ss,fff to milliseconds.
9 | * @param s - The time string to convert
10 | * @throws TypeError If the time string is invalid
11 | * @returns Milliseconds
12 | */
13 | toMilliseconds: (s: string) => {
14 | const match = /^\s*(\d{1,2}:)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?\s*$/.exec(s);
15 | if (!match) {
16 | throw new TypeError(`Invalid time format: ${s}`);
17 | }
18 | const hh = match[1] ? parseInt(match[1].replace(":", "")) : 0;
19 | const mm = parseInt(match[2], 10);
20 | const ss = parseInt(match[3], 10);
21 | const ff = match[4] ? parseInt(match[4], 10) : 0;
22 | const ms = hh * 3600 * 1000 + mm * 60 * 1000 + ss * 1000 + ff;
23 | return ms;
24 | },
25 | /**
26 | * Converts milliseconds to a time string in format of hh:mm:ss.fff.
27 | * @param ms - Milliseconds
28 | * @returns Time string in format of hh:mm:ss.fff
29 | */
30 | toTimeString: (ms: number) => {
31 | const hh = Math.floor(ms / 1000 / 3600);
32 | const mm = Math.floor((ms / 1000 / 60) % 60);
33 | const ss = Math.floor((ms / 1000) % 60);
34 | const ff = Math.floor(ms % 1000);
35 | const time = `${(hh < 10 ? "0" : "") + hh}:${mm < 10 ? "0" : ""}${mm}:${ss < 10 ? "0" : ""}${ss}.${ff < 100 ? "0" : ""}${
36 | ff < 10 ? "0" : ""
37 | }${ff}`;
38 | return time;
39 | },
40 | };
41 |
42 | /**
43 | * Parses captions in WebVTT format (Web Video Text Tracks Format).
44 | * @param content - The subtitle content
45 | * @param options - Parse options
46 | * @returns Parsed captions
47 | */
48 | const parse = (content: string, options: ParseOptions) => {
49 | let index = 1;
50 | const captions: Caption[] = [];
51 | const parts = content.split(/\r?\n\s*\n/);
52 | for (const part of parts) {
53 | // WebVTT data
54 | const regex =
55 | /^([^\r\n]+\r?\n)?((?:\d{1,2}:)?\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\s*-->\s*((?:\d{1,2}:)?\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)[^\S\r\n]?.*\r?\n([\s\S]*)$/;
56 | const match = regex.exec(part);
57 | if (match) {
58 | const caption = {} as ContentCaption;
59 | caption.type = "caption";
60 | caption.index = index++;
61 | if (match[1]) {
62 | caption.cue = match[1].replace(/[\r\n]*/g, "");
63 | }
64 | caption.start = helper.toMilliseconds(match[2]);
65 | caption.end = helper.toMilliseconds(match[3]);
66 | caption.duration = caption.end - caption.start;
67 | caption.content = match[4];
68 | caption.text = caption.content
69 | .replace(/<[^>]+>/g, "") // bold or italic
70 | .replace(/\{[^}]+\}/g, ""); // {b}bold{/b} or {i}italic{/i}
71 | captions.push(caption);
72 | continue;
73 | }
74 |
75 | // WebVTT meta
76 | // FIXME: prevent backtracking
77 | // eslint-disable-next-line regexp/no-super-linear-backtracking
78 | const meta = /^([A-Z]+)(\r?\n([\s\S]*))?$/.exec(part) ?? /^([A-Z]+)\s+([^\r\n]*)$/.exec(part);
79 | if (meta) {
80 | const caption = {} as MetaCaption;
81 | caption.type = "meta";
82 | caption.name = meta[1];
83 | if (meta[3]) {
84 | caption.data = meta[3];
85 | }
86 | captions.push(caption);
87 | continue;
88 | }
89 |
90 | if (options.verbose) {
91 | console.warn("Unknown part", part);
92 | }
93 | }
94 | return captions;
95 | };
96 |
97 | /**
98 | * Builds captions in WebVTT format (Web Video Text Tracks Format).
99 | * @param captions - The captions to build
100 | * @param options - Build options
101 | * @returns The built captions string in WebVTT format
102 | */
103 | const build = (captions: Caption[], options: BuildOptions) => {
104 | const eol = options.eol ?? "\r\n";
105 | let content = `WEBVTT${eol}${eol}`;
106 | for (let i = 0; i < captions.length; i++) {
107 | const caption = captions[i];
108 | if (caption.type === "meta") {
109 | if (caption.name === "WEBVTT") {
110 | continue;
111 | }
112 | content += caption.name + eol;
113 | content += typeof caption.data === "string" ? caption.data + eol : "";
114 | content += eol;
115 | continue;
116 | }
117 |
118 | if (!caption.type || caption.type === "caption") {
119 | content += (i + 1).toString() + eol;
120 | content += `${helper.toTimeString(caption.start)} --> ${helper.toTimeString(caption.end)}${eol}`;
121 | content += caption.text + eol;
122 | content += eol;
123 | continue;
124 | }
125 |
126 | if (options.verbose) {
127 | console.log("SKIP:", caption);
128 | }
129 | }
130 |
131 | return content;
132 | };
133 |
134 | /**
135 | * Detects whether the content is in WebVTT format.
136 | * @param content - The subtitle content
137 | * @returns Whether the content is in WebVTT format
138 | */
139 | const detect = (content: string) => {
140 | /*
141 | WEBVTT
142 | ...
143 | */
144 | return /^\s*WEBVTT\r?\n/.test(content);
145 | };
146 |
147 | export default buildHandler({ name: FORMAT_NAME, build, detect, helper, parse });
148 | export { FORMAT_NAME as name, build, detect, helper, parse };
149 |
--------------------------------------------------------------------------------
/lib/handler.ts:
--------------------------------------------------------------------------------
1 | import { BaseHandler, BuildFunction, DetectFunction, Helper, ParseFunction, ParseOptions } from "./types/handler.js";
2 |
3 | /**
4 | * Handler class.
5 | */
6 | export class Handler implements BaseHandler {
7 | name: string;
8 | helper?: Helper;
9 | build: BuildFunction;
10 | detect: DetectFunction;
11 | parse: ParseFunction;
12 |
13 | /**
14 | * Creates a new handler.
15 | * @param args - The handler properties (`name`, `build`, `detect`, `helper` and `parse`)
16 | * @see
17 | * - {@link BaseHandler}
18 | * - {@link BuildFunction}
19 | * - {@link DetectFunction}
20 | * - {@link Helper}
21 | * - {@link ParseFunction}
22 | * - {@link ParseOptions}
23 | * @example
24 | * ```ts
25 | * const handler = new Handler({
26 | * name: "ext",
27 | * build: (captions: Caption[], options: BuildOptions): string => {
28 | * // ...
29 | * },
30 | * detect: (content: string): boolean | string => {
31 | * // ...
32 | * },
33 | * parse: (content: string, options: ParseOptions): Caption[] => {
34 | * // ...
35 | * },
36 | * });
37 | * ```
38 | */
39 | constructor({ name, build, detect, helper, parse }: BaseHandler) {
40 | this.name = name;
41 | this.helper = helper;
42 | this.build = build;
43 | this.detect = (content: string) => {
44 | if (typeof content !== "string") {
45 | throw new TypeError(`Expected string, got ${typeof content}!`);
46 | }
47 |
48 | return detect(content);
49 | };
50 | this.parse = (content: string, _options: ParseOptions) => {
51 | if (typeof content !== "string") {
52 | throw new TypeError(`Expected string, got ${typeof content}!`);
53 | }
54 |
55 | return parse(content, _options);
56 | };
57 | }
58 | }
59 |
60 | /**
61 | * Build a handler.
62 | * @param args - The handler properties
63 | * @returns The handler
64 | * @see {@link Handler}
65 | */
66 | export const buildHandler = (args: BaseHandler) => {
67 | return new Handler(args);
68 | };
69 |
--------------------------------------------------------------------------------
/lib/subsrt.ts:
--------------------------------------------------------------------------------
1 | import formats from "./format/index.js";
2 | import { BuildOptions, Caption, ConvertOptions, ParseOptions, ResyncOptions } from "./types/handler.js";
3 | import { ResyncFunction, SubsrtInterface } from "./types/subsrt.js";
4 |
5 | /**
6 | * Clones an object.
7 | * @param obj - The object to clone
8 | * @returns The cloned object
9 | */
10 | const clone = (obj: T) => JSON.parse(JSON.stringify(obj)) as T;
11 |
12 | /**
13 | * Main subsrt class.
14 | */
15 | class Subsrt implements SubsrtInterface {
16 | format = formats;
17 |
18 | /**
19 | * Gets a list of supported subtitle formats.
20 | * @returns The list of supported subtitle formats
21 | */
22 | list = () => Object.keys(this.format);
23 |
24 | /**
25 | * Detects a subtitle format from the content.
26 | * @param content - The subtitle content
27 | * @returns The detected format
28 | */
29 | detect = (content: string) => {
30 | const formats = this.list();
31 | for (const format of formats) {
32 | const handler = this.format[format];
33 | if (typeof handler === "undefined") {
34 | continue;
35 | }
36 | if (typeof handler.detect !== "function") {
37 | continue;
38 | }
39 | // Function 'detect' can return true or format name
40 | const detected = handler.detect(content);
41 | if (detected === true || detected === format) {
42 | return format;
43 | }
44 | }
45 | return "";
46 | };
47 |
48 | /**
49 | * Parses a subtitle content.
50 | * @param content - The subtitle content
51 | * @param options - The parsing options
52 | * @throws TypeError If the format cannot be determined
53 | * @throws TypeError If the format is not supported
54 | * @throws TypeError If the handler does not support 'parse' op
55 | * @returns The parsed captions
56 | */
57 | parse = (content: string, options = {} as ParseOptions) => {
58 | const format = options.format ?? this.detect(content);
59 | if (!format || format.trim().length === 0) {
60 | throw new TypeError("Cannot determine subtitle format");
61 | }
62 |
63 | const handler = this.format[format];
64 | if (typeof handler === "undefined") {
65 | throw new TypeError(`Unsupported subtitle format: ${format}`);
66 | }
67 |
68 | const func = handler.parse;
69 | if (typeof func !== "function") {
70 | throw new TypeError(`Subtitle format does not support 'parse' op: ${format}`);
71 | }
72 |
73 | return func(content, options);
74 | };
75 |
76 | /**
77 | * Builds a subtitle content.
78 | * @param captions - The captions to build
79 | * @param options - The building options
80 | * @throws TypeError If the format cannot be determined
81 | * @throws TypeError If the format is not supported
82 | * @throws TypeError If the handler does not support 'build' op
83 | * @returns The built subtitle content
84 | */
85 | build = (captions: Caption[], options = {} as BuildOptions) => {
86 | const format = options.format ?? "srt";
87 | if (!format || format.trim().length === 0) {
88 | throw new TypeError("Cannot determine subtitle format");
89 | }
90 |
91 | const handler = this.format[format];
92 | if (typeof handler === "undefined") {
93 | throw new TypeError(`Unsupported subtitle format: ${format}`);
94 | }
95 |
96 | const func = handler.build;
97 | if (typeof func !== "function") {
98 | throw new TypeError(`Subtitle format does not support 'build' op: ${format}`);
99 | }
100 |
101 | return func(captions, options);
102 | };
103 |
104 | /**
105 | * Converts subtitle format.
106 | * @param content - The subtitle content
107 | * @param options - The conversion options
108 | * @returns The converted subtitle content
109 | */
110 | convert = (content: string, _options: ConvertOptions | string = {} as ConvertOptions) => {
111 | let options = {} as ConvertOptions;
112 | if (typeof _options === "string") {
113 | options.to = _options;
114 | } else {
115 | options = _options;
116 | }
117 |
118 | const parseOptions = {
119 | format: options.from ?? undefined,
120 | verbose: options.verbose,
121 | eol: options.eol,
122 | } as ParseOptions;
123 | let captions = this.parse(content, parseOptions);
124 |
125 | if (options.resync) {
126 | captions = this.resync(captions, options.resync);
127 | }
128 |
129 | const buildOptions = {
130 | format: options.to || options.format,
131 | verbose: options.verbose,
132 | eol: options.eol,
133 | } as BuildOptions;
134 | const result = this.build(captions, buildOptions);
135 |
136 | return result;
137 | };
138 |
139 | /**
140 | * Shifts the time of the captions.
141 | * @param captions - The captions to resync
142 | * @param options - The resync options
143 | * @throws TypeError If the 'options' argument is not defined
144 | * @returns The resynced captions
145 | */
146 | // skipcq: JS-0105
147 | resync = (captions: Caption[], options: ResyncFunction | number | ResyncOptions = {} as ResyncOptions) => {
148 | let func: ResyncFunction,
149 | ratio: number,
150 | frame = false,
151 | offset: number;
152 | if (typeof options === "function") {
153 | func = options; // User's function to handle time shift
154 | } else if (typeof options === "number") {
155 | offset = options; // Time shift (+/- offset)
156 | func = (a) => [a[0] + offset, a[1] + offset];
157 | } else {
158 | offset = (options.offset ?? 0) * (options.frame ? (options.fps ?? 25) : 1);
159 | ratio = options.ratio ?? 1.0;
160 | frame = options.frame ?? false;
161 | func = (a) => [Math.round(a[0] * ratio + offset), Math.round(a[1] * ratio + offset)];
162 | }
163 |
164 | const resynced: Caption[] = [];
165 | for (const _caption of captions) {
166 | const caption = clone(_caption);
167 | if (!caption.type || caption.type === "caption") {
168 | if (frame && caption.frame) {
169 | const shift = func([caption.frame.start, caption.frame.end]);
170 | if (shift && shift.length === 2) {
171 | caption.frame.start = shift[0];
172 | caption.frame.end = shift[1];
173 | caption.frame.count = caption.frame.end - caption.frame.start;
174 | }
175 | } else {
176 | const shift = func([caption.start, caption.end]);
177 | if (shift && shift.length === 2) {
178 | caption.start = shift[0];
179 | caption.end = shift[1];
180 | caption.duration = caption.end - caption.start;
181 | }
182 | }
183 | }
184 | resynced.push(caption);
185 | }
186 |
187 | return resynced;
188 | };
189 | }
190 |
191 | const subsrt = new Subsrt();
192 | export default subsrt;
193 | export const { format, list, detect, parse, build, convert, resync } = subsrt;
194 |
--------------------------------------------------------------------------------
/lib/types/handler.ts:
--------------------------------------------------------------------------------
1 | export interface ContentCaption {
2 | type: "caption";
3 | index: number;
4 | start: number;
5 | end: number;
6 | duration: number;
7 | cue?: string;
8 | content: string;
9 | text: string;
10 | frame?: {
11 | start: number;
12 | end: number;
13 | count: number;
14 | };
15 | data?: Record;
16 | }
17 |
18 | export interface MetaCaption {
19 | type: "meta";
20 | name: string;
21 | data: string | Record;
22 | tag?: string;
23 | }
24 |
25 | export interface StyleCaption {
26 | type: "style";
27 | data: Record;
28 | }
29 |
30 | export type Caption = ContentCaption | MetaCaption | StyleCaption;
31 |
32 | export interface ParseOptions {
33 | format?: string;
34 | verbose?: boolean;
35 | eol?: string;
36 | // Only for smi
37 | // preserveSpaces?: boolean;
38 | }
39 |
40 | export interface BuildOptions extends ParseOptions {
41 | format: string;
42 | }
43 |
44 | export type ConvertOptions = ParseOptions & {
45 | to: string;
46 | resync?: ResyncOptions;
47 | } & (
48 | | {
49 | format: string;
50 | from?: never;
51 | }
52 | | {
53 | format?: never;
54 | from: string;
55 | }
56 | );
57 |
58 | export interface ResyncOptions {
59 | offset?: number;
60 | ratio?: number;
61 | frame?: boolean;
62 | fps?: number;
63 | }
64 |
65 | export interface Helper {
66 | toMilliseconds?: (time: string) => number;
67 | toTimeString?: (ms: number) => string;
68 | htmlEncode?: (text: string) => string;
69 | htmlDecode?: (text: string, eol: string) => string;
70 | }
71 | export type BuildFunction = (captions: Caption[], options: BuildOptions) => string;
72 | export type DetectFunction = (content: string) => boolean | string;
73 | export type ParseFunction = (content: string, options: ParseOptions) => Caption[];
74 |
75 | export interface BaseHandler {
76 | name: string;
77 | helper?: Helper;
78 | build: BuildFunction;
79 | detect: DetectFunction;
80 | parse: ParseFunction;
81 | }
82 |
--------------------------------------------------------------------------------
/lib/types/subsrt.ts:
--------------------------------------------------------------------------------
1 | import { Handler } from "../handler.js";
2 |
3 | import { BaseHandler, Caption, ConvertOptions, ResyncOptions } from "./handler";
4 |
5 | export type ResyncFunction = (a: number[]) => number[];
6 |
7 | export type SubsrtFormats = Record;
8 |
9 | export interface SubsrtInterface extends Omit {
10 | format: SubsrtFormats;
11 | list: () => string[];
12 | convert: (content: string, options?: ConvertOptions | string) => string;
13 | resync: (captions: Caption[], options?: ResyncFunction | number | ResyncOptions) => Caption[];
14 | }
15 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "subsrt-ts",
3 | "description": "Subtitle JavaScript library and command line tool with no dependencies.",
4 | "keywords": [
5 | "subtitle",
6 | "captions",
7 | "srt",
8 | "sub",
9 | "sbv",
10 | "vtt",
11 | "ssa",
12 | "ass",
13 | "smi",
14 | "sami",
15 | "subrip",
16 | "lrc",
17 | "lyrics",
18 | "json"
19 | ],
20 | "version": "2.1.2",
21 | "author": "Rakuzen25",
22 | "contributors": [
23 | "Papn Kukn"
24 | ],
25 | "license": "MIT",
26 | "type": "module",
27 | "main": "dist/subsrt.js",
28 | "bin": "bin/subsrt.js",
29 | "types": "dist/subsrt.d.ts",
30 | "files": [
31 | "dist",
32 | "bin",
33 | "app.js"
34 | ],
35 | "scripts": {
36 | "format": "prettier --write . && eslint --fix . && tsc --noEmit",
37 | "lint": "prettier --check . && eslint . && tsc --noEmit",
38 | "build": "rm -rf dist && tsc --noEmitOnError",
39 | "test": "NODE_OPTIONS=--experimental-vm-modules pnpm jest",
40 | "testCoverage": "NODE_OPTIONS=--experimental-vm-modules pnpm jest --coverage=true --coverageReporters=cobertura",
41 | "docs": "pnpm typedoc --plugin typedoc-plugin-missing-exports lib/**.ts",
42 | "preversion": "pnpm run format && pnpm run lint && pnpm run build && pnpm run test",
43 | "prepublishOnly": "pnpm run test && pnpm run lint"
44 | },
45 | "repository": {
46 | "type": "git",
47 | "url": "https://github.com/rakuzen25/subsrt-ts"
48 | },
49 | "bugs": {
50 | "url": "https://github.com/rakuzen25/subsrt-ts/issues"
51 | },
52 | "devDependencies": {
53 | "@types/jest": "^29.5.12",
54 | "@types/node": "^20.11.16",
55 | "@typescript-eslint/eslint-plugin": "^7.0.0",
56 | "@typescript-eslint/parser": "^7.0.0",
57 | "eslint": "^8.56.0",
58 | "eslint-config-prettier": "^9.1.0",
59 | "eslint-import-resolver-typescript": "^3.6.1",
60 | "eslint-plugin-import": "^2.29.1",
61 | "eslint-plugin-jest": "^28.0.0",
62 | "eslint-plugin-regexp": "^2.2.0",
63 | "eslint-plugin-tsdoc": "^0.4.0",
64 | "jest": "^29.7.0",
65 | "prettier": "^3.2.5",
66 | "ts-jest": "^29.1.2",
67 | "typedoc": "^0.26.0",
68 | "typedoc-plugin-missing-exports": "^3.0.0",
69 | "typescript": "^5.3.3"
70 | },
71 | "packageManager": "pnpm@9.14.2"
72 | }
73 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:base", ":automergeMinor", "group:allNonMajor"]
4 | }
5 |
--------------------------------------------------------------------------------
/subsrt.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 | node app %*
3 |
--------------------------------------------------------------------------------
/test/build.test.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from "fs";
2 |
3 | import { build, list, parse } from "../lib/subsrt";
4 | import { BuildOptions, Caption } from "../lib/types/handler";
5 |
6 | describe("Build", () => {
7 | const formats = list();
8 | test.each(formats)("should build a subtitle file", (ext) => {
9 | console.log(`Build .${ext}`);
10 | const json = readFileSync("./test/fixtures/sample.json", "utf8");
11 | const captions = JSON.parse(json) as Caption[];
12 | const content = build(captions, {
13 | format: ext,
14 | closeTags: ext === "smi" ? true : undefined,
15 | } as BuildOptions);
16 | expect(content.length).toBeGreaterThan(0);
17 | });
18 |
19 | test("should build LRC tags", () => {
20 | const captions = [
21 | { type: "meta", tag: "ar", data: "Artist" },
22 | { type: "meta", tag: "ti", data: "Title" },
23 | { type: "meta", tag: "au", data: "Author" },
24 | { type: "meta", tag: "al", data: "Album" },
25 | { type: "meta", tag: "by", data: "Creator" },
26 | { type: "meta", tag: "offset", data: "100" },
27 | { type: "caption", start: 0, text: "Lyrics" },
28 | ] as Caption[];
29 | const content = build(captions, { format: "lrc" });
30 | expect(content).toMatch(/\[ar:.*\]/);
31 | expect(content).toMatch(/\[ti:.*\]/);
32 | expect(content).toMatch(/\[au:.*\]/);
33 | expect(content).toMatch(/\[al:.*\]/);
34 | expect(content).toMatch(/\[by:.*\]/);
35 | expect(content).toMatch(/\[offset:.*\]/);
36 | });
37 |
38 | test("should not have duplicate WEBVTT header", () => {
39 | const vtt = readFileSync("./test/fixtures/sample.vtt", "utf8");
40 | const captions = parse(vtt, { format: "vtt" });
41 | const content = build(captions, { format: "vtt" });
42 | expect(content).not.toMatch(/WEBVTT\r?\nWEBVTT/);
43 | });
44 | });
45 |
--------------------------------------------------------------------------------
/test/convert.test.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from "fs";
2 |
3 | import { convert, detect, list, parse } from "../lib/subsrt";
4 | import { ContentCaption } from "../lib/types/handler";
5 |
6 | describe("Convert", () => {
7 | const extensions = list();
8 | const cases = extensions.map((ext1) => extensions.map((ext2) => [ext1, ext2])).flat();
9 | test.each(cases)("should convert a subtitle file", (ext1, ext2) => {
10 | if (ext1 === ext2) {
11 | return;
12 | }
13 |
14 | console.log(`Convert .${ext1} to .${ext2}`);
15 |
16 | const content1 = readFileSync(`./test/fixtures/sample.${ext1}`, "utf8");
17 | const content2 = convert(content1, { from: ext1, to: ext2 });
18 |
19 | expect(typeof content2).toBe("string");
20 | expect(content2.length).toBeGreaterThan(0);
21 |
22 | const format = detect(content2);
23 | expect(format).toBe(ext2);
24 |
25 | const captions = parse(content2, { format: ext2 });
26 | expect(typeof captions).not.toBe("undefined");
27 | expect(captions.length).toBeGreaterThan(0);
28 |
29 | // String "to" format as second argument
30 | const content3 = convert(content1, ext2);
31 | expect(content3).toBe(content2);
32 | });
33 |
34 | test("should resync +3000 ms after conversion", () => {
35 | const srt = readFileSync("./test/fixtures/sample.srt", "utf8");
36 | const captions = parse(srt, { format: "srt" });
37 | const convertedAndResynced = convert(srt, {
38 | from: "srt",
39 | to: "vtt",
40 | resync: {
41 | offset: 3000,
42 | },
43 | });
44 |
45 | expect(typeof convertedAndResynced).toBe("string");
46 | expect(convertedAndResynced.length).toBeGreaterThan(0);
47 |
48 | const format = detect(convertedAndResynced);
49 | expect(format).toBe("vtt");
50 |
51 | const resynced = parse(convertedAndResynced, { format });
52 | expect(typeof resynced).toBe("object");
53 | expect(resynced.length).toBeGreaterThan(0);
54 | expect(resynced).toHaveLength(captions.length + 1); // Extra WEBVTT header
55 |
56 | expect((resynced[1] as ContentCaption).start).toBe((captions[0] as ContentCaption).start + 3000);
57 | expect((resynced[1] as ContentCaption).end).toBe((captions[0] as ContentCaption).end + 3000);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/test/customFormat.test.ts:
--------------------------------------------------------------------------------
1 | import { build, convert, detect, format, parse, resync } from "../lib/subsrt";
2 | import { ContentCaption } from "../lib/types/handler";
3 |
4 | describe("Custom format", () => {
5 | beforeAll(() => {
6 | format.line = {
7 | name: "line",
8 | parse: (content, _options) =>
9 | content.split("\n").map((text, index) => ({
10 | type: "caption",
11 | index,
12 | start: index * 10,
13 | end: (index + 1) * 10 - 1,
14 | duration: 10,
15 | content: text,
16 | text,
17 | })),
18 | build: (captions, options) => captions.map((caption) => (caption as ContentCaption).text).join(options.eol ?? "\n"),
19 | detect: (content) => content.includes("\n"),
20 | };
21 | });
22 |
23 | test("should parse a custom format", () => {
24 | const content = "Hello\nWorld";
25 | const captions = parse(content, { format: "line" }) as ContentCaption[];
26 |
27 | expect(typeof captions).toBe("object");
28 | expect(captions.length).toBeGreaterThan(0);
29 | expect(captions).toHaveLength(2);
30 | expect(captions[0].start).toBe(0);
31 | expect(captions[0].end).toBe(9);
32 | expect(captions[1].start).toBe(10);
33 | expect(captions[1].end).toBe(19);
34 | });
35 |
36 | test("should build a custom format", () => {
37 | const captions = [
38 | {
39 | type: "caption",
40 | index: 0,
41 | start: 0,
42 | end: 9,
43 | duration: 10,
44 | content: "Hello",
45 | text: "Hello",
46 | },
47 | {
48 | type: "caption",
49 | index: 1,
50 | start: 10,
51 | end: 19,
52 | duration: 10,
53 | content: "World",
54 | text: "World",
55 | },
56 | ] as ContentCaption[];
57 | const content = build(captions, { format: "line" });
58 |
59 | expect(typeof content).toBe("string");
60 | expect(content.length).toBeGreaterThan(0);
61 | expect(content).toBe("Hello\nWorld");
62 | });
63 |
64 | test("should detect a custom format", () => {
65 | const content = "Hello\nWorld";
66 | const detected = detect(content);
67 |
68 | expect(typeof detected).toBe("string");
69 | expect(detected.length).toBeGreaterThan(0);
70 | expect(detected).toBe("line");
71 | });
72 |
73 | test("should convert a custom format", () => {
74 | const content = "Hello\nWorld";
75 | const converted = convert(content, { from: "line", to: "srt" });
76 |
77 | expect(typeof converted).toBe("string");
78 | expect(converted.length).toBeGreaterThan(0);
79 |
80 | const convertedAndResynced = convert(content, {
81 | from: "line",
82 | to: "srt",
83 | resync: {
84 | offset: 3000,
85 | },
86 | });
87 | expect(typeof convertedAndResynced).toBe("string");
88 | expect(convertedAndResynced.length).toBeGreaterThan(0);
89 | expect(convertedAndResynced).toHaveLength(converted.length);
90 |
91 | const format = detect(convertedAndResynced);
92 | expect(format).toBe("srt");
93 |
94 | const resynced = parse(convertedAndResynced, { format });
95 | expect(typeof resynced).toBe("object");
96 | expect(resynced.length).toBeGreaterThan(0);
97 | expect(resynced).toHaveLength(2);
98 | expect((resynced[0] as ContentCaption).start).toBe(3000);
99 | expect((resynced[0] as ContentCaption).end).toBe(3009);
100 | });
101 |
102 | test("should resync a custom format", () => {
103 | const content = "Hello\nWorld";
104 | const captions = parse(content, { format: "line" }) as ContentCaption[];
105 | const resynced = resync(captions, +3000);
106 |
107 | expect(typeof resynced).toBe("object");
108 | expect(resynced.length).toBeGreaterThan(0);
109 | expect(resynced).toHaveLength(captions.length);
110 | expect((resynced[0] as ContentCaption).start).toBe(captions[0].start + 3000);
111 | expect((resynced[0] as ContentCaption).end).toBe(captions[0].end + 3000);
112 |
113 | const content2 = build(resynced, { format: "line" });
114 | expect(typeof content2).toBe("string");
115 | expect(content2.length).toBeGreaterThan(0);
116 | expect(content2).toBe("Hello\nWorld");
117 | });
118 |
119 | test("should throw an error or skip when the format does not have corresponding methods", () => {
120 | // @ts-expect-error For testing purposes
121 | format.line.build = undefined;
122 | expect(() => {
123 | build([], { format: "line" });
124 | }).toThrow(TypeError);
125 |
126 | // @ts-expect-error For testing purposes
127 | format.line.parse = undefined;
128 | expect(() => {
129 | parse("Hello\nWorld", { format: "line" });
130 | }).toThrow(TypeError);
131 |
132 | // @ts-expect-error For testing purposes
133 | format.line.detect = undefined;
134 | expect(detect("Hello\nWorld")).toBe("");
135 | });
136 | });
137 |
--------------------------------------------------------------------------------
/test/detect.test.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from "fs";
2 |
3 | import { detect, list } from "../lib/subsrt";
4 |
5 | describe("Detect", () => {
6 | const formats = list();
7 | test.each(formats)("should detect a subtitle file", (ext) => {
8 | console.log(`Detect .${ext}`);
9 | const content = readFileSync(`./test/fixtures/sample.${ext}`, "utf8");
10 |
11 | const expected = ext;
12 | const actual = detect(content);
13 |
14 | expect(actual).toBe(expected);
15 | });
16 |
17 | test("should return an empty string when the format is not supported", () => {
18 | expect(detect("Hello\nWorld")).toBe("");
19 | });
20 |
21 | test("should throw an error when the input is not a string", () => {
22 | // @ts-expect-error For testing purposes
23 | expect(() => detect(1)).toThrow(TypeError);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/test/fixtures/sample.ass:
--------------------------------------------------------------------------------
1 | [Script Info]
2 | ; Script generated by Aegisub
3 | ; http://www.aegisub.net
4 | Title: Neon Genesis Evangelion - Episode 26 (neutral Spanish)
5 | Original Script: RoRo
6 | Script Updated By: version 2.8.01
7 | ScriptType: v4.00+
8 | Collisions: Normal
9 | PlayResY: 600
10 | PlayDepth: 0
11 | Timer: 100,0000
12 | Video Aspect Ratio: 0
13 | Video Zoom: 6
14 | Video Position: 0
15 |
16 | [V4+ Styles]
17 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
18 | Style: DefaultVCD, Arial,28,&H00B4FCFC,&H00B4FCFC,&H00000008,&H80000008,-1,0,0,0,100,100,0.00,0.00,1,1.00,2.00,2,30,30,30,0
19 |
20 | [Events]
21 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
22 | Dialogue: 0,0:00:01.18,0:00:06.85,DefaultVCD, NTP,0000,0000,0000,,{\pos(400,570)}Like an Angel with pity on nobody\NThe second line in subtitle
23 |
--------------------------------------------------------------------------------
/test/fixtures/sample.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "type": "caption",
4 | "index": 1,
5 | "start": 599,
6 | "end": 4160,
7 | "duration": 3561,
8 | "content": ">> ALICE: Hi, my name is Alice Miller and this is John Brown",
9 | "text": "Hi, my name is Alice Miller and this is John Brown"
10 | },
11 | {
12 | "type": "caption",
13 | "index": 2,
14 | "start": 4160,
15 | "end": 6770,
16 | "duration": 2610,
17 | "content": ">> JOHN: and we're the owners of Miller Bakery.",
18 | "text": "and we're the owners of Miller Bakery."
19 | },
20 | {
21 | "type": "caption",
22 | "index": 3,
23 | "start": 6770,
24 | "end": 10880,
25 | "duration": 4110,
26 | "content": ">> ALICE: Today we'll be teaching you how to make\r\nour famous chocolate chip cookies!",
27 | "text": "Today we'll be teaching you how to make\r\nour famous chocolate chip cookies!"
28 | },
29 | {
30 | "type": "caption",
31 | "index": 4,
32 | "start": 10880,
33 | "end": 16700,
34 | "duration": 5820,
35 | "content": "[intro music]",
36 | "text": "[intro music]"
37 | },
38 | {
39 | "type": "caption",
40 | "index": 5,
41 | "start": 16700,
42 | "end": 21480,
43 | "duration": 4780,
44 | "content": "Okay, so we have all the ingredients laid out here",
45 | "text": "Okay, so we have all the ingredients laid out here"
46 | }
47 | ]
48 |
--------------------------------------------------------------------------------
/test/fixtures/sample.lrc:
--------------------------------------------------------------------------------
1 | [ar:Lyrics artist]
2 | [al:Album where the song is from]
3 | [ti:Lyrics (song) title]
4 | [au:Creator of the Songtext]
5 | [length:How long the song is]
6 | [by:Creator of the LRC file]
7 | [offset:+/- Overall timestamp adjustment in milliseconds, + shifts time up, - shifts down]
8 |
9 | [re:The player or editor that created the LRC file]
10 | [ve:version of program]
11 |
12 | [00:12.00]Line 1 lyrics
13 | [00:17.20]F: Line 2 lyrics
14 | [00:21.10]M: Line 3 lyrics
15 | [00:24.00]Line 4 lyrics
16 | [00:28.25]D: Line 5 lyrics
17 | [00:29.02]Line 6 lyrics
18 |
--------------------------------------------------------------------------------
/test/fixtures/sample.sbv:
--------------------------------------------------------------------------------
1 | 0:00:00.599,0:00:04.160
2 | >> ALICE: Hi, my name is Alice Miller[br]and this is John Brown
3 |
4 | 0:00:04.160,0:00:06.770
5 | >> JOHN: and we're the owners of Miller Bakery.
6 |
7 | 0:00:06.770,0:00:10.880
8 | >> ALICE: Today we'll be teaching you how to make
9 | our famous chocolate chip cookies!
10 |
11 | 0:00:10.880,0:00:16.700
12 | [intro music]
13 |
14 | 0:00:16.700,0:00:21.480
15 | Okay, so we have all the ingredients laid out here
16 |
--------------------------------------------------------------------------------
/test/fixtures/sample.smi:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
14 |
15 |
16 | 500년 전,
17 |
18 |
19 | 살려서는 안 될 인간의 목숨을 구했다
20 |
21 |
22 | 이 때문에, 그녀는 얼음연못에 감금되었다
23 |
24 | 밤낮으로 극심한 추위가 고통이었다
25 |
26 |
27 | 매일 연못 위를 맴돌았다
28 |
29 |
30 | 빨리! 빨리!
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/test/fixtures/sample.srt:
--------------------------------------------------------------------------------
1 | 1
2 | 00:00:00,599 --> 00:00:04,160
3 | >> ALICE: Hi, my name is Alice Miller and this is John Brown
4 |
5 | 2
6 | 00:00:04,160 --> 00:00:06,770
7 | >> JOHN: and we're the owners of Miller Bakery.
8 |
9 | 3
10 | 00:00:06,770 --> 00:00:10,880
11 | >> ALICE: Today we'll be teaching you how to make
12 | our famous chocolate chip cookies!
13 |
14 | 4
15 | 00:00:10,880 --> 00:00:16,700
16 | [intro music]
17 |
18 | 5
19 | 00:00:16,700 --> 00:00:21,480
20 | Okay, so we have all the ingredients laid out here
21 |
--------------------------------------------------------------------------------
/test/fixtures/sample.ssa:
--------------------------------------------------------------------------------
1 | [Script Info]
2 | ; This is a Sub Station Alpha v4 script.
3 | ; For Sub Station Alpha info and downloads,
4 | ; go to http://www.eswat.demon.co.uk/
5 | Title: Neon Genesis Evangelion - Episode 26 (neutral Spanish)
6 | Original Script: RoRo
7 | Script Updated By: version 2.8.01
8 | ScriptType: v4.00
9 | Collisions: Normal
10 | PlayResY: 600
11 | PlayDepth: 0
12 | Timer: 100,0000
13 |
14 | [V4 Styles]
15 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, TertiaryColour, BackColour, Bold, Italic, BorderStyle, Outline, Shadow,
16 | Alignment, MarginL, MarginR, MarginV, AlphaLevel, Encoding
17 | Style: DefaultVCD, Arial,28,11861244,11861244,11861244,-2147483640,-1,0,1,1,2,2,30,30,30,0,0
18 |
19 | [Events]
20 | Format: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
21 | Dialogue: Marked=0,0:00:01.18,0:00:06.85,DefaultVCD, NTP,0000,0000,0000,,{\pos(400,570)}Like an angel with pity on nobody
22 |
--------------------------------------------------------------------------------
/test/fixtures/sample.sub:
--------------------------------------------------------------------------------
1 | {14975}{104000}Hi, my name is Alice Miller and this is John Brown
2 | {104000}{169250}and we're the owners of Miller Bakery.
3 | {169250}{272000}Today we'll be teaching you how to make|our famous chocolate chip cookies!
4 | {272000}{417500}[intro music]
5 | {417500}{537000}Okay, so we have all the ingredients laid out here
6 |
--------------------------------------------------------------------------------
/test/fixtures/sample.vtt:
--------------------------------------------------------------------------------
1 | WEBVTT
2 |
3 | NOTE
4 | This file was written by Jill. I hope
5 | you enjoy reading it. Some things to
6 | bear in mind:
7 | - I was lip-reading, so the cues may
8 | not be 100% accurate
9 | - I didn’t pay too close attention to
10 | when the cues should start or end.
11 |
12 | STYLE
13 | ::cue {
14 | background-image: linear-gradient(to bottom, dimgray, lightgray);
15 | color: papayawhip;
16 | }
17 | /* Style blocks cannot use blank lines nor "dash dash greater than" */
18 |
19 | 00:01.000 --> 00:04.000
20 | — Hi, my name is Alice Miller and this is John Brown.
21 | — And we're the owners of Miller Bakery.
22 |
23 | NOTE check next cue
24 |
25 | 00:05.000 --> 00:09.000
26 | Today we'll be teaching you how to make
27 | our famous chocolate chip cookies!
28 |
29 | test
30 | 00:20.000 --> 00:22.000
31 | This is a test.
32 |
33 | 123
34 | 00:22.000 --> 00:24.000
35 | That’s an, an, that’s an L!
36 |
37 | crédit de transcription
38 | 00:24.000 --> 00:30.000
39 | Okay, so we have all the ingredients laid out here
40 |
41 | NOTE end of file
42 |
--------------------------------------------------------------------------------
/test/parse.test.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from "fs";
2 |
3 | import { list, parse } from "../lib/subsrt";
4 |
5 | describe("Parse", () => {
6 | const formats = list();
7 | test.each(formats)("should parse a subtitle file", (ext) => {
8 | console.log(`Parse .${ext}`);
9 | const content = readFileSync(`./test/fixtures/sample.${ext}`, "utf8");
10 | const captions = parse(content, { format: ext });
11 | expect(captions.length).toBeGreaterThan(1);
12 | });
13 |
14 | test("should throw an error when the format is not supported", () => {
15 | expect(() => parse("Hello\nWorld")).toThrow(TypeError);
16 | expect(() => {
17 | parse("Hello\nWorld", { format: "unsupported" });
18 | }).toThrow(TypeError);
19 | });
20 |
21 | test("should throw an error when the input is not a string", () => {
22 | // @ts-expect-error For testing purposes
23 | expect(() => parse(1)).toThrow(TypeError);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/test/resync.test.ts:
--------------------------------------------------------------------------------
1 | import { readFileSync } from "fs";
2 |
3 | import { SUBParseOptions } from "../lib/format/types/sub";
4 | import { parse, resync } from "../lib/subsrt";
5 | import { ContentCaption } from "../lib/types/handler";
6 |
7 | describe("Resync", () => {
8 | test("should resync +3000 ms", () => {
9 | const srt = readFileSync("./test/fixtures/sample.srt", "utf8");
10 | const captions = parse(srt);
11 | const resynced = resync(captions, +3000);
12 |
13 | expect(typeof resynced).toBe("object");
14 | expect(resynced.length).toBeGreaterThan(0);
15 | expect(resynced).toHaveLength(captions.length);
16 |
17 | expect((resynced[0] as ContentCaption).start).toBe((captions[0] as ContentCaption).start + 3000);
18 | expect((resynced[0] as ContentCaption).end).toBe((captions[0] as ContentCaption).end + 3000);
19 | });
20 |
21 | test("should resync -250 ms", () => {
22 | const sbv = readFileSync("./test/fixtures/sample.sbv", "utf8");
23 | const captions = parse(sbv);
24 | const resynced = resync(captions, -250);
25 |
26 | expect(typeof resynced).toBe("object");
27 | expect(resynced.length).toBeGreaterThan(0);
28 | expect(resynced).toHaveLength(captions.length);
29 |
30 | expect((resynced[3] as ContentCaption).start).toBe((captions[3] as ContentCaption).start - 250);
31 | expect((resynced[3] as ContentCaption).end).toBe((captions[3] as ContentCaption).end - 250);
32 | });
33 |
34 | test("should resync 25 to 30 FPS", () => {
35 | const sub = readFileSync("./test/fixtures/sample.sub", "utf8");
36 | const captions = parse(sub, { fps: 25 } as SUBParseOptions);
37 | const resynced = resync(captions, { ratio: 30 / 25, frame: true });
38 |
39 | expect(typeof resynced).toBe("object");
40 | expect(resynced.length).toBeGreaterThan(0);
41 | expect(resynced).toHaveLength(captions.length);
42 |
43 | expect((resynced[3] as ContentCaption).frame).toBeDefined();
44 | expect((resynced[3] as ContentCaption).frame?.start).toBe((((captions[3] as ContentCaption)?.frame?.start ?? 0) * 30) / 25);
45 | expect((resynced[3] as ContentCaption).frame?.end).toBe((((captions[3] as ContentCaption)?.frame?.end ?? 0) * 30) / 25);
46 | expect((resynced[3] as ContentCaption).frame?.count).toBe((((captions[3] as ContentCaption)?.frame?.count ?? 0) * 30) / 25);
47 | });
48 |
49 | test("should resync with non-linear function", () => {
50 | const vtt = readFileSync("./test/fixtures/sample.vtt", "utf8");
51 | const captions = parse(vtt);
52 | const resynced = resync(captions, (a) => [
53 | a[0] + 0, // Keep the start time
54 | a[1] + 500, // Extend each end time by 500 ms
55 | ]);
56 |
57 | expect(typeof resynced).toBe("object");
58 | expect(resynced.length).toBeGreaterThan(0);
59 | expect(resynced).toHaveLength(captions.length);
60 |
61 | expect((resynced[3] as ContentCaption).start).toBe((captions[3] as ContentCaption).start);
62 | expect((resynced[3] as ContentCaption).end).toBe((captions[3] as ContentCaption).end + 500);
63 | });
64 | });
65 |
--------------------------------------------------------------------------------
/test/time.test.ts:
--------------------------------------------------------------------------------
1 | import { format, list } from "../lib/subsrt";
2 |
3 | describe("Time", () => {
4 | const formats = list();
5 | const fixtures = [0, 90, 1000, 60000, 3600000, 7236250];
6 | test.each(formats)("should convert time to milliseconds", (ext) => {
7 | console.log(`Time .${ext}`);
8 | const handler = format[ext];
9 | if (!handler.helper) {
10 | console.log(`Time .${ext} skipped`);
11 | return;
12 | }
13 | const toMilliseconds = handler.helper.toMilliseconds,
14 | toTimeString = handler.helper.toTimeString;
15 | if (typeof toMilliseconds !== "function" || typeof toTimeString !== "function") {
16 | console.log(`Time .${ext} skipped`);
17 | return;
18 | }
19 | for (const value of fixtures) {
20 | const str = toTimeString(value);
21 | const ms = toMilliseconds(str);
22 | expect(ms).toBe(value);
23 | }
24 |
25 | // Test for invalid time
26 | expect(() => {
27 | toMilliseconds("Hello");
28 | }).toThrow(TypeError);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "noEmit": true,
4 | "types": ["@types/node", "jest"]
5 | },
6 | "exclude": ["dist"],
7 | "extends": "./tsconfig.json"
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "declaration": true,
5 | "lib": ["es2022", "dom"],
6 | "module": "es2022",
7 | "moduleResolution": "Node",
8 | "outDir": "dist",
9 | "rootDir": "lib",
10 | "strict": true,
11 | "target": "es6"
12 | },
13 | "exclude": ["test", "dist"]
14 | }
15 |
--------------------------------------------------------------------------------