@danything/arib 2.1.0 → 2.2.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/dist/ttml.js ADDED
@@ -0,0 +1,455 @@
1
+ /**
2
+ * 4K の放送 (MMT/TLV) の字幕 (ARIB STD-B62 の TTML) を、字幕の文字の配置 (`caption-text.ts`) にする。
3
+ * TS の字幕の `b24caption.ts` に当たり、受け側は TS の字幕と同じように描くだけで済む。
4
+ *
5
+ * 字幕の文書は MPU 1 つに 1 つ来る (`mmt-media.ts` の `CaptionUnit`)。文書の中の時刻 (begin・end・dur) は、
6
+ * MPU の最初の提示時刻からのずれとして読む (推定。字幕のパケットは実機の録りにまだ来ていない)。
7
+ *
8
+ * 読むのは文字・位置 (region の origin・extent、textAlign・displayAlign)・大きさ (fontSize・lineHeight)・
9
+ * 色 (color・backgroundColor・textOutline)・太字・斜体・下線・ルビ。画像を参照する字幕 (PNG) と
10
+ * 縦書き・アニメーション (set) は読まない
11
+ */
12
+ import { CAPTION_TEXT_VERSION, } from "./caption-text.js";
13
+ import { CLOCK } from "./mmt-media.js";
14
+ const ENTITIES = {
15
+ lt: "<",
16
+ gt: ">",
17
+ amp: "&",
18
+ quot: '"',
19
+ apos: "'",
20
+ };
21
+ function unescape(text) {
22
+ return text.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (whole, name) => {
23
+ const code = name;
24
+ if (code.startsWith("#x") || code.startsWith("#X"))
25
+ return String.fromCodePoint(parseInt(code.slice(2), 16));
26
+ if (code.startsWith("#"))
27
+ return String.fromCodePoint(Number(code.slice(1)));
28
+ return ENTITIES[code] ?? whole;
29
+ });
30
+ }
31
+ const localName = (name) => name.slice(name.indexOf(":") + 1);
32
+ /** 字幕の文書を読むだけの小さな XML の読み取り。壊れていれば null */
33
+ function parseXml(text) {
34
+ const root = { name: "", attributes: {}, children: [] };
35
+ const stack = [root];
36
+ const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[([\s\S]*?)\]\]>|<![^>]*>|<\/([^\s>]+)\s*>|<([^\s/>]+)((?:\s+[^\s=]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)/g;
37
+ for (const match of text.matchAll(token)) {
38
+ const top = stack.at(-1);
39
+ const [, cdata, close, open, attributes, selfClosing, chars] = match;
40
+ if (cdata !== undefined)
41
+ top.children.push(cdata);
42
+ else if (chars !== undefined)
43
+ top.children.push(unescape(chars));
44
+ else if (close !== undefined) {
45
+ if (stack.length < 2 || top.name !== localName(close))
46
+ return null;
47
+ stack.pop();
48
+ }
49
+ else if (open !== undefined) {
50
+ const element = {
51
+ name: localName(open),
52
+ attributes: {},
53
+ children: [],
54
+ };
55
+ for (const [, key = "", , a, b] of (attributes ?? "").matchAll(/([^\s=]+)\s*=\s*("([^"]*)"|'([^']*)')/g))
56
+ element.attributes[localName(key)] = unescape(a ?? b ?? "");
57
+ top.children.push(element);
58
+ if (selfClosing !== "/")
59
+ stack.push(element);
60
+ }
61
+ }
62
+ const tt = root.children.find((c) => typeof c !== "string");
63
+ return stack.length === 1 && tt?.name === "tt" ? tt : null;
64
+ }
65
+ function* elements(node, name) {
66
+ for (const child of node.children) {
67
+ if (typeof child === "string")
68
+ continue;
69
+ if (child.name === name)
70
+ yield child;
71
+ yield* elements(child, name);
72
+ }
73
+ }
74
+ /** 時刻の書き方 (`00:00:01.500`・`00:00:01:15`・`1.5s`・`1500ms`・`90f`) を秒に。読めなければ null */
75
+ function seconds(value, frameRate) {
76
+ if (value === undefined)
77
+ return null;
78
+ const clock = /^(\d+):(\d{2}):(\d{2}(?:\.\d+)?)(?::(\d+(?:\.\d+)?))?$/.exec(value.trim());
79
+ if (clock !== null) {
80
+ const [, h = "0", m = "0", s = "0", f] = clock;
81
+ return (Number(h) * 3600 +
82
+ Number(m) * 60 +
83
+ Number(s) +
84
+ (f === undefined ? 0 : Number(f) / frameRate));
85
+ }
86
+ const offset = /^(\d+(?:\.\d+)?)(h|m|s|ms|f)$/.exec(value.trim());
87
+ if (offset === null)
88
+ return null;
89
+ const [, count = "0", unit] = offset;
90
+ const scale = {
91
+ h: 3600,
92
+ m: 60,
93
+ s: 1,
94
+ ms: 0.001,
95
+ f: 1 / frameRate,
96
+ }[unit];
97
+ return Number(count) * scale;
98
+ }
99
+ /** 名前で書く色 (TTML の名前の色) */
100
+ const NAMED = {
101
+ transparent: "#00000000",
102
+ black: "#000000ff",
103
+ silver: "#c0c0c0ff",
104
+ gray: "#808080ff",
105
+ white: "#ffffffff",
106
+ maroon: "#800000ff",
107
+ red: "#ff0000ff",
108
+ purple: "#800080ff",
109
+ fuchsia: "#ff00ffff",
110
+ magenta: "#ff00ffff",
111
+ green: "#008000ff",
112
+ lime: "#00ff00ff",
113
+ olive: "#808000ff",
114
+ yellow: "#ffff00ff",
115
+ navy: "#000080ff",
116
+ blue: "#0000ffff",
117
+ teal: "#008080ff",
118
+ aqua: "#00ffffff",
119
+ cyan: "#00ffffff",
120
+ };
121
+ const hex2 = (value) => Math.max(0, Math.min(255, Math.round(value)))
122
+ .toString(16)
123
+ .padStart(2, "0");
124
+ /** 色を `#rrggbbaa` に。読めなければ null */
125
+ function color(value) {
126
+ if (value === undefined)
127
+ return null;
128
+ const text = value.trim().toLowerCase();
129
+ if (/^#[0-9a-f]{6}$/.test(text))
130
+ return `${text}ff`;
131
+ if (/^#[0-9a-f]{8}$/.test(text))
132
+ return text;
133
+ const rgb = /^rgba?\(([^)]*)\)$/.exec(text);
134
+ if (rgb !== null) {
135
+ const [r = 0, g = 0, b = 0, a = 255] = (rgb[1] ?? "")
136
+ .split(",")
137
+ .map(Number);
138
+ return `#${hex2(r)}${hex2(g)}${hex2(b)}${hex2(a)}`;
139
+ }
140
+ return NAMED[text] ?? null;
141
+ }
142
+ /** 長さ 1 つ (`12px`・`50%`・`1.5c`)。% は `whole` に対して。読めなければ null */
143
+ function length(value, whole, cell) {
144
+ const match = /^(-?\d+(?:\.\d+)?)(px|%|c|em)$/.exec(value.trim());
145
+ if (match === null)
146
+ return null;
147
+ const number = Number(match[1]);
148
+ switch (match[2]) {
149
+ case "px":
150
+ return number;
151
+ case "%":
152
+ return (number / 100) * whole;
153
+ default:
154
+ return number * cell;
155
+ }
156
+ }
157
+ /** 2 つの長さ (`x y`・`幅 高さ`)。1 つしか無ければ両方に使う */
158
+ function pair(value, frame, wholeX = frame.width, wholeY = frame.height) {
159
+ if (value === undefined)
160
+ return null;
161
+ const parts = value.trim().split(/\s+/);
162
+ const x = length(parts[0] ?? "", wholeX, frame.width / frame.columns);
163
+ const y = length(parts[1] ?? parts[0] ?? "", wholeY, frame.height / frame.rows);
164
+ return x === null || y === null ? null : [x, y];
165
+ }
166
+ /** `style` で参照した名前の付いた style を、参照の順に重ねる (style がさらに参照するものも先に) */
167
+ function referenced(ids, context, seen = new Set()) {
168
+ let out = {};
169
+ for (const id of (ids ?? "").split(/\s+/)) {
170
+ const style = context.styles.get(id);
171
+ if (style === undefined || seen.has(id))
172
+ continue;
173
+ seen.add(id);
174
+ out = { ...out, ...referenced(style["style"], context, seen), ...style };
175
+ }
176
+ return out;
177
+ }
178
+ /** 要素の見た目。親から受け継いだものに、参照した style と要素の属性を重ねる */
179
+ function styleOf(element, inherited, context) {
180
+ return {
181
+ ...inherited,
182
+ ...referenced(element.attributes["style"], context),
183
+ ...element.attributes,
184
+ };
185
+ }
186
+ /** p の中身を行に分ける。xml:space が default なら、空白の並びを 1 つにして行の端を落とす */
187
+ function lines(p, style, context) {
188
+ const out = [[]];
189
+ const walk = (node, inherited) => {
190
+ if (typeof node === "string") {
191
+ const preserve = inherited["space"] === "preserve";
192
+ const text = preserve ? node : node.replace(/\s+/g, " ");
193
+ for (const char of text)
194
+ out.at(-1).push({ char, style: inherited });
195
+ return;
196
+ }
197
+ if (node.name === "br") {
198
+ out.push([]);
199
+ return;
200
+ }
201
+ if (node.name !== "span")
202
+ return;
203
+ const own = styleOf(node, inherited, context);
204
+ // ルビの入れ物: 親字 (base) の並びと、ルビ (text) を組にする
205
+ if (own["ruby"] === "container") {
206
+ const parts = node.children.filter((c) => typeof c !== "string");
207
+ const find = (kind) => parts.find((c) => styleOf(c, own, context)["ruby"] === kind) ??
208
+ parts
209
+ .filter((c) => styleOf(c, own, context)["ruby"] === `${kind}Container`)
210
+ .flatMap((c) => c.children.filter((x) => typeof x !== "string"))[0];
211
+ const base = find("base");
212
+ const ruby = find("text");
213
+ const before = out.at(-1).length;
214
+ if (base !== undefined)
215
+ walk(base, own);
216
+ const added = out.at(-1).length - before;
217
+ const first = out.at(-1)[before];
218
+ if (ruby !== undefined && first !== undefined && added > 0)
219
+ first.ruby = { text: textOf(ruby).trim(), count: added };
220
+ return;
221
+ }
222
+ for (const child of node.children)
223
+ walk(child, own);
224
+ };
225
+ for (const child of p.children)
226
+ walk(child, style);
227
+ return out.map((line) => style["space"] === "preserve" ? line : trimLine(line));
228
+ }
229
+ function textOf(node) {
230
+ return typeof node === "string" ? node : node.children.map(textOf).join("");
231
+ }
232
+ function trimLine(line) {
233
+ let start = 0;
234
+ let end = line.length;
235
+ while (start < end && line[start].char === " ")
236
+ start++;
237
+ while (end > start && line[end - 1].char === " ")
238
+ end--;
239
+ return line.slice(start, end);
240
+ }
241
+ /** 半角の字 (ASCII・半角カナ)。区画の幅を半分にする */
242
+ function narrow(char) {
243
+ const code = char.codePointAt(0);
244
+ return code < 0x7f || (code >= 0xff61 && code <= 0xff9f);
245
+ }
246
+ /** 見た目から、字の大きさ (縦) と横の縮め方 */
247
+ function fontSize(style, frame) {
248
+ const cell = frame.height / frame.rows;
249
+ const parts = (style["fontSize"] ?? "1c").trim().split(/\s+/);
250
+ const height = length(parts[1] ?? parts[0] ?? "", cell, cell) ?? cell;
251
+ const width = parts[1] === undefined
252
+ ? height
253
+ : (length(parts[0] ?? "", cell, cell) ?? height);
254
+ return [height, height > 0 ? width / height : 1];
255
+ }
256
+ function lineHeight(style, size, frame) {
257
+ const value = style["lineHeight"];
258
+ if (value === undefined || value === "normal")
259
+ return size * 1.2;
260
+ return length(value, size, frame.height / frame.rows) ?? size * 1.2;
261
+ }
262
+ /** 1 行を区画に並べた run にする。`x`・`y` は行の左上 */
263
+ function lineRuns(line, x, y, height, frame) {
264
+ const runs = [];
265
+ const rubies = [];
266
+ let at = x;
267
+ line.forEach((glyph, i) => {
268
+ const [size, scaleX] = fontSize(glyph.style, frame);
269
+ const w = glyphWidth(glyph.char, glyph.style, frame);
270
+ const run = runOf(glyph.style, glyph.char, at, y, w, height, size, scaleX);
271
+ const last = runs.at(-1);
272
+ if (last !== undefined &&
273
+ sameLook(last, run) &&
274
+ Math.abs(last.x + last.w * [...last.text].length - at) < 1e-6)
275
+ last.text += glyph.char;
276
+ else
277
+ runs.push(run);
278
+ if (glyph.ruby !== undefined) {
279
+ // 親字の並びの幅の真ん中に、半分の大きさで、行の上へはみ出して置く
280
+ const baseWidth = line
281
+ .slice(i, i + glyph.ruby.count)
282
+ .reduce((sum, g) => sum + glyphWidth(g.char, g.style, frame), 0);
283
+ const rubySize = size / 2;
284
+ const chars = [...glyph.ruby.text];
285
+ const widths = chars.map((c) => rubySize * scaleX * (narrow(c) ? 0.5 : 1));
286
+ let rx = at + (baseWidth - widths.reduce((a, b) => a + b, 0)) / 2;
287
+ chars.forEach((char, k) => {
288
+ const rw = widths[k];
289
+ const ruby = runOf(glyph.style, char, rx, y - rubySize, rw, rubySize, rubySize, scaleX);
290
+ ruby.ruby = true;
291
+ ruby.bg = "#00000000";
292
+ rubies.push(ruby);
293
+ rx += rw;
294
+ });
295
+ }
296
+ at += w;
297
+ });
298
+ return [...runs, ...rubies];
299
+ }
300
+ /** 1 字の区画の幅 */
301
+ function glyphWidth(char, style, frame) {
302
+ const [size, scaleX] = fontSize(style, frame);
303
+ return size * scaleX * (narrow(char) ? 0.5 : 1);
304
+ }
305
+ function runOf(style, text, x, y, w, h, size, scaleX) {
306
+ const run = {
307
+ x,
308
+ y,
309
+ w,
310
+ h,
311
+ fx: 0,
312
+ fy: (h - size) / 2,
313
+ size,
314
+ scaleX,
315
+ text,
316
+ fg: color(style["color"]) ?? "#ffffffff",
317
+ bg: color(style["backgroundColor"]) ?? "#00000000",
318
+ };
319
+ const outline = style["textOutline"]?.trim().split(/\s+/) ?? ["none"];
320
+ if (outline[0] !== "none")
321
+ run.stroke = color(outline[0]) ?? run.fg;
322
+ if (style["fontWeight"] === "bold")
323
+ run.bold = true;
324
+ if (style["fontStyle"] === "italic" || style["fontStyle"] === "oblique")
325
+ run.italic = true;
326
+ if (style["textDecoration"]?.includes("underline") === true)
327
+ run.underline = true;
328
+ return run;
329
+ }
330
+ function sameLook(a, b) {
331
+ return (a.y === b.y &&
332
+ a.w === b.w &&
333
+ a.h === b.h &&
334
+ a.size === b.size &&
335
+ a.scaleX === b.scaleX &&
336
+ a.fg === b.fg &&
337
+ a.bg === b.bg &&
338
+ a.stroke === b.stroke &&
339
+ a.bold === b.bold &&
340
+ a.italic === b.italic &&
341
+ a.underline === b.underline &&
342
+ a.ruby === undefined &&
343
+ b.ruby === undefined);
344
+ }
345
+ /** p を、region の中に置いた run にする */
346
+ function layout(p, style, context) {
347
+ const { frame } = context;
348
+ const region = context.regions.get(style["region"] ?? "");
349
+ const regionStyle = region === undefined ? {} : styleOf(region, {}, context);
350
+ const merged = { ...regionStyle, ...style };
351
+ const [x, y] = pair(regionStyle["origin"], frame) ?? [0, 0];
352
+ const [width, height] = pair(regionStyle["extent"], frame) ?? [
353
+ frame.width - x,
354
+ frame.height - y,
355
+ ];
356
+ const rows = lines(p, merged, context).filter((line) => line.length > 0);
357
+ const heights = rows.map((line) => Math.max(...line.map((g) => lineHeight(g.style, fontSize(g.style, frame)[0], frame))));
358
+ const total = heights.reduce((sum, h) => sum + h, 0);
359
+ const align = merged["displayAlign"];
360
+ let top = y +
361
+ (align === "after"
362
+ ? height - total
363
+ : align === "center"
364
+ ? (height - total) / 2
365
+ : 0);
366
+ const runs = [];
367
+ rows.forEach((line, i) => {
368
+ const lineWidth = line.reduce((sum, g) => sum + glyphWidth(g.char, g.style, frame), 0);
369
+ const textAlign = merged["textAlign"];
370
+ const left = x +
371
+ (textAlign === "center"
372
+ ? (width - lineWidth) / 2
373
+ : textAlign === "right" || textAlign === "end"
374
+ ? width - lineWidth
375
+ : 0);
376
+ runs.push(...lineRuns(line, left, top, heights[i], frame));
377
+ top += heights[i];
378
+ });
379
+ return runs;
380
+ }
381
+ /**
382
+ * TTML の文書を、出す時刻ごとの字幕の文字の配置にする。`start` は文書の時刻の始まり (MPU の最初の提示時刻、90 kHz)。
383
+ *
384
+ * 段落 (p) の出る・消える時刻ごとに 1 枚を作る (`CaptionPage` は前の 1 枚を丸ごと置き換えるので、
385
+ * その時刻に出ている段落を全部並べる)。最初の 1 枚は `start` で、何も出ていなければ空 (前の文書の字幕を消す)。
386
+ * 読めない文書は空の並びを返す
387
+ */
388
+ export function ttmlPages(document, start) {
389
+ const tt = parseXml(document);
390
+ if (tt === null)
391
+ return [];
392
+ const [columns = 32, rows = 15] = (tt.attributes["cellResolution"] ?? "32 15")
393
+ .split(/\s+/)
394
+ .map(Number);
395
+ const [width = 1920, height = 1080] = /^\s*(\d+(?:\.\d+)?)px\s+(\d+(?:\.\d+)?)px\s*$/
396
+ .exec(tt.attributes["extent"] ?? "")
397
+ ?.slice(1)
398
+ .map(Number) ?? [1920, 1080];
399
+ const context = {
400
+ frame: { width, height, columns, rows },
401
+ frameRate: Number(tt.attributes["frameRate"] ?? 30) || 30,
402
+ styles: new Map(),
403
+ regions: new Map(),
404
+ };
405
+ for (const style of elements(tt, "style")) {
406
+ const id = style.attributes["id"];
407
+ if (id !== undefined)
408
+ context.styles.set(id, style.attributes);
409
+ }
410
+ for (const region of elements(tt, "region")) {
411
+ const id = region.attributes["id"];
412
+ if (id !== undefined)
413
+ context.regions.set(id, region);
414
+ }
415
+ const paragraphs = [];
416
+ /** 時刻は親の始まりからのずれ (par)。end も dur も無ければ親の終わりまで */
417
+ const walk = (node, inherited, begin, end) => {
418
+ const style = styleOf(node, inherited, context);
419
+ const own = seconds(node.attributes["begin"], context.frameRate);
420
+ const from = begin + (own ?? 0);
421
+ const until = seconds(node.attributes["end"], context.frameRate);
422
+ const dur = seconds(node.attributes["dur"], context.frameRate);
423
+ const to = Math.min(end, until !== null ? begin + until : Infinity, dur !== null ? from + dur : Infinity);
424
+ if (node.name === "p") {
425
+ paragraphs.push({
426
+ begin: from,
427
+ end: to,
428
+ runs: layout(node, style, context),
429
+ });
430
+ return;
431
+ }
432
+ for (const child of node.children)
433
+ if (typeof child !== "string")
434
+ walk(child, style, from, to);
435
+ };
436
+ const body = [...elements(tt, "body")][0];
437
+ if (body !== undefined)
438
+ walk(body, {}, 0, Infinity);
439
+ const times = [
440
+ ...new Set([0, ...paragraphs.flatMap((p) => [p.begin, p.end])]),
441
+ ]
442
+ .filter((t) => Number.isFinite(t) && t >= 0)
443
+ .sort((a, b) => a - b);
444
+ return times.map((t) => ({
445
+ at: start + Math.round(t * CLOCK),
446
+ page: {
447
+ v: CAPTION_TEXT_VERSION,
448
+ plane: [width, height],
449
+ duration: null,
450
+ runs: paragraphs
451
+ .filter((p) => p.begin <= t && t < p.end)
452
+ .flatMap((p) => p.runs),
453
+ },
454
+ }));
455
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@danything/arib",
3
- "version": "2.1.0",
4
- "description": "日本のデジタル放送 (ARIB) の TS を読む: PSI・PES・EIT・ARIB の文字・字幕・データ放送",
3
+ "version": "2.2.0",
4
+ "description": "日本のデジタル放送 (ARIB) の TS と MMT/TLV を読む: PSI・PES・EIT・ARIB の文字・字幕・データ放送、TLV・MMTP・MMT-SI・MH-EIT",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -16,7 +16,9 @@
16
16
  "epg",
17
17
  "b24",
18
18
  "caption",
19
- "bml"
19
+ "bml",
20
+ "mmt",
21
+ "tlv"
20
22
  ],
21
23
  "type": "module",
22
24
  "sideEffects": false,