@nxtedition/lib 23.11.2 → 23.12.1

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.
Files changed (2) hide show
  1. package/ass.js +248 -85
  2. package/package.json +2 -1
package/ass.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // @ts-check
2
- import fp from 'lodash/fp.js'
3
2
  import { wordwrap } from './wordwrap.js'
4
3
 
5
4
  const BASE_WIDTH = 1920
@@ -12,6 +11,29 @@ const BASE_HEIGHT = 1080
12
11
  * @property {number | null} [end]
13
12
  * @property {string | null} [style]
14
13
  * @property {string | null} [text]
14
+ * @property {SubtitleEventStyleOverrides} [styleOverrides]
15
+ *
16
+ * @typedef {object} SubtitleEventStyleOverrides
17
+ * @property {string} [marginL]
18
+ * @property {string} [marginR]
19
+ * @property {string} [marginV]
20
+ * @property {string} [alignment]
21
+ * @property {string} [fontname]
22
+ * @property {string} [fontsize]
23
+ * @property {string} [primaryColour]
24
+ * @property {string} [secondaryColour]
25
+ * @property {string} [outlineColour]
26
+ * @property {string} [backColour]
27
+ * @property {string} [bold]
28
+ * @property {string} [italic]
29
+ * @property {string} [underline]
30
+ * @property {string} [strikeOut]
31
+ *
32
+ * @typedef {object} EmbeddableSubtitleFontFace
33
+ * @property {string} family
34
+ * @property {'normal'|'bold'} weight
35
+ * @property {'normal'|'italic'} style
36
+ * @property {Uint8Array} data
15
37
  *
16
38
  * @typedef {object} Styles
17
39
  * @property {string} [name]
@@ -44,6 +66,7 @@ const BASE_HEIGHT = 1080
44
66
  * @property {number} [height]
45
67
  * @property {boolean} [scaledBorderAndShadow] TODO: default to true when all client styles depend on it?
46
68
  * @property {boolean} [futureWordWrapping]
69
+ * @property {EmbeddableSubtitleFontFace[]} [fonts]
47
70
  */
48
71
 
49
72
  /**
@@ -59,6 +82,7 @@ export function encodeASS(
59
82
  height = BASE_HEIGHT,
60
83
  scaledBorderAndShadow = false,
61
84
  futureWordWrapping = false,
85
+ fonts = [],
62
86
  } = {},
63
87
  ) {
64
88
  const scale = typeof width === 'number' ? BASE_HEIGHT / height : 1
@@ -66,9 +90,13 @@ export function encodeASS(
66
90
  const playResY = scale * (height || BASE_HEIGHT)
67
91
  return [
68
92
  encASSHeader({ playResX, playResY, scaledBorderAndShadow, futureWordWrapping }),
93
+ encFontFaces(fonts),
69
94
  encASSStyles(styles),
95
+ '',
70
96
  encASSEvents(events, styles, playResX, futureWordWrapping),
71
- ].join('\n')
97
+ ]
98
+ .filter((x) => typeof x === 'string')
99
+ .join('\n')
72
100
  }
73
101
 
74
102
  /**
@@ -78,34 +106,111 @@ export function encodeASS(
78
106
  * @param {boolean} futureWordWrapping
79
107
  */
80
108
  function formatDialogues(events, styles, scriptResX, futureWordWrapping) {
81
- let s = ''
82
- for (const { start, duration, end = duration != null ? start + duration : null, text, style } of [
83
- ...events,
84
- ].sort((a, b) => a.start - b.start)) {
85
- const styleName = style || 'nxt-default'
86
- const eventStyle = styles[styleName]
87
- const fontSize = parseFloat(eventStyle.fontsize || '32')
88
- const fontFamily = eventStyle.fontname ?? 'Arial'
89
- const maxWidth = Math.max(
90
- 0,
91
- scriptResX - parseFloat(eventStyle.marginL || '0') - parseFloat(eventStyle.marginR || '0'),
109
+ return [...events]
110
+ .sort((a, b) => a.start - b.start)
111
+ .map((event) => {
112
+ const {
113
+ start,
114
+ duration,
115
+ end = duration != null ? start + duration : null,
116
+ text,
117
+ style,
118
+ styleOverrides,
119
+ } = event
120
+
121
+ if (typeof text !== 'string' || text.length === 0 || typeof start !== 'number') {
122
+ return null
123
+ }
124
+
125
+ const styleName = style || 'nxt-default'
126
+ const eventStyle = styles[styleName] ?? {}
127
+ const textOptions = {
128
+ fontSize: parseFloat(eventStyle.fontsize || '32'),
129
+ fontFamily: eventStyle.fontname ?? 'Arial',
130
+ maxWidth: Math.max(
131
+ 0,
132
+ scriptResX -
133
+ parseFloat(eventStyle.marginL || '0') -
134
+ parseFloat(eventStyle.marginR || '0'),
135
+ ),
136
+ styleOverrides,
137
+ futureWordWrapping,
138
+ }
139
+
140
+ return `Dialogue: ${[
141
+ /* Layer */ '0',
142
+ /* Start */ formatASSTime(start) || '0:00:00.00',
143
+ /* End */ formatASSTime(end) || '9:59:59.00',
144
+ /* Style */ styleName,
145
+ /* Name */ '',
146
+ /* MarginL */ (styleOverrides?.marginL ?? '0').padStart(4, '0'),
147
+ /* MarginR */ (styleOverrides?.marginR ?? '0').padStart(4, '0'),
148
+ /* MarginV */ (styleOverrides?.marginV ?? '0').padStart(4, '0'),
149
+ /* Effect */ '',
150
+ /* Text */ encodeText(text, textOptions),
151
+ ]
152
+ .filter((s) => typeof s === 'string')
153
+ .join(',')}`
154
+ })
155
+ .join('\n')
156
+ }
157
+
158
+ /**
159
+ * @param {string} text
160
+ * @param {object} options
161
+ * @param {number} options.fontSize
162
+ * @param {string} options.fontFamily
163
+ * @param {number} options.maxWidth
164
+ * @param {SubtitleEventStyleOverrides} [options.styleOverrides]
165
+ * @param {boolean} options.futureWordWrapping
166
+ */
167
+ function encodeText(text, { styleOverrides, futureWordWrapping, ...options }) {
168
+ return `${getOverrideTags(styleOverrides)}${text
169
+ .split(/\\n|\n|\\N/)
170
+ .map((line) => line.trim())
171
+ .flatMap((line) =>
172
+ futureWordWrapping ? wordwrap(line, { ...options, breakWord: false }) : [line],
92
173
  )
93
- if (typeof text === 'string' && text.length > 0 && Number.isFinite(start)) {
94
- s += `Dialogue: 0,${formatASSTime(start) || '0:00:00.00'},${
95
- formatASSTime(end) || '9:59:59.00'
96
- },${styleName},,0000,0000,0000,,${text
97
- .split(/\\n|\n|\\N/)
98
- .map((line) => line.trim())
99
- .flatMap((line) =>
100
- futureWordWrapping
101
- ? wordwrap(line, { fontSize, fontFamily, maxWidth, breakWord: false })
102
- : [line],
103
- )
104
- .map((line) => line.trim())
105
- .join('\\N')}\n`
106
- }
107
- }
108
- return s
174
+ .map((line) => line.trim())
175
+ .join('\\N')}`
176
+ }
177
+
178
+ /**
179
+ * @param {SubtitleEventStyleOverrides} [styleOverrides]
180
+ */
181
+ function getOverrideTags(styleOverrides) {
182
+ return Object.entries(styleOverrides ?? {})
183
+ .map(([key, value]) => {
184
+ switch (key) {
185
+ case 'alignment':
186
+ return `an${value}`
187
+ case 'primaryColour':
188
+ return `c${value}`
189
+ case 'secondaryColour':
190
+ return `2c${value}`
191
+ case 'outlineColour':
192
+ return `3c${value}`
193
+ case 'backColour':
194
+ return `4c${value}`
195
+ case 'bold':
196
+ return `b${value === '0' ? '0' : '1'}`
197
+ case 'italic':
198
+ return `i${value === '0' ? '0' : '1'}`
199
+ case 'underline':
200
+ return `u${value === '0' ? '0' : '1'}`
201
+ case 'strikeOut':
202
+ return `s${value === '0' ? '0' : '1'}`
203
+ case 'fontname':
204
+ return `fn${value}`
205
+ case 'fontsize':
206
+ return `fs${value}`
207
+ default:
208
+ return null
209
+ }
210
+ })
211
+ .filter((tag) => typeof tag === 'string')
212
+ .map((tag) => `{\\${tag}}`)
213
+ .join('')
109
214
  }
110
215
 
111
216
  /**
@@ -117,70 +222,48 @@ function formatDialogues(events, styles, scriptResX, futureWordWrapping) {
117
222
  function encASSEvents(events, styles, scriptResX, futureWordWrapping) {
118
223
  return `[Events]
119
224
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
120
- ${formatDialogues(events, styles, scriptResX, futureWordWrapping)}`
225
+ ${formatDialogues(events, styles, scriptResX, futureWordWrapping)}\n`
121
226
  }
122
227
 
123
228
  /**
124
229
  * @param {{ [styleName: string]: Styles }} styles
230
+ * @returns {string}
125
231
  */
126
- const formatStyles = fp.pipe(
127
- fp.entries,
128
- fp.reduce((s, [id, style]) => {
129
- return (
130
- s +
131
- 'Style: ' +
132
- id +
133
- ',' +
134
- style.fontname +
135
- ',' +
136
- style.fontsize +
137
- ',' +
138
- style.primaryColour +
139
- ',' +
140
- style.secondaryColour +
141
- ',' +
142
- style.outlineColour +
143
- ',' +
144
- style.backColour +
145
- ',' +
146
- style.bold +
147
- ',' +
148
- style.italic +
149
- ',' +
150
- style.underline +
151
- ',' +
152
- style.strikeOut +
153
- ',' +
154
- style.scaleX +
155
- ',' +
156
- style.scaleY +
157
- ',' +
158
- style.spacing +
159
- ',' +
160
- style.angle +
161
- ',' +
162
- style.borderStyle +
163
- ',' +
164
- style.outline +
165
- ',' +
166
- style.shadow +
167
- ',' +
168
- style.alignment +
169
- ',' +
170
- style.marginL +
171
- ',' +
172
- style.marginR +
173
- ',' +
174
- style.marginV +
175
- ',' +
176
- style.encoding +
177
- '\n'
232
+ const formatStyles = (styles) =>
233
+ Object.entries(styles)
234
+ .map(([id, style]) =>
235
+ [
236
+ `Style: ${id}`,
237
+ style.fontname,
238
+ style.fontsize,
239
+ style.primaryColour,
240
+ style.secondaryColour,
241
+ style.outlineColour,
242
+ style.backColour,
243
+ style.bold,
244
+ style.italic,
245
+ style.underline,
246
+ style.strikeOut,
247
+ style.scaleX,
248
+ style.scaleY,
249
+ style.spacing,
250
+ style.angle,
251
+ style.borderStyle,
252
+ style.outline,
253
+ style.shadow,
254
+ style.alignment,
255
+ style.marginL,
256
+ style.marginR,
257
+ style.marginV,
258
+ style.encoding,
259
+ ]
260
+ .map((s) => s ?? '')
261
+ .join(','),
178
262
  )
179
- }, ''),
180
- )
263
+ .join('\n')
181
264
 
182
265
  /**
183
- * @param {Styles} styles
266
+ * @param {{ [styleName: string]: Styles }} styles
184
267
  * @returns {string}
185
268
  */
186
269
  function encASSStyles(styles) {
@@ -195,6 +278,7 @@ ${formatStyles(styles)}`
195
278
  * @param {number} params.playResY
196
279
  * @param {boolean} [params.scaledBorderAndShadow]
197
280
  * @param {boolean} params.futureWordWrapping
281
+ * @returns {string}
198
282
  */
199
283
  function encASSHeader({ playResX, playResY, scaledBorderAndShadow, futureWordWrapping }) {
200
284
  // WrapStyle
@@ -243,3 +327,82 @@ function formatASSTime(seconds) {
243
327
  (cs === 0 ? '00' : cs < 10 ? '0' + cs : cs)
244
328
  )
245
329
  }
330
+
331
+ /**
332
+ * @param {EmbeddableSubtitleFontFace[]} fontFaces
333
+ * @returns {string | undefined}
334
+ */
335
+ function encFontFaces(fontFaces) {
336
+ if (fontFaces.length === 0) {
337
+ return
338
+ }
339
+ return [
340
+ '[Fonts]',
341
+ ...fontFaces.map((fontFace) => {
342
+ return [`fontname: ${getFontName(fontFace)}`, ...uuencodeForSSA(fontFace.data)]
343
+ }),
344
+ ]
345
+ .filter((x) => x !== undefined)
346
+ .flat()
347
+ .join('\n')
348
+ }
349
+
350
+ /**
351
+ * @param {EmbeddableSubtitleFontFace} fontFace
352
+ */
353
+ function getFontName(fontFace) {
354
+ const { family, weight, style } = fontFace
355
+ return `${family}_${weight === 'bold' ? 'B' : ''}${style === 'italic' ? 'I' : ''}0`
356
+ }
357
+
358
+ /**
359
+ * UUEncode a binary file (e.g. font file) for SSA/ASS embedding.
360
+ * @param {Uint8Array} data - Binary content of the file.
361
+ * @returns {string[]} - Encoded lines, each max 80 characters.
362
+ */
363
+ function uuencodeForSSA(data) {
364
+ const result = []
365
+ const outputLineLength = 80
366
+
367
+ let i = 0
368
+ const fullChunks = Math.floor(data.length / 3)
369
+
370
+ // Encode 3-byte chunks
371
+ while (i < fullChunks * 3) {
372
+ const b1 = data[i++]
373
+ const b2 = data[i++]
374
+ const b3 = data[i++]
375
+ const n = (b1 << 16) | (b2 << 8) | b3
376
+
377
+ const c1 = ((n >> 18) & 0x3f) + 33
378
+ const c2 = ((n >> 12) & 0x3f) + 33
379
+ const c3 = ((n >> 6) & 0x3f) + 33
380
+ const c4 = (n & 0x3f) + 33
381
+
382
+ result.push(String.fromCharCode(c1, c2, c3, c4))
383
+ }
384
+
385
+ // Handle remainder
386
+ const remaining = data.length % 3
387
+ if (remaining === 1) {
388
+ const last = data[i] * 0x100
389
+ const c1 = ((last >> 10) & 0x3f) + 33
390
+ const c2 = ((last >> 4) & 0x3f) + 33
391
+ result.push(String.fromCharCode(c1, c2))
392
+ } else if (remaining === 2) {
393
+ const last = ((data[i] << 8) | data[i + 1]) * 0x10000
394
+ const c1 = ((last >> 16) & 0x3f) + 33
395
+ const c2 = ((last >> 10) & 0x3f) + 33
396
+ const c3 = ((last >> 4) & 0x3f) + 33
397
+ result.push(String.fromCharCode(c1, c2, c3))
398
+ }
399
+
400
+ // Break into 80-char lines
401
+ const finalLines = []
402
+ const allChars = result.join('')
403
+ for (let j = 0; j < allChars.length; j += outputLineLength) {
404
+ finalLines.push(allChars.slice(j, j + outputLineLength))
405
+ }
406
+
407
+ return finalLines
408
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/lib",
3
- "version": "23.11.2",
3
+ "version": "23.12.1",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "type": "module",
@@ -99,6 +99,7 @@
99
99
  "@nxtedition/deepstream.io-client-js": ">=28.1.15",
100
100
  "@types/lodash": "^4.17.16",
101
101
  "@types/node": "^22.15.1",
102
+ "canvas": "^3.1.0",
102
103
  "eslint": "^9.25.1",
103
104
  "eslint-config-prettier": "^10.1.2",
104
105
  "eslint-config-standard": "^17.0.0",