@cardenelabs/dragon 0.34.0 → 0.35.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.
Files changed (127) hide show
  1. package/README.md +39 -720
  2. package/dist/cli.cjs +4529 -0
  3. package/dist/cli.cjs.map +1 -0
  4. package/dist/cli.js +4523 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/export.cjs +3912 -0
  7. package/dist/export.cjs.map +1 -0
  8. package/dist/export.d.cts +36 -0
  9. package/dist/export.d.ts +36 -0
  10. package/dist/export.js +3907 -0
  11. package/dist/export.js.map +1 -0
  12. package/dist/fonts.cjs +7 -0
  13. package/dist/fonts.cjs.map +1 -0
  14. package/dist/fonts.js +4 -0
  15. package/dist/fonts.js.map +1 -0
  16. package/dist/index.cjs +4105 -20356
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +227 -5522
  19. package/dist/index.d.ts +227 -5522
  20. package/dist/index.js +4091 -20283
  21. package/dist/index.js.map +1 -1
  22. package/dist/player.global.js +2 -0
  23. package/dist/schema-BBWfniwH.d.cts +112 -0
  24. package/dist/schema-BBWfniwH.d.ts +112 -0
  25. package/package.json +39 -24
  26. package/src/cli.ts +237 -0
  27. package/src/export/capture.ts +277 -0
  28. package/src/export/fonts.ts +68 -0
  29. package/src/export/html.ts +32 -0
  30. package/src/export/index.ts +146 -0
  31. package/src/field-messages.ts +104 -0
  32. package/src/figure-text.ts +42 -0
  33. package/src/index.ts +30 -283
  34. package/src/layout/architecture.ts +381 -0
  35. package/src/layout/measure.ts +21 -0
  36. package/src/layout/scene.ts +353 -0
  37. package/src/layout/sequence.ts +189 -0
  38. package/src/load-fonts.ts +8 -0
  39. package/src/look/fonts.ts +33 -0
  40. package/src/look/palettes.json +209 -0
  41. package/src/look/values.ts +155 -0
  42. package/src/markdown.ts +35 -0
  43. package/src/messages.ts +122 -0
  44. package/src/parser.ts +342 -310
  45. package/src/player-global.ts +4 -0
  46. package/src/player.ts +271 -0
  47. package/src/printer.ts +76 -0
  48. package/src/render/icons.ts +41 -0
  49. package/src/render/motion.ts +106 -0
  50. package/src/render/parts.ts +279 -0
  51. package/src/render/svg.ts +341 -0
  52. package/src/schema.ts +161 -16
  53. package/src/schemas/diagram.schema.json +150 -0
  54. package/src/schemas/validate-diagram.d.ts +12 -0
  55. package/src/schemas/validate-diagram.js +8 -0
  56. package/src/timeline.ts +166 -0
  57. package/src/tokenize.ts +20 -281
  58. package/src/version.ts +55 -0
  59. package/src/view.ts +16 -0
  60. package/src/words.ts +79 -0
  61. package/examples/quick-start.md +0 -142
  62. package/src/canvas-bounds.ts +0 -52
  63. package/src/color.ts +0 -197
  64. package/src/compile/actor-ids.ts +0 -297
  65. package/src/compile/actor-option-notice.ts +0 -379
  66. package/src/compile/actors.ts +0 -103
  67. package/src/compile/c4.ts +0 -74
  68. package/src/compile/chain.ts +0 -93
  69. package/src/compile/chart-fields.ts +0 -193
  70. package/src/compile/deep-rewrite.ts +0 -30
  71. package/src/compile/derived-source.ts +0 -18
  72. package/src/compile/direction.ts +0 -102
  73. package/src/compile/edge-option-notice.ts +0 -112
  74. package/src/compile/er-relation.ts +0 -200
  75. package/src/compile/flow.ts +0 -54
  76. package/src/compile/flowchart.ts +0 -344
  77. package/src/compile/funnel.ts +0 -87
  78. package/src/compile/gantt.ts +0 -337
  79. package/src/compile/generic.ts +0 -696
  80. package/src/compile/hierarchy.ts +0 -92
  81. package/src/compile/journey.ts +0 -93
  82. package/src/compile/kinds.ts +0 -139
  83. package/src/compile/lanes.ts +0 -138
  84. package/src/compile/metro.ts +0 -271
  85. package/src/compile/mind.ts +0 -393
  86. package/src/compile/node-title.ts +0 -24
  87. package/src/compile/notice.ts +0 -141
  88. package/src/compile/order.ts +0 -57
  89. package/src/compile/parts-lookup.ts +0 -17
  90. package/src/compile/parts.ts +0 -2709
  91. package/src/compile/placement-apply.ts +0 -689
  92. package/src/compile/placement.ts +0 -15
  93. package/src/compile/quadrant.ts +0 -265
  94. package/src/compile/record.ts +0 -75
  95. package/src/compile/row-marks.ts +0 -128
  96. package/src/compile/rows.ts +0 -40
  97. package/src/compile/sequence.ts +0 -305
  98. package/src/compile/slug.ts +0 -37
  99. package/src/compile/stages.ts +0 -247
  100. package/src/compile/subtitle.ts +0 -35
  101. package/src/compile/swimlane-marks.ts +0 -87
  102. package/src/compile/swimlane.ts +0 -82
  103. package/src/compile/timeline.ts +0 -213
  104. package/src/compile/topology.ts +0 -44
  105. package/src/compile/tree.ts +0 -69
  106. package/src/compile/value-chart.ts +0 -337
  107. package/src/compile/values.ts +0 -432
  108. package/src/compile/word-state.ts +0 -82
  109. package/src/compile.ts +0 -3168
  110. package/src/focus.ts +0 -46
  111. package/src/input-size.ts +0 -181
  112. package/src/json-line-map.ts +0 -30
  113. package/src/json-parser.ts +0 -4306
  114. package/src/keywords.ts +0 -417
  115. package/src/near-name.ts +0 -42
  116. package/src/notation-lint.ts +0 -342
  117. package/src/relative-pos.ts +0 -251
  118. package/src/schemas/diagram.json +0 -3088
  119. package/src/types.ts +0 -1009
  120. package/src/v05/index.ts +0 -9
  121. package/src/v05/input-table.generated.ts +0 -138
  122. package/src/v05/parser-types.ts +0 -32
  123. package/src/v05/parser.ts +0 -6668
  124. package/src/v05/readout-table.generated.ts +0 -1088
  125. package/src/v05/removed-syntax.ts +0 -115
  126. package/src/value-syntax.ts +0 -313
  127. package/src/write-position.ts +0 -270
package/src/cli.ts ADDED
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dragon export <図の file>` の命令。原本の JSON (`.json`) か記法 (`.dragon`) を読み、mp4、GIF、HTML、PNG を書き出す。
4
+ * 書き出した file の path を 1 行ずつ出す。
5
+ *
6
+ * 使い方の文と誤りの文は、図の中の字と同じ言葉で出す (#3003)。`--lang` を渡せばその言葉、無ければ記法の file の言葉、
7
+ * JSON の原本と file を読む前の誤りは日本語。記法の行の誤りだけは、書き方の例が file の書き方に合うよう file の言葉で出す。
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { basename, extname, resolve } from "node:path";
11
+ import { parseArgs } from "node:util";
12
+ import { type ExportFormat, exportDiagram, FORMATS } from "./export/index";
13
+ import { PALETTES, type Theme } from "./look/values";
14
+ import { parseNotation } from "./parser";
15
+ import { type NotationLang, notationLanguage } from "./words";
16
+
17
+ const OPTIONS = {
18
+ out: { type: "string" },
19
+ width: { type: "string" },
20
+ palette: { type: "string" },
21
+ theme: { type: "string" },
22
+ lang: { type: "string" },
23
+ fps: { type: "string" },
24
+ "gif-fps": { type: "string" },
25
+ format: { type: "string" },
26
+ } as const;
27
+
28
+ type Values = Partial<Record<keyof typeof OPTIONS, string>>;
29
+
30
+ const THEMES: readonly Theme[] = ["light", "dark"];
31
+ /** 色味の名前。使い方の文と `--palette` の値の確かめに使う */
32
+ const PALETTE_NAMES = Object.keys(PALETTES).join(" / ");
33
+
34
+ interface CliText {
35
+ usage: string;
36
+ unknownOption: (name: string) => string;
37
+ missingValue: (name: string) => string;
38
+ extraFiles: (extra: string[]) => string;
39
+ noFile: (path: string) => string;
40
+ unreadable: (path: string, detail: string) => string;
41
+ badJson: (path: string, detail: string) => string;
42
+ cannotWrite: (path: string, detail: string) => string;
43
+ badValue: (option: string, value: string, allowed: string) => string;
44
+ /** 設定ごとの許す値の言い方 */
45
+ allowed: { lang: string; theme: string; positive: string; format: string };
46
+ }
47
+
48
+ /** 命令が出す文。英語は日本語の文をそのまま英語にする */
49
+ const CLI_TEXT: Record<NotationLang, CliText> = {
50
+ ja: {
51
+ usage: [
52
+ "使い方: dragon export <図の file (.json か .dragon)> [設定]",
53
+ " --out <dir> 書き出す先 (既定は今の dir)",
54
+ " --width <px> 枠の幅。見せ方を選び、寄って見せる窓の幅になる",
55
+ ` --palette <名前> 色味 (${PALETTE_NAMES})`,
56
+ " --theme <明暗> light か dark",
57
+ " --lang <言葉> 図の中の字と命令の文の言葉 (ja か en)。既定は記法の file の言葉で、JSON は ja",
58
+ " --fps <数> mp4 の 1 秒のコマの数 (既定 30)",
59
+ " --gif-fps <数> GIF の 1 秒のコマの数 (既定 10)",
60
+ " --format <形> 書き出す形を , で区切る (既定 mp4,gif,html,png)",
61
+ ].join("\n"),
62
+ unknownOption: (name) => `知らない設定: ${name}`,
63
+ missingValue: (name) => `設定に値が無い: ${name}`,
64
+ extraFiles: (extra) => `図の file は 1 つだけ渡す。余分な引数: ${extra.join(" ")}`,
65
+ noFile: (path) => `図の file が無い: ${path}`,
66
+ unreadable: (path, detail) => `図の file を読めない: ${path} (${detail})`,
67
+ badJson: (path, detail) => `JSON として読めない: ${path} (${detail})`,
68
+ cannotWrite: (path, detail) => `書き出せない: ${path} (${detail})`,
69
+ badValue: (option, value, allowed) => `使えない値: ${option} ${value} (${allowed})`,
70
+ allowed: { lang: "ja か en", theme: "light か dark", positive: "0 より大きい数", format: `${FORMATS.join(" / ")} を , で区切る` },
71
+ },
72
+ en: {
73
+ usage: [
74
+ "Usage: dragon export <diagram file (.json or .dragon)> [options]",
75
+ " --out <dir> Where to write (default: the current dir)",
76
+ " --width <px> Frame width. Picks the view and sets the window width when following",
77
+ ` --palette <name> Colors (${PALETTE_NAMES})`,
78
+ " --theme <mode> light or dark",
79
+ " --lang <lang> Language of the diagram text and command messages (ja or en). Defaults to the notation file's language, and ja for JSON",
80
+ " --fps <n> Frames per second of the mp4 (default 30)",
81
+ " --gif-fps <n> Frames per second of the GIF (default 10)",
82
+ " --format <list> Formats to write, separated by , (default mp4,gif,html,png)",
83
+ ].join("\n"),
84
+ unknownOption: (name) => `Unknown option: ${name}`,
85
+ missingValue: (name) => `Option needs a value: ${name}`,
86
+ extraFiles: (extra) => `Pass only one diagram file. Extra arguments: ${extra.join(" ")}`,
87
+ noFile: (path) => `No such diagram file: ${path}`,
88
+ unreadable: (path, detail) => `Cannot read the diagram file: ${path} (${detail})`,
89
+ badJson: (path, detail) => `Cannot read as JSON: ${path} (${detail})`,
90
+ cannotWrite: (path, detail) => `Cannot write: ${path} (${detail})`,
91
+ badValue: (option, value, allowed) => `Invalid value: ${option} ${value} (${allowed})`,
92
+ allowed: { lang: "ja or en", theme: "light or dark", positive: "a number above 0", format: `${FORMATS.join(" / ")} separated by ,` },
93
+ },
94
+ };
95
+
96
+ const isLang = (value: string | undefined): value is NotationLang => value === "ja" || value === "en";
97
+
98
+ const number = (value: string | undefined): number | undefined => (value === undefined ? undefined : Number(value));
99
+
100
+ const detail = (error: unknown): string => (error instanceof Error ? error.message : String(error));
101
+
102
+ /** 設定を読む前に `--lang` の値を引く。設定の誤りの文も、選んだ言葉で出すため */
103
+ function langOf(args: string[]): string | undefined {
104
+ for (const [i, arg] of args.entries()) {
105
+ if (arg === "--lang") return args[i + 1];
106
+ if (arg.startsWith("--lang=")) return arg.slice("--lang=".length);
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ /**
112
+ * 設定の誤りを、Node の文ではなく命令の言葉で返す。誤りが無ければ `undefined`。
113
+ * 値の要る設定の後ろに `-` で始まる語が続く時は、その語を値と取り違えずに値が無いとみなす (Node も同じ形を断る)。
114
+ */
115
+ function optionProblem(args: string[], text: CliText): string | undefined {
116
+ const { tokens } = parseArgs({ args, options: OPTIONS, allowPositionals: true, strict: false, tokens: true });
117
+ for (const token of tokens) {
118
+ if (token.kind !== "option") continue;
119
+ if (!Object.hasOwn(OPTIONS, token.name)) return text.unknownOption(token.rawName);
120
+ if (token.value === undefined || (!token.inlineValue && token.value.startsWith("-"))) return text.missingValue(token.rawName);
121
+ }
122
+ return undefined;
123
+ }
124
+
125
+ /**
126
+ * 設定の値を確かめ、使えない値を命令の言葉で返す。使えない値が無ければ `undefined` (#3011)。
127
+ * 確かめないと、`--format xyz` は何も書き出さずに成功として終わり、`--theme blue` などは既定の値で黙って書き出す。
128
+ */
129
+ function valueProblem(values: Values, text: CliText): string | undefined {
130
+ const bad = (name: keyof typeof OPTIONS, value: string, allowed: string): string => text.badValue(`--${name}`, value, allowed);
131
+ if (values.lang !== undefined && !isLang(values.lang)) return bad("lang", values.lang, text.allowed.lang);
132
+ if (values.theme !== undefined && !THEMES.some((theme) => theme === values.theme)) return bad("theme", values.theme, text.allowed.theme);
133
+ if (values.palette !== undefined && !Object.hasOwn(PALETTES, values.palette)) return bad("palette", values.palette, PALETTE_NAMES);
134
+ if (values.format !== undefined && !values.format.split(",").every((format) => FORMATS.some((known) => known === format))) {
135
+ return bad("format", values.format, text.allowed.format);
136
+ }
137
+ for (const name of ["width", "fps", "gif-fps"] as const) {
138
+ const value = values[name];
139
+ if (value !== undefined && !(Number.isFinite(Number(value)) && Number(value) > 0)) return bad(name, value, text.allowed.positive);
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ /** Node が file か dir を扱えなかった誤りなら、扱えなかった path を返す。それ以外の誤りは `undefined` */
145
+ function failedPath(error: unknown): string | undefined {
146
+ if (!(error instanceof Error) || !("syscall" in error) || !("path" in error)) return undefined;
147
+ return typeof error.path === "string" ? error.path : undefined;
148
+ }
149
+
150
+ /** 図の file を読む。記法の file はその言葉も返し、JSON の原本は言葉を持たない */
151
+ function read(file: string, text: CliText): { diagram: unknown; lang?: NotationLang } {
152
+ let source: string;
153
+ try {
154
+ source = readFileSync(file, "utf8");
155
+ } catch (error) {
156
+ const missing = error instanceof Error && "code" in error && error.code === "ENOENT";
157
+ throw new Error(missing ? text.noFile(file) : text.unreadable(file, detail(error)));
158
+ }
159
+ if (extname(file) !== ".dragon") {
160
+ try {
161
+ return { diagram: JSON.parse(source) };
162
+ } catch (error) {
163
+ throw new Error(text.badJson(file, detail(error)));
164
+ }
165
+ }
166
+ const parsed = parseNotation(source);
167
+ if (!parsed.ok) throw new Error(parsed.errors.map((e) => `${file}:${e.line} ${e.message}`).join("\n"));
168
+ const lang = notationLanguage(source);
169
+ return lang === undefined ? { diagram: parsed.diagram } : { diagram: parsed.diagram, lang };
170
+ }
171
+
172
+ async function main(argv: string[]): Promise<number> {
173
+ const [command, ...rest] = argv;
174
+ const chosen = langOf(argv);
175
+ // file を読む前の文は、渡した言葉か日本語で出す。知らない言葉は日本語の使い方で断る
176
+ const early = CLI_TEXT[isLang(chosen) ? chosen : "ja"];
177
+ if (command !== "export") {
178
+ console.error(early.usage);
179
+ return 2;
180
+ }
181
+ const problem = optionProblem(rest, early);
182
+ if (problem !== undefined) {
183
+ console.error(`${problem}\n${early.usage}`);
184
+ return 2;
185
+ }
186
+ const { values, positionals } = parseArgs({ args: rest, options: OPTIONS, allowPositionals: true });
187
+ const badValue = valueProblem(values, early);
188
+ if (badValue !== undefined) {
189
+ console.error(`${badValue}\n${early.usage}`);
190
+ return 2;
191
+ }
192
+ const [file, ...extra] = positionals;
193
+ if (!file) {
194
+ console.error(early.usage);
195
+ return 2;
196
+ }
197
+ // 2 つ目以降を黙って捨てると、`--out` を書き忘れた時に思った所と違う所へ書き出す (#3007)
198
+ if (extra.length > 0) {
199
+ console.error(`${early.extraFiles(extra)}\n${early.usage}`);
200
+ return 2;
201
+ }
202
+ const width = number(values.width);
203
+ const fps = number(values.fps);
204
+ const gifFps = number(values["gif-fps"]);
205
+ const { diagram, lang: fileLang } = read(file, early);
206
+ // 渡した言葉を記法の file の言葉より先に使う。JSON の原本で渡さなければ、書き出しの既定 (日本語) になる
207
+ const lang = isLang(chosen) ? chosen : fileLang;
208
+ let result: Awaited<ReturnType<typeof exportDiagram>>;
209
+ try {
210
+ result = await exportDiagram(diagram, {
211
+ outDir: resolve(values.out ?? "."),
212
+ name: basename(file, extname(file)),
213
+ ...(values.format ? { formats: values.format.split(",") as ExportFormat[] } : {}),
214
+ ...(width === undefined ? {} : { width }),
215
+ ...(values.palette ? { palette: values.palette } : {}),
216
+ ...(values.theme ? { theme: values.theme as Theme } : {}),
217
+ ...(lang ? { lang } : {}),
218
+ ...(fps === undefined ? {} : { fps }),
219
+ ...(gifFps === undefined ? {} : { gifFps }),
220
+ });
221
+ } catch (error) {
222
+ // 書き出し先に書けない時だけ命令の言葉で書き直す。描けない時と道具が無い時の断りは、書き出しが言葉に合わせて出す (#3009)
223
+ const path = failedPath(error);
224
+ if (path !== undefined) throw new Error(CLI_TEXT[lang ?? "ja"].cannotWrite(path, detail(error)));
225
+ throw error instanceof Error ? error : new Error(String(error));
226
+ }
227
+ for (const path of Object.values(result.files)) console.log(path);
228
+ return 0;
229
+ }
230
+
231
+ main(process.argv.slice(2)).then(
232
+ (code) => process.exit(code),
233
+ (error: unknown) => {
234
+ console.error(detail(error));
235
+ process.exit(1);
236
+ },
237
+ );
@@ -0,0 +1,277 @@
1
+ /**
2
+ * 画面なしの Chromium で、頁と同じ描画を時刻を止めて 1 コマずつ撮る。
3
+ *
4
+ * 時刻は頁の止め絵と同じく、図の `--play` を `paused` にし、`--at` に秒を入れて止める。
5
+ * 撮ったコマは同じ頁の中で詰める。mp4 は WebCodecs で H.264 にして `mediabunny` で箱に入れ、
6
+ * GIF は `gifenc` で詰める。GIF は前のコマと同じ所を透明にし、変わった所だけを書いて小さくする。
7
+ * WebCodecs は安全な頁でしか使えないので、頁は `http://localhost/` に置く。
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { createRequire } from "node:module";
11
+ import { dirname, join } from "node:path";
12
+ import type { Browser, Page } from "playwright-core";
13
+ import type { NotationLang } from "../words";
14
+
15
+ const require_ = createRequire(import.meta.url);
16
+ /** 頁に読ませる詰める道具。どちらも 1 file の ES module */
17
+ const SCRIPTS: Record<string, string> = {
18
+ "/mediabunny.js": join(dirname(require_.resolve("mediabunny")), "mediabunny.min.mjs"),
19
+ "/gifenc.js": require_.resolve("gifenc/dist/gifenc.esm.js"),
20
+ };
21
+
22
+ /** CSS の 1px を何画素で撮るか */
23
+ const SCALE = 2;
24
+
25
+ const INSTALL_ADD = "`pnpm add -D playwright-core`";
26
+ const INSTALL_CHROMIUM = "`pnpm exec playwright-core install chromium`";
27
+
28
+ /** 書き出す道具が無い時の断りの文。言葉ごとに持つ (#3003) */
29
+ const LAUNCH_TEXT: Record<NotationLang, { missing: string; cannotLaunch: string; install: string }> = {
30
+ ja: {
31
+ missing: "書き出しには playwright-core が要る。",
32
+ cannotLaunch: "Chromium を起動できない。",
33
+ install: `${INSTALL_ADD} で入れ、${INSTALL_CHROMIUM} で Chromium を入れる`,
34
+ },
35
+ en: {
36
+ missing: "Exporting needs playwright-core. ",
37
+ cannotLaunch: "Cannot launch Chromium. ",
38
+ install: `Install playwright-core with ${INSTALL_ADD} and Chromium with ${INSTALL_CHROMIUM}`,
39
+ },
40
+ };
41
+
42
+ /** 画面なしの Chromium を起こす。起こせない時は、渡した言葉で入れ方を添えて断る */
43
+ export async function launch(lang: NotationLang): Promise<Browser> {
44
+ const text = LAUNCH_TEXT[lang];
45
+ let chromium: (typeof import("playwright-core"))["chromium"];
46
+ try {
47
+ ({ chromium } = await import("playwright-core"));
48
+ } catch {
49
+ throw new Error(`${text.missing}${text.install}`);
50
+ }
51
+ try {
52
+ return await chromium.launch();
53
+ } catch (error) {
54
+ throw new Error(`${text.cannotLaunch}${text.install} (${String(error).split("\n")[0]})`);
55
+ }
56
+ }
57
+
58
+ /** 図の SVG の大きさ (CSS の px) */
59
+ export function sizeOf(svg: string): { width: number; height: number } {
60
+ const [, width = "0", height = "0"] = /^<svg\b[^>]*?\swidth="(\d+)"\s+height="(\d+)"/.exec(svg) ?? [];
61
+ return { width: Number(width), height: Number(height) };
62
+ }
63
+
64
+ export interface Stage {
65
+ page: Page;
66
+ width: number;
67
+ height: number;
68
+ }
69
+
70
+ /**
71
+ * 図 1 枚を置いた頁を開き、字が読み込まれるまで待つ。`body` は頁の地の CSS。
72
+ * 図は置く前から止めておく。置いてから止めると、止めるまでに進んだ分だけ毎回絵がずれる。
73
+ */
74
+ export async function stage(browser: Browser, svg: string, fontCss: string, body: string): Promise<Stage> {
75
+ const { width, height } = sizeOf(svg);
76
+ const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: SCALE });
77
+ const paused = svg.replace(/^<svg\b/, '<svg style="--play:paused;--at:0"');
78
+ const html =
79
+ `<!doctype html><html><head><meta charset="utf-8"><style>${fontCss}html,body{margin:0}body{${body}}svg{display:block}</style>` +
80
+ `<script type="module">import * as mb from "/mediabunny.js";import * as gif from "/gifenc.js";window.__tools={mb,gif};</script></head><body>${paused}</body></html>`;
81
+ await page.route("http://localhost/**", async (route) => {
82
+ const path = new URL(route.request().url()).pathname;
83
+ if (path === "/") return route.fulfill({ contentType: "text/html; charset=utf-8", body: html });
84
+ const script = SCRIPTS[path];
85
+ return script ? route.fulfill({ contentType: "text/javascript", body: readFileSync(script) }) : route.fulfill({ status: 404 });
86
+ });
87
+ await page.goto("http://localhost/");
88
+ await page.waitForFunction(() => "__tools" in window);
89
+ await page.evaluate(async () => {
90
+ const loads: Promise<FontFace>[] = [];
91
+ document.fonts.forEach((face) => loads.push(face.load()));
92
+ await Promise.all(loads);
93
+ await document.fonts.ready;
94
+ });
95
+ return { page, width, height };
96
+ }
97
+
98
+ /** 止めてある図の時刻を `t` 秒に動かし、描き終わるまで待つ */
99
+ async function at(page: Page, t: number): Promise<void> {
100
+ await page.evaluate(
101
+ (t) =>
102
+ new Promise<void>((done) => {
103
+ document.querySelector("svg")?.style.setProperty("--at", String(t));
104
+ requestAnimationFrame(() => requestAnimationFrame(() => done()));
105
+ }),
106
+ t,
107
+ );
108
+ }
109
+
110
+ /** `t` 秒の止め絵の PNG。図の外 (角の丸みの外) は透明にする */
111
+ export async function still(stage: Stage, t: number): Promise<Buffer> {
112
+ await at(stage.page, t);
113
+ return stage.page.screenshot({ type: "png", omitBackground: true });
114
+ }
115
+
116
+ export interface Film {
117
+ frames: number;
118
+ mp4?: Buffer;
119
+ gif?: Buffer;
120
+ }
121
+
122
+ /** 頁の中で詰める道具。`sample` で GIF の色の表の元を集め、`seal` で色の表を決めてから `push` でコマを渡す */
123
+ interface FilmBox {
124
+ sample(png: string): Promise<void>;
125
+ seal(): void;
126
+ push(png: string, i: number): Promise<void>;
127
+ finish(): Promise<{ mp4?: string; gif?: string }>;
128
+ }
129
+
130
+ /**
131
+ * 1 周 (`total` 秒) を `fps` で撮り、mp4 と GIF に詰める。GIF は `gifEvery` コマに 1 枚を使い、0 なら作らない。
132
+ * GIF の色の表は `samples` 秒の絵 (段ごとの止め絵) から 1 つだけ作り、全てのコマをその表で塗る。
133
+ * コマごとに表を作ると、寄って追う図でコマ全体が動いた後、止まった所の淡い色がコマごとの表でばらばらに残る。
134
+ */
135
+ export async function film(stage: Stage, plan: { total: number; fps: number; mp4: boolean; gifEvery: number; samples: number[] }): Promise<Film> {
136
+ const { page, width, height } = stage;
137
+ await page.evaluate(
138
+ async ({ width, height, scale, fps, mp4, gifEvery }) => {
139
+ const { mb, gif } = (window as unknown as { __tools: { mb: any; gif: any } }).__tools;
140
+ let add: (bitmap: ImageBitmap, i: number) => Promise<void> = () => Promise.resolve();
141
+ let close: () => Promise<Uint8Array | undefined> = () => Promise.resolve(undefined);
142
+ if (mp4) {
143
+ const canvas = new OffscreenCanvas(width * scale, height * scale);
144
+ const context = canvas.getContext("2d") as OffscreenCanvasRenderingContext2D;
145
+ const target = new mb.BufferTarget();
146
+ const output = new mb.Output({ format: new mb.Mp4OutputFormat({ fastStart: "in-memory" }), target });
147
+ const source = new mb.CanvasSource(canvas, { codec: "avc", bitrate: mb.QUALITY_MEDIUM, keyFrameInterval: 2 });
148
+ output.addVideoTrack(source, { frameRate: fps });
149
+ await output.start();
150
+ add = async (bitmap, i) => {
151
+ context.drawImage(bitmap, 0, 0);
152
+ await source.add(i / fps, 1 / fps);
153
+ };
154
+ close = async () => {
155
+ await output.finalize();
156
+ return new Uint8Array(target.buffer as ArrayBuffer);
157
+ };
158
+ }
159
+
160
+ // GIF は CSS の 1px を 1 画素で詰める。前のコマの元の絵と 1 つでも色が違う画素だけを書き、残りは透明にする。
161
+ // 撮る絵は毎回同じなので、動いていない所はぴったり一致する。
162
+ // 差に幅を持たせると、ゆっくり消える物の最後の数段が書かれず、薄い残りになる
163
+ const small = new OffscreenCanvas(width, height);
164
+ const smallContext = small.getContext("2d", { willReadFrequently: true }) as OffscreenCanvasRenderingContext2D;
165
+ const decode = (png: string): Promise<ImageBitmap> => createImageBitmap(new Blob([Uint8Array.from(atob(png), (c) => c.charCodeAt(0))], { type: "image/png" }));
166
+ const read = (bitmap: ImageBitmap): Uint8ClampedArray => {
167
+ smallContext.drawImage(bitmap, 0, 0, width, height);
168
+ return smallContext.getImageData(0, 0, width, height).data;
169
+ };
170
+ const encoder = gifEvery ? gif.GIFEncoder() : undefined;
171
+ const delay = (gifEvery / fps) * 1000;
172
+ const sampled: Uint8ClampedArray[] = [];
173
+ // 色の表。最後の 1 色は透明に使う
174
+ let colors: number[][] = [];
175
+ // 色ごとに表のどの色で塗るかを 1 度だけ決めて覚える。`gifenc` の `applyPalette` は似た色の組ごとに
176
+ // そのコマで最初に見た色で塗る色を決めるので、同じ色でもコマによって塗る色が変わり、
177
+ // 動いた所だけを書くと、書いたコマごとに淡い色がずれて前の絵の形が浮く
178
+ const nearest = new Map<number, number>();
179
+ const toIndex = (data: Uint8ClampedArray): Uint8Array => {
180
+ const index = new Uint8Array(data.length / 4);
181
+ for (let p = 0; p < index.length; p++) {
182
+ const i = p * 4;
183
+ const key = ((data[i] as number) << 16) | ((data[i + 1] as number) << 8) | (data[i + 2] as number);
184
+ let k = nearest.get(key);
185
+ if (k === undefined) {
186
+ k = gif.nearestColorIndex(colors, [data[i], data[i + 1], data[i + 2]]) as number;
187
+ nearest.set(key, k);
188
+ }
189
+ index[p] = k;
190
+ }
191
+ return index;
192
+ };
193
+ let previous: Uint8ClampedArray | undefined;
194
+ const writeGif = (data: Uint8ClampedArray): void => {
195
+ const clear = colors.length;
196
+ const index = toIndex(data);
197
+ const before = previous;
198
+ previous = data;
199
+ if (!before) {
200
+ encoder.writeFrame(index, width, height, { palette: [...colors, [0, 0, 0]], delay, dispose: 1 });
201
+ return;
202
+ }
203
+ let changed = 0;
204
+ for (let p = 0; p < index.length; p++) {
205
+ const i = p * 4;
206
+ if (data[i] === before[i] && data[i + 1] === before[i + 1] && data[i + 2] === before[i + 2]) index[p] = clear;
207
+ else changed++;
208
+ }
209
+ // 変わった所が無いコマは、透明な 1 画素だけを書いて時間だけ進める
210
+ if (!changed) encoder.writeFrame(new Uint8Array([clear]), 1, 1, { transparent: true, transparentIndex: clear, delay, dispose: 1 });
211
+ else encoder.writeFrame(index, width, height, { transparent: true, transparentIndex: clear, delay, dispose: 1 });
212
+ };
213
+
214
+ const base64 = (bytes: Uint8Array): string => {
215
+ let text = "";
216
+ for (let i = 0; i < bytes.length; i += 0x8000) text += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
217
+ return btoa(text);
218
+ };
219
+ const box: FilmBox = {
220
+ async sample(png) {
221
+ const bitmap = await decode(png);
222
+ sampled.push(read(bitmap));
223
+ bitmap.close();
224
+ },
225
+ seal() {
226
+ const all = new Uint8ClampedArray(sampled.reduce((n, data) => n + data.length, 0));
227
+ let offset = 0;
228
+ for (const data of sampled) {
229
+ all.set(data, offset);
230
+ offset += data.length;
231
+ }
232
+ colors = gif.quantize(all, 255);
233
+ },
234
+ async push(png, i) {
235
+ const bitmap = await decode(png);
236
+ await add(bitmap, i);
237
+ if (gifEvery && i % gifEvery === 0) writeGif(read(bitmap));
238
+ bitmap.close();
239
+ },
240
+ async finish() {
241
+ const video = await close();
242
+ if (encoder) encoder.finish();
243
+ return { ...(video ? { mp4: base64(video) } : {}), ...(encoder ? { gif: base64(encoder.bytes() as Uint8Array) } : {}) };
244
+ },
245
+ };
246
+ (window as unknown as { __film: FilmBox }).__film = box;
247
+ },
248
+ { width, height, scale: SCALE, fps: plan.fps, mp4: plan.mp4, gifEvery: plan.gifEvery },
249
+ );
250
+
251
+ // コマは CDP で撮る (止め絵の撮り方の半分ほどの時間で済む)。
252
+ // CDP は頁の画素の細かさを見ないので、撮る範囲に倍率を付けて止め絵と同じ細かさにする
253
+ const cdp = await page.context().newCDPSession(page);
254
+ const clip = { x: 0, y: 0, width, height, scale: SCALE };
255
+ const shoot = async (t: number): Promise<string> => {
256
+ await at(page, t);
257
+ return (await cdp.send("Page.captureScreenshot", { format: "png", optimizeForSpeed: true, clip })).data;
258
+ };
259
+ if (plan.gifEvery) {
260
+ for (const t of plan.samples) {
261
+ const png = await shoot(t);
262
+ await page.evaluate((png) => (window as unknown as { __film: FilmBox }).__film.sample(png), png);
263
+ }
264
+ await page.evaluate(() => (window as unknown as { __film: FilmBox }).__film.seal());
265
+ }
266
+ const frames = Math.round(plan.total * plan.fps);
267
+ for (let i = 0; i < frames; i++) {
268
+ const png = await shoot(i / plan.fps);
269
+ await page.evaluate(([png, i]) => (window as unknown as { __film: FilmBox }).__film.push(png, i), [png, i] as const);
270
+ }
271
+ const out = await page.evaluate(() => (window as unknown as { __film: FilmBox }).__film.finish());
272
+ return {
273
+ frames,
274
+ ...(out.mp4 ? { mp4: Buffer.from(out.mp4, "base64") } : {}),
275
+ ...(out.gif ? { gif: Buffer.from(out.gif, "base64") } : {}),
276
+ };
277
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 書き出す図で描く字だけに、同梱した字の file を削る。
3
+ *
4
+ * 同梱した字の package は、字の範囲ごとに分けた file (woff2) と、file ごとの範囲の表 (`unicode.json`) を持つ。
5
+ * 描く字が入る file だけを、描く字だけに削り、`data:` で埋めた `@font-face` にする。
6
+ * 太さの軸は、値の表 (`TYPE`) が使う太さの間に縮める。
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { readFile } from "node:fs/promises";
10
+ import { createRequire } from "node:module";
11
+ import { dirname, join } from "node:path";
12
+ import { FONTS } from "../look/fonts";
13
+ import { TYPE } from "../look/values";
14
+
15
+ const require_ = createRequire(import.meta.url);
16
+ // subset-font は型を持たないので、使う形だけをここで名指す
17
+ const subsetFont = require_("subset-font") as (
18
+ font: Buffer,
19
+ text: string,
20
+ options: { targetFormat: "woff2"; variationAxes: Record<string, { min: number; max: number }> },
21
+ ) => Promise<Buffer>;
22
+
23
+ /** 字の名前と、その字を持つ package */
24
+ const PACKAGES = [
25
+ { family: FONTS.sans, name: "noto-sans-jp" },
26
+ { family: FONTS.mono, name: "jetbrains-mono" },
27
+ ] as const;
28
+
29
+ const weights = Object.values(TYPE).map((style) => style.weight);
30
+ const WEIGHT = { min: Math.min(...weights), max: Math.max(...weights) };
31
+
32
+ /** `U+30-39,U+3000-303F` の形の範囲の一覧 */
33
+ function ranges(list: string): [number, number][] {
34
+ return list.split(",").map((part) => {
35
+ const [from = "", to = from] = part.trim().replace(/^U\+/i, "").split("-");
36
+ return [parseInt(from, 16), parseInt(to, 16)];
37
+ });
38
+ }
39
+
40
+ const hex = (code: number): string => `U+${code.toString(16).toUpperCase()}`;
41
+
42
+ /** 字を空白ごと集める。改行は字の file に要らない */
43
+ export function charsOf(texts: string[]): string[] {
44
+ return [...new Set([...texts.join("")])].filter((char) => char !== "\n" && char !== "\r" && char !== "\t").sort();
45
+ }
46
+
47
+ /** 描く字だけに削った字の file を `data:` で埋めた `@font-face` の並び */
48
+ export async function fontFaces(chars: string[]): Promise<string> {
49
+ const faces = await Promise.all(
50
+ PACKAGES.flatMap(({ family, name }) => {
51
+ const base = dirname(require_.resolve(`@fontsource-variable/${name}/unicode.json`));
52
+ const table = JSON.parse(readFileSync(join(base, "unicode.json"), "utf8")) as Record<string, string>;
53
+ return Object.entries(table).map(async ([key, list]) => {
54
+ const span = ranges(list);
55
+ const own = chars.filter((char) => {
56
+ const code = char.codePointAt(0) ?? -1;
57
+ return span.some(([from, to]) => code >= from && code <= to);
58
+ });
59
+ if (!own.length) return "";
60
+ const file = join(base, "files", `${name}-${key.replace(/[[\]]/g, "")}-wght-normal.woff2`);
61
+ const woff2 = await subsetFont(await readFile(file), own.join(""), { targetFormat: "woff2", variationAxes: { wght: WEIGHT } });
62
+ const unicode = own.map((char) => hex(char.codePointAt(0) ?? 0)).join(",");
63
+ return `@font-face{font-family:"${family}";font-style:normal;font-display:block;font-weight:${WEIGHT.min} ${WEIGHT.max};src:url(data:font/woff2;base64,${woff2.toString("base64")}) format("woff2");unicode-range:${unicode}}`;
64
+ });
65
+ }),
66
+ );
67
+ return faces.join("");
68
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 1 file で開ける HTML。削った字と、頁に図を置いて動かす script と、原本の JSON を収める。
3
+ *
4
+ * 開くと頁と同じく、枠の幅で見せ方を選び、動きを嫌う設定では止め絵を釦で送る。
5
+ * 外へ何も取りに行かない。
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import type { Theme } from "../look/values";
9
+ import { esc } from "../render/parts";
10
+ import type { Diagram } from "../schema";
11
+ import type { NotationLang } from "../words";
12
+
13
+ /** 頁に図を置いて動かす script。build で `player-global.ts` を 1 file に束ねた物 */
14
+ const PLAYER = new URL("./player.global.js", import.meta.url);
15
+
16
+ /** `lang` は図の中の字 (全体図の札と読み上げの名前) の言葉。頁の `lang` 属性も同じにし、名前をその言葉の声で読ませる (#3001) */
17
+ export function pageHtml(diagram: Diagram, look: { palette: string; theme: Theme; vars: string; lang: NotationLang }, fontCss: string): string {
18
+ // `</script>` で script が途切れないよう、`<` を JSON の逃がし方で書く
19
+ const json = JSON.stringify(diagram).replace(/</g, "\\u003c");
20
+ const options = JSON.stringify({ palette: look.palette, theme: look.theme, lang: look.lang });
21
+ return [
22
+ "<!doctype html>",
23
+ `<html lang="${look.lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">`,
24
+ `<title>${esc(diagram.title)}</title>`,
25
+ `<style>${fontCss}:root{${look.vars}}html,body{margin:0}body{background:var(--bg)}</style>`,
26
+ '</head><body><div id="dragon"></div>',
27
+ `<script type="application/json" id="dragon-diagram">${json}</script>`,
28
+ `<script>${readFileSync(PLAYER, "utf8").replace(/<\/script/gi, "<\\/script")}</script>`,
29
+ `<script>DragonPlayer.mountDiagram(document.getElementById("dragon"),JSON.parse(document.getElementById("dragon-diagram").textContent),${options});</script>`,
30
+ "</body></html>",
31
+ ].join("\n");
32
+ }