/** * 同じ原本の JSON から、mp4、GIF、HTML、PNG を書き出す。 * * 見せ方は頁と同じく枠の幅で選ぶ (`chooseView`)。幅は寄って見せる窓の幅にもなる。 * 寄って見せる図で幅の指定が無い時は、記事の本文ほどの枠 (`DEFAULTS.followWidth`) で寄る。 * PNG は最後の段の止め絵を、全体を止めた見せ方で撮る。 * 字は図で描く字だけに削り、撮る頁と HTML の両方に同じ物を埋める。 */ import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { paletteVars, resolvePalette, type Theme } from "../look/values"; import { BUTTON_TEXT, stepNo } from "../player"; import { drawSvg, type Rendered, renderDiagram } from "../render/svg"; import type { ViewMode } from "../schema"; import { chooseView } from "../view"; import type { NotationLang } from "../words"; import { film, launch, sizeOf, stage, still } from "./capture"; import { charsOf, fontFaces } from "./fonts"; import { pageHtml } from "./html"; export type ExportFormat = "mp4" | "gif" | "html" | "png"; export interface ExportOptions { /** 書き出す先の dir。無ければ作り、書き出しに失敗した時は作った dir を残さない (#3013) */ outDir: string; /** file の名前 (拡張子を除く)。既定は `diagram` */ name?: string; /** 書き出す形。既定は 4 つ全て */ formats?: ExportFormat[]; /** 枠の幅 (CSS の px)。見せ方を選び、寄って見せる窓の幅になる */ width?: number; palette?: string; theme?: Theme; /** mp4 の 1 秒のコマの数 */ fps?: number; /** GIF の 1 秒のコマの数 */ gifFps?: number; /** 図の中の字 (全体図の札、HTML の読み上げの名前、描けない時の断りの文) の言葉。書かなければ日本語 (#3001) */ lang?: NotationLang; } export interface ExportResult { /** 書き出した file の path */ files: Partial>; view: ViewMode; /** 撮ったコマの数 */ frames: number; /** 動画のコマの大きさ (CSS の px)。撮る画素はこの 2 倍 */ width: number; height: number; } /** 書き出せる形。命令が `--format` の値を確かめる時にも使う */ export const FORMATS: readonly ExportFormat[] = ["mp4", "gif", "html", "png"]; const DEFAULTS = { fps: 30, gifFps: 10, followWidth: 728 }; const unescape = (s: string): string => s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&"); /** SVG の中で描かれる字 (`` の中身) */ const svgTexts = (svg: string): string[] => [...svg.matchAll(/]*>([\s\S]*?)<\/text>/g)].map((m) => unescape((m[1] ?? "").replace(/<[^>]+>/g, ""))); /** 頁と書き出しで描かれうる字。寄って見せる時の全体図の題 (`followSvg` の中) と、釦と段の番号の字も含む */ function drawnTexts(rendered: Rendered, followSvg: string | undefined): string[] { const { diagram } = rendered; const svgs = followSvg === undefined ? [rendered.svg] : [rendered.svg, followSvg]; const count = diagram.steps.length; return [...svgs.flatMap(svgTexts), ...diagram.steps.flatMap((step, s) => [step.title, step.body, stepNo(s, count)]), ...Object.values(BUTTON_TEXT)]; } export async function exportDiagram(input: unknown, options: ExportOptions): Promise { const formats = options.formats ?? FORMATS; const lang = options.lang ?? "ja"; const rendered = renderDiagram(input, { ...(options.palette ? { palette: options.palette } : {}), ...(options.theme ? { theme: options.theme } : {}), lang, }); const { diagram, layout, timeline, theme } = rendered; const palette = resolvePalette(rendered.palette); const vars = paletteVars(palette, theme); const width = options.width ?? (rendered.view === "follow" ? DEFAULTS.followWidth : layout.width); const view = chooseView(rendered, width).mode; // 寄って見せる図は動画と字の file の両方に使うので 1 度だけ描き、2 つの全体図の札の言葉を揃える const followSvg = rendered.view === "fixed" ? undefined : drawSvg(diagram, layout, timeline, palette, theme, { width, lang }); const videoSvg = view === "follow" && followSvg ? followSvg : rendered.svg; const fontCss = await fontFaces(charsOf(drawnTexts(rendered, followSvg))); // 撮る物を全て撮り終えてから書く。撮る所で失敗した時に、書き出し先の dir と途中の file を残さないため (#3013) const outputs: [ExportFormat, string | Buffer][] = []; if (formats.includes("html")) outputs.push(["html", pageHtml(diagram, { palette: palette.id, theme, vars, lang }, fontCss)]); let frames = 0; const mp4 = formats.includes("mp4"); const gif = formats.includes("gif"); if (mp4 || gif || formats.includes("png")) { const browser = await launch(lang); try { if (formats.includes("png")) { const fixed = await stage(browser, rendered.svg, fontCss, ""); outputs.push(["png", await still(fixed, timeline.stills[timeline.stills.length - 1] ?? 0)]); } if (mp4 || gif) { const fps = mp4 ? (options.fps ?? DEFAULTS.fps) : (options.gifFps ?? DEFAULTS.gifFps); const gifEvery = gif ? Math.max(1, Math.round(fps / (options.gifFps ?? DEFAULTS.gifFps))) : 0; const shot = await film(await stage(browser, videoSvg, fontCss, `${vars};background:var(--bg)`), { total: timeline.total, fps, mp4, gifEvery, // GIF の色の表は、最初の絵と段ごとの止め絵から作る samples: [0, ...timeline.stills], }); frames = shot.frames; if (shot.mp4) outputs.push(["mp4", shot.mp4]); if (shot.gif) outputs.push(["gif", shot.gif]); } } finally { await browser.close(); } } const files = writeOutputs(options.outDir, options.name ?? "diagram", outputs); return { files, view, frames, ...sizeOf(videoSvg) }; } /** * 書き出し先の dir を作り、撮った物を並びの順に書く。 * 書いている途中で失敗した時は、この回に作った dir を中身ごと消してから投げ直す (#3013)。 * 前から在った dir は消さない。 */ function writeOutputs(outDir: string, name: string, outputs: [ExportFormat, string | Buffer][]): Partial> { // 作った dir の内で一番上の path。前から在った時は undefined const created = mkdirSync(outDir, { recursive: true }); const files: Partial> = {}; try { for (const [format, data] of outputs) { const path = join(outDir, `${name}.${format}`); writeFileSync(path, data); files[format] = path; } } catch (error) { if (created !== undefined) rmSync(created, { recursive: true, force: true }); throw error; } return files; }