@danything/arib 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.
@@ -0,0 +1,987 @@
1
+ /**
2
+ * 放送の字幕 (ARIB STD-B24 の字幕文) をデコードして、**字の置き場所まで決める** ([caption-text.ts](caption-text.ts))。
3
+ *
4
+ * libaribcaption の「デコードする (decoder)」側を写したもの — 置き場所・大きさ・色・外字の置き換えまでは
5
+ * 同じ計算で、描く (renderer) 手前で止める。描くのは受け側 (ブラウザ・アプリ)。
6
+ *
7
+ * 写したのは日本の放送で要るところだけ: 8単位符号 (JIS)・Profile A (フルセグ)。言語は1つを選んで読む
8
+ * (既定は第1言語。`new B24CaptionDecoder(2)` で第2言語)。
9
+ * ブラジルの Latin・UTF-8・ワンセグ (Profile C) は持たない。
10
+ *
11
+ * ## libaribcaption と違えたところ
12
+ *
13
+ * - **字幕管理データの「送り直し」の見分け。** あちらは群 (A/B) を `(id & 0xF0) >> 8` で
14
+ * 取っていて常に 0 になり、最初の1回しか読まない。ここは群を正しく見る
15
+ * (番組が変わって群が替わったときに書式を読み直す)
16
+ * - **点滅 (FLC) を読み取る。** あちらは読み捨てている
17
+ *
18
+ * 入力は字幕の PES の中身 (データ識別 0x80 から)。ffmpeg が `-c:s copy` で Matroska
19
+ * (`S_ARIBSUB`) に入れたコマの中身がそのままこれになる。
20
+ */
21
+ import { createHash } from "node:crypto";
22
+ import { ADDITIONAL, ALNUM_FULL, ALNUM_HALF, CLUT, DRCS_REPLACE, HIRAGANA, JISX0201, JISX0201_HALF, KANA_SYMBOLS_HALF, KANJI_DIFF, KANJI_SYMBOLS_HALF, KATAKANA, } from "./b24-tables.js";
23
+ import { ascii, u16, u24 } from "./bytes.js";
24
+ import { CAPTION_TEXT_VERSION, captionLanguage, } from "./caption-text.js";
25
+ import { smoothDrcs } from "./drcs-smooth.js";
26
+ const KANJI = { set: "kanji", bytes: 2 };
27
+ const ALNUM = { set: "alnum", bytes: 1 };
28
+ const HIRA = { set: "hiragana", bytes: 1 };
29
+ const KATA = { set: "katakana", bytes: 1 };
30
+ const MACRO = { set: "macro", bytes: 1 };
31
+ const MOSAIC = { set: "mosaic", bytes: 1 };
32
+ /** 終端バイト → 集合 (G の指示)。プロポーショナルも JIS X 0213 も同じ表で引く (libaribcaption と同じ) */
33
+ const G_SETS = new Map([
34
+ [0x42, KANJI],
35
+ [0x4a, ALNUM],
36
+ [0x30, HIRA],
37
+ [0x31, KATA],
38
+ [0x32, MOSAIC],
39
+ [0x33, MOSAIC],
40
+ [0x34, MOSAIC],
41
+ [0x35, MOSAIC],
42
+ [0x36, ALNUM],
43
+ [0x37, HIRA],
44
+ [0x38, KATA],
45
+ [0x49, { set: "jisx0201", bytes: 1 }],
46
+ [0x39, KANJI],
47
+ [0x3a, KANJI],
48
+ [0x3b, { set: "additional", bytes: 2 }],
49
+ ]);
50
+ /** 終端バイト → DRCS の面 (0x40 は2バイトの面 0)。0x70 はマクロ */
51
+ function drcsSet(final) {
52
+ if (final === 0x70)
53
+ return MACRO;
54
+ if (final < 0x40 || final > 0x4f)
55
+ return undefined;
56
+ const index = final - 0x40;
57
+ return { set: { drcs: index }, bytes: index === 0 ? 2 : 1 };
58
+ }
59
+ /** マクロの終わりはどれも同じ: G3 にマクロを載せて (ESC 0x2B 0x20 0x70)、LS0・LS2R */
60
+ const MACRO_TAIL = [0x1b, 0x2b, 0x20, 0x70, 0x0f, 0x1b, 0x7d];
61
+ /** 既定のマクロ (0x60〜0x6F)。符号の集合を組み替えるだけ。ここに書くのは G0〜G2 の指示 (ESC 列) */
62
+ const MACROS = [
63
+ "1b2442 1b294a 1b2a30",
64
+ "1b2442 1b2931 1b2a30",
65
+ "1b2442 1b292041 1b2a30",
66
+ "1b2832 1b2934 1b2a35",
67
+ "1b2832 1b2933 1b2a35",
68
+ "1b2832 1b292041 1b2a35",
69
+ "1b282041 1b292042 1b2a2043",
70
+ "1b282044 1b292045 1b2a2046",
71
+ "1b282047 1b292048 1b2a2049",
72
+ "1b28204a 1b29204b 1b2a204c",
73
+ "1b28204d 1b29204e 1b2a204f",
74
+ "1b2442 1b292042 1b2a30",
75
+ "1b2442 1b292043 1b2a30",
76
+ "1b2442 1b292044 1b2a30",
77
+ "1b2831 1b2930 1b2a4a",
78
+ "1b284a 1b2932 1b2a2041",
79
+ ].map((head) => {
80
+ const bytes = [...head.matchAll(/[0-9a-f]{2}/g)].map(([b]) => Number.parseInt(b, 16));
81
+ return Uint8Array.from([...bytes, ...MACRO_TAIL]);
82
+ });
83
+ const eucjp = new TextDecoder("euc-jp");
84
+ /** 漢字の表 (区×94+点 → 字)。EUC-JP で読み、libaribcaption と違うところだけ差し替える */
85
+ const kanjiOverride = new Map();
86
+ for (const [start, chars] of KANJI_DIFF) {
87
+ let i = start;
88
+ for (const char of chars)
89
+ kanjiOverride.set(i++, char.codePointAt(0));
90
+ }
91
+ const kanjiCache = new Map();
92
+ function kanji(index) {
93
+ const known = kanjiOverride.get(index) ?? kanjiCache.get(index);
94
+ if (known !== undefined)
95
+ return known;
96
+ const text = eucjp.decode(Uint8Array.of(Math.floor(index / 94) + 0xa1, (index % 94) + 0xa1));
97
+ const code = text.codePointAt(0) ?? 0xfffd;
98
+ kanjiCache.set(index, code);
99
+ return code;
100
+ }
101
+ /** 表の文字列から i 番目のコードポイント (サロゲートを跨ぐので配列にしておく) */
102
+ const points = (table) => [...table].map((c) => c.codePointAt(0));
103
+ const T = {
104
+ hiragana: points(HIRAGANA),
105
+ katakana: points(KATAKANA),
106
+ kanaHalf: points(KANA_SYMBOLS_HALF),
107
+ jisx0201: points(JISX0201),
108
+ jisx0201Half: points(JISX0201_HALF),
109
+ alnumFull: points(ALNUM_FULL),
110
+ alnumHalf: points(ALNUM_HALF),
111
+ kanjiHalf: points(KANJI_SYMBOLS_HALF),
112
+ additional: points(ADDITIONAL),
113
+ };
114
+ /** 色。CLUT の組 (0〜7) と番号 (0〜15) */
115
+ function clut(palette, index) {
116
+ return `#${CLUT[palette * 16 + index] ?? CLUT[index]}`;
117
+ }
118
+ /** 区画の幅か高さ。`floor((字の大きさ + 字間・行間) × 倍率)` */
119
+ function section(size, spacing, scale) {
120
+ return Math.floor((size + spacing) * scale);
121
+ }
122
+ /** 半角の字 (libaribcaption の IsHalfwidthCharacter)。中くらいでも縮めずに描く */
123
+ function isHalfwidth(code) {
124
+ return ((code !== 0 && code <= 0xff) ||
125
+ (code >= 0xff61 && code <= 0xff9f) ||
126
+ (code >= 0xffe8 && code <= 0xffee));
127
+ }
128
+ /**
129
+ * 送る画像 (なめらかにした濃淡。drcs-smooth.ts) を元の画像の MD5 ごとに覚えておく。放送は字幕文のたびに
130
+ * 同じ外字を定義し直してくるので、定義ごとではなくここで1回だけ作る
131
+ */
132
+ const smoothed = new Map();
133
+ const SMOOTHED_MAX = 256;
134
+ function sendable(drcs) {
135
+ // MD5 は画素だけから取っているので、大きさも鍵に入れる
136
+ const key = `${drcs.md5}:${drcs.width}x${drcs.height}:${drcs.depth}`;
137
+ let sent = smoothed.get(key);
138
+ if (sent === undefined) {
139
+ sent = {
140
+ ...smoothDrcs(drcs.width, drcs.height, drcs.depth, drcs.bits, drcs.pixels),
141
+ md5: drcs.md5,
142
+ };
143
+ if (smoothed.size >= SMOOTHED_MAX)
144
+ smoothed.clear();
145
+ smoothed.set(key, sent);
146
+ }
147
+ return sent;
148
+ }
149
+ /**
150
+ * 字幕のストリーム1本ぶんのデコーダ。**状態を持つ** (書式・位置・色・外字は字幕文を跨いで続く) ので、
151
+ * ストリームごとに1つ作って、届いた順に食わせる。
152
+ *
153
+ * **読むのは1つの言語だけ** (`language`。1〜8)。言語ごとに書式も位置も別に続くので、2つの言語を出し分けるなら
154
+ * 言語ごとに1つ作って、同じコマを両方に食わせる (`server/captions.ts` の `CaptionText`)
155
+ */
156
+ export class B24CaptionDecoder {
157
+ language;
158
+ /** 字幕管理データに書いてある言語。まだ届いていなければ空 */
159
+ languages = [];
160
+ gx = [KANJI, ALNUM, HIRA, MACRO];
161
+ gl = 0;
162
+ gr = 2;
163
+ drcsMaps = Array.from({ length: 16 }, () => new Map());
164
+ prevGroup = -1;
165
+ swf = 7;
166
+ planeWidth = 960;
167
+ planeHeight = 540;
168
+ areaWidth = 960;
169
+ areaHeight = 540;
170
+ areaX = 0;
171
+ areaY = 0;
172
+ posInited = false;
173
+ posX = 0;
174
+ posY = 0;
175
+ charWidth = 36;
176
+ charHeight = 36;
177
+ hSpacing = 4;
178
+ vSpacing = 24;
179
+ hScale = 1;
180
+ vScale = 1;
181
+ underline = false;
182
+ bold = false;
183
+ italic = false;
184
+ stroke = null;
185
+ box = 0;
186
+ flash = false;
187
+ palette = 0;
188
+ fg = clut(0, 7);
189
+ bg = clut(0, 8);
190
+ caption = {
191
+ chars: [],
192
+ clear: false,
193
+ wait: null,
194
+ drcs: new Map(),
195
+ };
196
+ /** @param language 読む言語 (1〜8。データグループの番号) */
197
+ constructor(language = 1) {
198
+ this.language = language;
199
+ this.resetState();
200
+ }
201
+ /**
202
+ * 字幕の PES の中身を1つ食わせる。
203
+ *
204
+ * @returns 出す1枚。出すものが無ければ (字幕管理データ・送り直し・待ちだけの字幕文) null
205
+ */
206
+ decode(data) {
207
+ const begin = groupAt(data);
208
+ if (begin < 0 || begin + 5 > data.length)
209
+ return null;
210
+ const groupId = data[begin] >> 2;
211
+ const size = u16(data, begin + 3);
212
+ if (size === 0 || begin + 5 + size > data.length)
213
+ return null;
214
+ const body = data.subarray(begin + 5, begin + 5 + size);
215
+ this.caption = { chars: [], clear: false, wait: null, drcs: new Map() };
216
+ const id = groupId & 0x0f;
217
+ const group = groupId >> 4;
218
+ if (id === 0) {
219
+ // 字幕管理データ。**同じ群は送り直し** (ARIB TR-B14 4.2.4)
220
+ if (group === this.prevGroup)
221
+ return null;
222
+ this.prevGroup = group;
223
+ this.management(body);
224
+ }
225
+ else {
226
+ // 選んだ言語の字幕文だけ
227
+ if (id !== this.language)
228
+ return null;
229
+ this.statement(body);
230
+ }
231
+ return toPage(this.caption, [this.planeWidth, this.planeHeight]);
232
+ }
233
+ resetGraphicSets() {
234
+ this.gx = [KANJI, ALNUM, HIRA, MACRO];
235
+ this.gl = 0;
236
+ this.gr = 2;
237
+ }
238
+ /** 書式 (SWF) に合わせた面と字の大きさ。Profile A の表 */
239
+ resetWritingFormat() {
240
+ const [w, h, hs, vs] = this.swf === 5
241
+ ? [1920, 1080, 4, 24]
242
+ : this.swf === 8
243
+ ? [960, 540, 12, 24]
244
+ : this.swf === 9
245
+ ? [720, 480, 4, 16]
246
+ : this.swf === 10
247
+ ? [720, 480, 8, 24]
248
+ : [960, 540, 4, 24];
249
+ this.planeWidth = this.areaWidth = w;
250
+ this.planeHeight = this.areaHeight = h;
251
+ this.charWidth = 36;
252
+ this.charHeight = 36;
253
+ this.hSpacing = hs;
254
+ this.vSpacing = vs;
255
+ }
256
+ resetState() {
257
+ this.resetGraphicSets();
258
+ this.resetWritingFormat();
259
+ this.areaX = 0;
260
+ this.areaY = 0;
261
+ this.posInited = false;
262
+ this.posX = 0;
263
+ this.posY = 0;
264
+ this.hScale = 1;
265
+ this.vScale = 1;
266
+ this.underline = false;
267
+ this.bold = false;
268
+ this.italic = false;
269
+ this.stroke = null;
270
+ this.box = 0;
271
+ this.flash = false;
272
+ this.palette = 0;
273
+ this.fg = clut(0, 7);
274
+ this.bg = clut(0, 8);
275
+ }
276
+ management(data) {
277
+ if (data.length < 10)
278
+ return;
279
+ let at = 1;
280
+ if (data[0] >> 6 === 0b10)
281
+ at += 5; // OTM
282
+ const languages = data[at++];
283
+ if (languages === 0 || languages > 8)
284
+ return;
285
+ const found = [];
286
+ for (let i = 0; i < languages; i++) {
287
+ if (at + 6 > data.length)
288
+ return;
289
+ const tag = data[at] >> 5;
290
+ const dmf = data[at] & 0x0f;
291
+ at += 1;
292
+ if (dmf === 0b1100 || dmf === 0b1101 || dmf === 0b1110)
293
+ at += 1;
294
+ const code = ascii(data, at, 3);
295
+ at += 3; // ISO 639
296
+ const format = data[at] >> 4;
297
+ at += 1;
298
+ found.push(captionLanguage(tag + 1, /^[a-z]{3}$/.test(code) ? code : ""));
299
+ // 書式は選んだ言語のものを使う
300
+ if (tag === this.language - 1) {
301
+ this.swf = format - 1;
302
+ this.resetGraphicSets();
303
+ this.resetWritingFormat();
304
+ }
305
+ }
306
+ this.languages = found;
307
+ if (at + 3 > data.length)
308
+ return;
309
+ const length = u24(data, at);
310
+ at += 3;
311
+ if (length > 0 && at + length <= data.length)
312
+ this.dataUnits(data.subarray(at, at + length));
313
+ }
314
+ statement(data) {
315
+ if (data.length < 4)
316
+ return;
317
+ let at = 1;
318
+ const tmd = data[0] >> 6;
319
+ if (tmd === 0b01 || tmd === 0b10)
320
+ at += 5; // STM
321
+ if (at + 4 > data.length)
322
+ return;
323
+ const length = u24(data, at);
324
+ at += 3;
325
+ if (length > 0 && at + length <= data.length)
326
+ this.dataUnits(data.subarray(at, at + length));
327
+ }
328
+ dataUnits(data) {
329
+ let at = 0;
330
+ while (at + 5 <= data.length) {
331
+ if (data[at] !== 0x1f)
332
+ return;
333
+ const parameter = data[at + 1];
334
+ const size = u24(data, at + 2);
335
+ if (size === 0 || at + 5 + size > data.length)
336
+ return;
337
+ const unit = data.subarray(at + 5, at + 5 + size);
338
+ if (parameter === 0x20)
339
+ this.body(unit);
340
+ else if (parameter === 0x30)
341
+ this.defineDrcs(unit, 1);
342
+ else if (parameter === 0x31)
343
+ this.defineDrcs(unit, 2);
344
+ at += 5 + size;
345
+ }
346
+ }
347
+ /** 本文。**読めないところで止める** (そこまでの字は出す。libaribcaption と同じ) */
348
+ body(data) {
349
+ let at = 0;
350
+ while (at < data.length) {
351
+ const byte = data[at];
352
+ const used = byte <= 0x20
353
+ ? this.c0(data, at)
354
+ : byte < 0x7f
355
+ ? this.graphic(data, at, this.gx[this.gl])
356
+ : byte <= 0xa0
357
+ ? this.c1(data, at)
358
+ : byte < 0xff
359
+ ? this.graphic(data, at, this.gx[this.gr])
360
+ : 0;
361
+ if (used <= 0)
362
+ return;
363
+ at += used;
364
+ }
365
+ }
366
+ defineDrcs(data, byteCount) {
367
+ if (data.length === 0)
368
+ return;
369
+ let at = 1;
370
+ const codes = data[0];
371
+ for (let i = 0; i < codes; i++) {
372
+ if (at + 3 > data.length)
373
+ return;
374
+ const code = u16(data, at);
375
+ const fonts = data[at + 2];
376
+ at += 3;
377
+ for (let j = 0; j < fonts; j++) {
378
+ if (at + 4 > data.length)
379
+ return;
380
+ const mode = data[at] & 0x0f;
381
+ at += 1;
382
+ if (mode === 0b0000 || mode === 0b0001) {
383
+ const depth = data[at] + 2;
384
+ const width = data[at + 1];
385
+ const height = data[at + 2];
386
+ at += 3;
387
+ let bits = 0;
388
+ for (let n = depth - 1; n > 0; n >>= 1)
389
+ bits++;
390
+ const size = Math.ceil((width * height * bits) / 8);
391
+ if (at + size > data.length)
392
+ return;
393
+ const pixels = data.slice(at, at + size);
394
+ at += size;
395
+ const md5 = createHash("md5").update(pixels).digest("hex");
396
+ const drcs = {
397
+ width,
398
+ height,
399
+ depth,
400
+ bits,
401
+ pixels,
402
+ replaced: DRCS_REPLACE.get(md5) ?? null,
403
+ md5,
404
+ };
405
+ if (byteCount === 1) {
406
+ const set = drcsSet(((code & 0x0f00) >> 8) + 0x40);
407
+ if (set !== undefined && typeof set.set === "object") {
408
+ this.drcsMaps[set.set.drcs].set(code & 0x7f, drcs);
409
+ }
410
+ }
411
+ else {
412
+ const key = code >= 0xec00 && code <= 0xf8ff ? code : code & 0x7f7f;
413
+ this.drcsMaps[0].set(key, drcs);
414
+ }
415
+ }
416
+ else {
417
+ // 幾何図形。読み飛ばす
418
+ if (at + 4 > data.length)
419
+ return;
420
+ at += 4 + u16(data, at + 2);
421
+ }
422
+ }
423
+ }
424
+ }
425
+ /** @returns 使ったバイト数。0 なら読めない */
426
+ c0(data, at) {
427
+ const rest = data.length - at;
428
+ switch (data[at]) {
429
+ case 0x08: // APB
430
+ this.moveRelative(-1, 0);
431
+ return 1;
432
+ case 0x09: // APF
433
+ this.moveRelative(1, 0);
434
+ return 1;
435
+ case 0x0a: // APD
436
+ this.moveRelative(0, 1);
437
+ return 1;
438
+ case 0x0b: // APU
439
+ this.moveRelative(0, -1);
440
+ return 1;
441
+ case 0x0c: // CS
442
+ this.resetState();
443
+ this.caption.clear = true;
444
+ return 1;
445
+ case 0x0d: // APR
446
+ this.newline();
447
+ return 1;
448
+ case 0x0e: // LS1
449
+ this.gl = 1;
450
+ return 1;
451
+ case 0x0f: // LS0
452
+ this.gl = 0;
453
+ return 1;
454
+ case 0x16: // PAPF
455
+ if (rest < 2)
456
+ return 0;
457
+ this.moveRelative(data[at + 1] & 0x3f, 0);
458
+ return 2;
459
+ case 0x19: // SS2
460
+ case 0x1d: {
461
+ // SS3
462
+ if (rest < 2)
463
+ return 0;
464
+ const used = this.graphic(data, at + 1, this.gx[data[at] === 0x19 ? 2 : 3]);
465
+ return used <= 0 ? 0 : 1 + used;
466
+ }
467
+ case 0x1b: {
468
+ if (rest < 2)
469
+ return 0;
470
+ const used = this.escape(data, at + 1);
471
+ return used < 0 ? 0 : 1 + used;
472
+ }
473
+ case 0x1c: // APS
474
+ if (rest < 3)
475
+ return 0;
476
+ this.setAbsolute(data[at + 2] & 0x3f, data[at + 1] & 0x3f);
477
+ return 3;
478
+ case 0x20: // SP
479
+ this.push(this.msz() ? 0x20 : 0x3000);
480
+ this.moveRelative(1, 0);
481
+ return 1;
482
+ default:
483
+ return 1;
484
+ }
485
+ }
486
+ escape(data, at) {
487
+ const rest = data.length - at;
488
+ const byte = data[at];
489
+ switch (byte) {
490
+ case 0x6e: // LS2
491
+ this.gl = 2;
492
+ return 1;
493
+ case 0x6f: // LS3
494
+ this.gl = 3;
495
+ return 1;
496
+ case 0x7e: // LS1R
497
+ this.gr = 1;
498
+ return 1;
499
+ case 0x7d: // LS2R
500
+ this.gr = 2;
501
+ return 1;
502
+ case 0x7c: // LS3R
503
+ this.gr = 3;
504
+ return 1;
505
+ }
506
+ if (byte === 0x24) {
507
+ if (rest < 2)
508
+ return -1;
509
+ const next = data[at + 1];
510
+ if (next >= 0x28 && next <= 0x2b) {
511
+ if (rest < 3)
512
+ return -1;
513
+ if (data[at + 2] === 0x20) {
514
+ if (rest < 4)
515
+ return -1;
516
+ const set = drcsSet(data[at + 3]);
517
+ if (set !== undefined)
518
+ this.gx[next - 0x28] = set;
519
+ return 4;
520
+ }
521
+ const set = G_SETS.get(data[at + 2]);
522
+ if (set !== undefined)
523
+ this.gx[next - 0x28] = set;
524
+ return 3;
525
+ }
526
+ const set = G_SETS.get(next);
527
+ if (set !== undefined)
528
+ this.gx[0] = set;
529
+ return 2;
530
+ }
531
+ if (byte >= 0x28 && byte <= 0x2b) {
532
+ if (rest < 2)
533
+ return -1;
534
+ if (data[at + 1] === 0x20) {
535
+ if (rest < 3)
536
+ return -1;
537
+ const set = drcsSet(data[at + 2]);
538
+ if (set !== undefined)
539
+ this.gx[byte - 0x28] = set;
540
+ return 3;
541
+ }
542
+ const set = G_SETS.get(data[at + 1]);
543
+ if (set !== undefined)
544
+ this.gx[byte - 0x28] = set;
545
+ return 2;
546
+ }
547
+ // 知らない ESC は、ESC 1バイトだけ読んだことにする (libaribcaption と同じ)
548
+ return 0;
549
+ }
550
+ c1(data, at) {
551
+ const rest = data.length - at;
552
+ const byte = data[at];
553
+ if (byte >= 0x80 && byte <= 0x87) {
554
+ // BKF〜WHF
555
+ this.fg = clut(this.palette, byte - 0x80);
556
+ return 1;
557
+ }
558
+ switch (byte) {
559
+ case 0x90: {
560
+ // COL
561
+ if (rest < 2)
562
+ return 0;
563
+ const p = data[at + 1];
564
+ if (p === 0x20) {
565
+ if (rest < 3)
566
+ return 0;
567
+ this.palette = data[at + 2] & 0x07;
568
+ return 3;
569
+ }
570
+ if (p >= 0x48 && p <= 0x7f) {
571
+ if ((p & 0xf0) === 0x40)
572
+ this.fg = clut(this.palette, p & 0x0f);
573
+ else if ((p & 0xf0) === 0x50)
574
+ this.bg = clut(this.palette, p & 0x0f);
575
+ return 2;
576
+ }
577
+ return 0;
578
+ }
579
+ case 0x93: // POL
580
+ case 0x94: // WMM
581
+ return 2;
582
+ case 0x88: // SSZ
583
+ this.hScale = 0.5;
584
+ this.vScale = 0.5;
585
+ return 1;
586
+ case 0x89: // MSZ
587
+ this.hScale = 0.5;
588
+ this.vScale = 1;
589
+ return 1;
590
+ case 0x8a: // NSZ
591
+ this.hScale = 1;
592
+ this.vScale = 1;
593
+ return 1;
594
+ case 0x8b: // SZX
595
+ if (rest < 2)
596
+ return 0;
597
+ if (data[at + 1] === 0x41)
598
+ this.vScale = 2;
599
+ else if (data[at + 1] === 0x44)
600
+ this.hScale = 2;
601
+ else if (data[at + 1] === 0x45) {
602
+ this.hScale = 2;
603
+ this.vScale = 2;
604
+ }
605
+ return 2;
606
+ case 0x91: // FLC。0x40 正相・0x47 逆相で点滅、0x4F で止める
607
+ if (rest < 2)
608
+ return 0;
609
+ this.flash = data[at + 1] === 0x40 || data[at + 1] === 0x47;
610
+ return 2;
611
+ case 0x92: // CDC
612
+ if (rest < 2)
613
+ return 0;
614
+ if (data[at + 1] === 0x20)
615
+ return rest < 3 ? 0 : 3;
616
+ return 2;
617
+ case 0x9d: // TIME
618
+ if (rest < 3)
619
+ return 0;
620
+ if (data[at + 1] === 0x20) {
621
+ this.caption.wait =
622
+ (this.caption.wait ?? 0) + (data[at + 2] & 0x3f) * 100;
623
+ return 3;
624
+ }
625
+ return data[at + 1] === 0x28 ? 3 : 0;
626
+ case 0x95: // MACRO (TR-B14 で使わない)
627
+ return 0;
628
+ case 0x98: // RPC (libaribcaption も繰り返さない)
629
+ return rest < 2 ? 0 : 2;
630
+ case 0x9a: // STL
631
+ this.underline = true;
632
+ return 1;
633
+ case 0x99: // SPL
634
+ this.underline = false;
635
+ return 1;
636
+ case 0x97: // HLC
637
+ if (rest < 2)
638
+ return 0;
639
+ this.box = data[at + 1] & 0x0f;
640
+ return 2;
641
+ case 0x9b: {
642
+ const used = this.csi(data, at + 1);
643
+ return used <= 0 ? 0 : 1 + used;
644
+ }
645
+ default:
646
+ return 1;
647
+ }
648
+ }
649
+ csi(data, at) {
650
+ const rest = data.length - at;
651
+ let offset = 0;
652
+ let p1 = 0;
653
+ let p2 = 0;
654
+ let count = 0;
655
+ while (offset < rest) {
656
+ const byte = data[at + offset];
657
+ if (byte >= 0x30 && byte <= 0x39) {
658
+ if (count <= 1)
659
+ p2 = p2 * 10 + (byte & 0x0f);
660
+ }
661
+ else if (byte === 0x20) {
662
+ if (count === 0)
663
+ p1 = p2;
664
+ count++;
665
+ break;
666
+ }
667
+ else if (byte === 0x3b) {
668
+ if (count === 0) {
669
+ p1 = p2;
670
+ p2 = 0;
671
+ }
672
+ count++;
673
+ }
674
+ offset++;
675
+ }
676
+ if (++offset >= rest)
677
+ return 0;
678
+ switch (data[at + offset]) {
679
+ case 0x53: // SWF
680
+ if (count === 1)
681
+ this.swf = p1;
682
+ this.resetWritingFormat();
683
+ break;
684
+ case 0x56: // SDF
685
+ this.areaWidth = p1;
686
+ this.areaHeight = p2;
687
+ break;
688
+ case 0x57: // SSM
689
+ this.charWidth = p1;
690
+ this.charHeight = p2;
691
+ break;
692
+ case 0x58: // SHS
693
+ this.hSpacing = p1;
694
+ break;
695
+ case 0x59: // SVS
696
+ this.vSpacing = p1;
697
+ break;
698
+ case 0x5f: // SDP
699
+ this.areaX = p1;
700
+ if (count >= 2)
701
+ this.areaY = p2;
702
+ if (!this.posInited)
703
+ this.setAbsolute(0, 0);
704
+ break;
705
+ case 0x61: // ACPS
706
+ this.posInited = true;
707
+ this.posX = p1;
708
+ this.posY = p2;
709
+ break;
710
+ case 0x63: // ORN
711
+ if (p1 === 0)
712
+ this.stroke = null;
713
+ else if (p1 === 1 && count >= 2) {
714
+ const palette = Math.floor(p2 / 100);
715
+ const index = p2 % 100;
716
+ if (palette >= 8 || index >= 16)
717
+ return 0;
718
+ this.stroke = clut(palette, index);
719
+ }
720
+ break;
721
+ case 0x64: // MDF
722
+ if (p1 === 0) {
723
+ this.bold = false;
724
+ this.italic = false;
725
+ }
726
+ else if (p1 === 1)
727
+ this.bold = true;
728
+ else if (p1 === 2)
729
+ this.italic = true;
730
+ else if (p1 === 3) {
731
+ this.bold = true;
732
+ this.italic = true;
733
+ }
734
+ break;
735
+ }
736
+ return offset + 1;
737
+ }
738
+ /** 中くらい (MSZ) か。半角へ寄せる見分けに使う */
739
+ msz() {
740
+ return this.hScale * 2 === this.vScale;
741
+ }
742
+ graphic(data, at, entry) {
743
+ const ch = data[at] & 0x7f;
744
+ if (ch < 0x21 || ch >= 0x7f)
745
+ return 0;
746
+ let ch2 = 0;
747
+ if (entry.bytes === 2) {
748
+ if (at + 1 >= data.length)
749
+ return 0;
750
+ ch2 = data[at + 1] & 0x7f;
751
+ if (ch2 < 0x21 || ch2 >= 0x7f)
752
+ return 0;
753
+ }
754
+ const set = entry.set;
755
+ const index = ch - 0x21;
756
+ if (set === "hiragana" || set === "katakana") {
757
+ let code = (set === "hiragana" ? T.hiragana : T.katakana)[index];
758
+ if (ch >= 0x79 && this.msz())
759
+ code = T.kanaHalf[ch - 0x79];
760
+ this.push(code);
761
+ this.moveRelative(1, 0);
762
+ }
763
+ else if (set === "jisx0201") {
764
+ this.push((this.msz() ? T.jisx0201Half : T.jisx0201)[index]);
765
+ this.moveRelative(1, 0);
766
+ }
767
+ else if (set === "kanji" || set === "additional") {
768
+ const ku = ch - 0x21;
769
+ const ten = ch2 - 0x21;
770
+ let code;
771
+ if (ku < 84) {
772
+ const i = ku * 94 + ten;
773
+ code = kanji(i);
774
+ if (this.msz()) {
775
+ if (ku < 2)
776
+ code = T.kanjiHalf[i];
777
+ if (code === 0x3000 || (code >= 0xff01 && code <= 0xff5e))
778
+ code = (code & 0xff) + 0x20;
779
+ }
780
+ }
781
+ else {
782
+ code = T.additional[(ku - 84) * 94 + ten] ?? 0xfffd;
783
+ }
784
+ this.push(code);
785
+ this.moveRelative(1, 0);
786
+ }
787
+ else if (set === "alnum") {
788
+ this.push((this.msz() ? T.alnumHalf : T.alnumFull)[index]);
789
+ this.moveRelative(1, 0);
790
+ }
791
+ else if (set === "macro") {
792
+ if (ch >= 0x60 && ch <= 0x6f)
793
+ this.body(MACROS[ch & 0x0f]);
794
+ }
795
+ else if (typeof set === "object") {
796
+ const key = entry.bytes === 2 ? (ch << 8) | ch2 : ch;
797
+ const drcs = this.drcsMaps[set.drcs].get(key);
798
+ if (drcs === undefined) {
799
+ // 定義されていない外字は、ゲタ (〓) で埋める
800
+ this.push(0x3013);
801
+ }
802
+ else {
803
+ const code = (set.drcs << 16) | key;
804
+ this.caption.drcs.set(code, drcs);
805
+ if (drcs.replaced !== null)
806
+ this.push(drcs.replaced);
807
+ else
808
+ this.push(0x3013, code);
809
+ }
810
+ this.moveRelative(1, 0);
811
+ }
812
+ // モザイクは描かない (libaribcaption と同じ)
813
+ return entry.bytes;
814
+ }
815
+ push(code, drcs = null) {
816
+ this.caption.chars.push({
817
+ code,
818
+ drcs,
819
+ x: this.posX,
820
+ y: this.posY - this.sectionHeight(),
821
+ charWidth: this.charWidth,
822
+ charHeight: this.charHeight,
823
+ hSpacing: this.hSpacing,
824
+ vSpacing: this.vSpacing,
825
+ hScale: this.hScale,
826
+ vScale: this.vScale,
827
+ fg: this.fg,
828
+ bg: this.bg,
829
+ stroke: this.stroke,
830
+ underline: this.underline,
831
+ bold: this.bold,
832
+ italic: this.italic,
833
+ box: this.box,
834
+ flash: this.flash,
835
+ ruby: (this.hScale === 0.5 && this.vScale === 0.5) ||
836
+ (this.charWidth === 18 && this.charHeight === 18),
837
+ });
838
+ }
839
+ sectionWidth() {
840
+ return section(this.charWidth, this.hSpacing, this.hScale);
841
+ }
842
+ sectionHeight() {
843
+ return section(this.charHeight, this.vSpacing, this.vScale);
844
+ }
845
+ setAbsolute(x, y) {
846
+ this.posInited = true;
847
+ this.posX = this.areaX + x * this.sectionWidth();
848
+ this.posY = this.areaY + (y + 1) * this.sectionHeight();
849
+ }
850
+ moveRelative(x, y) {
851
+ if (this.posX < 0 || this.posY < 0)
852
+ this.setAbsolute(0, 0);
853
+ this.posInited = true;
854
+ while (x < 0) {
855
+ this.posX -= this.sectionWidth();
856
+ x++;
857
+ if (this.posX < this.areaX) {
858
+ this.posX = this.areaX + this.areaWidth - this.sectionWidth();
859
+ y--;
860
+ }
861
+ }
862
+ while (x > 0) {
863
+ this.posX += this.sectionWidth();
864
+ x--;
865
+ if (this.posX >= this.areaX + this.areaWidth) {
866
+ this.posX = this.areaX;
867
+ y++;
868
+ }
869
+ }
870
+ while (y < 0) {
871
+ this.posY -= this.sectionHeight();
872
+ y++;
873
+ if (this.posY < this.areaY)
874
+ this.posY = this.areaY + this.areaHeight;
875
+ }
876
+ while (y > 0) {
877
+ this.posY += this.sectionHeight();
878
+ y--;
879
+ if (this.posY > this.areaY + this.areaHeight)
880
+ this.posY = this.areaY + this.sectionHeight();
881
+ }
882
+ }
883
+ newline() {
884
+ if (this.posX < 0 || this.posY < 0)
885
+ this.setAbsolute(0, 0);
886
+ this.posInited = true;
887
+ this.posX = this.areaX;
888
+ this.posY += this.sectionHeight();
889
+ }
890
+ }
891
+ /**
892
+ * デコードした1枚を、送る形式にする。**同じ見た目で続いている字を1つの並びに束ねる。**
893
+ *
894
+ * libaribcaption の描き手 (region_renderer) が字ごとに計算している置き場所を、ここで
895
+ * 面の単位のまま済ませておく。区画は `floor((字の幅 + 字間) × 倍率)`、字の枠は区画の中で
896
+ * 字間の半分だけ右下へずらしたところ。
897
+ *
898
+ * @returns 出すものが無い (待ちだけの字幕文) なら null。CS だけなら「消す」1枚
899
+ */
900
+ function toPage(caption, plane) {
901
+ if (caption.chars.length === 0 && !caption.clear)
902
+ return null;
903
+ const runs = [];
904
+ const drcs = {};
905
+ let last = null;
906
+ for (const char of caption.chars) {
907
+ const w = section(char.charWidth, char.hSpacing, char.hScale);
908
+ const h = section(char.charHeight, char.vSpacing, char.vScale);
909
+ const size = char.charHeight * char.vScale;
910
+ const glyphWidth = char.charWidth * char.hScale;
911
+ // 中くらいの半角の字は縮めない (もともと半分の幅。libaribcaption の DrawChar と同じ)
912
+ const halfAsked = Math.abs(glyphWidth / size - 0.5) < 0.05;
913
+ const scaleX = halfAsked && isHalfwidth(char.code) ? 1 : glyphWidth / size;
914
+ const run = {
915
+ x: char.x,
916
+ y: char.y,
917
+ w,
918
+ h,
919
+ fx: (char.hSpacing * char.hScale) / 2,
920
+ fy: (char.vSpacing * char.vScale) / 2,
921
+ size,
922
+ scaleX,
923
+ text: String.fromCodePoint(char.code),
924
+ fg: char.fg,
925
+ bg: char.bg,
926
+ };
927
+ if (char.drcs !== null) {
928
+ const key = String(char.drcs);
929
+ run.drcs = key;
930
+ const found = caption.drcs.get(char.drcs);
931
+ if (found !== undefined && drcs[key] === undefined) {
932
+ drcs[key] = sendable(found);
933
+ }
934
+ }
935
+ if (char.stroke !== null)
936
+ run.stroke = char.stroke;
937
+ if (char.underline)
938
+ run.underline = true;
939
+ if (char.bold)
940
+ run.bold = true;
941
+ if (char.italic)
942
+ run.italic = true;
943
+ if (char.box !== 0)
944
+ run.box = char.box;
945
+ if (char.flash)
946
+ run.flash = true;
947
+ if (char.ruby)
948
+ run.ruby = true;
949
+ // 見た目が同じで、すぐ右に続いていれば束ねる。外字の画像は1字ずつ
950
+ const { x: _x, y: _y, text: _t, ...looks } = run;
951
+ const key = JSON.stringify(looks);
952
+ if (last !== null &&
953
+ run.drcs === undefined &&
954
+ last.key === key &&
955
+ last.run.y === run.y &&
956
+ last.run.x + last.count * last.run.w === run.x) {
957
+ last.run.text += run.text;
958
+ last.count++;
959
+ continue;
960
+ }
961
+ runs.push(run);
962
+ last = { run, count: 1, key };
963
+ }
964
+ const page = {
965
+ v: CAPTION_TEXT_VERSION,
966
+ plane,
967
+ duration: caption.wait,
968
+ runs,
969
+ };
970
+ if (Object.keys(drcs).length > 0)
971
+ page.drcs = drcs;
972
+ return page;
973
+ }
974
+ /**
975
+ * 字幕の PES の中身で、データグループの頭の位置。データ識別 (0x80 = 字幕) と私的ストリーム識別 (0xFF) が
976
+ * 合わなければ -1
977
+ */
978
+ function groupAt(data) {
979
+ if (data.length < 3 || data[0] !== 0x80 || data[1] !== 0xff)
980
+ return -1;
981
+ return 3 + (data[2] & 0x0f);
982
+ }
983
+ /** 字幕の PES の中身が字幕管理データか (データグループの番号が 0)。読めないものは false */
984
+ export function isManagement(data) {
985
+ const begin = groupAt(data);
986
+ return (begin >= 0 && begin < data.length && ((data[begin] >> 2) & 0x0f) === 0);
987
+ }