@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.
@@ -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
- // Вендорный шрифт: ширина текста в SVG считается из AWT-метрик, поэтому
19
- // шрифт пинуется, чтобы рендер не зависел от того, что установлено на машине.
20
- const FONTS_DIR = path.join(VENDOR_DIR, 'fonts');
21
- const DEFAULT_FONT_NAME = 'Liberation Sans';
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
- export const httpGet = async (url) => {
28
- // return new pending promise
29
- return new Promise((resolve, reject) => {
30
- // select http or https module, depending on reqested url
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
- export const renderDiagram = (content, { javaBin, jarPath, includePath, format, charset, isDitaa }) => new Promise((resolve, reject) => {
57
- const argv = [
58
- '-Djava.awt.headless=true',
59
- `-Dplantuml.include.path=${includePath}`,
60
- `-Dsun.java2d.fontpath=prepend:${FONTS_DIR}`,
61
- '-jar',
62
- jarPath,
63
- ...(isDitaa ? [] : ['-Playout=smetana']),
64
- `-SdefaultFontName=${DEFAULT_FONT_NAME}`,
65
- '-charset',
66
- charset,
67
- `-t${format}`,
68
- '-pipe'
69
- ];
70
- const child = spawn(javaBin, argv);
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', reject); // java не найдена и пр.
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
- // Smetana печатает диагностический шум (UNSURE_ABOUT…) — это не ошибка
84
- // рендера, пользователю не показываем; остальной stderr пробрасываем.
85
- const errText = Buffer.concat(stderr)
86
- .toString('utf8')
87
- .split('\n')
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
- // Get the old checksums (from last run) of all PUML-files
106
- const oldChecksums = cacheConf.get('checksums') || [];
107
- const newChecksums = [];
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
- // PlantUML-диаграмма. Проект целиком на D2 java не трогает (скачивание не инициируется).
122
- const needsJava = tree.some((item) => item.diagrams.some((d) => d.engine === 'plantuml'));
123
- let javaBin = null;
124
- if (needsJava) {
125
- javaBin = (await resolveJava({ log: (m) => console.log(chalk.gray(m)) })).path;
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
- const taskList = [];
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
- const outName = `${path.parse(diagram.dir).name}.${diagramOutputFormat(diagram, options)}`;
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.find((x) => x === cksum) && (await fs.existsSync(bkFilePath))) {
154
- await fsextra.copyFileSync(bkFilePath, filePath);
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
- else {
157
- const outFormat = diagramOutputFormat(diagram, options);
158
- // PNG-выход не-ditaa диаграмм — растеризацией SVG (resvg), а не нативным
159
- // движком: единый детерминированный PNG для PlantUML и D2. ditaa остаётся
160
- // нативным PlantUML-PNG (у него нет SVG-представления) — не растеризуем.
161
- const needsRaster = outFormat === 'png' && !diagram.isDitaa;
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
- if (entryPath !== null) {
166
- rendered = renderD2(entryPath, { layout: options.D2_LAYOUT });
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
- else {
169
- // needsJava гарантирует резолв для plantuml-ветки; guard — нарратив для типов.
170
- if (!javaBin)
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
- await taskPromises;
192
- // Add diagram checksum
193
- newChecksums.push(cksum);
329
+ if (entryPath !== null)
330
+ d2Tasks.push(task);
331
+ else
332
+ otherTasks.push(task);
194
333
  }
195
334
  }
196
- // store all puml checksums
197
- cacheConf.set('checksums', newChecksums);
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';