subcraft 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -2
- package/dist/index.cjs +292 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +180 -0
- package/dist/index.d.ts +180 -0
- package/dist/index.js +280 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ranjith Gowda
|
|
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.
|
package/README.md
CHANGED
|
@@ -1,3 +1,296 @@
|
|
|
1
|
-
#
|
|
1
|
+
# subcraft
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Zero-dependency, isomorphic TypeScript engine for subtitle parsing, frame-rate retiming, and Unicode grapheme-cluster quality linting (powered by `Intl.Segmenter`).**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/subcraft)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](https://www.npmjs.com/package/subcraft)
|
|
8
|
+
[](https://vitest.dev/)
|
|
9
|
+
[](https://opensource.org/licenses/MIT)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Highlights
|
|
14
|
+
|
|
15
|
+
* **100% Zero Dependencies:** Lightweight and fast. Runs natively in any JavaScript runtime (Browsers, Web Workers, Node.js 20+, and Edge runtimes) without polyfills.
|
|
16
|
+
* **Resilient Parsing:** Strips UTF-8 BOM (`\uFEFF`), normalizes mixed line endings (`CRLF`, `CR`, `LF`), and captures syntax errors without crashing or throwing unhandled exceptions.
|
|
17
|
+
* **Deterministic Timecode Math:** Lossless millisecond arithmetic with integer precision (`Math.round`), frame-rate drift scaling (`retime`), and linear audio-sync shifting (`shiftTimecodes`).
|
|
18
|
+
* **Grapheme-Aware Quality Linter:** Uses the native ECMAScript `Intl.Segmenter` API to measure true visual characters rather than UTF-16 code units—eliminating false reading-speed warnings in Indic scripts (Kannada, Hindi) and emoji-heavy text.
|
|
19
|
+
* **First-Class TypeScript:** Authored in strict TypeScript with comprehensive type declarations (`.d.ts` and `.d.cts`) and verified dual ESM/CJS exports via `publint`.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Table of Contents
|
|
24
|
+
|
|
25
|
+
1. [Installation](#installation)
|
|
26
|
+
2. [Quick Start](#quick-start)
|
|
27
|
+
3. [Core Modules](#core-modules)
|
|
28
|
+
- [1. SRT Parsing & Resilient Error Collection](#1-srt-parsing--resilient-error-collection)
|
|
29
|
+
- [2. SRT Serialization](#2-srt-serialization)
|
|
30
|
+
- [3. Timecode Conversions](#3-timecode-conversions)
|
|
31
|
+
- [4. Audio Sync & Frame-Rate Drift Retiming](#4-audio-sync--frame-rate-drift-retiming)
|
|
32
|
+
- [5. Locale-Aware Quality Linter](#5-locale-aware-quality-linter)
|
|
33
|
+
4. [The Indic / Unicode Grapheme Problem](#the-indic--unicode-grapheme-problem)
|
|
34
|
+
5. [Comparison with Legacy Libraries](#comparison-with-legacy-libraries)
|
|
35
|
+
6. [API Reference & Types](#api-reference--types)
|
|
36
|
+
7. [License](#license)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Using npm
|
|
44
|
+
npm install subcraft
|
|
45
|
+
|
|
46
|
+
# Using pnpm
|
|
47
|
+
pnpm add subcraft
|
|
48
|
+
|
|
49
|
+
# Using yarn
|
|
50
|
+
yarn add subcraft
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { parseSRT, stringifySRT, lintCues, shiftTimecodes } from 'subcraft';
|
|
59
|
+
|
|
60
|
+
const rawSRT = `1
|
|
61
|
+
00:00:01,000 --> 00:00:04,000
|
|
62
|
+
Welcome to subcraft!
|
|
63
|
+
|
|
64
|
+
2
|
|
65
|
+
00:00:03,800 --> 00:00:06,000
|
|
66
|
+
Overlapping subtitle cue.`;
|
|
67
|
+
|
|
68
|
+
// 1. Parse SRT text resiliently
|
|
69
|
+
const { cues, errors } = parseSRT(rawSRT);
|
|
70
|
+
console.log(`Parsed ${cues.length} cues with ${errors.length} syntax errors.`);
|
|
71
|
+
|
|
72
|
+
// 2. Lint for quality defects (overlaps, reading speed, duration)
|
|
73
|
+
const diagnostics = lintCues(cues);
|
|
74
|
+
console.log(diagnostics);
|
|
75
|
+
// Output flags: Cue 2 overlaps with Cue 1 by 200ms!
|
|
76
|
+
|
|
77
|
+
// 3. Shift timings by +500ms (to fix audio desync)
|
|
78
|
+
const shiftedCues = shiftTimecodes(cues, 500);
|
|
79
|
+
|
|
80
|
+
// 4. Serialize back to standard SRT string
|
|
81
|
+
const updatedSRT = stringifySRT(shiftedCues);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Core Modules
|
|
87
|
+
|
|
88
|
+
### 1. SRT Parsing & Resilient Error Collection
|
|
89
|
+
|
|
90
|
+
Unlike traditional parsers that throw fatal exceptions when encountering corrupted blocks or missing line numbers, `parseSRT` collects syntax errors alongside successfully parsed cues:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
import { parseSRT } from 'subcraft';
|
|
94
|
+
|
|
95
|
+
const { cues, errors } = parseSRT(dirtySubtitleContent);
|
|
96
|
+
|
|
97
|
+
if (errors.length > 0) {
|
|
98
|
+
errors.forEach(err => {
|
|
99
|
+
console.warn(`Block error around index ${err.index}: ${err.message}`);
|
|
100
|
+
console.warn(`Raw block: "${err.rawBlock}"`);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
* Automatically strips UTF-8 BOM (`\uFEFF`).
|
|
106
|
+
* Normalizes Windows (`\r\n`) and Unix (`\n`) newlines.
|
|
107
|
+
* Handles missing or non-numeric cue IDs by assigning fallback sequential numbers.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
### 2. SRT Serialization
|
|
112
|
+
|
|
113
|
+
Formats structured `SubtitleCue[]` objects back into strict standard SubRip (.srt) syntax:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
import { stringifySRT, SubtitleCue } from 'subcraft';
|
|
117
|
+
|
|
118
|
+
const cues: SubtitleCue[] = [
|
|
119
|
+
{ id: '1', start: 1000, end: 4000, text: 'Hello world' }
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const srtString = stringifySRT(cues);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### 3. Timecode Conversions
|
|
128
|
+
|
|
129
|
+
Convert between standard timecode strings and integer milliseconds without floating-point drift:
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
import { timecodeToMs, msToTimecode } from 'subcraft';
|
|
133
|
+
|
|
134
|
+
// Convert string to milliseconds
|
|
135
|
+
timecodeToMs('00:01:23,456'); // Returns: 83456
|
|
136
|
+
timecodeToMs('01:23.456'); // WebVTT short form -> Returns: 83456
|
|
137
|
+
|
|
138
|
+
// Convert milliseconds to formatted timecode
|
|
139
|
+
msToTimecode(83456, 'srt'); // "00:01:23,456"
|
|
140
|
+
msToTimecode(83456, 'vtt'); // "00:01:23.456"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### 4. Audio Sync & Frame-Rate Drift Retiming
|
|
146
|
+
|
|
147
|
+
#### Linear Shift (`shiftTimecodes`)
|
|
148
|
+
Add or subtract a constant offset across all cues (e.g. delaying subtitles by 500ms). Negative results are safely clamped to `0ms`:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
import { shiftTimecodes } from 'subcraft';
|
|
152
|
+
|
|
153
|
+
// Shift all cues forward by 1.5 seconds
|
|
154
|
+
const delayed = shiftTimecodes(cues, 1500);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
#### Frame-Rate Scaling (`retime`)
|
|
158
|
+
When videos are converted between frame rates (e.g., 24 fps Film to 25 fps PAL television), timecodes drift multiplicatively over time. `retime` applies a scaling ratio, strictly rounding each millisecond with `Math.round`:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { retime } from 'subcraft';
|
|
162
|
+
|
|
163
|
+
// 24 fps to 25 fps (PAL speed-up): video runs ~4% faster, timecodes compress
|
|
164
|
+
const palCues = retime(filmCues, 24 / 25); // ratio: 0.96
|
|
165
|
+
|
|
166
|
+
// 25 fps to 24 fps (Slowdown): video runs slower, timecodes stretch
|
|
167
|
+
const filmCues = retime(palCues, 25 / 24); // ratio: 1.04167
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
### 5. Locale-Aware Quality Linter
|
|
173
|
+
|
|
174
|
+
Inspects subtitles against broadcaster standards (Netflix / BBC style guides):
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import { lintCues, DEFAULT_PROFILES } from 'subcraft';
|
|
178
|
+
|
|
179
|
+
// Lint using standard Latin rules (default)
|
|
180
|
+
const diagnostics = lintCues(cues);
|
|
181
|
+
|
|
182
|
+
// Lint using Indic (Kannada) profile
|
|
183
|
+
const kannadaDiagnostics = lintCues(cues, DEFAULT_PROFILES.indicKannada);
|
|
184
|
+
|
|
185
|
+
// Custom profile overrides
|
|
186
|
+
const customDiagnostics = lintCues(cues, {
|
|
187
|
+
maxCps: 18,
|
|
188
|
+
maxCpl: 40,
|
|
189
|
+
minDurationMs: 1000,
|
|
190
|
+
maxDurationMs: 6000,
|
|
191
|
+
includeSpaces: true,
|
|
192
|
+
locale: 'kn',
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
#### Rules Enforced:
|
|
197
|
+
| Rule | Severity | Description | Default Threshold |
|
|
198
|
+
| :--- | :---: | :--- | :--- |
|
|
199
|
+
| `out-of-order` | `error` | Cue start timestamp is earlier than previous cue start | Ascending chronological order |
|
|
200
|
+
| `zero-or-negative-duration` | `error` | Cue duration is zero or negative (`end <= start`) | `end > start` |
|
|
201
|
+
| `overlap` | `error` | Cue start timestamp precedes the previous cue's end timestamp | `start >= prevEnd` |
|
|
202
|
+
| `min-duration` | `warning` | Cue flashes too quickly for human cognitive reading | Min `800ms` |
|
|
203
|
+
| `max-duration` | `warning` | Cue stays on screen for too long, distracting from video | Max `7,000ms` |
|
|
204
|
+
| `max-cpl` | `warning` | Line length exceeds display boundary limits | Max `42` CPL (Latin) / `35` CPL (Indic) |
|
|
205
|
+
| `max-cps` | `warning` | Reading speed exceeds human reading speed capacity | Max `20` CPS (Latin) / `15` CPS (Indic) |
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## The Indic / Unicode Grapheme Problem
|
|
210
|
+
|
|
211
|
+
In standard JavaScript, `string.length` counts **UTF-16 code units**, not visual characters.
|
|
212
|
+
|
|
213
|
+
In Indic scripts (such as Kannada, Hindi, Telugu, Tamil), conjunct consonants, viramas, and vowel modifiers (matras) are stored across multiple code units:
|
|
214
|
+
|
|
215
|
+
```javascript
|
|
216
|
+
// Standard JavaScript string.length:
|
|
217
|
+
console.log('ವಿದ್ಯಾರ್ಥಿ'.length); // 10 code units!
|
|
218
|
+
console.log('ಬೆಂಗಳೂರು'.length); // 8 code units!
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
If a subtitle validation tool calculates **Characters Per Second (CPS)** using `string.length`, it counts **10 characters** for a 5-character word like `"ವಿದ್ಯಾರ್ಥಿ"`. This **doubles the reading speed calculation**, triggering false-positive "Reading speed too fast!" warnings.
|
|
222
|
+
|
|
223
|
+
### How `subcraft` Solves This:
|
|
224
|
+
`subcraft` uses the native ECMAScript standard **`Intl.Segmenter`** (UAX #29 Unicode grapheme clustering):
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { countGraphemes } from 'subcraft';
|
|
228
|
+
|
|
229
|
+
countGraphemes('ವಿದ್ಯಾರ್ಥಿ', 'kn'); // Accurately returns: 5 graphemes
|
|
230
|
+
countGraphemes('ಬೆಂಗಳೂರು', 'kn'); // Accurately returns: 4 graphemes
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`subcraft` also automatically strips HTML markup (`<i>`, `<b>`, `<font...>`) before counting characters, ensuring that visual styling does not artificially penalize reading speed metrics.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Comparison with Legacy Libraries
|
|
238
|
+
|
|
239
|
+
| Feature | `subcraft` | `subtitle.js` (legacy) | `srt-parser-2` |
|
|
240
|
+
| :--- | :---: | :---: | :---: |
|
|
241
|
+
| **Dependencies** | **0 (Zero)** | Depends on Node.js `stream` | 0 |
|
|
242
|
+
| **Browser & Web Worker Native** | ✅ **100% Native** | ❌ Requires Node polyfills | ✅ |
|
|
243
|
+
| **Built-in Quality Linter** | ✅ **Comprehensive** | ❌ None | ❌ None |
|
|
244
|
+
| **Unicode Grapheme CPS / CPL** | ✅ **`Intl.Segmenter`** | ❌ None | ❌ None |
|
|
245
|
+
| **Overlap & Chronology Checks** | ✅ **Yes** | ❌ None | ❌ None |
|
|
246
|
+
| **Frame-rate Drift Retiming** | ✅ **Yes (`retime`)** | ❌ Only linear shift | ❌ None |
|
|
247
|
+
| **Resilient Error Collection** | ✅ **Returns `{ cues, errors }`** | ❌ Throws errors | ❌ Incomplete |
|
|
248
|
+
| **Type Definitions** | ✅ **Strict ESM + CJS `.d.ts`** | ⚠️ Partial | ⚠️ Basic |
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## API Reference & Types
|
|
253
|
+
|
|
254
|
+
### Data Models
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
export interface SubtitleCue {
|
|
258
|
+
id: string; // Unique identifier (e.g., "1", "2")
|
|
259
|
+
start: number; // Integer start time in milliseconds
|
|
260
|
+
end: number; // Integer end time in milliseconds
|
|
261
|
+
text: string; // Raw subtitle text payload (may contain newlines)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ParseResult {
|
|
265
|
+
cues: SubtitleCue[];
|
|
266
|
+
errors: ParseError[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface ParseError {
|
|
270
|
+
index?: number;
|
|
271
|
+
rawBlock: string;
|
|
272
|
+
message: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface LintDiagnostic {
|
|
276
|
+
cueId: string;
|
|
277
|
+
rule:
|
|
278
|
+
| 'out-of-order'
|
|
279
|
+
| 'zero-or-negative-duration'
|
|
280
|
+
| 'overlap'
|
|
281
|
+
| 'min-duration'
|
|
282
|
+
| 'max-duration'
|
|
283
|
+
| 'max-cpl'
|
|
284
|
+
| 'max-cps';
|
|
285
|
+
severity: 'error' | 'warning';
|
|
286
|
+
message: string;
|
|
287
|
+
expected?: number | string;
|
|
288
|
+
actual?: number | string;
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## License
|
|
295
|
+
|
|
296
|
+
MIT © 2026 [Ranjith Gowda](https://github.com/Ranjithgowda007)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/types.ts
|
|
4
|
+
var getCueDuration = (cue) => cue.end - cue.start;
|
|
5
|
+
|
|
6
|
+
// src/timecode.ts
|
|
7
|
+
var TIMECODE_REGEX = /^(?:(?:(\d{1,2}):)?(\d{2}):)?(\d{2})[,.](\d{3})$/;
|
|
8
|
+
function timecodeToMs(timecode) {
|
|
9
|
+
const trimmed = timecode.trim();
|
|
10
|
+
const match = trimmed.match(TIMECODE_REGEX);
|
|
11
|
+
if (!match) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Invalid timecode format: "${timecode}". Expected "HH:MM:SS,mmm" or "HH:MM:SS.mmm".`
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
const hours = match[1] ? parseInt(match[1], 10) : 0;
|
|
17
|
+
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
|
18
|
+
const seconds = parseInt(match[3] ?? "0", 10);
|
|
19
|
+
const milliseconds = parseInt(match[4] ?? "0", 10);
|
|
20
|
+
return hours * 36e5 + minutes * 6e4 + seconds * 1e3 + milliseconds;
|
|
21
|
+
}
|
|
22
|
+
function msToTimecode(ms, format = "srt") {
|
|
23
|
+
const safeMs = Math.max(0, Math.floor(ms));
|
|
24
|
+
const hours = Math.floor(safeMs / 36e5);
|
|
25
|
+
const minutes = Math.floor(safeMs % 36e5 / 6e4);
|
|
26
|
+
const seconds = Math.floor(safeMs % 6e4 / 1e3);
|
|
27
|
+
const remainderMs = safeMs % 1e3;
|
|
28
|
+
const hh = String(hours).padStart(2, "0");
|
|
29
|
+
const mm = String(minutes).padStart(2, "0");
|
|
30
|
+
const ss = String(seconds).padStart(2, "0");
|
|
31
|
+
const mmm = String(remainderMs).padStart(3, "0");
|
|
32
|
+
const delimiter = format === "vtt" ? "." : ",";
|
|
33
|
+
return `${hh}:${mm}:${ss}${delimiter}${mmm}`;
|
|
34
|
+
}
|
|
35
|
+
function shiftTimecodes(cues, offsetMs) {
|
|
36
|
+
return cues.map((cue) => ({
|
|
37
|
+
...cue,
|
|
38
|
+
start: Math.max(0, cue.start + offsetMs),
|
|
39
|
+
end: Math.max(0, cue.end + offsetMs)
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
function retime(cues, ratio) {
|
|
43
|
+
if (ratio <= 0) {
|
|
44
|
+
throw new Error(`Retime ratio must be greater than 0. Received: ${ratio}`);
|
|
45
|
+
}
|
|
46
|
+
return cues.map((cue) => ({
|
|
47
|
+
...cue,
|
|
48
|
+
start: Math.round(cue.start * ratio),
|
|
49
|
+
end: Math.round(cue.end * ratio)
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/parser.ts
|
|
54
|
+
var ARROW_REGEX = /((?:\d{1,2}:)?\d{2}:\d{2}[,.]\d{3})\s*-->\s*((?:\d{1,2}:)?\d{2}:\d{2}[,.]\d{3})/;
|
|
55
|
+
function parseSRT(rawText) {
|
|
56
|
+
const cues = [];
|
|
57
|
+
const errors = [];
|
|
58
|
+
if (!rawText || !rawText.trim()) {
|
|
59
|
+
return { cues, errors };
|
|
60
|
+
}
|
|
61
|
+
const cleanText = rawText.replace(/^\uFEFF/, "");
|
|
62
|
+
const normalized = cleanText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
63
|
+
const rawBlocks = normalized.trim().split(/\n\s*\n+/);
|
|
64
|
+
let fallbackIndex = 1;
|
|
65
|
+
for (let bIndex = 0; bIndex < rawBlocks.length; bIndex++) {
|
|
66
|
+
const rawBlock = rawBlocks[bIndex]?.trim();
|
|
67
|
+
if (!rawBlock) continue;
|
|
68
|
+
const lines = rawBlock.split("\n");
|
|
69
|
+
let id;
|
|
70
|
+
let timecodeLine;
|
|
71
|
+
let textLines;
|
|
72
|
+
if (lines[0] && ARROW_REGEX.test(lines[0])) {
|
|
73
|
+
id = String(fallbackIndex);
|
|
74
|
+
timecodeLine = lines[0];
|
|
75
|
+
textLines = lines.slice(1);
|
|
76
|
+
} else if (lines[1] && ARROW_REGEX.test(lines[1])) {
|
|
77
|
+
id = lines[0]?.trim() || String(fallbackIndex);
|
|
78
|
+
timecodeLine = lines[1];
|
|
79
|
+
textLines = lines.slice(2);
|
|
80
|
+
} else {
|
|
81
|
+
errors.push({
|
|
82
|
+
index: fallbackIndex,
|
|
83
|
+
rawBlock,
|
|
84
|
+
message: 'Invalid block format. Expected timecode arrow "-->" on line 1 or 2.'
|
|
85
|
+
});
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const match = timecodeLine.match(ARROW_REGEX);
|
|
89
|
+
if (!match || !match[1] || !match[2]) {
|
|
90
|
+
errors.push({
|
|
91
|
+
index: fallbackIndex,
|
|
92
|
+
rawBlock,
|
|
93
|
+
message: `Failed to extract start and end timestamps from: "${timecodeLine}".`
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const start = timecodeToMs(match[1]);
|
|
99
|
+
const end = timecodeToMs(match[2]);
|
|
100
|
+
const text = textLines.join("\n").trim();
|
|
101
|
+
cues.push({
|
|
102
|
+
id,
|
|
103
|
+
start,
|
|
104
|
+
end,
|
|
105
|
+
text
|
|
106
|
+
});
|
|
107
|
+
fallbackIndex++;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
errors.push({
|
|
110
|
+
index: fallbackIndex,
|
|
111
|
+
rawBlock,
|
|
112
|
+
message: err instanceof Error ? err.message : String(err)
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { cues, errors };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/serializer.ts
|
|
120
|
+
function stringifySRT(cues) {
|
|
121
|
+
if (!cues || cues.length === 0) {
|
|
122
|
+
return "";
|
|
123
|
+
}
|
|
124
|
+
const blocks = cues.map((cue, idx) => {
|
|
125
|
+
const id = cue.id ? String(cue.id) : String(idx + 1);
|
|
126
|
+
const startStr = msToTimecode(cue.start, "srt");
|
|
127
|
+
const endStr = msToTimecode(cue.end, "srt");
|
|
128
|
+
const text = cue.text ?? "";
|
|
129
|
+
return `${id}
|
|
130
|
+
${startStr} --> ${endStr}
|
|
131
|
+
${text}`;
|
|
132
|
+
});
|
|
133
|
+
return blocks.join("\n\n") + "\n";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/linter.ts
|
|
137
|
+
var DEFAULT_PROFILES = {
|
|
138
|
+
standardLatin: {
|
|
139
|
+
name: "Standard Latin (Netflix/BBC Style Guide)",
|
|
140
|
+
locale: "en",
|
|
141
|
+
maxCps: 20,
|
|
142
|
+
maxCpl: 42,
|
|
143
|
+
minDurationMs: 800,
|
|
144
|
+
maxDurationMs: 7e3,
|
|
145
|
+
includeSpaces: true
|
|
146
|
+
},
|
|
147
|
+
indicKannada: {
|
|
148
|
+
name: "Indic - Kannada Locale",
|
|
149
|
+
locale: "kn",
|
|
150
|
+
maxCps: 15,
|
|
151
|
+
maxCpl: 35,
|
|
152
|
+
minDurationMs: 800,
|
|
153
|
+
maxDurationMs: 7e3,
|
|
154
|
+
includeSpaces: true
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
function stripFormattingTags(text) {
|
|
158
|
+
return text.replace(/<[^>]*>/g, "");
|
|
159
|
+
}
|
|
160
|
+
function countGraphemes(text, locale = "en") {
|
|
161
|
+
if (!text) return 0;
|
|
162
|
+
if (typeof Intl !== "undefined" && "Segmenter" in Intl) {
|
|
163
|
+
const segmenter = new Intl.Segmenter(locale, { granularity: "grapheme" });
|
|
164
|
+
let count = 0;
|
|
165
|
+
for (const _ of segmenter.segment(text)) {
|
|
166
|
+
count++;
|
|
167
|
+
}
|
|
168
|
+
return count;
|
|
169
|
+
}
|
|
170
|
+
return [...text].length;
|
|
171
|
+
}
|
|
172
|
+
function lintCues(cues, customProfile) {
|
|
173
|
+
const diagnostics = [];
|
|
174
|
+
if (!cues || cues.length === 0) return diagnostics;
|
|
175
|
+
const profile = {
|
|
176
|
+
...DEFAULT_PROFILES["standardLatin"],
|
|
177
|
+
...customProfile
|
|
178
|
+
};
|
|
179
|
+
const {
|
|
180
|
+
locale = "en",
|
|
181
|
+
maxCps,
|
|
182
|
+
maxCpl,
|
|
183
|
+
minDurationMs,
|
|
184
|
+
maxDurationMs,
|
|
185
|
+
includeSpaces = true
|
|
186
|
+
} = profile;
|
|
187
|
+
for (let i = 0; i < cues.length; i++) {
|
|
188
|
+
const cue = cues[i];
|
|
189
|
+
if (!cue) continue;
|
|
190
|
+
const prevCue = i > 0 ? cues[i - 1] : void 0;
|
|
191
|
+
const duration = cue.end - cue.start;
|
|
192
|
+
if (prevCue && cue.start < prevCue.start) {
|
|
193
|
+
diagnostics.push({
|
|
194
|
+
cueId: cue.id,
|
|
195
|
+
rule: "out-of-order",
|
|
196
|
+
severity: "error",
|
|
197
|
+
message: `Cue starts at ${cue.start}ms before previous cue start of ${prevCue.start}ms.`,
|
|
198
|
+
expected: `start >= ${prevCue.start}`,
|
|
199
|
+
actual: `start = ${cue.start}`
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (cue.end <= cue.start) {
|
|
203
|
+
diagnostics.push({
|
|
204
|
+
cueId: cue.id,
|
|
205
|
+
rule: "zero-or-negative-duration",
|
|
206
|
+
severity: "error",
|
|
207
|
+
message: `Cue has invalid duration (${duration}ms). End timestamp must be greater than start timestamp.`,
|
|
208
|
+
expected: `end > ${cue.start}`,
|
|
209
|
+
actual: `end = ${cue.end}`
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (prevCue && cue.start < prevCue.end && cue.start >= prevCue.start) {
|
|
213
|
+
const overlapMs = prevCue.end - cue.start;
|
|
214
|
+
diagnostics.push({
|
|
215
|
+
cueId: cue.id,
|
|
216
|
+
rule: "overlap",
|
|
217
|
+
severity: "error",
|
|
218
|
+
message: `Cue overlaps with previous cue by ${overlapMs}ms.`,
|
|
219
|
+
expected: `start >= ${prevCue.end}`,
|
|
220
|
+
actual: `start = ${cue.start}`
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (duration > 0 && duration < minDurationMs) {
|
|
224
|
+
diagnostics.push({
|
|
225
|
+
cueId: cue.id,
|
|
226
|
+
rule: "min-duration",
|
|
227
|
+
severity: "warning",
|
|
228
|
+
message: `Cue duration of ${duration}ms is below the minimum threshold of ${minDurationMs}ms.`,
|
|
229
|
+
expected: minDurationMs,
|
|
230
|
+
actual: duration
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (duration > maxDurationMs) {
|
|
234
|
+
diagnostics.push({
|
|
235
|
+
cueId: cue.id,
|
|
236
|
+
rule: "max-duration",
|
|
237
|
+
severity: "warning",
|
|
238
|
+
message: `Cue duration of ${duration}ms exceeds the maximum threshold of ${maxDurationMs}ms.`,
|
|
239
|
+
expected: maxDurationMs,
|
|
240
|
+
actual: duration
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
const rawText = cue.text || "";
|
|
244
|
+
const cleanText = stripFormattingTags(rawText);
|
|
245
|
+
const lines = cleanText.split("\n");
|
|
246
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
247
|
+
const line = lines[lineIndex];
|
|
248
|
+
if (!line) continue;
|
|
249
|
+
const cpl = countGraphemes(line, locale);
|
|
250
|
+
if (cpl > maxCpl) {
|
|
251
|
+
diagnostics.push({
|
|
252
|
+
cueId: cue.id,
|
|
253
|
+
rule: "max-cpl",
|
|
254
|
+
severity: "warning",
|
|
255
|
+
message: `Line ${lineIndex + 1} has ${cpl} characters, exceeding the maximum limit of ${maxCpl} CPL.`,
|
|
256
|
+
expected: maxCpl,
|
|
257
|
+
actual: cpl
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (duration > 0) {
|
|
262
|
+
const textToMeasure = includeSpaces ? cleanText : cleanText.replace(/\s+/g, "");
|
|
263
|
+
const totalGraphemes = countGraphemes(textToMeasure, locale);
|
|
264
|
+
const cps = Number((totalGraphemes / duration * 1e3).toFixed(1));
|
|
265
|
+
if (cps > maxCps) {
|
|
266
|
+
diagnostics.push({
|
|
267
|
+
cueId: cue.id,
|
|
268
|
+
rule: "max-cps",
|
|
269
|
+
severity: "warning",
|
|
270
|
+
message: `Reading speed of ${cps} CPS exceeds maximum limit of ${maxCps} CPS.`,
|
|
271
|
+
expected: maxCps,
|
|
272
|
+
actual: cps
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return diagnostics;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
exports.DEFAULT_PROFILES = DEFAULT_PROFILES;
|
|
281
|
+
exports.countGraphemes = countGraphemes;
|
|
282
|
+
exports.getCueDuration = getCueDuration;
|
|
283
|
+
exports.lintCues = lintCues;
|
|
284
|
+
exports.msToTimecode = msToTimecode;
|
|
285
|
+
exports.parseSRT = parseSRT;
|
|
286
|
+
exports.retime = retime;
|
|
287
|
+
exports.shiftTimecodes = shiftTimecodes;
|
|
288
|
+
exports.stringifySRT = stringifySRT;
|
|
289
|
+
exports.stripFormattingTags = stripFormattingTags;
|
|
290
|
+
exports.timecodeToMs = timecodeToMs;
|
|
291
|
+
//# sourceMappingURL=index.cjs.map
|
|
292
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types.ts","../src/timecode.ts","../src/parser.ts","../src/serializer.ts","../src/linter.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Core normalized representation of a single subtitle cue.\n * Timestamps are strictly stored in integer milliseconds.\n */\nexport interface SubtitleCue {\n /** Unique sequential identifier (e.g. \"1\", \"2\") */\n id: string;\n /** Start time in integer milliseconds */\n start: number;\n /** End time in integer milliseconds */\n end: number;\n /** Raw subtitle text content (may contain newlines) */\n text: string;\n}\n\n/**\n * Calculates the duration of a cue in milliseconds.\n * Derived helper function to keep the core data model minimal and immutable.\n */\nexport const getCueDuration = (cue: SubtitleCue): number => cue.end - cue.start;\n\n/** Supported subtitle file formats */\nexport type TimecodeFormat = 'srt' | 'vtt';\n\n/**\n * Detailed error reported when a cue block fails parsing syntax rules.\n */\nexport interface ParseError {\n index?: number;\n rawBlock: string;\n message: string;\n}\n\n/**\n * Resilient parser result returning successfully parsed cues\n * alongside any syntax errors encountered without throwing.\n */\nexport interface ParseResult {\n cues: SubtitleCue[];\n errors: ParseError[];\n}\n\n/** Severity levels for lint diagnostics */\nexport type LintSeverity = 'error' | 'warning';\n\n/** Quality rules enforced by the linter */\nexport type LintRule =\n | 'out-of-order'\n | 'overlap'\n | 'zero-or-negative-duration'\n | 'min-duration'\n | 'max-duration'\n | 'max-cpl'\n | 'max-cps';\n\n/**\n * Diagnostic report produced when a cue violates a quality rule.\n */\nexport interface LintDiagnostic {\n cueId: string;\n rule: LintRule;\n severity: LintSeverity;\n message: string;\n expected?: number | string;\n actual?: number | string;\n}\n\n/**\n * Configuration options for locale-aware subtitle validation.\n */\nexport interface LintProfile {\n name: string;\n locale?: string;\n maxCps: number;\n maxCpl: number;\n minDurationMs: number;\n maxDurationMs: number;\n includeSpaces?: boolean;\n}\n","import type { SubtitleCue, TimecodeFormat } from './types.js';\n\n/**\n * Regular expression to match both SRT (comma) and WebVTT (dot) timecodes.\n * Supports:\n * - HH:MM:SS,mmm or HH:MM:SS.mmm\n * - MM:SS.mmm (WebVTT short format)\n */\nconst TIMECODE_REGEX = /^(?:(?:(\\d{1,2}):)?(\\d{2}):)?(\\d{2})[,.](\\d{3})$/;\n\n/**\n * Converts a formatted timecode string into integer milliseconds.\n *\n * Supported formats:\n * - \"01:23:45,678\" (SRT standard)\n * - \"01:23:45.678\" (WebVTT standard)\n * - \"23:45.678\" (WebVTT short form without hours)\n *\n * @throws {Error} if timecode format is invalid\n */\nexport function timecodeToMs(timecode: string): number {\n const trimmed = timecode.trim();\n const match = trimmed.match(TIMECODE_REGEX);\n\n if (!match) {\n throw new Error(\n `Invalid timecode format: \"${timecode}\". Expected \"HH:MM:SS,mmm\" or \"HH:MM:SS.mmm\".`\n );\n }\n\n const hours = match[1] ? parseInt(match[1], 10) : 0;\n const minutes = match[2] ? parseInt(match[2], 10) : 0;\n const seconds = parseInt(match[3] ?? '0', 10);\n const milliseconds = parseInt(match[4] ?? '0', 10);\n\n return (\n hours * 3_600_000 +\n minutes * 60_000 +\n seconds * 1_000 +\n milliseconds\n );\n}\n\n/**\n * Converts integer milliseconds into a standard formatted timecode string.\n *\n * @param ms - Non-negative integer milliseconds (negative values clamped to 0)\n * @param format - 'srt' (uses comma delimiter) or 'vtt' (uses dot delimiter)\n *\n * @example\n * msToTimecode(83456, 'srt') // \"00:01:23,456\"\n * msToTimecode(83456, 'vtt') // \"00:01:23.456\"\n */\nexport function msToTimecode(ms: number, format: TimecodeFormat = 'srt'): string {\n const safeMs = Math.max(0, Math.floor(ms));\n\n const hours = Math.floor(safeMs / 3_600_000);\n const minutes = Math.floor((safeMs % 3_600_000) / 60_000);\n const seconds = Math.floor((safeMs % 60_000) / 1_000);\n const remainderMs = safeMs % 1_000;\n\n const hh = String(hours).padStart(2, '0');\n const mm = String(minutes).padStart(2, '0');\n const ss = String(seconds).padStart(2, '0');\n const mmm = String(remainderMs).padStart(3, '0');\n\n const delimiter = format === 'vtt' ? '.' : ',';\n return `${hh}:${mm}:${ss}${delimiter}${mmm}`;\n}\n\n/**\n * Linearly shifts timecodes by a constant millisecond offset.\n * Used to correct audio-sync delay or advance.\n * Clamps negative results to 0.\n *\n * @param cues - Array of SubtitleCue objects\n * @param offsetMs - Offset in milliseconds (positive advances, negative delays)\n * @returns A new immutable array of shifted cues\n */\nexport function shiftTimecodes(\n cues: readonly SubtitleCue[],\n offsetMs: number\n): SubtitleCue[] {\n return cues.map((cue) => ({\n ...cue,\n start: Math.max(0, cue.start + offsetMs),\n end: Math.max(0, cue.end + offsetMs),\n }));\n}\n\n/**\n * Scales all cue timecodes by a multiplicative ratio.\n * Used for frame-rate drift compensation between video standards.\n * All results are strictly rounded to integer milliseconds with Math.round.\n *\n * Direction definition:\n * targetTimestamp = Math.round(sourceTimestamp * ratio)\n *\n * @example\n * // 24 fps to 25 fps (PAL speed-up): video is ~4% faster, timecodes compress\n * const palCues = retime(filmCues, 24 / 25); // ratio = 0.96\n *\n * @example\n * // 25 fps to 24 fps (Slow-down): video is ~4% slower, timecodes stretch\n * const filmCues = retime(palCues, 25 / 24); // ratio = 1.04167\n *\n * @param cues - Array of SubtitleCue objects\n * @param ratio - Positive scaling multiplier (e.g., 24/25 or 25/24)\n * @returns A new immutable array of retimed cues\n */\nexport function retime(\n cues: readonly SubtitleCue[],\n ratio: number\n): SubtitleCue[] {\n if (ratio <= 0) {\n throw new Error(`Retime ratio must be greater than 0. Received: ${ratio}`);\n }\n\n return cues.map((cue) => ({\n ...cue,\n start: Math.round(cue.start * ratio),\n end: Math.round(cue.end * ratio),\n }));\n}\n","import { timecodeToMs } from './timecode.js';\nimport type { SubtitleCue, ParseError, ParseResult } from './types.js';\n\n/** Matches the standard SRT timestamp line: 00:00:00,000 --> 00:00:00,000 */\nconst ARROW_REGEX =\n /((?:\\d{1,2}:)?\\d{2}:\\d{2}[,.]\\d{3})\\s*-->\\s*((?:\\d{1,2}:)?\\d{2}:\\d{2}[,.]\\d{3})/;\n\n/**\n * Parses raw SRT subtitle text into structured SubtitleCue objects.\n * Resilient parser that collects syntax errors without throwing unhandled exceptions.\n *\n * Handles:\n * - UTF-8 Byte Order Marks (BOM `\\uFEFF`)\n * - Windows (CRLF), Unix (LF), and legacy Mac (CR) line endings\n * - Optional or missing cue numeric index headers\n * - Multi-line subtitle texts\n * - Trailing and irregular blank lines\n *\n * @param rawText - Raw SRT file content as a string\n * @returns {ParseResult} Object containing successfully parsed cues and any block errors\n */\nexport function parseSRT(rawText: string): ParseResult {\n const cues: SubtitleCue[] = [];\n const errors: ParseError[] = [];\n\n if (!rawText || !rawText.trim()) {\n return { cues, errors };\n }\n\n // 1. Strip UTF-8 Byte Order Mark (BOM)\n const cleanText = rawText.replace(/^\\uFEFF/, '');\n\n // 2. Normalize CRLF and CR to standard Unix LF\n const normalized = cleanText.replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n');\n\n // 3. Split by 2 or more consecutive newlines into cue blocks\n const rawBlocks = normalized.trim().split(/\\n\\s*\\n+/);\n\n let fallbackIndex = 1;\n\n for (let bIndex = 0; bIndex < rawBlocks.length; bIndex++) {\n const rawBlock = rawBlocks[bIndex]?.trim();\n if (!rawBlock) continue;\n\n const lines = rawBlock.split('\\n');\n\n let id: string;\n let timecodeLine: string;\n let textLines: string[];\n\n // Check if line 0 is the timecode arrow (missing numeric index)\n if (lines[0] && ARROW_REGEX.test(lines[0])) {\n id = String(fallbackIndex);\n timecodeLine = lines[0];\n textLines = lines.slice(1);\n } else if (lines[1] && ARROW_REGEX.test(lines[1])) {\n // Standard SRT: line 0 is ID, line 1 is timecode\n id = lines[0]?.trim() || String(fallbackIndex);\n timecodeLine = lines[1];\n textLines = lines.slice(2);\n } else {\n errors.push({\n index: fallbackIndex,\n rawBlock,\n message:\n 'Invalid block format. Expected timecode arrow \"-->\" on line 1 or 2.',\n });\n continue;\n }\n\n const match = timecodeLine.match(ARROW_REGEX);\n if (!match || !match[1] || !match[2]) {\n errors.push({\n index: fallbackIndex,\n rawBlock,\n message: `Failed to extract start and end timestamps from: \"${timecodeLine}\".`,\n });\n continue;\n }\n\n try {\n const start = timecodeToMs(match[1]);\n const end = timecodeToMs(match[2]);\n const text = textLines.join('\\n').trim();\n\n cues.push({\n id,\n start,\n end,\n text,\n });\n\n fallbackIndex++;\n } catch (err) {\n errors.push({\n index: fallbackIndex,\n rawBlock,\n message: err instanceof Error ? err.message : String(err),\n });\n }\n }\n\n return { cues, errors };\n}\n","import { msToTimecode } from './timecode.js';\nimport type { SubtitleCue } from './types.js';\n\n/**\n * Serializes an array of SubtitleCue objects into standard SRT file format.\n *\n * Output format:\n * 1\n * 00:00:20,000 --> 00:00:24,000\n * Subtitle text\n *\n * @param cues - Array of SubtitleCue objects to format\n * @returns Standard formatted SRT string\n */\nexport function stringifySRT(cues: readonly SubtitleCue[]): string {\n if (!cues || cues.length === 0) {\n return '';\n }\n\n const blocks = cues.map((cue, idx) => {\n const id = cue.id ? String(cue.id) : String(idx + 1);\n const startStr = msToTimecode(cue.start, 'srt');\n const endStr = msToTimecode(cue.end, 'srt');\n const text = cue.text ?? '';\n\n return `${id}\\n${startStr} --> ${endStr}\\n${text}`;\n });\n\n return blocks.join('\\n\\n') + '\\n';\n}\n","import type {\n SubtitleCue,\n LintDiagnostic,\n LintProfile,\n} from './types.js';\n\n/** Default quality profiles for subtitle verification */\nexport const DEFAULT_PROFILES: Record<string, LintProfile> = {\n standardLatin: {\n name: 'Standard Latin (Netflix/BBC Style Guide)',\n locale: 'en',\n maxCps: 20,\n maxCpl: 42,\n minDurationMs: 800,\n maxDurationMs: 7000,\n includeSpaces: true,\n },\n indicKannada: {\n name: 'Indic - Kannada Locale',\n locale: 'kn',\n maxCps: 15,\n maxCpl: 35,\n minDurationMs: 800,\n maxDurationMs: 7000,\n includeSpaces: true,\n },\n};\n\n/**\n * Strips HTML formatting tags from subtitle text before measuring character limits.\n * Handles <i>, <b>, <u>, <font color=\"...\">, etc.\n */\nexport function stripFormattingTags(text: string): string {\n return text.replace(/<[^>]*>/g, '');\n}\n\n/**\n * Counts user-perceived grapheme clusters using native ECMAScript Intl.Segmenter.\n * Accurately measures multi-byte conjuncts and vowel modifiers in Indic scripts (Kannada, Hindi),\n * as well as emojis and complex Unicode characters without UTF-16 code unit inflation.\n */\nexport function countGraphemes(text: string, locale: string = 'en'): number {\n if (!text) return 0;\n\n if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {\n const segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });\n let count = 0;\n // Iterate over segments efficiently without allocating full array\n for (const _ of segmenter.segment(text)) {\n count++;\n }\n return count;\n }\n\n // Fallback for environments lacking Intl.Segmenter\n return [...text].length;\n}\n\n/**\n * Inspects an array of SubtitleCues against timing, structural, and reading speed rules.\n *\n * Rules verified:\n * - out-of-order: Cues not in ascending chronological order\n * - zero-or-negative-duration: Cues where end <= start\n * - overlap: Cues overlapping chronologically with preceding cues\n * - min-duration: Cues flashing too fast for comprehension (< 800ms)\n * - max-duration: Cues remaining on screen too long (> 7000ms)\n * - max-cpl: Any line exceeding characters-per-line limit\n * - max-cps: Reading speed exceeding characters-per-second limit\n *\n * @param cues - Array of SubtitleCue objects to inspect\n * @param customProfile - Optional custom thresholds or language profile\n * @returns Array of LintDiagnostic reports\n */\nexport function lintCues(\n cues: readonly SubtitleCue[],\n customProfile?: Partial<LintProfile>\n): LintDiagnostic[] {\n const diagnostics: LintDiagnostic[] = [];\n if (!cues || cues.length === 0) return diagnostics;\n\n const profile: LintProfile = {\n ...DEFAULT_PROFILES['standardLatin']!,\n ...customProfile,\n };\n\n const {\n locale = 'en',\n maxCps,\n maxCpl,\n minDurationMs,\n maxDurationMs,\n includeSpaces = true,\n } = profile;\n\n for (let i = 0; i < cues.length; i++) {\n const cue = cues[i];\n if (!cue) continue;\n\n const prevCue = i > 0 ? cues[i - 1] : undefined;\n const duration = cue.end - cue.start;\n\n // --- TIMING RULES ---\n\n // 1. Out-of-order check\n if (prevCue && cue.start < prevCue.start) {\n diagnostics.push({\n cueId: cue.id,\n rule: 'out-of-order',\n severity: 'error',\n message: `Cue starts at ${cue.start}ms before previous cue start of ${prevCue.start}ms.`,\n expected: `start >= ${prevCue.start}`,\n actual: `start = ${cue.start}`,\n });\n }\n\n // 2. Zero or negative duration check\n if (cue.end <= cue.start) {\n diagnostics.push({\n cueId: cue.id,\n rule: 'zero-or-negative-duration',\n severity: 'error',\n message: `Cue has invalid duration (${duration}ms). End timestamp must be greater than start timestamp.`,\n expected: `end > ${cue.start}`,\n actual: `end = ${cue.end}`,\n });\n }\n\n // 3. Overlap check (only if not out-of-order)\n if (prevCue && cue.start < prevCue.end && cue.start >= prevCue.start) {\n const overlapMs = prevCue.end - cue.start;\n diagnostics.push({\n cueId: cue.id,\n rule: 'overlap',\n severity: 'error',\n message: `Cue overlaps with previous cue by ${overlapMs}ms.`,\n expected: `start >= ${prevCue.end}`,\n actual: `start = ${cue.start}`,\n });\n }\n\n // 4. Min duration check (only if duration is positive)\n if (duration > 0 && duration < minDurationMs) {\n diagnostics.push({\n cueId: cue.id,\n rule: 'min-duration',\n severity: 'warning',\n message: `Cue duration of ${duration}ms is below the minimum threshold of ${minDurationMs}ms.`,\n expected: minDurationMs,\n actual: duration,\n });\n }\n\n // 5. Max duration check\n if (duration > maxDurationMs) {\n diagnostics.push({\n cueId: cue.id,\n rule: 'max-duration',\n severity: 'warning',\n message: `Cue duration of ${duration}ms exceeds the maximum threshold of ${maxDurationMs}ms.`,\n expected: maxDurationMs,\n actual: duration,\n });\n }\n\n // --- TEXT & PERCEPTION RULES ---\n\n const rawText = cue.text || '';\n const cleanText = stripFormattingTags(rawText);\n\n // 6. Characters Per Line (CPL) check\n const lines = cleanText.split('\\n');\n for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {\n const line = lines[lineIndex];\n if (!line) continue;\n\n const cpl = countGraphemes(line, locale);\n if (cpl > maxCpl) {\n diagnostics.push({\n cueId: cue.id,\n rule: 'max-cpl',\n severity: 'warning',\n message: `Line ${lineIndex + 1} has ${cpl} characters, exceeding the maximum limit of ${maxCpl} CPL.`,\n expected: maxCpl,\n actual: cpl,\n });\n }\n }\n\n // 7. Characters Per Second (CPS) reading speed check\n if (duration > 0) {\n const textToMeasure = includeSpaces\n ? cleanText\n : cleanText.replace(/\\s+/g, '');\n\n const totalGraphemes = countGraphemes(textToMeasure, locale);\n const cps = Number(((totalGraphemes / duration) * 1000).toFixed(1));\n\n if (cps > maxCps) {\n diagnostics.push({\n cueId: cue.id,\n rule: 'max-cps',\n severity: 'warning',\n message: `Reading speed of ${cps} CPS exceeds maximum limit of ${maxCps} CPS.`,\n expected: maxCps,\n actual: cps,\n });\n }\n }\n }\n\n return diagnostics;\n}\n"]}
|