mirror of
https://github.com/StepanovPlaton/SSAUGenerator.git
synced 2026-11-01 10:49:08 +04:00
first commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
report.docx
|
||||
lab-gen.exe
|
||||
*.log
|
||||
*.pdf
|
||||
324
AGENTS.md
Normal file
324
AGENTS.md
Normal 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
42
README.md
Normal 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
140
config.yaml
Normal 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: Вспомогательные материалы
|
||||
14
content/01-introduction.md
Normal file
14
content/01-introduction.md
Normal file
@@ -0,0 +1,14 @@
|
||||
Целью работы является разработка информационной системы учёта
|
||||
заявок для отдела технической поддержки. Актуальность темы обусловлена ростом числа обращений и
|
||||
необходимостью их оперативной обработки [@1].
|
||||
|
||||
Для достижения поставленной цели необходимо решить следующие задачи:
|
||||
|
||||
- проанализировать предметную область и существующие решения;
|
||||
- спроектировать архитектуру информационной системы;
|
||||
- определить состав данных и пользовательских сценариев;
|
||||
- рассчитать показатели эффективности разработанного решения.
|
||||
|
||||
При выполнении работы использованы методы системного анализа, объектного моделирования и
|
||||
статистической обработки данных [@2]. Требования к оформлению приняты в соответствии с
|
||||
действующими стандартами [@3], [@4].
|
||||
107
content/02-analysis.md
Normal file
107
content/02-analysis.md
Normal 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
126
content/03-design.md
Normal 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
12
content/04-conclusion.md
Normal file
@@ -0,0 +1,12 @@
|
||||
В ходе работы проанализирована предметная область, спроектирована архитектура информационной
|
||||
системы и определены показатели эффективности [@6], [@7].
|
||||
|
||||
Основные результаты работы:
|
||||
|
||||
- выполнена классификация обращений и определены их приоритеты;
|
||||
- разработана модель данных и описаны пользовательские сценарии;
|
||||
- рассчитан коэффициент снижения трудозатрат.
|
||||
|
||||
Применение информационной системы позволяет сократить среднее время обработки заявки с 46 до
|
||||
18 минут, что подтверждает достижение поставленной цели. Единицы измерений приведены в
|
||||
соответствии с действующим стандартом [@8].
|
||||
8
content/05-bibliography.md
Normal file
8
content/05-bibliography.md
Normal 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
38
content/06-appendix-a.md
Normal 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
18
content/07-appendix-b.md
Normal 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 | Сбор и хранение логов |
|
||||
:::
|
||||
BIN
content/assets/images/appendix-scheme.png
Normal file
BIN
content/assets/images/appendix-scheme.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
BIN
content/assets/images/architecture.png
Normal file
BIN
content/assets/images/architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
content/assets/images/chart.png
Normal file
BIN
content/assets/images/chart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
content/assets/images/data-flow.png
Normal file
BIN
content/assets/images/data-flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
content/assets/images/interface.png
Normal file
BIN
content/assets/images/interface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
content/assets/title-page.docx
Normal file
BIN
content/assets/title-page.docx
Normal file
Binary file not shown.
218
documentation/STO_SGAU_02068410-004-2018.md
Normal file
218
documentation/STO_SGAU_02068410-004-2018.md
Normal 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
2326
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal 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
53
src/build.js
Normal 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
150
src/cli.js
Normal 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
123
src/config/defaults.js
Normal 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
53
src/config/load.js
Normal 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
405
src/config/template.js
Normal 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
95
src/config/validate.js
Normal 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
3
src/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = { ...require('./build'), ...require('./config/load') };
|
||||
103
src/markdown/container.js
Normal file
103
src/markdown/container.js
Normal 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
37
src/markdown/inline.js
Normal 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
47
src/markdown/parse.js
Normal 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
452
src/render/document.js
Normal 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
640
src/render/primitives.js
Normal 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
93
src/render/references.js
Normal 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 };
|
||||
66
src/render/sample-titlepage.js
Normal file
66
src/render/sample-titlepage.js
Normal 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
181
src/render/styles.js
Normal 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
141
src/render/titlepage.js
Normal 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
31
src/util/deepmerge.js
Normal 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
19
src/util/errors.js
Normal 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
18
src/util/logger.js
Normal 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
141
src/util/png.js
Normal 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 };
|
||||
Reference in New Issue
Block a user