first commit

This commit is contained in:
2026-10-03 23:43:38 +04:00
commit 3a8045a141
40 changed files with 6262 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
report.docx
lab-gen.exe
*.log
*.pdf

324
AGENTS.md Normal file
View File

@@ -0,0 +1,324 @@
# AGENTS.md — генератор lab-gen
`lab-gen` — автономный инструмент, который собирает отчёт в формате **DOCX** из обычных
**Markdown**-файлов по фиксированному дизайну СТО 02068410-004-2018. Поведение полностью
задаётся файлом `config.yaml`.
Документ создан и для людей, и для ИИ-агентов: он описывает CLI, формат контента, схему
конфига и порядок сборки `.exe`.
---
## 1. Требования
- Windows (собранный `.exe` под `win-x64`).
- Node.js **только для сборки** `.exe` из исходников. Конечному пользователю Node.js не нужен.
- Никаких Java/Chrome/интернета: инструмент не рендерит диаграммы, а вставляет готовые PNG.
## 2. Состав дистрибутива
```
lab-gen.exe # исполняемый файл
config.yaml # настройки (создаётся автоматически при первом запуске)
content/ # Markdown-файлы документа и ресурсы
*.md
assets/
images/ # PNG для рисунков (готовятся заранее)
title-page.docx # титульный лист (по умолчанию content/assets/title-page.docx)
report.docx # результат сборки (путь задаётся document.output)
```
Все относительные пути в `config.yaml` считаются **от каталога самого `config.yaml`**.
Сам `config.yaml` и папки ищутся **рядом с `lab-gen.exe`**.
## 3. Командная строка
```
lab-gen [опции]
--config <путь> путь к config.yaml (по умолчанию рядом с exe)
--content <dir> каталог с config.yaml, content/ и assets/ (по умолчанию рядом с exe)
--output <путь> переопределить путь выходного .docx
--check только проверить конфиг и ссылки, без записи файла
--init создать шаблон config.yaml/content/assets и выйти
--verbose подробный вывод
--version показать версию
--help справка
```
Поведение:
- Если `config.yaml` не найден, инструмент **сам создаёт** `config.yaml`, примеры `content/*.md`
и `assets/images/placeholder.png`, печатает подсказку и завершается.
- `--check` не пишет DOCX, но сообщает обо всех ошибках (битые ссылки, выход `[@n]` за границы
списка источников, отсутствующие картинки и т.п.).
Типичный цикл работы:
```powershell
.\lab-gen.exe --init # первый запуск: получить шаблон
# ... правим config.yaml и content/*.md ...
.\lab-gen.exe --check # проверить
.\lab-gen.exe # собрать report.docx
```
## 4. Схема `config.yaml`
Любой ключ можно опустить — берётся значение по умолчанию (см. `src/config/defaults.js`).
### document
| Ключ | Тип | Смысл |
|------|-----|-------|
| `output` | string | путь выходного `.docx` |
| `titlePage` | string/null | путь к готовому `title-page.docx` (по умолчанию `content/assets/title-page.docx`); `null` — без титульника |
| `features.updateFields` | bool | обновлять поля (в т.ч. оглавление) при открытии в Word |
**Титульный лист.** Берётся готовый DOCX по пути `document.titlePage` и склеивается перед
документом; между титульником и основным текстом вставляется разрыв страницы. При `--init`
создаётся пример `content/assets/title-page.docx` — замените его своим. Значение `null`
отключает титульный лист.
### paths
| Ключ | Смысл |
|------|-------|
| `assets` | корень ресурсов; `file=images/x.png` в директивах ищется здесь (по умолчанию `content/assets`) |
### output
Путь и имя выходного `.docx` задаются ключом `document.output` (по умолчанию
`report.docx`). Путь считается от каталога `config.yaml` и может быть переопределён
флагом `--output`.
### page
`width`, `height` (DXA, 1/20 pt), `margins: {left,right,top,bottom}` (twips). A4 и поля СТО
заданы по умолчанию.
### typography
| Ключ | Смысл |
|------|-------|
| `font` | гарнитура (по умолчанию Times New Roman) |
| `sizeBody` / `sizeTable` / `sizeSmall` | размеры в half-points (28 = 14 pt) |
| `lineSpacing` | множитель межстрочного интервала (1.5 = полуторный) |
| `color` | цвет текста (hex, без `#`) |
| `useBold` | разрешить полужирный (СТО: `false`) |
| `allowEmphasis` | разрешить курсив (СТО: `false`) |
### headings
Интервалы/выравнивание для `struct`, `section`, `subsection`, `point` (twips).
### captions
`figure`, `table`, `tableContinue` — префиксы и тире подписей (`Рисунок`, `Таблица`, `—`).
### lists
`bulletChar`, `letterSuffix`, `numberSuffix`, `nestedLevelStep`, `forbiddenLetters`.
### numbering
`figures`, `tables`, `formulas`: `global` (сквозная по документу) или `section` (в пределах
раздела, вид `1.1`). Приложения всегда имеют префикс буквы (`А.1`).
### formula
`spacingBeforeAfter`, `whereTabPosition`.
### table
`font`, `size`, `borderColor`, `borderSize`, `cellMargins: {top,bottom,left,right}`,
`headerFill`, `cellFill`.
### figure
`maxWidth` (px; `null` — по ширине полосы), `maxHeight` (px).
### pageNumber
`align`, `format`, `startFrom`.
### toc
`enabled` (bool), `title`, `depth` (1..3). Оглавление вставляется записью
`sections: [{ type: toc }]`.
### bibliography / citation / appendix
`bibliography.title`, `citation: {bracketOpen,bracketClose,pagePrefix}`,
`appendix: {labelPrefix, forbiddenLetters}`.
### sections — структура документа
Упорядоченный список частей:
```yaml
sections:
- { type: toc } # поле оглавления
- { file: content/01-introduction.md, type: structural, title: Введение }
- { file: content/02-main.md, type: numbered } # # = 1, ## = 1.1, ### = 1.1.1
- { file: content/03-conclusion.md, type: structural, title: Заключение }
- { file: content/04-bibliography.md, type: bibliography }
- { file: content/05-appendix-a.md, type: appendix, letter: А, title: Приложение }
```
Типы частей:
| type | Поведение |
|------|-----------|
| `toc` | вставляет оглавление (не требует `file`) |
| `structural` | ненумерованный раздел (Введение/Заключение): заголовок = `title`, ЗАГЛАВНЫМИ, по центру, с новой страницы |
| `numbered` | нумеруемый раздел; `#` = раздел, `##`/`###` = подраздел/пункт |
| `bibliography` | список источников; нумеруется автоматически |
| `appendix` | приложение: заголовок «ПРИЛОЖЕНИЕ <letter>» + `title`, нумерация рисунков/таблиц с префиксом буквы |
## 5. Формат контента
### Заголовки
В файле `type: numbered`:
```md
# НАЗВАНИЕ РАЗДЕЛА -> 1 НАЗВАНИЕ РАЗДЕЛА (с новой страницы)
## Подраздел -> 1.1 Подраздел
### Пункт -> 1.1.1 Пункт
```
Номера подставляются автоматически (Word-numbering). В `structural`/`appendix` заголовок
берётся из конфига, а заголовки внутри файла рендерятся как обычные заголовки без номера.
### Абзацы, списки
Обычные абзацы и списки Markdown. Списки:
```md
- первый пункт;
- второй пункт:
1. вложенный пункт (выведется как «а) вложенный пункт»);
2. ещё один.
```
Маркер вложенности выбирается автоматически: тире → `а)` → `1)`. Полужирный/курсив по
умолчанию игнорируются (СТО).
### Таблицы
- Обычная GFM-таблица без директивы — таблица **без подписи и номера**.
- С подписью и автономером — директива `::: table`:
```md
::: table "Название таблицы" id=params note="примечание"
| Параметр | Значение |
|---|---|
| A | 1 |
| B | 2 |
:::
```
### Рисунки
```md
::: figure "Название рисунка" id=arch file=images/arch.png
а) первый поясняемый элемент;
б) второй поясняемый элемент.
:::
```
`file` — относительно `config.paths.assets`. Если файла нет — предупреждение, подпись
всё равно выводится.
**Подписи по СТО.** Подпись ставится **под** рисунком по центру: `Рисунок N — Наименование`
(с прописной буквы, без точки в конце). Если на рисунке обозначены отдельные детали,
пояснительные данные размещают **над подписью** и оформляют перечислением `а)`, `б)`, `в)` —
именно так, как показано в примере выше. Если рисунок общий и пояснять нечего, указывают
только подпись `Рисунок N — Наименование`, а тело директивы оставляют пустым:
```md
::: figure "Общая схема системы" id=common file=images/common.png
:::
```
В приложениях номер рисунка получает префикс буквы: `Рисунок А.1 — Наименование`.
### Формулы
```md
::: formula id=line
y = a*x + b
:::
::: where
y — результат;
x — аргумент;
a — коэффициент.
:::
```
В формулах поддерживается простой синтаксис: `^` (степень), `_` (индекс), `/` (дробь).
Номер формулы ставится автоматически справа.
### Примечания и термины
```md
::: note
Текст примечания.
:::
::: notes
- первое примечание;
- второе примечание.
:::
::: definitions
Термин — определение;
Ещё термин — определение.
:::
```
### Разрыв страницы
```md
::: pagebreak
:::
```
Тематический разрыв `---` также трактуется как разрыв страницы.
### Ссылки на источники
`[@1]` — ссылка на 1-й источник, `[@1, с. 96]` — с номером страницы. Список источников —
это Markdown-список в файле `type: bibliography`; порядок строк = номера источников.
Ссылка вне диапазона — ошибка в `--check`.
### Перекрёстные ссылки
`@fig:id`, `@tbl:id`, `@eq:id` подставляют актуальный номер рисунка/таблицы/формулы
(например `2`, `1.1`, `А.1`). `id` задаётся в директиве.
### Блоки кода
Функция листингов удалена: fenced-блоки ``` игнорируются с предупреждением.
## 6. Проверка и ошибки
`lab-gen --check` проверяет:
- корректность `config.yaml` (типы, существование файлов, допустимые буквы приложений);
- наличие всех `sections[].file`;
- существование меток в `@fig/@tbl/@eq`;
- диапазон ссылок `[@n]`.
Ошибки печатаются списком и возвращают код выхода `1`. Предупреждения (например,
отсутствующий PNG) не останавливают сборку.
## 7. Сборка `.exe` из исходников
```powershell
npm install
npm run build:exe # -> lab-gen.exe в корне (target node24-win-x64)
npm run generate # локальная сборка (node src/cli.js)
npm run check # проверка конфига и ссылок
```
Упаковка выполняется через `@yao-pkg/pkg`. Чтобы уменьшить размер, можно добавить
`--compress Brotli` в команду `build:exe`.
### Подготовка диаграмм
Инструмент не рендерит диаграммы: PNG готовятся заранее и кладутся в `content/assets/images/`.
Диаграммы можно строить любым внешним средством (PlantUML, Mermaid, draw.io, редактор).
## 8. Замечания по дизайну
- Дизайн фиксирован под СТО 02068410-004-2018; меняются только параметры из `config.yaml`.
- Полужирный и курсив по умолчанию отключены (`typography.useBold`, `allowEmphasis`).
- Шрифт, размеры, интервалы, отступы и подписи настраиваются в конфиге.
- Оглавление — поле Word; при открытии нажмите «Обновить поле», либо включено
`document.features.updateFields`.

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# lab-gen
Автономный генератор отчётов **DOCX** из **Markdown** по дизайну
[СТО 02068410-004-2018](documentation/STO_SGAU_02068410-004-2018.md).
Поведение полностью задаётся файлом `config.yaml`; путь выходного файла — ключ
`document.output`.
Полная документация: **[AGENTS.md](AGENTS.md)** — CLI, схема конфига, формат контента,
директивы и сборка `.exe`.
## Быстрый старт
```powershell
.\lab-gen.exe --init # создать config.yaml, примеры content/ и content/assets/
# ... правим config.yaml и content/*.md ...
.\lab-gen.exe --check # проверить ссылки и конфиг
.\lab-gen.exe # собрать report.docx
```
## Разработка
```powershell
npm install
npm run generate # локальная сборка report.docx
npm run check # проверка конфига и ссылок
npm run build:exe # собрать lab-gen.exe в корне (@yao-pkg/pkg, node24-win-x64)
```
## Структура репозитория
| Путь | Назначение |
|------|------------|
| `src/cli.js` | точка входа, разбор аргументов |
| `src/config/` | дефолты, загрузка и валидация `config.yaml`, шаблон |
| `src/markdown/` | markdown-it, директивы `:::`, инлайн-ссылки `[@n]`, `@fig/@tbl/@eq` |
| `src/render/` | сборка DOCX: стили, примитивы, нумерация, слияние титульника |
| `src/build.js` | оркестрация сборки |
| `content/` | примеры Markdown |
| `content/assets/` | PNG для рисунков, опциональный `title-page.docx` |
| `documentation/` | стандарт СТО |
Инструмент не рендерит диаграммы: PNG готовятся заранее и кладутся в `content/assets/images/`.

140
config.yaml Normal file
View File

@@ -0,0 +1,140 @@
# Конфигурация генератора lab-gen.
# Все относительные пути считаются от каталога этого файла.
# Любой ключ можно опустить — будет использовано значение по умолчанию.
document:
output: report.docx
titlePage: content/assets/title-page.docx
features:
updateFields: true
paths:
assets: content/assets
page:
width: 11906
height: 16838
margins:
left: 1701
right: 851
top: 1134
bottom: 1134
typography:
font: Times New Roman
sizeBody: 28
sizeTable: 28
sizeSmall: 24
lineSpacing: 1.5
indentFirst: 709
color: '000000'
useBold: false
allowEmphasis: false
headings:
struct:
spacingBefore: 360
spacingAfter: 560
align: center
section:
spacingBefore: 920
spacingAfter: 0
align: left
subsection:
spacingBefore: 284
spacingBeforeAfterSection: 240
spacingAfter: 0
align: left
point:
spacingBefore: 360
spacingAfter: 0
captions:
figure:
prefix: Рисунок
dash: —
align: center
table:
prefix: Таблица
dash: —
align: left
tableContinue:
prefix: Продолжение таблицы
lists:
bulletChar: '-'
letterSuffix: )
numberSuffix: )
forbiddenLetters:
- ё
- з
- й
- о
- ч
- ь
- ы
- ъ
nestedLevelStep: 360
numbering:
figures: global
tables: global
formulas: global
formula:
spacingBeforeAfter: 360
whereTabPosition: 720
table:
font: Times New Roman
size: 28
borderColor: '000000'
borderSize: 1
cellMargins:
top: 57
bottom: 57
left: 113
right: 113
headerFill: FFFFFF
cellFill: FFFFFF
figure:
maxWidth: null
maxHeight: 620
pageNumber:
align: center
format: decimal
startFrom: 1
toc:
enabled: true
title: Содержание
depth: 3
bibliography:
title: Список использованных источников
citation:
bracketOpen: '['
bracketClose: ']'
pagePrefix: с.
appendix:
labelPrefix: ПРИЛОЖЕНИЕ
forbiddenLetters:
- Ё
- З
- Й
- О
- Ч
- Ъ
- Ы
- Ь
sections:
- type: toc
- file: content/01-introduction.md
type: structural
title: Введение
- file: content/02-analysis.md
type: numbered
- file: content/03-design.md
type: numbered
- file: content/04-conclusion.md
type: structural
title: Заключение
- file: content/05-bibliography.md
type: bibliography
- file: content/06-appendix-a.md
type: appendix
letter: А
title: Сводные таблицы
- file: content/07-appendix-b.md
type: appendix
letter: Б
title: Вспомогательные материалы

View File

@@ -0,0 +1,14 @@
Целью работы является разработка информационной системы учёта
заявок для отдела технической поддержки. Актуальность темы обусловлена ростом числа обращений и
необходимостью их оперативной обработки [@1].
Для достижения поставленной цели необходимо решить следующие задачи:
- проанализировать предметную область и существующие решения;
- спроектировать архитектуру информационной системы;
- определить состав данных и пользовательских сценариев;
- рассчитать показатели эффективности разработанного решения.
При выполнении работы использованы методы системного анализа, объектного моделирования и
статистической обработки данных [@2]. Требования к оформлению приняты в соответствии с
действующими стандартами [@3], [@4].

107
content/02-analysis.md Normal file
View File

@@ -0,0 +1,107 @@
# АНАЛИЗ ПРЕДМЕТНОЙ ОБЛАСТИ
Раздел посвящён анализу процесса обработки обращений и обоснованию требований к информационной
системе. Рассматриваются участники процесса, информационные потоки и ограничения предметной
области.
## Обзор процесса обработки обращений
Процесс обработки заявки включает регистрацию, классификацию, назначение исполнителя и закрытие.
Схема процесса приведена на рисунке @fig:architecture.
::: figure "Схема процесса обработки обращений" id=architecture file=images/architecture.png
а) обозначение информационного потока;
б) блоки этапов обработки.
:::
Среднее время обработки одной заявки до внедрения системы составляло 46 минут. Распределение
обращений по категориям представлено в таблице @tbl:categories.
::: table "Распределение обращений по категориям" id=categories
| Категория обращения | Количество, шт. | Доля, % |
|---|---|---|
| Консультация | 420 | 28,0 |
| Настройка оборудования | 315 | 21,0 |
| Восстановление доступа | 270 | 18,0 |
| Программная ошибка | 210 | 14,0 |
| Установка программного обеспечения | 135 | 9,0 |
| Сетевые подключения | 90 | 6,0 |
| Прочие обращения | 60 | 4,0 |
| Итого | 1500 | 100,0 |
:::
Структура обращений по приоритетам приведена в таблице @tbl:priorities.
::: table "Обращения по приоритетам" id=priorities
| Приоритет | Количество, шт. | Норматив реакции, мин |
|---|---|---|
| Критический | 75 | 15 |
| Высокий | 225 | 30 |
| Средний | 600 | 60 |
| Низкий | 600 | 120 |
| Итого | 1500 | — |
:::
## Показатели эффективности
Для оценки эффективности применяется коэффициент снижения трудозатрат, рассчитываемый по
формуле @eq:efficiency.
::: formula id=efficiency
K = (T_base - T_new)/T_base
:::
::: where
K — коэффициент снижения трудозатрат;
T_base — среднее время обработки заявки до внедрения, мин;
T_new — среднее время обработки заявки после внедрения, мин.
:::
Значения исходных данных для расчёта приведены в таблице @tbl:baseline.
::: table "Исходные данные для расчёта эффективности" id=baseline
| Показатель | Обозначение | Значение |
|---|---|---|
| Среднее время до внедрения, мин | T_base | 46 |
| Среднее время после внедрения, мин | T_new | 18 |
| Число обращений в месяц, шт. | N | 125 |
| Стоимость часа работы, руб. | C | 420 |
| Число рабочих часов в месяце, ч | H | 164 |
| Коэффициент накладных расходов | k | 0,35 |
| Годовой фонд рабочего времени, ч | F | 1970 |
| Нормативный срок окупаемости, лет | T_ok | 3 |
| Доля автоматизируемых операций | d | 0,72 |
| Коэффициент потерь рабочего времени | p | 0,08 |
:::
## Обзор существующих решений
Сравнительная характеристика существующих решений приведена в таблице @tbl:comparison. Анализ
выполнен на основе открытых источников [@5], [@6].
::: table "Сравнение существующих решений" id=comparison
| Критерий | Решение A | Решение B | Решение C | Разрабатываемое решение |
|---|---|---|---|---|
| Стоимость лицензии | Высокая | Средняя | Низкая | Низкая |
| Открытый исходный код | Нет | Да | Да | Да |
| Русскоязычный интерфейс | Да | Нет | Да | Да |
| Интеграция по API | Ограниченная | Полная | Частичная | Полная |
| Модуль отчётности | Да | Нет | Да | Да |
| Развёртывание локально | Только облако | Да | Да | Да |
| Поддержка ОС | Windows | Linux | Кроссплатформенная | Кроссплатформенная |
| Стоимость владения | Высокая | Низкая | Средняя | Низкая |
| Срок внедрения, мес | 6 | 4 | 3 | 2 |
| Техническая поддержка | Платная | Сообщество | Платная | Внутренняя |
:::
::: note
Выбор решения выполнен по совокупности функциональных и экономических критериев.
:::
## Термины и определения
::: definitions
Заявка — обращение пользователя, зарегистрированное в информационной системе;
Исполнитель — сотрудник, назначенный ответственным за обработку заявки;
Регламент — документ, определяющий порядок и сроки обработки обращений.
:::

126
content/03-design.md Normal file
View File

@@ -0,0 +1,126 @@
# ПРОЕКТИРОВАНИЕ ИНФОРМАЦИОННОЙ СИСТЕМЫ
В разделе описаны архитектура, модель данных и пользовательские сценарии разрабатываемой
информационной системы.
## Архитектура системы
Система построена по трёхуровневой архитектуре. Взаимодействие компонентов показано на
рисунке @fig:data-flow.
::: figure "Схема информационных потоков" id=data-flow file=images/data-flow.png
а) направление передачи данных;
б) компоненты системы.
:::
Состав программных модулей приведён в таблице @tbl:modules.
::: table "Состав программных модулей" id=modules
| Модуль | Назначение | Технология |
|---|---|---|
| Веб-интерфейс | Отображение рабочих экранов оператора | React |
| API-шлюз | Обработка HTTP-запросов клиентов | Node.js |
| Модуль заявок | Жизненный цикл обращения | Node.js |
| Модуль уведомлений | Рассылка оповещений исполнителям | Node.js |
| Модуль отчётности | Формирование отчётов за период | Node.js |
| Служба аутентификации | Проверка учётных данных | JWT |
| Хранилище данных | Хранение заявок и справочников | PostgreSQL |
| Кэш | Ускорение чтения справочников | Redis |
| Очередь сообщений | Асинхронная обработка событий | RabbitMQ |
| Сборщик журналов | Централизованное логирование | ELK |
| Планировщик | Периодические задачи и отчёты | Cron |
| Модуль интеграции | Обмен данными с внешними системами | REST |
:::
## Модель данных
Основные сущности предметной области и их атрибуты приведены в таблице @tbl:entities.
::: table "Основные сущности и атрибуты" id=entities
| Сущность | Атрибут | Тип | Обяз. | Описание |
|---|---|---|---|---|
| Заявка | id | Целое | Да | Уникальный идентификатор |
| Заявка | subject | Строка | Да | Тема обращения |
| Заявка | description | Текст | Да | Описание проблемы |
| Заявка | category_id | Целое | Да | Ссылка на категорию |
| Заявка | priority_id | Целое | Да | Ссылка на приоритет |
| Заявка | assignee_id | Целое | Нет | Ссылка на исполнителя |
| Заявка | created_at | Дата/время | Да | Момент регистрации |
| Заявка | closed_at | Дата/время | Нет | Момент закрытия |
| Категория | id | Целое | Да | Уникальный идентификатор |
| Категория | name | Строка | Да | Наименование категории |
| Приоритет | id | Целое | Да | Уникальный идентификатор |
| Приоритет | name | Строка | Да | Наименование приоритета |
| Приоритет | reaction_min | Целое | Да | Норматив реакции, мин |
| Пользователь | id | Целое | Да | Уникальный идентификатор |
| Пользователь | full_name | Строка | Да | Фамилия, имя, отчество |
:::
Связи между сущностями описаны в таблице @tbl:relations.
::: table "Связи между сущностями" id=relations
| Связь | Тип | Описание |
|---|---|---|
| Заявка — Категория | Многие к одному | Заявка относится к одной категории |
| Заявка — Приоритет | Многие к одному | Заявка имеет один приоритет |
| Заявка — Пользователь | Многие к одному | Заявка назначена одному исполнителю |
| Заявка — Комментарий | Один ко многим | Заявка содержит комментарии |
| Пользователь — Роль | Многие ко многим | Пользователь может иметь роли |
| Категория — Подкатегория | Один ко многим | Категория содержит подкатегории |
:::
## Пользовательские сценарии
Интерфейс списка заявок приведён на рисунке @fig:interface.
::: figure "Интерфейс списка заявок" id=interface file=images/interface.png
а) область навигации;
б) табличное представление данных.
:::
Зависимость трудозатрат от выбранной стратегии обработки показана на рисунке @fig:chart.
::: figure "Зависимость трудозатрат от стратегии обработки" id=chart file=images/chart.png
Оценка получена по результатам пилотной эксплуатации.
:::
Экономия времени за месяц вычисляется по формуле @eq:gain.
::: formula id=gain
G = (T_base - T_new)*N
:::
::: where
G — экономия времени за месяц, мин;
N — число обращений в месяц.
:::
К системе предъявляются следующие требования:
- функциональные:
1. регистрация и классификация заявок;
2. назначение исполнителя и контроль сроков;
3. формирование отчётов за период.
- нефункциональные:
1. время отклика не более двух секунд;
2. одновременная работа не менее ста пользователей.
## Особенности реализации
При проектировании учтены ограничения, перечисленные в таблице @tbl:constraints.
::: table "Ограничения и меры" id=constraints
| Ограничение | Влияние | Мера |
|---|---|---|
| Ограниченный бюджет | Сужение выбора технологий | Применение открытых библиотек |
| Сжатые сроки | Риск неполной реализации | Итеративная разработка |
| Нагрузка до 100 пользователей | Требования к производительности | Кэширование и индексы |
| Устаревшее оборудование | Ограничение ресурсов | Облегчённый веб-интерфейс |
| Разрозненные данные | Сложность интеграции | Единая модель данных |
| Требования безопасности | Дополнительные проверки | Аутентификация и журналирование |
:::
::: notes
Оценка выполнена для среднего месяца пилотной эксплуатации.
Отклонение фактических значений не превышает десяти процентов.
:::

12
content/04-conclusion.md Normal file
View File

@@ -0,0 +1,12 @@
В ходе работы проанализирована предметная область, спроектирована архитектура информационной
системы и определены показатели эффективности [@6], [@7].
Основные результаты работы:
- выполнена классификация обращений и определены их приоритеты;
- разработана модель данных и описаны пользовательские сценарии;
- рассчитан коэффициент снижения трудозатрат.
Применение информационной системы позволяет сократить среднее время обработки заявки с 46 до
18 минут, что подтверждает достижение поставленной цели. Единицы измерений приведены в
соответствии с действующим стандартом [@8].

View File

@@ -0,0 +1,8 @@
- ГОСТ 7.32-2017. Система стандартов по информации, библиотечному и издательскому делу. Отчёт о научно-исследовательской работе. Структура и правила оформления. — М. : Стандартинформ, 2017. — 32 с.
- ГОСТ Р 7.0.5-2008. Библиографическая ссылка. Общие требования и правила составления. — М. : Стандартинформ, 2008. — 23 с.
- Иванов, И. И. Управление ИТ-сервисами : учебное пособие / И. И. Иванов. — Самара : Изд-во Самарского университета, 2019. — 184 с.
- Петров, П. П. Обзор систем Service Desk / П. П. Петров // Информационные технологии. — 2020. — № 4. — С. 25–33.
- Смирнова, А. А. Практика внедрения ITSM-решений / А. А. Смирнова. — М. : ДМК Пресс, 2021. — 256 с.
- Сидоров, С. С. Оценка эффективности информационных систем / С. С. Сидоров // Вестник информатики. — 2018. — Т. 12, № 2. — С. 45–52.
- Открытые ITSM-системы [Электронный ресурс]. — URL: https://example.org/itsm (дата обращения: 01.02.2026).
- ГОСТ 8.417-2002. Государственная система обеспечения единства измерений. Единицы величин. — М. : Стандартинформ, 2002. — 32 с.

38
content/06-appendix-a.md Normal file
View File

@@ -0,0 +1,38 @@
Сводные данные по обращениям за отчётный период приведены в таблице @tbl:app-a1.
::: table "Сводные данные по обращениям" id=app-a1
| Номер | Дата регистрации | Категория | Приоритет | Исполнитель | Статус | Время, мин |
|---|---|---|---|---|---|---|
| 1001 | 03.02.2026 | Консультация | Низкий | Орлова А. А. | Закрыта | 12 |
| 1002 | 03.02.2026 | Восстановление доступа | Высокий | Крылов И. И. | Закрыта | 24 |
| 1003 | 04.02.2026 | Программная ошибка | Критический | Никитин П. П. | Закрыта | 41 |
| 1004 | 04.02.2026 | Настройка оборудования | Средний | Орлова А. А. | Закрыта | 33 |
| 1005 | 05.02.2026 | Установка ПО | Низкий | Гусев Д. Д. | Закрыта | 18 |
| 1006 | 05.02.2026 | Сетевые подключения | Высокий | Крылов И. И. | Закрыта | 27 |
| 1007 | 06.02.2026 | Консультация | Низкий | Гусев Д. Д. | Закрыта | 9 |
| 1008 | 06.02.2026 | Программная ошибка | Критический | Никитин П. П. | Закрыта | 38 |
| 1009 | 07.02.2026 | Восстановление доступа | Средний | Орлова А. А. | Закрыта | 21 |
| 1010 | 07.02.2026 | Прочие обращения | Низкий | Гусев Д. Д. | Закрыта | 15 |
| 1011 | 10.02.2026 | Настройка оборудования | Высокий | Крылов И. И. | Закрыта | 30 |
| 1012 | 10.02.2026 | Консультация | Низкий | Орлова А. А. | Закрыта | 11 |
| 1013 | 11.02.2026 | Программная ошибка | Средний | Никитин П. П. | Закрыта | 35 |
| 1014 | 11.02.2026 | Сетевые подключения | Критический | Крылов И. И. | Закрыта | 44 |
| 1015 | 12.02.2026 | Установка ПО | Низкий | Гусев Д. Д. | Закрыта | 19 |
| 1016 | 12.02.2026 | Восстановление доступа | Высокий | Орлова А. А. | Закрыта | 26 |
| 1017 | 13.02.2026 | Консультация | Низкий | Гусев Д. Д. | Закрыта | 10 |
| 1018 | 13.02.2026 | Прочие обращения | Средний | Никитин П. П. | Закрыта | 29 |
| 1019 | 14.02.2026 | Настройка оборудования | Высокий | Крылов И. И. | Закрыта | 32 |
| 1020 | 14.02.2026 | Консультация | Низкий | Орлова А. А. | Закрыта | 13 |
:::
Детализация по исполнителям приведена в таблице @tbl:app-a2.
::: table "Детализация по исполнителям" id=app-a2
| Исполнитель | Отдел | Закрыто заявок | Среднее время, мин | Доля, % |
|---|---|---|---|---|
| Орлова А. А. | Первая линия | 164 | 15 | 32,8 |
| Крылов И. И. | Вторая линия | 138 | 27 | 27,6 |
| Никитин П. П. | Вторая линия | 112 | 38 | 22,4 |
| Гусев Д. Д. | Первая линия | 86 | 14 | 17,2 |
| Итого | — | 500 | 23 | 100,0 |
:::

18
content/07-appendix-b.md Normal file
View File

@@ -0,0 +1,18 @@
Схема размещения компонентов системы приведена на рисунке @fig:app-b1.
::: figure "Схема размещения компонентов" id=app-b1 file=images/appendix-scheme.png
а) узлы развёртывания;
б) связи между узлами.
:::
Параметры конфигурации стенда приведены в таблице @tbl:app-b1.
::: table "Параметры конфигурации стенда" id=app-b1
| Компонент | Процессор | Память, ГБ | Диск, ГБ | Назначение |
|---|---|---|---|---|
| Сервер приложений | 4 ядра | 8 | 40 | Выполнение API и логики |
| Сервер БД | 8 ядер | 16 | 200 | Хранение данных |
| Сервер кэша | 2 ядра | 4 | 20 | Кэширование справочников |
| Сервер очередей | 2 ядра | 4 | 20 | Асинхронные сообщения |
| Узел журналирования | 2 ядра | 8 | 100 | Сбор и хранение логов |
:::

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

View File

@@ -0,0 +1,218 @@
# Краткий гайд по оформлению учебных текстовых документов
**Источник:** СТО 02068410-004-2018 «Общие требования к учебным текстовым документам» (Самарский университет, 2018).
**Область применения:** обязателен для СПО; рекомендуется для высшего образования (бакалавриат, магистратура, специалитет). Распространяется на пояснительные записки, курсовые, контрольные, РГР, рефераты, ВКР и др.
---
## 1. Общие параметры страницы и текста
| Параметр | Требование |
|----------|------------|
| Формат | А4 (210×297 мм), одна сторона, белая бумага |
| Набор | Компьютерный (печать/графика) |
| Шрифт | Times New Roman, кегль 12–14 (высота знаков ≥ 1,8 мм) |
| Начертание | Полужирный **не применяется** |
| Цвет | Чёрный |
| Межстрочный интервал | 1,5 |
| Выравнивание | По ширине |
| Абзацный отступ | 1,25 см |
| Поля | слева ≥ 30 мм; справа ≥ 10 мм; сверху и снизу ≥ 20 мм |
**Запрещено:** помарки, следы удалённого текста, повреждения листов. Незначительные опечатки допускается исправлять подчисткой/закрашиванием с переписыванием черными чернилами.
**Страницы:** сквозная нумерация арабскими цифрами по центру внизу, без точки. Титульный лист входит в нумерацию, но номер на нём не ставится.
**Разделы:** каждый структурный элемент — с новой страницы; разделы основной части рекомендуется начинать с новой страницы.
---
## 2. Структура документа (порядок элементов)
1. Титульный лист
2. Задание *(при наличии)*
3. Реферат
4. Содержание
5. Введение
6. **Основная часть** *(разделы, подразделы)*
7. Заключение
8. Определения, обозначения и сокращения *(при наличии)*
9. Список использованных источников *(при наличии)*
10. Приложения *(при наличии)*
### Заголовки структурных элементов
- По центру строки, **ПРОПИСНЫМИ** буквами, без точки в конце, без подчёркивания.
- **Не нумеруются** (РЕФЕРАТ, СОДЕРЖАНИЕ, ВВЕДЕНИЕ, ЗАКЛЮЧЕНИЕ, ПРИЛОЖЕНИЕ и т.д.).
### Нумерация разделов основной части
| Уровень | Формат | Пример |
|---------|--------|--------|
| Раздел | арабская цифра, без точки в конце | `1 Заголовок раздела` |
| Подраздел | раздел.подраздел | `1.1 Заголовок` |
| Пункт | раздел.подраздел.пункт | `1.1.1` |
| Подпункт | … .подпункт | `1.1.1.1` |
- Запись с абзацного отступа; после последней цифры номера точка **не ставится**.
- Заголовки разделов/подразделов: с прописной буквы, без точки, без переносов слов, не подчёркивать.
- Пункты и подпункты, как правило, **без заголовков**.
- Если в разделе один подраздел — он всё равно нумеруется.
### Перечисления
- Простой список: дефис `-`.
- Со ссылками в тексте: `а)`, `б)` … (без букв ё, з, й, о, ч, ь, ы, ъ).
- Вложенный уровень: `1)`, `2)` …
- Каждый элемент — с абзацного отступа.
---
## 3. Содержание отдельных элементов
### Титульный лист
- Первая страница; по возможности — на бланке университета/кафедры.
- Примеры — в **приложении А** СТО.
### Реферат
- Объём текста реферата: **не более 850 знаков**.
- Содержит: число страниц, рисунков, таблиц, источников, приложений; сведения о графической части; **5–15 ключевых слов** (именительный падеж, прописные, через запятую); текст по объекту, цели, результатам, характеристикам, применению, эффективности (для ВКР).
### Содержание
- Включает введение, все разделы/подразделы/пункты с названиями, заключение, определения, список источников, приложения — **с номерами страниц**.
- Наименования — строчными, с прописной буквы.
- Введение, заключение, список источников и приложения в содержании **не нумеруются** как разделы.
### Введение и заключение
- **Введение:** актуальность, состояние проблемы, исходные данные, обоснование методики, новизна.
- **Заключение:** выводы, оценка результатов, рекомендации.
### Определения и сокращения
- Столбцом: слева — термин/обозначение (по алфавиту), справа через тире — расшифровка и размерность.
### Список источников
- По **ГОСТ 7.1** (полное описание) или **ГОСТ Р 7.0.5** (библиографические ссылки).
- Порядок — по упоминанию в тексте; нумерация арабскими цифрами **без точки**, с абзацного отступа.
- Примеры — в **приложении Г** СТО.
---
## 4. Иллюстрации (рисунки)
- Располагать **сразу после** первого упоминания в тексте, на следующей странице или в приложении.
- На каждый рисунок — **ссылка** в тексте («…в соответствии с рисунком 1…»).
- Нумерация: сквозная арабскими цифрами; при большом числе — в пределах раздела (`Рисунок 1.12`).
- Одна иллюстрация — `Рисунок 1`.
- Подпись **под** рисунком по центру: `Рисунок N — Наименование` (с прописной буквы, без точки в конце).
- Пояснительные данные (обозначения на рисунке) — над подписью.
- В приложениях: `Рисунок А.3` (буква приложения + номер).
- Фото меньше А4 — наклеить на лист А4. Допускается цветная печать, ручное исполнение.
---
## 5. Таблицы
- Размещать после первого упоминания или на следующей странице; на каждую таблицу — **ссылка** в тексте.
- Название **над** таблицей слева, без абзацного отступа: `Таблица N — Название` (без точки в конце).
- Нумерация: сквозная или в пределах раздела (`Таблица 6.4`); в приложениях — `Таблица Б.1`.
- Заголовки граф и строк — с прописной, в единственном числе; точки в конце заголовков граф **нет**.
- Таблица, как правило, ограничена линиями; головка отделена линией; диагонали в заголовках **не допускаются**.
- Допускается меньший кегль, чем в основном тексте; альбомная ориентация — по длинной стороне листа.
- Перенос на другую страницу: над продолжением — `Продолжение таблицы N` (без названия); головку/боковик повторять; нижнюю границу первой части не проводить.
- Пустые ячейки — прочерк; повторяющийся текст — кавычки или «То же» (для цифр/символов кавычки **нельзя**).
- Примечание к таблице — в конце таблицы, над нижней линией; **не должно содержать требований**.
---
## 6. Формулы
- На отдельной строке; сверху и снизу — **не менее одной пустой строки**.
- Перенос — после `=`, `+`, `-`, `×`, `:`; знак повторяется в начале следующей строки. Знак умножения — `×`, не `*` и не `•`.
- Расшифровка под формулой со слова **«где»** (без двоеточия), с размерностями.
- Нумерация справа в круглых скобках: `(1)`; в разделе — `(3.1)`; в приложении — `(Б.2)`.
- Ссылка в тексте: «…по формуле (2.1)…».
- Чёрный цвет; один способ набора в пределах одной формулы.
---
## 7. Ссылки и примечания
### Библиографические ссылки на источники
- В квадратных скобках по порядку упоминания: `[3]`.
- На фрагмент книги: `[10, с. 96]` или `[10, с. 96–98]` (ГОСТ Р 7.0.5).
- На стандарты в тексте — обозначение; год можно не указывать, если полное название есть в списке источников.
- **Не ссылаться** на подразделы/таблицы/рисунки **чужих** источников (только своего документа).
### Примечания
- После относящегося материала; слово «Примечание» — с прописной, с абзаца.
- Одно: `Примечание — Текст…`
- Несколько: заголовок «Примечания», далее нумерованный список `1`, `2`… (без точки после номера).
- **Не содержат требований.**
---
## 8. Приложения
- После списка использованных источников; сквозная нумерация страниц.
- На каждое приложение — **ссылка** в тексте; порядок — по порядку ссылок.
- Обозначение: `ПРИЛОЖЕНИЕ А` (русский алфавит, без Ё, З, Й, О, Ч, Ъ, Ы, Ь); при исчерпании — латиница или цифры.
- Заголовок приложения — под словом «ПРИЛОЖЕНИЕ», по центру, с прописной буквы.
- Внутренняя нумерация разделов: `В.4` (буква приложения + номер).
- Таблицы и рисунки в приложениях нумеруются отдельно: `Таблица А.1`, `Рисунок Б.2`.
**Что выносить в приложения:** громоздкие расчёты, большие таблицы/графики, массивы данных, описания приборов и методик, программы, вспомогательные иллюстрации.
---
## 9. Требования к изложению текста
- Стиль: краткий, точный, однозначный; научно-технические термины по стандартам.
- Для обязательных требований: «должен», «следует», «не допускается» и т.п.; для рекомендаций: «как правило», «при необходимости»; допустима повествовательная форма («применяют», «указывают»).
**Не допускается в основном тексте** (кроме формул, таблиц, рисунков):
- разговорная речь, техницизмы, синонимы для одного понятия;
- иностранные слова при наличии русских аналогов;
- произвольные сокращения (кроме ГОСТ Р 7.0.12 и орфографических);
- знак «−» перед отрицательными числами (писать «минус»);
- знак Ø для диаметра в тексте (писать «диаметр»);
- математические знаки без чисел (`>`, `<`, `=`, `%`, `№` и т.д.).
**Числа и единицы:**
- С единицами измерения — цифрами; от 1 до 9 без единиц — словами («пять труб», «12 м»).
- Единицы по **ГОСТ 8.417**; одна система в документе.
- Дроби — десятичные через **запятую**; диапазоны: `от 1 до 5 мм`.
- Единицу указывают после последнего числа в ряду: `1,50; 1,75; 2,00 м`.
- Не отрывать единицу от числа при переносе строки (кроме таблиц).
---
## 10. Нормативные ссылки СТО (для углублённого оформления)
| Документ | Назначение |
|----------|------------|
| ГОСТ 2.105-95 | Текстовые документы ЕСКД |
| ГОСТ 7.32-2001 | Структура отчёта о НИР |
| ГОСТ 7.9-95 | Реферат и аннотация |
| ГОСТ 7.1, ГОСТ Р 7.0.5, ГОСТ Р 7.0.12 | Библиография и сокращения |
| ГОСТ 8.417 | Единицы величин |
| ГОСТ 2.316 | Надписи на чертежах |
---
## 11. Практический чеклист перед сдачей
- [ ] А4, Times New Roman 12–14, интервал 1,5, поля по СТО
- [ ] Титульный лист по бланку (прил. А), номер страницы на нём скрыт
- [ ] Реферат ≤ 850 знаков, 5–15 ключевых слов
- [ ] Содержание с номерами страниц
- [ ] Разделы пронумерованы (`1`, `1.1`, `1.1.1`), структурные элементы — ЗАГЛАВНЫМИ по центру
- [ ] Все рисунки и таблицы имеют ссылки и подписи
- [ ] Формулы с расшифровкой «где» и нумерацией справа
- [ ] Список источников по ГОСТ, ссылки `[N]` в тексте
- [ ] Приложения после списка, с обозначениями А, Б, В…
- [ ] Сквозная нумерация страниц внизу по центру
---
*Документ составлен по СТО 02068410-004-2018. Для титульного листа, задания, реферата и библиографии используйте обязательные приложения А, Б, В и справочное приложение Г исходного стандарта.*

2326
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "lab-gen",
"version": "2.0.0",
"private": true,
"description": "Автономный генератор отчётов DOCX из Markdown по СТО 02068410-004-2018",
"bin": {
"lab-gen": "src/cli.js"
},
"main": "src/index.js",
"scripts": {
"generate": "node src/cli.js",
"check": "node src/cli.js --check",
"build:exe": "pkg . --targets node24-win-x64 --compress Brotli --output lab-gen.exe"
},
"dependencies": {
"docx": "^9.5.0",
"docx-merger": "^1.2.2",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"markdown-it": "^14.1.0"
},
"devDependencies": {
"@yao-pkg/pkg": "^6.23.0"
},
"pkg": {
"scripts": [
"src/**/*.js"
],
"targets": [
"node24-win-x64"
]
}
}

53
src/build.js Normal file
View File

@@ -0,0 +1,53 @@
'use strict';
const fs = require('fs');
const { Packer } = require('docx');
const { createMarkdown, parseFile } = require('./markdown/parse');
const { buildContext } = require('./render/styles');
const { collectNumbers } = require('./render/references');
const { createState, renderSections, buildDocx } = require('./render/document');
const { mergeDocxBuffers } = require('./render/titlepage');
function loadSections(config, md) {
const sections = [];
for (const entry of config.sections) {
if (entry.type === 'toc') {
sections.push({ entry });
continue;
}
const { meta, tokens } = parseFile(md, entry.file);
sections.push({ entry, meta, tokens });
}
return sections;
}
/**
* Собирает документ. При check=true не пишет docx, только проверяет ссылки.
* @returns {Promise<{state: object, buffer: Buffer|null}>}
*/
async function buildDocument(config, { check = false } = {}) {
const md = createMarkdown();
const sections = loadSections(config, md);
const ctx = buildContext(config);
const state = createState(md, sections);
collectNumbers(sections, ctx, state);
const children = renderSections(ctx, state);
if (check) return { state, buffer: null };
const doc = buildDocx(ctx, children);
let buffer = await Packer.toBuffer(doc);
const titlePage = config.document.titlePage;
if (titlePage && fs.existsSync(titlePage)) {
buffer = await mergeDocxBuffers(fs.readFileSync(titlePage), buffer);
} else if (titlePage) {
state.warnings.push(`Титульник не найден, пропущен: ${titlePage}`);
}
return { state, buffer };
}
module.exports = { buildDocument, loadSections };

150
src/cli.js Normal file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { loadConfig } = require('./config/load');
const { validateConfig } = require('./config/validate');
const { ensureScaffold } = require('./config/template');
const { buildDocument } = require('./build');
const { createLogger } = require('./util/logger');
const { ConfigError } = require('./util/errors');
const VERSION = '2.0.0';
const HELP = `lab-gen ${VERSION} — генератор DOCX из Markdown (СТО 02068410-004-2018)
Использование:
lab-gen [опции]
Опции:
--config <путь> Путь к config.yaml (по умолчанию рядом с exe)
--content <dir> Каталог с config.yaml, content/ и assets/ (по умолчанию рядом с exe)
--output <путь> Переопределить путь выходного .docx
--check Только проверить конфиг и ссылки, без записи
--init Создать шаблон config.yaml/content/assets и выйти
--verbose Подробный вывод
--version Показать версию
--help Показать эту справку
`;
function parseArgs(argv) {
const opts = { check: false, init: false, verbose: false };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case '--config':
opts.config = argv[++i];
break;
case '--content':
opts.content = argv[++i];
break;
case '--output':
opts.output = argv[++i];
break;
case '--check':
opts.check = true;
break;
case '--init':
opts.init = true;
break;
case '--verbose':
opts.verbose = true;
break;
case '--version':
opts.version = true;
break;
case '--help':
case '-h':
opts.help = true;
break;
default:
if (arg.startsWith('--')) {
throw new ConfigError(`Неизвестная опция: ${arg}`);
}
}
}
return opts;
}
function defaultBaseDir() {
return process.pkg ? path.dirname(process.execPath) : process.cwd();
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
const logger = createLogger({ verbose: opts.verbose });
if (opts.help) {
process.stdout.write(HELP);
return 0;
}
if (opts.version) {
process.stdout.write(VERSION + '\n');
return 0;
}
const baseDir = opts.content ? path.resolve(opts.content) : defaultBaseDir();
const configPath = opts.config
? path.resolve(opts.config)
: path.join(baseDir, 'config.yaml');
if (opts.init || !fs.existsSync(configPath)) {
const targetDir = path.dirname(configPath);
const created = await ensureScaffold(targetDir);
if (created.length) {
logger.info('Создан шаблон генератора:');
for (const file of created) logger.info(' ' + path.relative(targetDir, file));
} else {
logger.info('Файлы уже существуют, ничего не создано.');
}
logger.info('');
logger.info('Отредактируйте config.yaml и файлы в content/, затем запустите lab-gen снова.');
return 0;
}
const { config } = loadConfig(configPath);
if (opts.output) config.document.output = path.resolve(opts.output);
const errors = validateConfig(config);
if (errors.length) {
logger.error('Конфигурация содержит ошибки:');
for (const err of errors) logger.error(' - ' + err);
return 1;
}
const { state, buffer } = await buildDocument(config, { check: opts.check });
for (const warning of state.warnings) logger.warn(warning);
if (state.errors.length) {
logger.error('Найдены проблемы в контенте:');
for (const err of state.errors) logger.error(' - ' + err);
return 1;
}
if (opts.check) {
logger.info('Проверка пройдена, ошибок нет.');
return 0;
}
fs.mkdirSync(path.dirname(config.document.output), { recursive: true });
fs.writeFileSync(config.document.output, buffer);
logger.info('Готово: ' + config.document.output);
return 0;
}
main()
.then((code) => {
process.exitCode = code || 0;
})
.catch((err) => {
if (err instanceof ConfigError) {
process.stderr.write('error: ' + err.message + '\n');
} else {
process.stderr.write((err && err.stack ? err.stack : String(err)) + '\n');
}
process.exitCode = 1;
});

123
src/config/defaults.js Normal file
View File

@@ -0,0 +1,123 @@
'use strict';
/**
* Значения по умолчанию для config.yaml.
* Соответствуют оформлению СТО 02068410-004-2018.
*/
const DEFAULT_CONFIG = {
document: {
output: 'report.docx',
titlePage: 'content/assets/title-page.docx',
features: { updateFields: true },
},
paths: {
assets: 'content/assets',
},
page: {
width: 11906,
height: 16838,
margins: { left: 1701, right: 851, top: 1134, bottom: 1134 },
},
typography: {
font: 'Times New Roman',
sizeBody: 28,
sizeTable: 28,
sizeSmall: 24,
lineSpacing: 1.5,
indentFirst: 709,
color: '000000',
useBold: false,
allowEmphasis: false,
},
headings: {
struct: { spacingBefore: 360, spacingAfter: 560, align: 'center' },
section: { spacingBefore: 920, spacingAfter: 0, align: 'left' },
subsection: {
spacingBefore: 284,
spacingBeforeAfterSection: 240,
spacingAfter: 0,
align: 'left',
},
point: { spacingBefore: 360, spacingAfter: 0 },
},
captions: {
figure: { prefix: 'Рисунок', dash: '—', align: 'center' },
table: { prefix: 'Таблица', dash: '—', align: 'left' },
tableContinue: { prefix: 'Продолжение таблицы' },
},
lists: {
bulletChar: '-',
letterSuffix: ')',
numberSuffix: ')',
forbiddenLetters: ['ё', 'з', 'й', 'о', 'ч', 'ь', 'ы', 'ъ'],
nestedLevelStep: 360,
},
numbering: {
figures: 'global',
tables: 'global',
formulas: 'global',
},
formula: {
spacingBeforeAfter: 360,
whereTabPosition: 720,
},
table: {
font: 'Times New Roman',
size: 28,
borderColor: '000000',
borderSize: 1,
cellMargins: { top: 57, bottom: 57, left: 113, right: 113 },
headerFill: 'FFFFFF',
cellFill: 'FFFFFF',
},
figure: {
maxWidth: null,
maxHeight: 620,
},
pageNumber: { align: 'center', format: 'decimal', startFrom: 1 },
toc: { enabled: true, title: 'Содержание', depth: 3 },
bibliography: { title: 'Список использованных источников' },
citation: { bracketOpen: '[', bracketClose: ']', pagePrefix: 'с.' },
appendix: {
labelPrefix: 'ПРИЛОЖЕНИЕ',
forbiddenLetters: ['Ё', 'З', 'Й', 'О', 'Ч', 'Ъ', 'Ы', 'Ь'],
},
sections: [
{ type: 'toc' },
{ file: 'content/01-introduction.md', type: 'structural', title: 'Введение' },
{ file: 'content/02-analysis.md', type: 'numbered' },
{ file: 'content/03-design.md', type: 'numbered' },
{ file: 'content/04-conclusion.md', type: 'structural', title: 'Заключение' },
{ file: 'content/05-bibliography.md', type: 'bibliography' },
{
file: 'content/06-appendix-a.md',
type: 'appendix',
letter: 'А',
title: 'Сводные таблицы',
},
{
file: 'content/07-appendix-b.md',
type: 'appendix',
letter: 'Б',
title: 'Вспомогательные материалы',
},
],
};
module.exports = { DEFAULT_CONFIG };

53
src/config/load.js Normal file
View File

@@ -0,0 +1,53 @@
'use strict';
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { DEFAULT_CONFIG } = require('./defaults');
const { deepMerge } = require('../util/deepmerge');
const { ConfigError } = require('../util/errors');
function resolveAgainst(baseDir, value) {
if (value === null || value === undefined || value === '') return null;
return path.resolve(baseDir, value);
}
/** Все относительные пути в конфиге считаются от каталога самого config.yaml. */
function resolvePaths(config, baseDir) {
config.document.output = resolveAgainst(baseDir, config.document.output);
config.document.titlePage = resolveAgainst(baseDir, config.document.titlePage);
config.paths.assets = resolveAgainst(baseDir, config.paths.assets) || baseDir;
config.sections = (config.sections || []).map((entry) => {
if (!entry || typeof entry !== 'object') return entry;
if (entry.type === 'toc' || !entry.file) return { ...entry };
return { ...entry, file: path.resolve(baseDir, entry.file) };
});
}
function loadConfig(configPath) {
const abs = path.resolve(configPath);
if (!fs.existsSync(abs)) {
throw new ConfigError(`Файл конфигурации не найден: ${abs}`);
}
let parsed;
try {
parsed = yaml.load(fs.readFileSync(abs, 'utf8'));
} catch (err) {
throw new ConfigError(`Ошибка разбора YAML (${abs}): ${err.message}`);
}
if (parsed === null || parsed === undefined) parsed = {};
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new ConfigError(`Ожидался объект в корне config.yaml: ${abs}`);
}
const baseDir = path.dirname(abs);
const config = deepMerge(DEFAULT_CONFIG, parsed);
resolvePaths(config, baseDir);
return { config, baseDir, configPath: abs };
}
module.exports = { loadConfig, resolvePaths, resolveAgainst };

405
src/config/template.js Normal file
View File

@@ -0,0 +1,405 @@
'use strict';
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { DEFAULT_CONFIG } = require('./defaults');
const { diagramPng, chartPng } = require('../util/png');
const { buildSampleTitlePage } = require('../render/sample-titlepage');
const CONFIG_HEADER = `# Конфигурация генератора lab-gen.
# Все относительные пути считаются от каталога этого файла.
# Любой ключ можно опустить — будет использовано значение по умолчанию.
`;
function defaultConfigYaml() {
return (
CONFIG_HEADER +
yaml.dump(DEFAULT_CONFIG, { noRefs: true, lineWidth: -1, sortKeys: false })
);
}
const SAMPLE = {
'content/01-introduction.md': `Целью работы является разработка информационной системы учёта
заявок для отдела технической поддержки. Актуальность темы обусловлена ростом числа обращений и
необходимостью их оперативной обработки [@1].
Для достижения поставленной цели необходимо решить следующие задачи:
- проанализировать предметную область и существующие решения;
- спроектировать архитектуру информационной системы;
- определить состав данных и пользовательских сценариев;
- рассчитать показатели эффективности разработанного решения.
При выполнении работы использованы методы системного анализа, объектного моделирования и
статистической обработки данных [@2]. Требования к оформлению приняты в соответствии с
действующими стандартами [@3], [@4].
`,
'content/02-analysis.md': `# АНАЛИЗ ПРЕДМЕТНОЙ ОБЛАСТИ
Раздел посвящён анализу процесса обработки обращений и обоснованию требований к информационной
системе. Рассматриваются участники процесса, информационные потоки и ограничения предметной
области.
## Обзор процесса обработки обращений
Процесс обработки заявки включает регистрацию, классификацию, назначение исполнителя и закрытие.
Схема процесса приведена на рисунке @fig:architecture.
::: figure "Схема процесса обработки обращений" id=architecture file=images/architecture.png
а) обозначение информационного потока;
б) блоки этапов обработки.
:::
Среднее время обработки одной заявки до внедрения системы составляло 46 минут. Распределение
обращений по категориям представлено в таблице @tbl:categories.
::: table "Распределение обращений по категориям" id=categories
| Категория обращения | Количество, шт. | Доля, % |
|---|---|---|
| Консультация | 420 | 28,0 |
| Настройка оборудования | 315 | 21,0 |
| Восстановление доступа | 270 | 18,0 |
| Программная ошибка | 210 | 14,0 |
| Установка программного обеспечения | 135 | 9,0 |
| Сетевые подключения | 90 | 6,0 |
| Прочие обращения | 60 | 4,0 |
| Итого | 1500 | 100,0 |
:::
Структура обращений по приоритетам приведена в таблице @tbl:priorities.
::: table "Обращения по приоритетам" id=priorities
| Приоритет | Количество, шт. | Норматив реакции, мин |
|---|---|---|
| Критический | 75 | 15 |
| Высокий | 225 | 30 |
| Средний | 600 | 60 |
| Низкий | 600 | 120 |
| Итого | 1500 | — |
:::
## Показатели эффективности
Для оценки эффективности применяется коэффициент снижения трудозатрат, рассчитываемый по
формуле @eq:efficiency.
::: formula id=efficiency
K = (T_base - T_new)/T_base
:::
::: where
K — коэффициент снижения трудозатрат;
T_base — среднее время обработки заявки до внедрения, мин;
T_new — среднее время обработки заявки после внедрения, мин.
:::
Значения исходных данных для расчёта приведены в таблице @tbl:baseline.
::: table "Исходные данные для расчёта эффективности" id=baseline
| Показатель | Обозначение | Значение |
|---|---|---|
| Среднее время до внедрения, мин | T_base | 46 |
| Среднее время после внедрения, мин | T_new | 18 |
| Число обращений в месяц, шт. | N | 125 |
| Стоимость часа работы, руб. | C | 420 |
| Число рабочих часов в месяце, ч | H | 164 |
| Коэффициент накладных расходов | k | 0,35 |
| Годовой фонд рабочего времени, ч | F | 1970 |
| Нормативный срок окупаемости, лет | T_ok | 3 |
| Доля автоматизируемых операций | d | 0,72 |
| Коэффициент потерь рабочего времени | p | 0,08 |
:::
## Обзор существующих решений
Сравнительная характеристика существующих решений приведена в таблице @tbl:comparison. Анализ
выполнен на основе открытых источников [@5], [@6].
::: table "Сравнение существующих решений" id=comparison
| Критерий | Решение A | Решение B | Решение C | Разрабатываемое решение |
|---|---|---|---|---|
| Стоимость лицензии | Высокая | Средняя | Низкая | Низкая |
| Открытый исходный код | Нет | Да | Да | Да |
| Русскоязычный интерфейс | Да | Нет | Да | Да |
| Интеграция по API | Ограниченная | Полная | Частичная | Полная |
| Модуль отчётности | Да | Нет | Да | Да |
| Развёртывание локально | Только облако | Да | Да | Да |
| Поддержка ОС | Windows | Linux | Кроссплатформенная | Кроссплатформенная |
| Стоимость владения | Высокая | Низкая | Средняя | Низкая |
| Срок внедрения, мес | 6 | 4 | 3 | 2 |
| Техническая поддержка | Платная | Сообщество | Платная | Внутренняя |
:::
::: note
Выбор решения выполнен по совокупности функциональных и экономических критериев.
:::
## Термины и определения
::: definitions
Заявка — обращение пользователя, зарегистрированное в информационной системе;
Исполнитель — сотрудник, назначенный ответственным за обработку заявки;
Регламент — документ, определяющий порядок и сроки обработки обращений.
:::
`,
'content/03-design.md': `# ПРОЕКТИРОВАНИЕ ИНФОРМАЦИОННОЙ СИСТЕМЫ
В разделе описаны архитектура, модель данных и пользовательские сценарии разрабатываемой
информационной системы.
## Архитектура системы
Система построена по трёхуровневой архитектуре. Взаимодействие компонентов показано на
рисунке @fig:data-flow.
::: figure "Схема информационных потоков" id=data-flow file=images/data-flow.png
а) направление передачи данных;
б) компоненты системы.
:::
Состав программных модулей приведён в таблице @tbl:modules.
::: table "Состав программных модулей" id=modules
| Модуль | Назначение | Технология |
|---|---|---|
| Веб-интерфейс | Отображение рабочих экранов оператора | React |
| API-шлюз | Обработка HTTP-запросов клиентов | Node.js |
| Модуль заявок | Жизненный цикл обращения | Node.js |
| Модуль уведомлений | Рассылка оповещений исполнителям | Node.js |
| Модуль отчётности | Формирование отчётов за период | Node.js |
| Служба аутентификации | Проверка учётных данных | JWT |
| Хранилище данных | Хранение заявок и справочников | PostgreSQL |
| Кэш | Ускорение чтения справочников | Redis |
| Очередь сообщений | Асинхронная обработка событий | RabbitMQ |
| Сборщик журналов | Централизованное логирование | ELK |
| Планировщик | Периодические задачи и отчёты | Cron |
| Модуль интеграции | Обмен данными с внешними системами | REST |
:::
## Модель данных
Основные сущности предметной области и их атрибуты приведены в таблице @tbl:entities.
::: table "Основные сущности и атрибуты" id=entities
| Сущность | Атрибут | Тип | Обяз. | Описание |
|---|---|---|---|---|
| Заявка | id | Целое | Да | Уникальный идентификатор |
| Заявка | subject | Строка | Да | Тема обращения |
| Заявка | description | Текст | Да | Описание проблемы |
| Заявка | category_id | Целое | Да | Ссылка на категорию |
| Заявка | priority_id | Целое | Да | Ссылка на приоритет |
| Заявка | assignee_id | Целое | Нет | Ссылка на исполнителя |
| Заявка | created_at | Дата/время | Да | Момент регистрации |
| Заявка | closed_at | Дата/время | Нет | Момент закрытия |
| Категория | id | Целое | Да | Уникальный идентификатор |
| Категория | name | Строка | Да | Наименование категории |
| Приоритет | id | Целое | Да | Уникальный идентификатор |
| Приоритет | name | Строка | Да | Наименование приоритета |
| Приоритет | reaction_min | Целое | Да | Норматив реакции, мин |
| Пользователь | id | Целое | Да | Уникальный идентификатор |
| Пользователь | full_name | Строка | Да | Фамилия, имя, отчество |
:::
Связи между сущностями описаны в таблице @tbl:relations.
::: table "Связи между сущностями" id=relations
| Связь | Тип | Описание |
|---|---|---|
| Заявка — Категория | Многие к одному | Заявка относится к одной категории |
| Заявка — Приоритет | Многие к одному | Заявка имеет один приоритет |
| Заявка — Пользователь | Многие к одному | Заявка назначена одному исполнителю |
| Заявка — Комментарий | Один ко многим | Заявка содержит комментарии |
| Пользователь — Роль | Многие ко многим | Пользователь может иметь роли |
| Категория — Подкатегория | Один ко многим | Категория содержит подкатегории |
:::
## Пользовательские сценарии
Интерфейс списка заявок приведён на рисунке @fig:interface.
::: figure "Интерфейс списка заявок" id=interface file=images/interface.png
а) область навигации;
б) табличное представление данных.
:::
Зависимость трудозатрат от выбранной стратегии обработки показана на рисунке @fig:chart.
::: figure "Зависимость трудозатрат от стратегии обработки" id=chart file=images/chart.png
Оценка получена по результатам пилотной эксплуатации.
:::
Экономия времени за месяц вычисляется по формуле @eq:gain.
::: formula id=gain
G = (T_base - T_new)*N
:::
::: where
G — экономия времени за месяц, мин;
N — число обращений в месяц.
:::
К системе предъявляются следующие требования:
- функциональные:
1. регистрация и классификация заявок;
2. назначение исполнителя и контроль сроков;
3. формирование отчётов за период.
- нефункциональные:
1. время отклика не более двух секунд;
2. одновременная работа не менее ста пользователей.
## Особенности реализации
При проектировании учтены ограничения, перечисленные в таблице @tbl:constraints.
::: table "Ограничения и меры" id=constraints
| Ограничение | Влияние | Мера |
|---|---|---|
| Ограниченный бюджет | Сужение выбора технологий | Применение открытых библиотек |
| Сжатые сроки | Риск неполной реализации | Итеративная разработка |
| Нагрузка до 100 пользователей | Требования к производительности | Кэширование и индексы |
| Устаревшее оборудование | Ограничение ресурсов | Облегчённый веб-интерфейс |
| Разрозненные данные | Сложность интеграции | Единая модель данных |
| Требования безопасности | Дополнительные проверки | Аутентификация и журналирование |
:::
::: notes
Оценка выполнена для среднего месяца пилотной эксплуатации.
Отклонение фактических значений не превышает десяти процентов.
:::
`,
'content/04-conclusion.md': `В ходе работы проанализирована предметная область, спроектирована архитектура информационной
системы и определены показатели эффективности [@6], [@7].
Основные результаты работы:
- выполнена классификация обращений и определены их приоритеты;
- разработана модель данных и описаны пользовательские сценарии;
- рассчитан коэффициент снижения трудозатрат.
Применение информационной системы позволяет сократить среднее время обработки заявки с 46 до
18 минут, что подтверждает достижение поставленной цели. Единицы измерений приведены в
соответствии с действующим стандартом [@8].
`,
'content/05-bibliography.md': `- ГОСТ 7.32-2017. Система стандартов по информации, библиотечному и издательскому делу. Отчёт о научно-исследовательской работе. Структура и правила оформления. — М. : Стандартинформ, 2017. — 32 с.
- ГОСТ Р 7.0.5-2008. Библиографическая ссылка. Общие требования и правила составления. — М. : Стандартинформ, 2008. — 23 с.
- Иванов, И. И. Управление ИТ-сервисами : учебное пособие / И. И. Иванов. — Самара : Изд-во Самарского университета, 2019. — 184 с.
- Петров, П. П. Обзор систем Service Desk / П. П. Петров // Информационные технологии. — 2020. — № 4. — С. 25–33.
- Смирнова, А. А. Практика внедрения ITSM-решений / А. А. Смирнова. — М. : ДМК Пресс, 2021. — 256 с.
- Сидоров, С. С. Оценка эффективности информационных систем / С. С. Сидоров // Вестник информатики. — 2018. — Т. 12, № 2. — С. 45–52.
- Открытые ITSM-системы [Электронный ресурс]. — URL: https://example.org/itsm (дата обращения: 01.02.2026).
- ГОСТ 8.417-2002. Государственная система обеспечения единства измерений. Единицы величин. — М. : Стандартинформ, 2002. — 32 с.
`,
'content/06-appendix-a.md': `Сводные данные по обращениям за отчётный период приведены в таблице @tbl:app-a1.
::: table "Сводные данные по обращениям" id=app-a1
| Номер | Дата регистрации | Категория | Приоритет | Исполнитель | Статус | Время, мин |
|---|---|---|---|---|---|---|
| 1001 | 03.02.2026 | Консультация | Низкий | Орлова А. А. | Закрыта | 12 |
| 1002 | 03.02.2026 | Восстановление доступа | Высокий | Крылов И. И. | Закрыта | 24 |
| 1003 | 04.02.2026 | Программная ошибка | Критический | Никитин П. П. | Закрыта | 41 |
| 1004 | 04.02.2026 | Настройка оборудования | Средний | Орлова А. А. | Закрыта | 33 |
| 1005 | 05.02.2026 | Установка ПО | Низкий | Гусев Д. Д. | Закрыта | 18 |
| 1006 | 05.02.2026 | Сетевые подключения | Высокий | Крылов И. И. | Закрыта | 27 |
| 1007 | 06.02.2026 | Консультация | Низкий | Гусев Д. Д. | Закрыта | 9 |
| 1008 | 06.02.2026 | Программная ошибка | Критический | Никитин П. П. | Закрыта | 38 |
| 1009 | 07.02.2026 | Восстановление доступа | Средний | Орлова А. А. | Закрыта | 21 |
| 1010 | 07.02.2026 | Прочие обращения | Низкий | Гусев Д. Д. | Закрыта | 15 |
| 1011 | 10.02.2026 | Настройка оборудования | Высокий | Крылов И. И. | Закрыта | 30 |
| 1012 | 10.02.2026 | Консультация | Низкий | Орлова А. А. | Закрыта | 11 |
| 1013 | 11.02.2026 | Программная ошибка | Средний | Никитин П. П. | Закрыта | 35 |
| 1014 | 11.02.2026 | Сетевые подключения | Критический | Крылов И. И. | Закрыта | 44 |
| 1015 | 12.02.2026 | Установка ПО | Низкий | Гусев Д. Д. | Закрыта | 19 |
| 1016 | 12.02.2026 | Восстановление доступа | Высокий | Орлова А. А. | Закрыта | 26 |
| 1017 | 13.02.2026 | Консультация | Низкий | Гусев Д. Д. | Закрыта | 10 |
| 1018 | 13.02.2026 | Прочие обращения | Средний | Никитин П. П. | Закрыта | 29 |
| 1019 | 14.02.2026 | Настройка оборудования | Высокий | Крылов И. И. | Закрыта | 32 |
| 1020 | 14.02.2026 | Консультация | Низкий | Орлова А. А. | Закрыта | 13 |
:::
Детализация по исполнителям приведена в таблице @tbl:app-a2.
::: table "Детализация по исполнителям" id=app-a2
| Исполнитель | Отдел | Закрыто заявок | Среднее время, мин | Доля, % |
|---|---|---|---|---|
| Орлова А. А. | Первая линия | 164 | 15 | 32,8 |
| Крылов И. И. | Вторая линия | 138 | 27 | 27,6 |
| Никитин П. П. | Вторая линия | 112 | 38 | 22,4 |
| Гусев Д. Д. | Первая линия | 86 | 14 | 17,2 |
| Итого | — | 500 | 23 | 100,0 |
:::
`,
'content/07-appendix-b.md': `Схема размещения компонентов системы приведена на рисунке @fig:app-b1.
::: figure "Схема размещения компонентов" id=app-b1 file=images/appendix-scheme.png
а) узлы развёртывания;
б) связи между узлами.
:::
Параметры конфигурации стенда приведены в таблице @tbl:app-b1.
::: table "Параметры конфигурации стенда" id=app-b1
| Компонент | Процессор | Память, ГБ | Диск, ГБ | Назначение |
|---|---|---|---|---|
| Сервер приложений | 4 ядра | 8 | 40 | Выполнение API и логики |
| Сервер БД | 8 ядер | 16 | 200 | Хранение данных |
| Сервер кэша | 2 ядра | 4 | 20 | Кэширование справочников |
| Сервер очередей | 2 ядра | 4 | 20 | Асинхронные сообщения |
| Узел журналирования | 2 ядра | 8 | 100 | Сбор и хранение логов |
:::
`,
};
const IMAGES = {
'content/assets/images/architecture.png': () => diagramPng(900, 420, [46, 94, 170]),
'content/assets/images/data-flow.png': () => diagramPng(900, 380, [28, 140, 96]),
'content/assets/images/interface.png': () => diagramPng(820, 480, [196, 120, 32]),
'content/assets/images/chart.png': () => chartPng(820, 420, [122, 70, 190]),
'content/assets/images/appendix-scheme.png': () => diagramPng(900, 520, [176, 48, 64]),
};
function scaffoldFiles() {
const files = { 'config.yaml': defaultConfigYaml() };
for (const [name, content] of Object.entries(SAMPLE)) files[name] = content;
for (const [name, make] of Object.entries(IMAGES)) {
files[name] = { binary: make() };
}
return files;
}
/** Создаёт config.yaml, примеры content/ и ассеты, если их ещё нет. */
async function ensureScaffold(baseDir) {
const files = scaffoldFiles();
const created = [];
for (const [name, content] of Object.entries(files)) {
const target = path.join(baseDir, name);
if (fs.existsSync(target)) continue;
fs.mkdirSync(path.dirname(target), { recursive: true });
if (content && typeof content === 'object' && content.binary) {
fs.writeFileSync(target, content.binary);
} else {
fs.writeFileSync(target, content, 'utf8');
}
created.push(target);
}
const titlePage = path.join(baseDir, 'content', 'assets', 'title-page.docx');
if (!fs.existsSync(titlePage)) {
fs.mkdirSync(path.dirname(titlePage), { recursive: true });
fs.writeFileSync(titlePage, await buildSampleTitlePage());
created.push(titlePage);
}
return created;
}
module.exports = { defaultConfigYaml, scaffoldFiles, ensureScaffold, SAMPLE };

95
src/config/validate.js Normal file
View File

@@ -0,0 +1,95 @@
'use strict';
const fs = require('fs');
const SECTION_TYPES = ['toc', 'structural', 'numbered', 'bibliography', 'appendix'];
const NUMBERING_SCOPES = ['global', 'section'];
function isPositiveInt(value) {
return Number.isInteger(value) && value > 0;
}
function validateConfig(config) {
const errors = [];
const { document: doc, typography, page, toc, appendix, numbering } = config;
if (!Array.isArray(config.sections) || config.sections.length === 0) {
errors.push('sections: должен быть непустой список частей документа');
} else {
const letters = new Set();
config.sections.forEach((entry, index) => {
const where = `sections[${index}]`;
if (!entry || typeof entry !== 'object') {
errors.push(`${where}: ожидался объект с полем type`);
return;
}
if (!SECTION_TYPES.includes(entry.type)) {
errors.push(
`${where}: неизвестный type "${entry.type}" (допустимо: ${SECTION_TYPES.join(', ')})`
);
return;
}
if (entry.type === 'toc') {
if (entry.file) errors.push(`${where}: часть type=toc не должна иметь file`);
return;
}
if (!entry.file) {
errors.push(`${where}: отсутствует file`);
} else if (!fs.existsSync(entry.file)) {
errors.push(`${where}: файл не найден: ${entry.file}`);
}
if (entry.type === 'structural' && !entry.title) {
errors.push(`${where}: для structural требуется title`);
}
if (entry.type === 'appendix') {
if (!entry.letter || String(entry.letter).length !== 1) {
errors.push(`${where}: для appendix требуется одиночная буква в letter`);
} else {
const letter = String(entry.letter).toUpperCase();
if (appendix.forbiddenLetters.map((l) => l.toUpperCase()).includes(letter)) {
errors.push(`${where}: буква приложения "${letter}" запрещена СТО`);
}
if (letters.has(letter)) errors.push(`${where}: повтор буквы приложения "${letter}"`);
letters.add(letter);
}
}
});
}
if (doc.titlePage && !fs.existsSync(doc.titlePage)) {
errors.push(`document.titlePage: файл не найден: ${doc.titlePage}`);
}
for (const key of ['sizeBody', 'sizeTable', 'sizeSmall']) {
if (!isPositiveInt(typography[key])) {
errors.push(`typography.${key}: ожидалось положительное целое (half-points)`);
}
}
if (!(typeof typography.lineSpacing === 'number' && typography.lineSpacing > 0)) {
errors.push('typography.lineSpacing: ожидалось положительное число');
}
for (const [key, value] of Object.entries(page.margins)) {
if (!Number.isInteger(value) || value < 0) {
errors.push(`page.margins.${key}: ожидалось неотрицательное целое (twips)`);
}
}
if (!isPositiveInt(toc.depth) || toc.depth > 3) {
errors.push('toc.depth: ожидалось 1..3');
}
if (typeof toc.enabled !== 'boolean') {
errors.push('toc.enabled: ожидалось true/false');
}
for (const key of ['figures', 'tables', 'formulas']) {
if (!NUMBERING_SCOPES.includes(numbering[key])) {
errors.push(`numbering.${key}: допустимо ${NUMBERING_SCOPES.join(' | ')}`);
}
}
return errors;
}
module.exports = { validateConfig, SECTION_TYPES };

3
src/index.js Normal file
View File

@@ -0,0 +1,3 @@
'use strict';
module.exports = { ...require('./build'), ...require('./config/load') };

103
src/markdown/container.js Normal file
View File

@@ -0,0 +1,103 @@
'use strict';
const NAME_RE = /^([A-Za-z][\w-]*)/;
/** Разбирает строку после имени директивы на позиционный title и key=value. */
function parseDirectiveAttrs(text) {
const attrs = {};
let positionalIndex = 0;
let i = 0;
const n = text.length;
const readValue = () => {
if (text[i] === '"' || text[i] === "'") {
const quote = text[i];
i += 1;
const start = i;
while (i < n && text[i] !== quote) i += 1;
const value = text.slice(start, i);
i += 1;
return value;
}
const start = i;
while (i < n && !/\s/.test(text[i])) i += 1;
return text.slice(start, i);
};
while (i < n) {
while (i < n && /\s/.test(text[i])) i += 1;
if (i >= n) break;
let token;
if (text[i] === '"' || text[i] === "'") {
token = readValue();
} else {
const start = i;
while (i < n && !/\s/.test(text[i]) && text[i] !== '=') i += 1;
token = text.slice(start, i);
}
if (text[i] === '=') {
i += 1;
const value = readValue();
if (token) attrs[token] = value;
} else if (token.length) {
if (positionalIndex === 0) attrs.title = token;
positionalIndex += 1;
}
}
return attrs;
}
function parseFirstLine(line) {
const trimmed = line.replace(/^:::\s*/, '');
const nameMatch = trimmed.match(NAME_RE);
if (!nameMatch) return null;
const name = nameMatch[1].toLowerCase();
const rest = trimmed.slice(nameMatch[0].length);
return { name, attrs: parseDirectiveAttrs(rest) };
}
function isClosing(line) {
return /^:::\s*$/.test(line);
}
function directiveRule(state, startLine, endLine, silent) {
const lineStart = state.bMarks[startLine] + state.tShift[startLine];
const lineEnd = state.eMarks[startLine];
const firstLine = state.src.slice(lineStart, lineEnd);
if (!/^:::\s*[A-Za-z]/.test(firstLine)) return false;
if (silent) return true;
const parsed = parseFirstLine(firstLine);
if (!parsed) return false;
const bodyLines = [];
let nextLine = startLine + 1;
for (; nextLine < endLine; nextLine++) {
const s = state.bMarks[nextLine] + state.tShift[nextLine];
const e = state.eMarks[nextLine];
const text = state.src.slice(s, e);
if (isClosing(text.trim())) break;
bodyLines.push(text);
}
const hasClosing = nextLine < endLine;
const body = bodyLines.join('\n');
const token = state.push('directive', 'div', 0);
token.info = { name: parsed.name, attrs: parsed.attrs, body };
token.block = true;
token.map = [startLine, hasClosing ? nextLine + 1 : nextLine];
state.line = hasClosing ? nextLine + 1 : nextLine;
return true;
}
function containerPlugin(md) {
md.block.ruler.before('fence', 'directive', directiveRule, {
alt: ['paragraph', 'blockquote', 'list'],
});
}
module.exports = { containerPlugin, parseDirectiveAttrs, parseFirstLine };

37
src/markdown/inline.js Normal file
View File

@@ -0,0 +1,37 @@
'use strict';
const CITATION_RE = /^\[@(\d+)(?:\s*,\s*с\.\s*([^\]]+))?\]/;
const XREF_RE = /^@(fig|tbl|eq):([A-Za-z0-9_-]+)/;
function citationRule(state, silent) {
if (state.src[state.pos] !== '[' || state.src[state.pos + 1] !== '@') return false;
const match = state.src.slice(state.pos).match(CITATION_RE);
if (!match) return false;
if (!silent) {
const token = state.push('citation', '', 0);
token.content = match[0];
token.meta = { n: Number(match[1]), page: match[2] || null };
}
state.pos += match[0].length;
return true;
}
function xrefRule(state, silent) {
if (state.src[state.pos] !== '@') return false;
const match = state.src.slice(state.pos).match(XREF_RE);
if (!match) return false;
if (!silent) {
const token = state.push('xref', '', 0);
token.content = match[0];
token.meta = { kind: match[1], label: match[2] };
}
state.pos += match[0].length;
return true;
}
function inlinePlugin(md) {
md.inline.ruler.before('link', 'citation', citationRule);
md.inline.ruler.before('link', 'xref', xrefRule);
}
module.exports = { inlinePlugin, CITATION_RE, XREF_RE };

47
src/markdown/parse.js Normal file
View File

@@ -0,0 +1,47 @@
'use strict';
const fs = require('fs');
const MarkdownIt = require('markdown-it');
const yaml = require('js-yaml');
const { containerPlugin } = require('./container');
const { inlinePlugin } = require('./inline');
function createMarkdown() {
const md = new MarkdownIt({
html: false,
linkify: false,
typographer: false,
breaks: false,
});
md.use(containerPlugin);
md.use(inlinePlugin);
return md;
}
/** Необязательный YAML front matter в начале файла. */
function splitFrontMatter(text) {
const normalized = text.replace(/^\uFEFF/, '');
const match = normalized.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!match) return { meta: {}, body: normalized };
let meta = {};
try {
meta = yaml.load(match[1]) || {};
} catch {
meta = {};
}
return { meta, body: normalized.slice(match[0].length) };
}
function parseMarkdown(md, text, env = {}) {
return md.parse(text, env);
}
function parseFile(md, filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
const { meta, body } = splitFrontMatter(raw);
const tokens = md.parse(body, {});
return { meta, body, tokens };
}
module.exports = { createMarkdown, parseMarkdown, parseFile, splitFrontMatter };

452
src/render/document.js Normal file
View File

@@ -0,0 +1,452 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { Document, ExternalHyperlink, TextRun } = require('docx');
const {
pageBreak,
paraRuns,
textRun,
sectionHeading,
subsectionHeading,
plainHeading,
pointParagraph,
structSection,
appendixStart,
tocSection,
figureBlock,
tableSto,
formulaFromText,
whereBlock,
noteSingle,
notesBlock,
definitionsBlock,
bibliographyList,
listParagraphRuns,
nextListInstance,
resetListInstances,
defaultSectionProps,
pageNumberFooter,
REFS,
} = require('./primitives');
function createState(md, sections) {
return {
md,
sections,
labelMap: new Map(),
biblioCount: 0,
errors: [],
warnings: [],
};
}
function firstInlineText(tokens) {
for (const token of tokens) {
if (token.type === 'inline' && String(token.content || '').trim()) {
return token.content.trim();
}
}
return '';
}
// ── inline ────────────────────────────────────────────────────────
function citationRun(ctx, meta, state) {
const { bracketOpen, bracketClose, pagePrefix } = ctx.config.citation;
if (meta.n < 1 || (state.biblioCount > 0 && meta.n > state.biblioCount)) {
state.errors.push(
`Ссылка [@${meta.n}] выходит за пределы списка источников (${state.biblioCount})`
);
return textRun(ctx, `[@${meta.n}]`);
}
const text = meta.page
? `${bracketOpen}${meta.n}, ${pagePrefix} ${meta.page}${bracketClose}`
: `${bracketOpen}${meta.n}${bracketClose}`;
return textRun(ctx, text);
}
function xrefRun(ctx, meta, state) {
const key = `${meta.kind}:${meta.label}`;
const value = state.labelMap.get(key);
if (value == null) {
state.errors.push(`Не найдена метка @${key}`);
return textRun(ctx, `@${key}`);
}
return textRun(ctx, String(value));
}
function renderInline(ctx, inlineToken, state) {
const runs = [];
const flags = { bold: false, italics: false };
const linkStack = [];
if (!inlineToken || !inlineToken.children) return runs;
const push = (run) => {
if (linkStack.length) linkStack[linkStack.length - 1].children.push(run);
else runs.push(run);
};
const closeLink = () => {
const link = linkStack.pop();
const hyper = new ExternalHyperlink({ children: link.children, link: link.href });
if (linkStack.length) linkStack[linkStack.length - 1].children.push(hyper);
else runs.push(hyper);
};
for (const child of inlineToken.children) {
switch (child.type) {
case 'text':
push(textRun(ctx, child.content, flags));
break;
case 'softbreak':
push(textRun(ctx, ' '));
break;
case 'hardbreak':
push(new TextRun({ break: 1, font: ctx.typography.font, size: ctx.typography.sizeBody }));
break;
case 'strong_open':
flags.bold = true;
break;
case 'strong_close':
flags.bold = false;
break;
case 'em_open':
flags.italics = true;
break;
case 'em_close':
flags.italics = false;
break;
case 's_open':
case 's_close':
break;
case 'code_inline':
push(textRun(ctx, child.content, { size: ctx.typography.sizeSmall }));
break;
case 'link_open':
linkStack.push({ href: child.attrGet('href') || '', children: [] });
break;
case 'link_close':
if (linkStack.length) closeLink();
break;
case 'citation':
push(citationRun(ctx, child.meta, state));
break;
case 'xref':
push(xrefRun(ctx, child.meta, state));
break;
case 'image':
state.warnings.push(
'Инлайн-изображение пропущено; используйте директиву ::: figure'
);
break;
default:
break;
}
}
while (linkStack.length) closeLink();
return runs;
}
// ── списки ────────────────────────────────────────────────────────
function renderList(ctx, tokens, startIndex, depth, listCtx, state) {
const open = tokens[startIndex];
const ordered = open.type === 'ordered_list_open';
const current = depth === 0 ? { ref: ordered ? REFS.number : REFS.dash } : listCtx;
if (depth === 0) current.instance = nextListInstance(current.ref);
const elements = [];
const level = Math.min(depth, 3);
let i = startIndex + 1;
const closeType = ordered ? 'ordered_list_close' : 'bullet_list_close';
while (i < tokens.length && tokens[i].type !== closeType) {
if (tokens[i].type !== 'list_item_open') {
i += 1;
continue;
}
i += 1;
let firstParagraph = true;
while (i < tokens.length && tokens[i].type !== 'list_item_close') {
const token = tokens[i];
if (token.type === 'paragraph_open') {
const runs = renderInline(ctx, tokens[i + 1], state);
if (firstParagraph) {
elements.push(listParagraphRuns(ctx, runs, current.ref, level, current.instance));
firstParagraph = false;
} else {
elements.push(
paraRuns(ctx, runs, {
extra: {
indent: {
left: ctx.config.lists.nestedLevelStep * (depth + 1),
firstLine: ctx.typography.indentFirst,
},
},
})
);
}
i += 3;
} else if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
const nested = renderList(ctx, tokens, i, depth + 1, current, state);
elements.push(...nested.elements);
i = nested.next;
} else if (token.type === 'directive') {
elements.push(...renderDirective(ctx, token, state));
i += 1;
} else if (token.type === 'table_open') {
const table = readTable(tokens, i);
elements.push(...tableSto(ctx, { label: '', title: '', rows: table.rows }));
i = table.next;
} else {
i += 1;
}
}
i += 1;
}
return { elements, next: i + 1 };
}
// ── таблицы из markdown ───────────────────────────────────────────
function readTable(tokens, startIndex) {
const rows = [];
let current = null;
let i = startIndex + 1;
for (; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === 'tr_open') {
current = [];
} else if (token.type === 'tr_close') {
if (current) rows.push(current);
current = null;
} else if (token.type === 'inline') {
if (current) current.push(token.content);
} else if (token.type === 'table_close') {
i += 1;
break;
}
}
return { rows, next: i };
}
// ── директивы ─────────────────────────────────────────────────────
function parseWhereBody(body) {
return body
.split(/\r?\n/)
.map((line) => line.replace(/^\s*(?:[-*]|\d+[.)])\s*/, '').trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+[—–-]\s+/);
if (parts.length < 2) return { symbol: line, desc: '' };
return { symbol: parts[0].trim(), desc: parts.slice(1).join(' — ').trim() };
});
}
function splitNoteItems(body) {
return body
.split(/\r?\n/)
.map((line) => line.replace(/^\s*(?:[-*]|\d+[.)])\s*/, '').trim())
.filter(Boolean);
}
function parseDefinitions(body) {
return parseWhereBody(body).map(({ symbol, desc }) => ({ term: symbol, definition: desc }));
}
function findTableRows(md, body) {
const tokens = md.parse(body, {});
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].type === 'table_open') return readTable(tokens, i).rows;
}
return null;
}
function renderDirective(ctx, token, state) {
const { name, attrs, body } = token.info;
switch (name) {
case 'pagebreak':
return [pageBreak()];
case 'figure': {
const cap = token.__caption || { label: '?' };
const notes = body
? body.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
: [];
if (attrs.file) {
const resolved = path.isAbsolute(attrs.file)
? attrs.file
: path.resolve(ctx.assetsRoot, attrs.file);
if (!fs.existsSync(resolved)) {
state.warnings.push(`Рисунок "${attrs.title || ''}": файл не найден: ${attrs.file}`);
}
}
return figureBlock(ctx, {
label: cap.label,
title: attrs.title || '',
imagePath: attrs.file,
notesLines: notes,
});
}
case 'table': {
const cap = token.__caption || { label: '?' };
const rows = findTableRows(state.md, body);
if (!rows) {
state.errors.push(
`Директива ::: table "${attrs.title || ''}": не найдена markdown-таблица`
);
return [];
}
return tableSto(ctx, {
label: cap.label,
title: attrs.title || '',
rows,
note: attrs.note,
});
}
case 'formula': {
const cap = token.__caption || { label: '?' };
return formulaFromText(ctx, body.trim(), cap.label);
}
case 'where':
return whereBlock(ctx, parseWhereBody(body));
case 'note':
return [noteSingle(ctx, body.trim().replace(/\s*\n\s*/g, ' '))];
case 'notes':
return notesBlock(ctx, splitNoteItems(body));
case 'definitions':
return definitionsBlock(ctx, parseDefinitions(body));
default:
state.warnings.push(`Неизвестная директива ::: ${name}`);
return [];
}
}
// ── общий обход токенов ───────────────────────────────────────────
function renderTokens(ctx, tokens, state, opts = {}) {
const mode = opts.mode || 'plain';
const out = [];
let firstH1 = true;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
switch (token.type) {
case 'heading_open': {
const level = Number(token.tag.slice(1));
const text = tokens[i + 1] ? tokens[i + 1].content : '';
if (mode === 'numbered') {
if (level <= 1) {
if (!(firstH1 && opts.suppressFirstPageBreak)) out.push(pageBreak());
firstH1 = false;
out.push(sectionHeading(ctx, text));
} else if (level === 2) {
out.push(subsectionHeading(ctx, text));
} else {
out.push(pointParagraph(ctx, text));
}
} else {
out.push(plainHeading(ctx, text, level));
}
i += 2;
break;
}
case 'paragraph_open':
out.push(paraRuns(ctx, renderInline(ctx, tokens[i + 1], state)));
i += 2;
break;
case 'directive':
out.push(...renderDirective(ctx, token, state));
break;
case 'bullet_list_open':
case 'ordered_list_open': {
const res = renderList(ctx, tokens, i, 0, null, state);
out.push(...res.elements);
i = res.next - 1;
break;
}
case 'table_open': {
const res = readTable(tokens, i);
out.push(...tableSto(ctx, { label: '', title: '', rows: res.rows }));
i = res.next - 1;
break;
}
case 'fence':
state.warnings.push('Блок кода проигнорирован (функция листингов удалена)');
break;
case 'hr':
out.push(pageBreak());
break;
default:
break;
}
}
return out;
}
function renderSections(ctx, state) {
resetListInstances();
const out = [];
let first = true;
for (const section of state.sections) {
const entry = section.entry;
if (entry.type === 'toc') {
if (!ctx.config.toc.enabled) continue;
out.push(...tocSection(ctx, [], { isFirstInDocument: first }));
first = false;
} else if (entry.type === 'structural') {
const title = entry.title || section.meta.title || firstInlineText(section.tokens) || '';
const body = renderTokens(ctx, section.tokens, state, { mode: 'plain' });
out.push(...structSection(ctx, title, body, { pageBreakBefore: !first }));
first = false;
} else if (entry.type === 'numbered') {
out.push(
...renderTokens(ctx, section.tokens, state, {
mode: 'numbered',
suppressFirstPageBreak: first,
})
);
first = false;
} else if (entry.type === 'bibliography') {
const body = bibliographyList(ctx, section.biblioEntries || []);
out.push(
...structSection(ctx, ctx.config.bibliography.title, body, { pageBreakBefore: !first })
);
first = false;
} else if (entry.type === 'appendix') {
out.push(...appendixStart(ctx, entry.letter, entry.title || ''));
out.push(...renderTokens(ctx, section.tokens, state, { mode: 'plain' }));
first = false;
}
}
return out;
}
function buildDocx(ctx, children) {
resetListInstances();
return new Document({
features: ctx.config.document.features,
numbering: ctx.numberingConfig,
styles: ctx.documentStyles,
sections: [
{
properties: defaultSectionProps(ctx),
footers: { default: pageNumberFooter(ctx) },
children,
},
],
});
}
module.exports = {
createState,
renderSections,
renderTokens,
renderInline,
readTable,
parseWhereBody,
buildDocx,
};

640
src/render/primitives.js Normal file
View File

@@ -0,0 +1,640 @@
'use strict';
const fs = require('fs');
const path = require('path');
const {
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
PageBreak,
AlignmentType,
HeadingLevel,
TabStopType,
WidthType,
BorderStyle,
ImageRun,
TableOfContents,
Footer,
PageNumber,
StyleLevel,
Math: DocxMath,
MathRun,
MathFraction,
MathSuperScript,
MathSubScript,
} = require('docx');
const { REFS } = require('./styles');
function alignmentOf(name) {
switch (name) {
case 'center':
return AlignmentType.CENTER;
case 'right':
return AlignmentType.RIGHT;
case 'both':
case 'justify':
return AlignmentType.BOTH;
default:
return AlignmentType.LEFT;
}
}
// ── базовые абзацы ────────────────────────────────────────────────
function textRun(ctx, text, opts = {}) {
const t = ctx.typography;
return new TextRun({
text: text == null ? '' : String(text),
font: t.font,
size: opts.size || t.sizeBody,
bold: t.useBold ? Boolean(opts.bold) : false,
italics: t.allowEmphasis ? Boolean(opts.italics) : false,
subScript: opts.subScript || false,
superScript: opts.superScript || false,
});
}
function flatten(...blocks) {
const result = [];
for (const block of blocks) {
if (block == null) continue;
if (Array.isArray(block)) result.push(...flatten(...block));
else result.push(block);
}
return result;
}
function paraRuns(ctx, runs, opts = {}) {
return new Paragraph({
children: runs,
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: opts.noFirstIndent ? {} : { firstLine: ctx.typography.indentFirst },
alignment: opts.alignment || AlignmentType.BOTH,
...opts.extra,
});
}
function para(ctx, text, opts = {}) {
return paraRuns(ctx, [textRun(ctx, text, opts)], opts);
}
function emptyLine(ctx) {
return new Paragraph({
children: [textRun(ctx, ' ')],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: {},
});
}
// ── заголовки ─────────────────────────────────────────────────────
function structHeading(ctx, title, opts = {}) {
const elements = [];
if (opts.pageBreakBefore !== false) elements.push(pageBreak());
const cfg = ctx.config.headings.struct;
elements.push(
new Paragraph({
heading: HeadingLevel.HEADING_1,
children: [textRun(ctx, String(title).toUpperCase())],
spacing: {
...ctx.typography.lineSpacing,
before: cfg.spacingBefore,
after: cfg.spacingAfter,
},
alignment: alignmentOf(cfg.align),
indent: {},
})
);
return elements;
}
function structSection(ctx, title, children, opts = {}) {
return flatten(structHeading(ctx, title, opts), children);
}
function sectionHeading(ctx, title) {
const cfg = ctx.config.headings.section;
return new Paragraph({
heading: HeadingLevel.HEADING_1,
numbering: { reference: REFS.outline, level: 0 },
children: [textRun(ctx, title)],
spacing: { ...ctx.typography.lineSpacing, before: cfg.spacingBefore, after: cfg.spacingAfter },
alignment: alignmentOf(cfg.align),
indent: { firstLine: ctx.typography.indentFirst },
});
}
function subsectionHeading(ctx, title, opts = {}) {
const cfg = ctx.config.headings.subsection;
return new Paragraph({
heading: HeadingLevel.HEADING_2,
numbering: { reference: REFS.outline, level: 1 },
children: [textRun(ctx, title)],
spacing: {
...ctx.typography.lineSpacing,
before: opts.spacingBefore ?? cfg.spacingBefore,
after: cfg.spacingAfter,
},
alignment: alignmentOf(cfg.align),
indent: { firstLine: ctx.typography.indentFirst },
});
}
function plainHeading(ctx, text, level = 2) {
const heading = level <= 1 ? HeadingLevel.HEADING_1 : level === 2 ? HeadingLevel.HEADING_2 : HeadingLevel.HEADING_3;
return new Paragraph({
heading,
children: [textRun(ctx, text)],
spacing: { ...ctx.typography.lineSpacing, before: 240, after: 240 },
alignment: AlignmentType.LEFT,
indent: {},
});
}
function pointParagraph(ctx, text) {
const cfg = ctx.config.headings.point;
return new Paragraph({
heading: HeadingLevel.HEADING_3,
numbering: { reference: REFS.outline, level: 2 },
children: [textRun(ctx, text)],
spacing: { ...ctx.typography.lineSpacing, before: cfg.spacingBefore, after: cfg.spacingAfter },
alignment: AlignmentType.BOTH,
indent: { firstLine: ctx.typography.indentFirst },
});
}
// ── подписи ───────────────────────────────────────────────────────
function figureCaption(ctx, label, title) {
const { prefix, dash } = ctx.config.captions.figure;
return new Paragraph({
children: [textRun(ctx, `${prefix} ${label} ${dash} ${title}`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: alignmentOf(ctx.config.captions.figure.align),
indent: {},
});
}
function tableCaption(ctx, label, title) {
const { prefix, dash } = ctx.config.captions.table;
return new Paragraph({
children: [textRun(ctx, `${prefix} ${label} ${dash} ${title}`)],
spacing: { ...ctx.typography.lineSpacing, before: 240, after: 0 },
alignment: alignmentOf(ctx.config.captions.table.align),
indent: {},
});
}
function tableContinuation(ctx, label, { pageBreakBefore = false } = {}) {
const { prefix } = ctx.config.captions.tableContinue;
return new Paragraph({
children: [textRun(ctx, `${prefix} ${label}`)],
spacing: { ...ctx.typography.lineSpacing, before: 240, after: 0 },
alignment: alignmentOf(ctx.config.captions.table.align),
indent: {},
...(pageBreakBefore ? { pageBreakBefore: true } : {}),
keepNext: true,
});
}
function tableNote(ctx, text) {
return new Paragraph({
children: [textRun(ctx, `Примечание ${ctx.config.captions.figure.dash} ${text}`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: AlignmentType.BOTH,
indent: { firstLine: ctx.typography.indentFirst },
});
}
// ── изображения ───────────────────────────────────────────────────
function imageTypeFromPath(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jpg' || ext === '.jpeg') return 'jpg';
if (ext === '.gif') return 'gif';
if (ext === '.bmp') return 'bmp';
return 'png';
}
function getImageDimensions(buffer, type) {
if (type === 'jpg') {
let i = 2;
while (i < buffer.length - 8) {
if (buffer[i] !== 0xff) {
i++;
continue;
}
const marker = buffer[i + 1];
if (marker === 0xc0 || marker === 0xc2) {
return {
width: buffer.readUInt16BE(i + 7),
height: buffer.readUInt16BE(i + 5),
};
}
const len = buffer.readUInt16BE(i + 2);
i += 2 + len;
}
}
if (type === 'png' && buffer.length > 24 && buffer.toString('ascii', 1, 4) === 'PNG') {
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
return null;
}
function scaleImageDimensions(width, height, maxWidthPx, maxHeightPx = Infinity) {
if (!width || !height) return { width: maxWidthPx, height: Math.round(maxWidthPx * 0.6) };
const ratio = Math.min(maxWidthPx / width, maxHeightPx / height, 1);
return { width: Math.round(width * ratio), height: Math.round(height * ratio) };
}
function figureBlock(ctx, { label, title, imagePath, notesLines = [] }) {
const resolved = imagePath
? path.isAbsolute(imagePath)
? imagePath
: path.resolve(ctx.assetsRoot, imagePath)
: null;
const elements = [];
if (resolved && fs.existsSync(resolved)) {
const data = fs.readFileSync(resolved);
const type = imageTypeFromPath(resolved);
const dims = getImageDimensions(data, type);
const scaled = scaleImageDimensions(
dims?.width,
dims?.height,
ctx.figureMaxWidthPx,
ctx.figureMaxHeightPx
);
elements.push(
new Paragraph({
children: [new ImageRun({ type, data, transformation: scaled })],
alignment: AlignmentType.CENTER,
indent: {},
keepNext: true,
})
);
}
for (const line of notesLines) {
if (String(line).trim()) {
elements.push(
new Paragraph({
children: [textRun(ctx, line)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: AlignmentType.CENTER,
indent: {},
keepNext: true,
})
);
}
}
elements.push(figureCaption(ctx, label, title));
return elements;
}
// ── таблицы ───────────────────────────────────────────────────────
function border(ctx) {
const t = ctx.config.table;
return { style: BorderStyle.SINGLE, size: t.borderSize, color: t.borderColor };
}
function tableRow(ctx, cells, isHeader = false) {
const t = ctx.config.table;
const b = border(ctx);
return new TableRow({
tableHeader: isHeader,
children: cells.map(
({ text, w }) =>
new TableCell({
width: { size: w, type: WidthType.DXA },
margins: {
top: t.cellMargins.top,
bottom: t.cellMargins.bottom,
left: t.cellMargins.left,
right: t.cellMargins.right,
},
borders: { top: b, bottom: b, left: b, right: b },
shading: { fill: isHeader ? t.headerFill : t.cellFill, type: 'CLEAR' },
children: String(text)
.split('\n')
.map(
(line) =>
new Paragraph({
children: [textRun(ctx, line, { size: t.size })],
spacing: { ...ctx.typography.lineSpacingSingle, before: 0, after: 0 },
alignment: AlignmentType.LEFT,
indent: {},
})
),
})
),
});
}
function tableSto(ctx, { label, title, rows, columnWidths, note }) {
const n = rows[0].length;
const totalW = columnWidths
? columnWidths.reduce((a, b) => a + b, 0)
: ctx.page.contentWidth;
const widths =
columnWidths ||
Array(n)
.fill(0)
.map((_, i) =>
i === n - 1
? ctx.page.contentWidth - Math.floor(ctx.page.contentWidth / n) * (n - 1)
: Math.floor(ctx.page.contentWidth / n)
);
const result = [];
if (label) result.push(tableCaption(ctx, label, title));
result.push(
new Table({
width: { size: totalW, type: WidthType.DXA },
columnWidths: widths,
rows: rows.map((row, ri) =>
tableRow(ctx, row.map((text, ci) => ({ text: String(text), w: widths[ci] })), ri === 0)
),
})
);
if (note) result.push(tableNote(ctx, note));
return result;
}
// ── формулы ───────────────────────────────────────────────────────
/**
* Разбирает простую формулу: `_` — индекс, `^` — степень, `/` — дробь.
* Дробь выделяется первой: `A/B` → numerator = parse(A), denominator = parse(B).
*/
function parseSimpleFormula(text) {
const trimmed = String(text).trim();
if (!trimmed) return [new MathRun('')];
const slash = trimmed.indexOf('/');
if (slash > 0 && trimmed.indexOf('/', slash + 1) === -1) {
return [
new MathFraction({
numerator: parseSimpleFormula(trimmed.slice(0, slash)),
denominator: parseSimpleFormula(trimmed.slice(slash + 1)),
}),
];
}
const children = [];
const re = /([_^])([A-Za-zА-Яа-я0-9]+)/g;
let last = 0;
let match;
while ((match = re.exec(trimmed)) !== null) {
if (match.index > last) children.push(new MathRun(trimmed.slice(last, match.index)));
const run = new MathRun(match[2]);
children.push(
match[1] === '_'
? new MathSubScript({ children: [run] })
: new MathSuperScript({ children: [run] })
);
last = re.lastIndex;
}
if (last < trimmed.length) children.push(new MathRun(trimmed.slice(last)));
return children.length ? children : [new MathRun(trimmed)];
}
function mathPara(ctx, mathChildren, formulaNum, opts = {}) {
const num = String(formulaNum).replace(/^\(|\)$/g, '');
const paragraph = new Paragraph({
children: [textRun(ctx, '\t'), new DocxMath({ children: mathChildren }), textRun(ctx, `\t(${num})`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: AlignmentType.LEFT,
indent: {},
tabStops: [
{ type: TabStopType.CENTER, position: ctx.page.contentWidth / 2 },
{ type: TabStopType.RIGHT, position: ctx.page.contentWidth },
],
});
if (opts.noSpacing) return paragraph;
return [emptyLine(ctx), paragraph, emptyLine(ctx)];
}
function formulaFromText(ctx, text, formulaNum) {
const normalized = String(text).replace(/\*/g, '×');
return mathPara(ctx, parseSimpleFormula(normalized), formulaNum);
}
function whereBlock(ctx, items) {
const dash = ctx.config.captions.figure.dash;
return items.map((item, i) => {
const prefix = textRun(ctx, i === 0 ? 'где\t' : '\t');
const parts = String(item.symbol || '').split(/(?=[_^])/);
const symbolRuns = parts.map((part) => {
if (part.startsWith('_')) return textRun(ctx, part.slice(1), { subScript: true });
if (part.startsWith('^')) return textRun(ctx, part.slice(1), { superScript: true });
return textRun(ctx, part);
});
return new Paragraph({
children: [prefix, ...symbolRuns, textRun(ctx, ` ${dash} ${item.desc}`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: {},
tabStops: [{ type: TabStopType.LEFT, position: ctx.config.formula.whereTabPosition }],
});
});
}
// ── список (нумерация Word) ───────────────────────────────────────
const listInstanceCounters = new Map();
function nextListInstance(reference) {
const instance = listInstanceCounters.get(reference) ?? 0;
listInstanceCounters.set(reference, instance + 1);
return instance;
}
function resetListInstances() {
listInstanceCounters.clear();
}
function listParagraphRuns(ctx, runs, reference, level, instance) {
return new Paragraph({
numbering: { reference, level, instance },
children: runs,
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: AlignmentType.BOTH,
});
}
// ── прочее ────────────────────────────────────────────────────────
function pageBreak() {
return new Paragraph({ children: [new PageBreak()], indent: {} });
}
function noteSingle(ctx, text) {
return new Paragraph({
children: [textRun(ctx, `Примечание ${ctx.config.captions.figure.dash} ${text}`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: { firstLine: ctx.typography.indentFirst },
});
}
function notesBlock(ctx, items) {
const result = [
new Paragraph({
children: [textRun(ctx, 'Примечания')],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: { firstLine: ctx.typography.indentFirst },
}),
];
items.forEach((text, i) => {
result.push(
new Paragraph({
children: [textRun(ctx, `${i + 1} ${text}`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: { firstLine: ctx.typography.indentFirst },
})
);
});
return result;
}
function definitionsBlock(ctx, pairs) {
const dash = ctx.config.captions.figure.dash;
return pairs.map(
({ term, definition }) =>
new Paragraph({
children: [textRun(ctx, `${term} ${dash} ${definition}`)],
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
indent: { firstLine: ctx.typography.indentFirst },
})
);
}
function appendixStart(ctx, letter, title) {
return [
pageBreak(),
new Paragraph({
heading: HeadingLevel.HEADING_1,
children: [textRun(ctx, `${ctx.config.appendix.labelPrefix} ${letter.toUpperCase()}`)],
spacing: { ...ctx.typography.lineSpacing, before: ctx.config.headings.struct.spacingBefore, after: 0 },
alignment: AlignmentType.CENTER,
indent: {},
}),
new Paragraph({
children: [textRun(ctx, title)],
spacing: {
...ctx.typography.lineSpacing,
before: 0,
after: ctx.config.headings.struct.spacingAfter,
},
alignment: AlignmentType.CENTER,
indent: {},
}),
];
}
function tableOfContents(ctx, cachedEntries = []) {
const { title, depth } = ctx.config.toc;
const levels = [];
for (let i = 1; i <= depth; i++) levels.push(new StyleLevel(`TOC${i}`, i));
return new TableOfContents(title, {
hyperlink: true,
headingStyleRange: `1-${depth}`,
cachedEntries,
stylesWithLevels: levels,
});
}
function tocSection(ctx, cachedEntries = [], opts = {}) {
const isFirst = opts.isFirstInDocument === true;
// Без завершающего разрыва: следующая часть сама начинает новую страницу,
// иначе после содержания появляется пустой лист.
return flatten(
structHeading(ctx, ctx.config.toc.title, { pageBreakBefore: !isFirst }),
tableOfContents(ctx, cachedEntries)
);
}
function defaultSectionProps(ctx) {
return {
page: {
size: { width: ctx.page.width, height: ctx.page.height },
margin: {
left: ctx.page.margins.left,
right: ctx.page.margins.right,
top: ctx.page.margins.top,
bottom: ctx.page.margins.bottom,
},
pageNumbers: { start: ctx.config.pageNumber.startFrom },
},
};
}
function pageNumberFooter(ctx) {
return new Footer({
children: [
new Paragraph({
alignment: alignmentOf(ctx.config.pageNumber.align),
children: [
new TextRun({
children: [PageNumber.CURRENT],
font: ctx.typography.font,
size: ctx.typography.sizeBody,
}),
],
}),
],
});
}
function bibliographyList(ctx, entries) {
return entries.map(
(text) =>
new Paragraph({
children: [textRun(ctx, String(text).replace(/^\d+[.)]\s*/, ''))],
numbering: { reference: REFS.bibliography, level: 0 },
spacing: { ...ctx.typography.lineSpacing, before: 0, after: 0 },
alignment: AlignmentType.BOTH,
indent: { left: 0, firstLine: ctx.typography.indentFirst },
})
);
}
module.exports = {
alignmentOf,
textRun,
flatten,
paraRuns,
para,
emptyLine,
structHeading,
structSection,
sectionHeading,
subsectionHeading,
plainHeading,
pointParagraph,
figureCaption,
tableCaption,
tableContinuation,
tableNote,
figureBlock,
tableSto,
parseSimpleFormula,
mathPara,
formulaFromText,
whereBlock,
nextListInstance,
resetListInstances,
listParagraphRuns,
pageBreak,
noteSingle,
notesBlock,
definitionsBlock,
appendixStart,
tableOfContents,
tocSection,
defaultSectionProps,
pageNumberFooter,
bibliographyList,
REFS,
};

93
src/render/references.js Normal file
View File

@@ -0,0 +1,93 @@
'use strict';
/** Текстовая выжимка из токенов — для списка источников. */
function extractInlineTexts(tokens) {
const out = [];
for (const token of tokens) {
if (token.type === 'inline') {
const text = String(token.content || '').replace(/\s+/g, ' ').trim();
if (text) out.push(text);
}
}
return out;
}
/**
* Проход 1: назначает номера рисункам/таблицам/формулам, собирает метки
* и подсчитывает записи библиографии. Номера кладутся прямо в токены.
*/
function collectNumbers(sections, ctx, state) {
const ncfg = ctx.config.numbering;
let bodyFig = 0;
let bodyTbl = 0;
let formula = 0;
let sectionNum = 0;
let secFig = 0;
let secTbl = 0;
state.labelMap = new Map();
state.biblioCount = 0;
for (const section of sections) {
const entry = section.entry;
if (entry.type === 'bibliography') {
section.biblioEntries = extractInlineTexts(section.tokens);
state.biblioCount += section.biblioEntries.length;
continue;
}
if (entry.type === 'toc') continue;
if (entry.type === 'appendix') {
section.letter = String(entry.letter).toUpperCase();
section.fig = 0;
section.tbl = 0;
}
if (entry.type === 'numbered') {
sectionNum += 1;
secFig = 0;
secTbl = 0;
}
for (const token of section.tokens) {
if (token.type !== 'directive') continue;
const { name, attrs } = token.info;
if (name === 'figure') {
let label;
if (entry.type === 'appendix') {
section.fig += 1;
label = `${section.letter}.${section.fig}`;
} else if (ncfg.figures === 'section') {
secFig += 1;
label = `${sectionNum}.${secFig}`;
} else {
bodyFig += 1;
label = String(bodyFig);
}
token.__caption = { label };
if (attrs.id) state.labelMap.set(`fig:${attrs.id}`, label);
} else if (name === 'table') {
let label;
if (entry.type === 'appendix') {
section.tbl += 1;
label = `${section.letter}.${section.tbl}`;
} else if (ncfg.tables === 'section') {
secTbl += 1;
label = `${sectionNum}.${secTbl}`;
} else {
bodyTbl += 1;
label = String(bodyTbl);
}
token.__caption = { label };
if (attrs.id) state.labelMap.set(`tbl:${attrs.id}`, label);
} else if (name === 'formula') {
formula += 1;
token.__caption = { label: String(formula) };
if (attrs.id) state.labelMap.set(`eq:${attrs.id}`, formula);
}
}
}
}
module.exports = { collectNumbers, extractInlineTexts };

View File

@@ -0,0 +1,66 @@
'use strict';
const { Document, Packer, Paragraph, TextRun, AlignmentType } = require('docx');
const FONT = 'Times New Roman';
const SIZE = 28;
const PAGE = { width: 11906, height: 16838 };
const MARGIN = { left: 1701, right: 851, top: 1134, bottom: 1134 };
function line(text = ' ', { align = AlignmentType.CENTER, bold = false, size = SIZE } = {}) {
return new Paragraph({
alignment: align,
spacing: { line: 360, lineRule: 'auto', before: 0, after: 0 },
indent: {},
children: [new TextRun({ text, font: FONT, size, bold })],
});
}
const blank = () => line(' ');
/** Готовый пример титульного листа (DOCX), склеивается в начало документа. */
async function buildSampleTitlePage() {
const doc = new Document({
features: { updateFields: false },
styles: {
default: {
document: { run: { font: FONT, size: SIZE, color: '000000' } },
},
},
sections: [
{
properties: { page: { size: PAGE, margin: MARGIN } },
children: [
line('МИНИСТЕРСТВО НАУКИ И ВЫСШЕГО ОБРАЗОВАНИЯ РОССИЙСКОЙ ФЕДЕРАЦИИ'),
line('Федеральное государственное автономное образовательное учреждение'),
line('высшего образования'),
line('«Самарский национальный исследовательский университет'),
line('имени академика С. П. Королёва»'),
blank(),
line('Кафедра программных систем'),
blank(),
blank(),
blank(),
line('ОТЧЁТ', { size: 32 }),
line('о выполнении практической работы'),
blank(),
line('Тема: «Разработка информационной системы учёта заявок»'),
blank(),
blank(),
line('Выполнил: студент группы 6212-010302D', { align: AlignmentType.RIGHT }),
line('Иванов И. И.', { align: AlignmentType.RIGHT }),
blank(),
line('Проверил: доцент, кандидат технических наук', { align: AlignmentType.RIGHT }),
line('Петров П. П.', { align: AlignmentType.RIGHT }),
blank(),
blank(),
blank(),
line('Самара 2026'),
],
},
],
});
return Packer.toBuffer(doc);
}
module.exports = { buildSampleTitlePage };

181
src/render/styles.js Normal file
View File

@@ -0,0 +1,181 @@
'use strict';
const { AlignmentType, LevelFormat, LevelSuffix } = require('docx');
const REFS = {
dash: 'listDash',
number: 'listNumber',
bibliography: 'bibliography',
outline: 'headingOutline',
};
function buildContext(config) {
const { page, typography, lists, numbering, table, figure } = config;
const contentWidth = page.width - page.margins.left - page.margins.right;
const lineSpacing = { line: Math.round(240 * typography.lineSpacing), lineRule: 'auto' };
const lineSpacingSingle = { line: 240, lineRule: 'auto' };
const ctx = {
config,
assetsRoot: config.paths.assets,
page: {
width: page.width,
height: page.height,
margins: page.margins,
contentWidth,
},
typography: {
font: typography.font,
sizeBody: typography.sizeBody,
sizeTable: typography.sizeTable,
sizeSmall: typography.sizeSmall,
color: typography.color,
useBold: typography.useBold,
allowEmphasis: typography.allowEmphasis,
lineSpacing,
lineSpacingSingle,
indentFirst: typography.indentFirst,
},
figureMaxWidthPx:
figure.maxWidth != null
? figure.maxWidth
: Math.round((contentWidth / 1440) * 96),
figureMaxHeightPx: figure.maxHeight,
};
ctx.numberingConfig = buildNumberingConfig(ctx);
ctx.documentStyles = buildDocumentStyles(ctx);
return ctx;
}
function buildNumberingConfig(ctx) {
const typography = ctx.typography;
const lists = ctx.config.lists;
const run = {
font: typography.font,
size: typography.sizeBody,
color: typography.color,
};
const indent = (lvl) => ({
left: lists.nestedLevelStep * lvl,
firstLine: typography.indentFirst,
});
const level = (lvl, format, text, { suffix = LevelSuffix.SPACE, indentOverride } = {}) => ({
level: lvl,
format,
text,
suffix,
alignment: AlignmentType.LEFT,
start: 1,
style: {
paragraph: {
indent: indentOverride || indent(lvl),
spacing: { ...typography.lineSpacing, before: 0, after: 0 },
},
run,
},
});
const outlineLevel = (lvl, text) => ({
level: lvl,
format: LevelFormat.DECIMAL,
text,
suffix: LevelSuffix.TAB,
alignment: AlignmentType.LEFT,
start: 1,
style: {
paragraph: {
indent: { left: 0, firstLine: typography.indentFirst },
spacing: { ...typography.lineSpacing, before: 0, after: 0 },
},
run,
},
});
const dashRef = [
level(0, LevelFormat.BULLET, lists.bulletChar),
level(1, LevelFormat.RUSSIAN_LOWER, `%2${lists.letterSuffix}`),
level(2, LevelFormat.DECIMAL, `%3${lists.numberSuffix}`),
level(3, LevelFormat.BULLET, lists.bulletChar),
];
const numberRef = [
level(0, LevelFormat.DECIMAL, `%1${lists.numberSuffix}`),
level(1, LevelFormat.RUSSIAN_LOWER, `%2${lists.letterSuffix}`),
level(2, LevelFormat.DECIMAL, `%3${lists.numberSuffix}`),
];
return {
config: [
{ reference: REFS.dash, levels: dashRef },
{ reference: REFS.number, levels: numberRef },
{
reference: REFS.bibliography,
levels: [
level(0, LevelFormat.DECIMAL, '%1', {
suffix: LevelSuffix.SPACE,
indentOverride: { left: 0, firstLine: typography.indentFirst },
}),
],
},
{
reference: REFS.outline,
levels: [outlineLevel(0, '%1'), outlineLevel(1, '%1.%2'), outlineLevel(2, '%1.%2.%3')],
},
],
};
}
function buildDocumentStyles(ctx) {
const run = {
font: ctx.typography.font,
size: ctx.typography.sizeBody,
bold: false,
color: ctx.typography.color,
};
const line = { line: 360, lineRule: 'auto' };
// Встроенные стили Heading1..3 задаются через механизм дефолтов docx,
// чтобы не дублировать styleId.
return {
default: {
document: {
run: {
font: ctx.typography.font,
size: ctx.typography.sizeBody,
color: ctx.typography.color,
},
},
heading1: { run, paragraph: { spacing: line, outlineLevel: 0 } },
heading2: { run, paragraph: { spacing: line, outlineLevel: 1, alignment: AlignmentType.LEFT } },
heading3: { run, paragraph: { spacing: line, outlineLevel: 2, alignment: AlignmentType.LEFT } },
},
paragraphStyles: [
{
id: 'TOC1',
name: 'TOC 1',
basedOn: 'Normal',
next: 'Normal',
run,
paragraph: { spacing: line },
},
{
id: 'TOC2',
name: 'TOC 2',
basedOn: 'Normal',
next: 'Normal',
run,
paragraph: { spacing: line, indent: { left: 360 } },
},
{
id: 'TOC3',
name: 'TOC 3',
basedOn: 'Normal',
next: 'Normal',
run,
paragraph: { spacing: line, indent: { left: 720 } },
},
],
};
}
module.exports = { buildContext, buildNumberingConfig, REFS };

141
src/render/titlepage.js Normal file
View File

@@ -0,0 +1,141 @@
'use strict';
const DocxMerger = require('docx-merger');
const JSZip = require('jszip');
const PARTS_TO_PATCH = ['word/numbering.xml', 'word/styles.xml', 'word/document.xml'];
/** docx-merger переименовывает numId в numbering.xml, но не в document.xml. */
function patchDocumentNumIds(xml, fileIndex) {
return xml.replace(
/<w:numId w:val="(\d+)"\s*\/>/g,
(_, id) => `<w:numId w:val="${id}${fileIndex}"/>`
);
}
async function prepareBufferForMerge(buffer, fileIndex) {
const zip = await JSZip.loadAsync(buffer);
const docFile = zip.file('word/document.xml');
if (docFile) {
const xml = await docFile.async('string');
zip.file('word/document.xml', patchDocumentNumIds(xml, fileIndex));
}
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
}
function toBinary(data) {
if (typeof data === 'string') return data;
return Buffer.from(data).toString('binary');
}
function getRootOpenTag(xml) {
const m = xml.match(/<[\w:][^>]*>/);
return m ? m[0] : null;
}
function collectUsedPrefixes(xml) {
const prefixes = new Set();
const re = /\b([a-z][a-z0-9]*):/gi;
let match;
while ((match = re.exec(xml)) !== null) prefixes.add(match[1]);
return prefixes;
}
function getDeclaredPrefixes(rootTag) {
const declared = new Set();
if (!rootTag) return declared;
const re = /xmlns:([a-zA-Z0-9]+)=/g;
let m;
while ((m = re.exec(rootTag))) declared.add(m[1]);
return declared;
}
function extractNamespaceAttrs(refRoot) {
const attrs = [];
const re = /(xmlns(?::[a-zA-Z0-9]+)?="[^"]*")|(mc:Ignorable="[^"]*")/g;
let m;
while ((m = re.exec(refRoot))) attrs.push(m[0]);
return attrs;
}
function patchXmlRootNamespaces(xml, refXml) {
const root = getRootOpenTag(xml);
const refRoot = getRootOpenTag(refXml);
if (!root || !refRoot) return xml;
const used = collectUsedPrefixes(xml);
const declared = getDeclaredPrefixes(root);
let insert = '';
for (const attr of extractNamespaceAttrs(refRoot)) {
if (attr.startsWith('mc:Ignorable')) {
if (!/mc:Ignorable=/.test(root)) insert += ` ${attr}`;
continue;
}
const prefixMatch = attr.match(/^xmlns:([a-zA-Z0-9]+)=/);
if (!prefixMatch) continue;
const prefix = prefixMatch[1];
if (used.has(prefix) && !declared.has(prefix)) insert += ` ${attr}`;
}
if (!insert) return xml;
const isSelfClosing = /\/>\s*$/.test(root);
const newRoot = isSelfClosing
? root.replace(/\/>\s*$/, `${insert} />`)
: root.replace(/>$/, `${insert}>`);
return xml.replace(root, newRoot);
}
async function patchDocxNamespaces(mergedBuffer, mainBuffer) {
const mergedZip = await JSZip.loadAsync(mergedBuffer);
const mainZip = await JSZip.loadAsync(mainBuffer);
for (const part of PARTS_TO_PATCH) {
const mergedFile = mergedZip.file(part);
const mainFile = mainZip.file(part);
if (!mergedFile || !mainFile) continue;
const mergedXml = await mergedFile.async('string');
const mainXml = await mainFile.async('string');
const patched = patchXmlRootNamespaces(mergedXml, mainXml);
if (patched !== mergedXml) mergedZip.file(part, patched);
}
return mergedZip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
}
/**
* Склеивает два DOCX: prepend идёт в начало main.
* @param {Buffer|string} prependBuffer
* @param {Buffer|string} mainBuffer
* @returns {Promise<Buffer>}
*/
async function mergeDocxBuffers(prependBuffer, mainBuffer) {
const prependPrepared = await prepareBufferForMerge(prependBuffer, 0);
const mainPrepared = await prepareBufferForMerge(mainBuffer, 1);
const mainBin = toBinary(mainPrepared);
const prependBin = toBinary(prependPrepared);
return new Promise((resolve, reject) => {
try {
const docx = new DocxMerger({ pageBreak: true }, [prependBin, mainBin]);
docx.save('nodebuffer', async (data) => {
try {
const merged = Buffer.from(data);
const patched = await patchDocxNamespaces(
merged,
Buffer.isBuffer(mainBuffer) ? mainBuffer : Buffer.from(mainBin, 'binary')
);
resolve(patched);
} catch (err) {
reject(err);
}
});
} catch (err) {
reject(err);
}
});
}
module.exports = { mergeDocxBuffers };

31
src/util/deepmerge.js Normal file
View File

@@ -0,0 +1,31 @@
'use strict';
function isPlainObject(value) {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
/**
* Рекурсивно сливает пользовательский конфиг поверх дефолтов.
* Массивы заменяются целиком (важно для `sections`).
*/
function deepMerge(base, override) {
if (!isPlainObject(base) || !isPlainObject(override)) {
return override === undefined ? base : override;
}
const result = { ...base };
for (const [key, value] of Object.entries(override)) {
if (isPlainObject(value) && isPlainObject(base[key])) {
result[key] = deepMerge(base[key], value);
} else {
result[key] = value;
}
}
return result;
}
module.exports = { deepMerge, isPlainObject };

19
src/util/errors.js Normal file
View File

@@ -0,0 +1,19 @@
'use strict';
class ConfigError extends Error {
constructor(message, { errors = [] } = {}) {
super(message);
this.name = 'ConfigError';
this.errors = errors;
}
}
class BuildError extends Error {
constructor(message, { errors = [] } = {}) {
super(message);
this.name = 'BuildError';
this.errors = errors;
}
}
module.exports = { ConfigError, BuildError };

18
src/util/logger.js Normal file
View File

@@ -0,0 +1,18 @@
'use strict';
function createLogger({ verbose = false } = {}) {
const write = (stream, prefix, args) => {
stream.write(prefix + args.map(String).join(' ') + '\n');
};
return {
info: (...args) => write(process.stdout, '', args),
warn: (...args) => write(process.stderr, 'warning: ', args),
error: (...args) => write(process.stderr, 'error: ', args),
debug: (...args) => {
if (verbose) write(process.stdout, 'debug: ', args);
},
verbose,
};
}
module.exports = { createLogger };

141
src/util/png.js Normal file
View File

@@ -0,0 +1,141 @@
'use strict';
const zlib = require('zlib');
const CRC_TABLE = (() => {
const table = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[n] = c;
}
return table;
})();
function crc32(buffer) {
let crc = -1;
for (let i = 0; i < buffer.length; i++) {
crc = CRC_TABLE[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ -1) >>> 0;
}
function chunk(type, data) {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([length, typeBuf, data, crc]);
}
/** Кодирует изображение, получая цвет пикселя через getPixel(x, y) -> [r,g,b]. */
function encodePng(width, height, getPixel) {
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // color type: truecolor
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
const rowLength = width * 3;
const raw = Buffer.alloc((rowLength + 1) * height);
for (let y = 0; y < height; y++) {
const offset = y * (rowLength + 1);
raw[offset] = 0; // filter: none
for (let x = 0; x < width; x++) {
const [r, g, b] = getPixel(x, y);
const px = offset + 1 + x * 3;
raw[px] = r;
raw[px + 1] = g;
raw[px + 2] = b;
}
}
return Buffer.concat([
signature,
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
function solidPng(width, height, color) {
return encodePng(width, height, () => color);
}
function mix(a, b, t) {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
/** Рисунок-заглушка «блок-схема»: рамка и несколько связанных блоков. */
function diagramPng(width, height, color) {
const bg = [255, 255, 255];
const fill = mix(color, bg, 0.72);
const cx = width / 2;
const cy = height / 2;
const blocks = [
{ x: 0.06, y: 0.3, w: 0.2, h: 0.4 },
{ x: 0.4, y: 0.12, w: 0.2, h: 0.32 },
{ x: 0.4, y: 0.56, w: 0.2, h: 0.32 },
{ x: 0.74, y: 0.3, w: 0.2, h: 0.4 },
];
return encodePng(width, height, (x, y) => {
const border =
x < 2 || y < 2 || x >= width - 2 || y >= height - 2 ? color : null;
if (border) return border;
if (Math.abs(x - cx) < 1 && y > height * 0.18 && y < height * 0.82) return color;
for (const b of blocks) {
const rx = b.x * width;
const ry = b.y * height;
const rw = b.w * width;
const rh = b.h * height;
const inside = x >= rx && x < rx + rw && y >= ry && y < ry + rh;
const edge =
inside &&
(x < rx + 2 || x >= rx + rw - 2 || y < ry + 2 || y >= ry + rh - 2);
if (edge) return color;
if (inside) return fill;
if (y >= ry + rh / 2 - 1 && y <= ry + rh / 2 + 1 && x > rx + rw && x <= cx) return color;
if (Math.abs(y - cy) < 1 && x > cx && x <= rx) return color;
}
return bg;
});
}
/** Рисунок-заглушка «столбчатая диаграмма». */
function chartPng(width, height, color) {
const bg = [255, 255, 255];
const fill = mix(color, bg, 0.6);
const bars = [0.45, 0.7, 0.35, 0.85, 0.55, 0.65];
const left = Math.round(width * 0.1);
const right = Math.round(width * 0.92);
const bottom = Math.round(height * 0.85);
const top = Math.round(height * 0.12);
const slot = (right - left) / bars.length;
const barW = slot * 0.55;
return encodePng(width, height, (x, y) => {
if (x < 2 || y < 2 || x >= width - 2 || y >= height - 2) return color;
if (Math.abs(x - left) < 2 || Math.abs(y - bottom) < 2) return color;
if (x < left || x > right || y > bottom) return bg;
for (let i = 0; i < bars.length; i++) {
const bx = left + i * slot + (slot - barW) / 2;
const by = bottom - (bottom - top) * bars[i];
const inside = x >= bx && x < bx + barW && y >= by && y <= bottom;
if (inside) return Math.abs(x - bx) < 2 ? color : fill;
}
return bg;
});
}
module.exports = { solidPng, diagramPng, chartPng, encodePng, crc32 };