@alfacapital-tech/c4builder 0.3.0-rc.1 → 0.3.0-rc.3
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/README.MD +11 -18
- package/dist/cli/commands/help.js +0 -2
- package/dist/cli/commands/jre.js +10 -3
- package/dist/cli/commands/list.js +9 -1
- package/dist/cli/commands/new.js +10 -29
- package/dist/cli/dispatch.js +135 -37
- package/dist/cli/wizard/collect.js +51 -91
- package/dist/config/defaults.js +4 -2
- package/dist/config/options.js +29 -1
- package/dist/config/schema.js +53 -32
- package/dist/core/build.js +74 -48
- package/dist/core/compose/docsify.template.js +2 -2
- package/dist/core/compose/markdown.js +146 -135
- package/dist/core/render/d2renderer.js +67 -11
- package/dist/core/render/diagrams.js +256 -100
- package/dist/core/render/fonts.js +8 -0
- package/dist/core/render/jre.js +155 -51
- package/dist/core/render/pngraster.js +18 -24
- package/dist/core/scan/tree.js +64 -16
- package/dist/util/http.js +55 -0
- package/dist/util/lock.js +58 -0
- package/dist/util/utils.js +10 -25
- package/package.json +5 -1
- package/vendor/fonts/NimbusSans-Bold.otf +0 -0
- package/vendor/fonts/NimbusSans-BoldItalic.otf +0 -0
- package/vendor/fonts/NimbusSans-Italic.otf +0 -0
- package/vendor/fonts/NimbusSans-Regular.otf +0 -0
- package/vendor/fonts/README.md +13 -10
- package/vendor/fonts/LiberationSans-Bold.ttf +0 -0
- package/vendor/fonts/LiberationSans-BoldItalic.ttf +0 -0
- package/vendor/fonts/LiberationSans-Italic.ttf +0 -0
- package/vendor/fonts/LiberationSans-Regular.ttf +0 -0
|
@@ -1,51 +1,95 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
|
-
import http from 'node:http';
|
|
5
|
-
import https from 'node:https';
|
|
6
5
|
import crypto from 'node:crypto';
|
|
7
6
|
import fsextra from 'fs-extra';
|
|
8
7
|
import { spawn } from 'node:child_process';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
9
|
import { writeFile, VENDORED_JAR } from "../../util/utils.js";
|
|
10
|
+
import { httpGetBuffer } from "../../util/http.js";
|
|
10
11
|
// D2-бэкенд: только статические хелперы (парсинг импортов) грузятся сразу; сам
|
|
11
12
|
// движок @terrastruct/d2 тянется лениво внутри renderD2/teardownD2.
|
|
12
13
|
import { renderD2, foldD2Imports } from "./d2renderer.js";
|
|
13
14
|
import { resolveJava } from "./jre.js";
|
|
14
15
|
// PNG-выход: SVG обоих движков растеризуется resvg (ленивая загрузка внутри модуля).
|
|
15
16
|
import { rasterizeSvgToPng } from "./pngraster.js";
|
|
17
|
+
// Шрифт-пин общий с resvg-растеризатором (одна точка правды, см. fonts.ts).
|
|
18
|
+
import { FONTS_DIR, DEFAULT_FONT_NAME } from "./fonts.js";
|
|
16
19
|
import { VENDOR_DIR } from "../../util/paths.js";
|
|
17
20
|
import { foldIncludes, diagramOutputFormat } from "../scan/tree.js";
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
const
|
|
21
|
+
// Таймаут рендера ОДНОЙ диаграммы: зависший JVM (циклы Smetana-layout) не эмитит
|
|
22
|
+
// ни 'close', ни 'error' — без таймаута await виснет вечно, а в watch-режиме
|
|
23
|
+
// isBuilding остаётся true навсегда. По таймауту процесс убивается (SIGTERM→SIGKILL).
|
|
24
|
+
const RENDER_TIMEOUT_MS = 120_000;
|
|
25
|
+
const SIGKILL_GRACE_MS = 5_000;
|
|
26
|
+
// Размер пула PlantUML-рендера. Каждая задача — отдельный процесс java (свой JVM,
|
|
27
|
+
// свои сотни МБ heap), поэтому пул НЕ равен числу ядер: дефолт — cpus-1, но не более 4.
|
|
28
|
+
// Переопределяется env C4BUILDER_RENDER_CONCURRENCY (целое ≥1); мусор — молча дефолт.
|
|
29
|
+
const renderConcurrency = () => {
|
|
30
|
+
const raw = process.env.C4BUILDER_RENDER_CONCURRENCY;
|
|
31
|
+
if (raw !== undefined) {
|
|
32
|
+
const n = Number(raw);
|
|
33
|
+
if (Number.isInteger(n) && n >= 1)
|
|
34
|
+
return n;
|
|
35
|
+
}
|
|
36
|
+
return Math.max(1, Math.min(4, os.cpus().length - 1));
|
|
37
|
+
};
|
|
38
|
+
// Пул ограниченной конкурентности: гоняет tasks не более `concurrency` одновременно.
|
|
39
|
+
// Порядок вызова задач сохраняется (worker'ы разбирают общий индекс next), результат —
|
|
40
|
+
// не собираем (каждая задача пишет свой файл сама). При первой ошибке новые задачи не
|
|
41
|
+
// стартуют, но УЖЕ запущенные дожидаются (finally у Promise.all в воркере) — чтобы не
|
|
42
|
+
// осталось висящих JVM, — после чего пробрасывается первая пойманная ошибка.
|
|
43
|
+
const runPool = async (tasks, concurrency) => {
|
|
44
|
+
let next = 0;
|
|
45
|
+
let firstError;
|
|
46
|
+
const worker = async () => {
|
|
47
|
+
while (next < tasks.length && firstError === undefined) {
|
|
48
|
+
const task = tasks[next++];
|
|
49
|
+
try {
|
|
50
|
+
await task();
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (firstError === undefined)
|
|
54
|
+
firstError = err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const width = Math.max(1, Math.min(concurrency, tasks.length));
|
|
59
|
+
const workers = [];
|
|
60
|
+
for (let i = 0; i < width; i++)
|
|
61
|
+
workers.push(worker());
|
|
62
|
+
await Promise.all(workers);
|
|
63
|
+
if (firstError !== undefined)
|
|
64
|
+
throw firstError;
|
|
65
|
+
};
|
|
66
|
+
// Версия движка @terrastruct/d2 для чексуммы кэша (апгрейд движка → перерендер).
|
|
67
|
+
// Читаем package.json лениво и один раз: pure-PlantUML сборки его не трогают.
|
|
68
|
+
let d2VersionCache = null;
|
|
69
|
+
const d2EngineVersion = () => {
|
|
70
|
+
if (d2VersionCache !== null)
|
|
71
|
+
return d2VersionCache;
|
|
72
|
+
try {
|
|
73
|
+
const req = createRequire(import.meta.url);
|
|
74
|
+
d2VersionCache = req('@terrastruct/d2/package.json').version;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
d2VersionCache = 'unknown';
|
|
78
|
+
}
|
|
79
|
+
return d2VersionCache;
|
|
80
|
+
};
|
|
81
|
+
// В ключ кеша: смена режима не должна отдавать картинку, отрисованную другим шрифтом
|
|
82
|
+
// (D2 тоже — его PNG растрирует общий resvg).
|
|
83
|
+
export const fontCacheTag = (options) => options.USE_SYSTEM_FONTS ? 'system' : DEFAULT_FONT_NAME;
|
|
22
84
|
export const getMime = (format) => {
|
|
23
85
|
if (format === 'svg')
|
|
24
86
|
return `image/svg+xml`;
|
|
25
87
|
return `image/${format}`;
|
|
26
88
|
};
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const lib = url.startsWith('https') ? https : http;
|
|
32
|
-
const request = lib.get(url, (response) => {
|
|
33
|
-
const status = response.statusCode; // ответ всегда со статусом
|
|
34
|
-
// handle http errors
|
|
35
|
-
if (status < 200 || status > 299) {
|
|
36
|
-
reject(new Error(`Failed to load page ${url}, status code: ${status}`));
|
|
37
|
-
}
|
|
38
|
-
// temporary data holder
|
|
39
|
-
const body = [];
|
|
40
|
-
// on every content chunk, push it to the data array
|
|
41
|
-
response.on('data', (chunk) => body.push(chunk));
|
|
42
|
-
// we are done, resolve promise with those joined chunks
|
|
43
|
-
response.on('end', () => resolve(Buffer.concat(body).toString('base64')));
|
|
44
|
-
});
|
|
45
|
-
// handle connection errors of the request
|
|
46
|
-
request.on('error', (err) => reject(err));
|
|
47
|
-
});
|
|
48
|
-
};
|
|
89
|
+
// Загрузка удалённо отрендеренной картинки (embed-ветка онлайн-рендера PlantUML) в
|
|
90
|
+
// base64. Редиректы и таймауты берём из общего util/http: прежде здесь был голый GET
|
|
91
|
+
// без обоих — 302 от PlantUML-сервера ронял embed-сборку, а зависший сокет вешал её.
|
|
92
|
+
export const httpGet = async (url) => (await httpGetBuffer(url)).toString('base64');
|
|
49
93
|
// Прямой вызов PlantUML: layout считает встроенный Java-движок Smetana
|
|
50
94
|
// (`-Playout=smetana`), внешний graphviz/dot не нужен. Диаграмма подаётся в stdin
|
|
51
95
|
// (`-pipe`), результат читается из stdout — имя выходного файла задаёт c4builder,
|
|
@@ -53,21 +97,32 @@ export const httpGet = async (url) => {
|
|
|
53
97
|
// ditaa рендерит собственный движок (layout не участвует), а `-Playout=smetana`
|
|
54
98
|
// на нём меняет размер холста — поэтому для ditaa флаг не передаётся (выход
|
|
55
99
|
// байт-в-байт совпадает с историческим).
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
100
|
+
//
|
|
101
|
+
// Оба имени шрифта пиним явно. `-SdefaultFontName` покрывает текст, а бэджи классов
|
|
102
|
+
// (буквы «C»/«A»/«I» в кружках) PlantUML рисует КРИВЫМИ отдельного шрифта
|
|
103
|
+
// CircledCharacterFontName: без пина это логический шрифт JVM, который резолвит
|
|
104
|
+
// системный fontconfig (Ubuntu → DejaVu/TrueType, Arch → Nimbus/CFF) — контуры
|
|
105
|
+
// расходятся между ОС. С пином шрифт берётся из vendor/fonts (JRE своих не несёт,
|
|
106
|
+
// freetype у Temurin бандлед), и SVG получается одинаковым везде.
|
|
107
|
+
// ditaa этим не лечится: его движок берёт AWT-шрифт мимо обеих опций.
|
|
108
|
+
// useSystemFonts убирает все три шрифтовых аргумента — рендер как до пина (машинозависим).
|
|
109
|
+
export const renderArgv = ({ jarPath, includePath, format, charset, isDitaa, useSystemFonts }) => [
|
|
110
|
+
'-Djava.awt.headless=true',
|
|
111
|
+
`-Dplantuml.include.path=${includePath}`,
|
|
112
|
+
...(useSystemFonts ? [] : [`-Dsun.java2d.fontpath=prepend:${FONTS_DIR}`]),
|
|
113
|
+
'-jar',
|
|
114
|
+
jarPath,
|
|
115
|
+
...(isDitaa ? [] : ['-Playout=smetana']),
|
|
116
|
+
...(useSystemFonts
|
|
117
|
+
? []
|
|
118
|
+
: [`-SdefaultFontName=${DEFAULT_FONT_NAME}`, `-SCircledCharacterFontName=${DEFAULT_FONT_NAME}`]),
|
|
119
|
+
'-charset',
|
|
120
|
+
charset,
|
|
121
|
+
`-t${format}`,
|
|
122
|
+
'-pipe'
|
|
123
|
+
];
|
|
124
|
+
export const renderDiagram = (content, options) => new Promise((resolve, reject) => {
|
|
125
|
+
const child = spawn(options.javaBin, renderArgv(options));
|
|
71
126
|
const stdout = [];
|
|
72
127
|
const stderr = [];
|
|
73
128
|
// stdio по умолчанию 'pipe' → потоки заведомо не null; берём их деструктуризацией
|
|
@@ -76,17 +131,49 @@ export const renderDiagram = (content, { javaBin, jarPath, includePath, format,
|
|
|
76
131
|
if (!stdin || !childOut || !childErr) {
|
|
77
132
|
return reject(new Error('PlantUML: не удалось открыть stdio-потоки процесса java'));
|
|
78
133
|
}
|
|
134
|
+
// Smetana печатает диагностический шум (UNSURE_ABOUT…) — это не ошибка
|
|
135
|
+
// рендера, пользователю не показываем; остальной stderr пробрасываем.
|
|
136
|
+
const cleanStderr = () => Buffer.concat(stderr)
|
|
137
|
+
.toString('utf8')
|
|
138
|
+
.split('\n')
|
|
139
|
+
.filter((line) => line.trim() && !/UNSURE_ABOUT/.test(line))
|
|
140
|
+
.join('\n');
|
|
141
|
+
let settled = false;
|
|
142
|
+
let sigkillTimer;
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
if (settled)
|
|
145
|
+
return;
|
|
146
|
+
settled = true;
|
|
147
|
+
// JVM завис: мягко SIGTERM, затем жёстко SIGKILL, если не среагировал.
|
|
148
|
+
child.kill('SIGTERM');
|
|
149
|
+
sigkillTimer = setTimeout(() => child.kill('SIGKILL'), SIGKILL_GRACE_MS);
|
|
150
|
+
sigkillTimer.unref();
|
|
151
|
+
const tail = cleanStderr();
|
|
152
|
+
reject(new Error(`PlantUML не завершился за ${RENDER_TIMEOUT_MS / 1000} c и был прерван` +
|
|
153
|
+
(tail ? `\n${tail}` : '')));
|
|
154
|
+
}, RENDER_TIMEOUT_MS);
|
|
155
|
+
timer.unref();
|
|
156
|
+
const clearTimers = () => {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
if (sigkillTimer)
|
|
159
|
+
clearTimeout(sigkillTimer);
|
|
160
|
+
};
|
|
79
161
|
childOut.on('data', (chunk) => stdout.push(chunk));
|
|
80
162
|
childErr.on('data', (chunk) => stderr.push(chunk));
|
|
81
|
-
child.on('error',
|
|
163
|
+
child.on('error', (err) => {
|
|
164
|
+
// java не найдена и пр.
|
|
165
|
+
clearTimers();
|
|
166
|
+
if (settled)
|
|
167
|
+
return;
|
|
168
|
+
settled = true;
|
|
169
|
+
reject(err);
|
|
170
|
+
});
|
|
82
171
|
child.on('close', (code) => {
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
.filter((line) => line.trim() && !/UNSURE_ABOUT/.test(line))
|
|
89
|
-
.join('\n');
|
|
172
|
+
clearTimers(); // гасим таймеры даже если сработал timeout (не убиваем чужой PID)
|
|
173
|
+
if (settled)
|
|
174
|
+
return;
|
|
175
|
+
settled = true;
|
|
176
|
+
const errText = cleanStderr();
|
|
90
177
|
if (code !== 0) {
|
|
91
178
|
return reject(new Error(`PlantUML завершился с кодом ${code}\n${errText}`));
|
|
92
179
|
}
|
|
@@ -102,9 +189,14 @@ export const renderDiagram = (content, { javaBin, jarPath, includePath, format,
|
|
|
102
189
|
// по чексумме. Его lifecycle (создание/удаление) держит оркестратор build(); сюда
|
|
103
190
|
// путь приходит явным параметром, а не пересчитывается из общей константы.
|
|
104
191
|
export const generateImages = async (tree, options, onImageGenerated, cacheConf, bkFolderName) => {
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
const
|
|
192
|
+
// Чексуммы прошлого прогона — в Set: попадание проверяется O(1) на диаграмму
|
|
193
|
+
// (было O(N) find → O(N²) на всё дерево).
|
|
194
|
+
const oldChecksums = new Set(cacheConf.get('checksums') || []);
|
|
195
|
+
// Каждой диаграмме — запись в порядке обхода дерева; ok выставляется задачей при
|
|
196
|
+
// УСПЕХЕ (рендер или восстановление из бэкапа). В кеш пишем только успешные: иначе
|
|
197
|
+
// упавший прогон терял бы чексуммы уже отрендеренных → их полный перерендер на
|
|
198
|
+
// следующем запуске (см. persist в finally ниже).
|
|
199
|
+
const checksumEntries = [];
|
|
108
200
|
let totalImages = 0;
|
|
109
201
|
let processedImages = 0;
|
|
110
202
|
// Рендерим единственным вендорным JAR — выбора версии больше нет. Легаси-ключ
|
|
@@ -117,17 +209,37 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
117
209
|
console.log(chalk.yellow(`Выбор версии PlantUML удалён — сборка идёт вендорным JAR ${VENDORED_JAR.version}. ` +
|
|
118
210
|
`Ключ plantumlVersion: "${pinned}" в .c4builder можно убрать.`));
|
|
119
211
|
}
|
|
120
|
-
// JRE резолвится ЛЕНИВО и один раз за сборку: только
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
212
|
+
// JRE резолвится ЛЕНИВО и один раз за сборку: только когда встретилась PlantUML-
|
|
213
|
+
// диаграмма, которую РЕАЛЬНО надо рендерить (кэш не сработал — restore-путь ниже
|
|
214
|
+
// делает continue до создания render-задачи). Проект целиком на D2 либо полностью
|
|
215
|
+
// восстановленный из бэкапа java не трогает — важно для офлайн-пересборки готового
|
|
216
|
+
// проекта на машине без установленной java (иначе резолв инициировал бы скачивание).
|
|
217
|
+
let javaPromise = null;
|
|
218
|
+
const getJava = () => {
|
|
219
|
+
if (!javaPromise) {
|
|
220
|
+
javaPromise = resolveJava({ log: (m) => console.log(chalk.gray(m)) }).then((r) => r.path);
|
|
221
|
+
}
|
|
222
|
+
return javaPromise;
|
|
223
|
+
};
|
|
127
224
|
for (const item of tree) {
|
|
128
225
|
totalImages += item.diagrams.length;
|
|
129
226
|
}
|
|
130
|
-
|
|
227
|
+
// Прогресс инкрементируем по ЗАВЕРШЕНИИ каждой задачи. Порядок завершения при
|
|
228
|
+
// конкурентности недетерминирован (сообщения `processed X/Y` могут идти вразнобой),
|
|
229
|
+
// но X монотонно растёт и финал = totalImages. Инкремент атомарен (один event-loop).
|
|
230
|
+
const withProgress = (task) => async () => {
|
|
231
|
+
await task();
|
|
232
|
+
processedImages++;
|
|
233
|
+
if (onImageGenerated)
|
|
234
|
+
onImageGenerated(processedImages, totalImages);
|
|
235
|
+
};
|
|
236
|
+
// D2-задачи гоняем отдельной очередью с конкурентностью 1: @terrastruct/d2 держит
|
|
237
|
+
// ЕДИНСТВЕННЫЙ worker с одним currentResolve/currentReject без корреляции запросов —
|
|
238
|
+
// два параллельных renderD2 затирают колбэк друг друга (промис виснет / результаты
|
|
239
|
+
// перепутываются). Движок всё равно однопоточный, так что сериализация D2 бесплатна.
|
|
240
|
+
// PlantUML — отдельные процессы java, их гоняем пулом renderConcurrency().
|
|
241
|
+
const d2Tasks = [];
|
|
242
|
+
const otherTasks = [];
|
|
131
243
|
for (const item of tree) {
|
|
132
244
|
for (const diagram of item.diagrams) {
|
|
133
245
|
// Чексумма = контент диаграммы + свёрнутый граф её зависимостей, чтобы
|
|
@@ -140,59 +252,103 @@ export const generateImages = async (tree, options, onImageGenerated, cacheConf,
|
|
|
140
252
|
const includes = entryPath !== null
|
|
141
253
|
? foldD2Imports(entryPath)
|
|
142
254
|
: foldIncludes(body, item.dir, item.dir, new Set());
|
|
255
|
+
const outFormat = diagramOutputFormat(diagram, options);
|
|
256
|
+
// renderKey: параметры, влияющие на БАЙТЫ вывода помимо контента и графа
|
|
257
|
+
// импортов. Без него смена layout (D2) или charset (PlantUML) не меняла
|
|
258
|
+
// чексумму → из бэкапа копировалась старая картинка (новые параметры молча
|
|
259
|
+
// игнорировались, кэш чистился только через --reset). Формат/движок/версия
|
|
260
|
+
// движка тоже в ключе: их смена обязана приводить к перерендеру.
|
|
261
|
+
const renderKey = (entryPath !== null
|
|
262
|
+
? `d2\0${d2EngineVersion()}\0layout=${options.D2_LAYOUT}\0fmt=${outFormat}`
|
|
263
|
+
: `puml\0${VENDORED_JAR.version}\0charset=${options.CHARSET}\0fmt=${outFormat}\0ditaa=${diagram.isDitaa}`) +
|
|
264
|
+
`\0font=${fontCacheTag(options)}`;
|
|
143
265
|
const cksum = crypto
|
|
144
266
|
.createHash('sha256')
|
|
145
|
-
.update(body + includes, 'utf-8')
|
|
267
|
+
.update(body + includes + renderKey, 'utf-8')
|
|
146
268
|
.digest('hex');
|
|
147
|
-
|
|
269
|
+
// Записи копим в порядке обхода дерева, СИНХРОННО и до запуска задач:
|
|
270
|
+
// порядок массива не зависит от того, кто из воркеров финиширует раньше —
|
|
271
|
+
// .c4builder.cache детерминирован (важно для сравнения кэша между сборками).
|
|
272
|
+
const cksumEntry = { cksum, ok: false };
|
|
273
|
+
checksumEntries.push(cksumEntry);
|
|
274
|
+
const outName = `${path.parse(diagram.dir).name}.${outFormat}`;
|
|
148
275
|
// path to backup image file
|
|
149
276
|
const bkFilePath = path.join(bkFolderName, item.dir.replace(options.ROOT_FOLDER, ''), outName);
|
|
150
277
|
// path to image in dist folder
|
|
151
278
|
const filePath = path.join(options.DIST_FOLDER, item.dir.replace(options.ROOT_FOLDER, ''), outName);
|
|
152
279
|
// if checksum exists (diagram untouched) and file/image exists - copy image back from backup folder
|
|
153
|
-
if (oldChecksums.
|
|
154
|
-
|
|
280
|
+
if (oldChecksums.has(cksum) && fs.existsSync(bkFilePath)) {
|
|
281
|
+
// Восстановление из бэкапа — без рендера; JVM/worker не задействованы,
|
|
282
|
+
// поэтому кладём в общий пул (kind не важен).
|
|
283
|
+
otherTasks.push(withProgress(async () => {
|
|
284
|
+
fsextra.copyFileSync(bkFilePath, filePath);
|
|
285
|
+
cksumEntry.ok = true;
|
|
286
|
+
}));
|
|
287
|
+
continue;
|
|
155
288
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
289
|
+
// PNG-выход не-ditaa диаграмм — растеризацией SVG (resvg), а не нативным
|
|
290
|
+
// движком: единый детерминированный PNG для PlantUML и D2. ditaa остаётся
|
|
291
|
+
// нативным PlantUML-PNG (у него нет SVG-представления) — не растеризуем.
|
|
292
|
+
const needsRaster = outFormat === 'png' && !diagram.isDitaa;
|
|
293
|
+
const diagramContent = diagram.content;
|
|
294
|
+
const includePath = item.dir;
|
|
295
|
+
const isDitaa = diagram.isDitaa;
|
|
296
|
+
const task = withProgress(async () => {
|
|
162
297
|
// render diagram to image: D2 через WASM, PlantUML — прямым вызовом java.
|
|
163
298
|
// Для растеризации PlantUML не-ditaa рендерим в svg (не -tpng), затем resvg.
|
|
164
299
|
let rendered;
|
|
165
|
-
|
|
166
|
-
|
|
300
|
+
try {
|
|
301
|
+
if (entryPath !== null) {
|
|
302
|
+
rendered = await renderD2(entryPath, { layout: options.D2_LAYOUT });
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
// Резолв JRE отложен до первого реального PlantUML-рендера (эта
|
|
306
|
+
// ветка исполняется лишь при промахе кэша). getJava мемоизирует.
|
|
307
|
+
const javaBin = await getJava();
|
|
308
|
+
rendered = await renderDiagram(diagramContent, {
|
|
309
|
+
javaBin,
|
|
310
|
+
jarPath,
|
|
311
|
+
includePath,
|
|
312
|
+
format: needsRaster ? 'svg' : outFormat,
|
|
313
|
+
charset: options.CHARSET,
|
|
314
|
+
isDitaa,
|
|
315
|
+
useSystemFonts: options.USE_SYSTEM_FONTS
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const image = needsRaster
|
|
319
|
+
? await rasterizeSvgToPng(rendered, options.USE_SYSTEM_FONTS)
|
|
320
|
+
: rendered;
|
|
321
|
+
await writeFile(filePath, image);
|
|
322
|
+
cksumEntry.ok = true;
|
|
167
323
|
}
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
throw new Error('PlantUML: JRE не резолвнут для plantuml-диаграммы');
|
|
172
|
-
rendered = renderDiagram(diagram.content, {
|
|
173
|
-
javaBin,
|
|
174
|
-
jarPath,
|
|
175
|
-
includePath: item.dir,
|
|
176
|
-
format: needsRaster ? 'svg' : outFormat,
|
|
177
|
-
charset: options.CHARSET,
|
|
178
|
-
isDitaa: diagram.isDitaa
|
|
179
|
-
});
|
|
324
|
+
catch (err) {
|
|
325
|
+
// Имя диаграммы в ошибку: renderDiagram/renderD2 сами его не знают.
|
|
326
|
+
throw new Error(`Диаграмма "${outName}": ${err.message || err}`);
|
|
180
327
|
}
|
|
181
|
-
const render = rendered
|
|
182
|
-
.then((image) => (needsRaster ? rasterizeSvgToPng(image) : image))
|
|
183
|
-
.then((image) => writeFile(filePath, image));
|
|
184
|
-
taskList.push(render);
|
|
185
|
-
}
|
|
186
|
-
const taskPromises = Promise.all(taskList).then(() => {
|
|
187
|
-
processedImages++;
|
|
188
|
-
if (onImageGenerated)
|
|
189
|
-
onImageGenerated(processedImages, totalImages);
|
|
190
328
|
});
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
329
|
+
if (entryPath !== null)
|
|
330
|
+
d2Tasks.push(task);
|
|
331
|
+
else
|
|
332
|
+
otherTasks.push(task);
|
|
194
333
|
}
|
|
195
334
|
}
|
|
196
|
-
//
|
|
197
|
-
|
|
335
|
+
// PlantUML-пул и D2-очередь работают независимо (процессы java vs WASM-worker) и
|
|
336
|
+
// параллельно друг другу. allSettled — чтобы падение одной очереди не оставляло
|
|
337
|
+
// вторую как floating promise с висящими JVM: обе доосушаются, затем бросаем первую
|
|
338
|
+
// ошибку (приоритет — PlantUML-пул, порядок между очередями не значим).
|
|
339
|
+
try {
|
|
340
|
+
const results = await Promise.allSettled([
|
|
341
|
+
runPool(otherTasks, renderConcurrency()),
|
|
342
|
+
runPool(d2Tasks, 1)
|
|
343
|
+
]);
|
|
344
|
+
const failed = results.find((r) => r.status === 'rejected');
|
|
345
|
+
if (failed && failed.status === 'rejected')
|
|
346
|
+
throw failed.reason;
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
// Персистим чексуммы даже при частичном падении: успешно обработанные диаграммы
|
|
350
|
+
// не перерендерятся на следующем прогоне. Вместе с сохранением dist_bk при ошибке
|
|
351
|
+
// (build.ts) повторный прогон после сбоя чинит только упавшую диаграмму.
|
|
352
|
+
cacheConf.set('checksums', checksumEntries.filter((e) => e.ok).map((e) => e.cksum));
|
|
353
|
+
}
|
|
198
354
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { VENDOR_DIR } from "../../util/paths.js";
|
|
3
|
+
// Единая точка детерминизма рендера текста. Вендорный шрифт пинуется в ОБА движка:
|
|
4
|
+
// JVM/PlantUML (`-Dsun.java2d.fontpath`, `-SdefaultFontName`) и resvg/PNG
|
|
5
|
+
// (`fontDirs`, `defaultFontFamily`). Значения обязаны совпадать — иначе метрики
|
|
6
|
+
// текста разъедутся и PNG-golden покраснеют. НЕ менять без пересбора эталонов.
|
|
7
|
+
export const FONTS_DIR = path.join(VENDOR_DIR, 'fonts');
|
|
8
|
+
export const DEFAULT_FONT_NAME = 'Nimbus Sans';
|