diff --git a/.gitignore b/.gitignore
index 4ecbf94..582c65b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,8 @@
node_modules/
report.docx
+presentation.pptx
DocxGenerator.exe
*.log
*.pdf
+.plantuml-cache/
+tools/plantuml.jar
diff --git a/README.md b/README.md
index 066d7ba..a056c3c 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,47 @@
-# DocxGenerator
+# lab_gen — генераторы отчётных документов
-Автономный генератор отчётов **DOCX** из **Markdown** по дизайну
-[СТО 02068410-004-2018](documentation/STO_SGAU_02068410-004-2018.md).
-Поведение полностью задаётся файлом `config.yaml`; путь выходного файла — ключ
-`document.output`.
+Монорепозиторий из двух независимых Node.js-генераторов, которые собирают
+отчётные документы из обычного Markdown по заданному шаблону/стандарту.
-Полная документация: **[AGENTS.md](AGENTS.md)** — CLI, схема конфига, формат контента,
-директивы и сборка `.exe`.
-
-## Быстрый старт
-
-```powershell
-.\DocxGenerator.exe --init # создать config.yaml, примеры content/ и content/assets/
-# ... правим config.yaml и content/*.md ...
-.\DocxGenerator.exe --check # проверить ссылки и конфиг
-.\DocxGenerator.exe # собрать report.docx
+```
+lab_gen/
+├── docx/ # генератор DOCX по СТО 02068410-004-2018 (отчёт)
+├── pptx/ # генератор PPTX по шаблону Самарского университета (презентация)
+└── tools/ # общие внешние инструменты (plantuml.jar)
```
-## Разработка
+## docx/ — DocxGenerator
+
+Автономный генератор отчётов **DOCX** из Markdown по дизайну
+СТО 02068410-004-2018. Поддерживает титульный лист, оглавление, таблицы, рисунки
+(включая PlantUML), формулы, список источников и приложения.
```powershell
+cd docx
npm install
-npm run generate # локальная сборка report.docx
-npm run check # проверка конфига и ссылок
-npm run build:exe # собрать DocxGenerator.exe в корне (@yao-pkg/pkg, node24-win-x64)
+npm run check # проверка конфига и ссылок
+npm run generate # собрать report.docx
```
-## Структура репозитория
+Полная документация — [docx/AGENTS.md](docx/AGENTS.md).
-| Путь | Назначение |
-|------|------------|
-| `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/` | стандарт СТО |
+## pptx/ — lab-gen-pptx
-Инструмент не рендерит диаграммы: PNG готовятся заранее и кладутся в `content/assets/images/`.
+Генератор презентаций **PPTX** из Markdown по фирменному шаблону Самарского
+университета (16:9, синий фон, логотип, фирменный шрифт). Задники и правила
+оформления извлечены из `presentation_16.pptx` и заданы в `config.yaml`.
+
+```powershell
+cd pptx
+npm install
+npm run check # проверка конфига и ссылок
+npm run generate # собрать presentation.pptx
+```
+
+Формат слайдов, схема конфига и CLI описаны в [pptx/AGENTS.md](pptx/AGENTS.md).
+
+## Требования
+
+- Node.js 18+ (для локальной сборки).
+- Java 11+ и `tools/plantuml.jar` — для рендеринга схем PlantUML (`.puml`)
+ в обоих генераторах. Путь задаётся ключом `plantuml.jar` в `config.yaml`.
diff --git a/content/01-introduction.md b/content/01-introduction.md
deleted file mode 100644
index 867fd17..0000000
--- a/content/01-introduction.md
+++ /dev/null
@@ -1,14 +0,0 @@
-Целью работы является разработка информационной системы учёта
-заявок для отдела технической поддержки. Актуальность темы обусловлена ростом числа обращений и
-необходимостью их оперативной обработки [@1].
-
-Для достижения поставленной цели необходимо решить следующие задачи:
-
-- проанализировать предметную область и существующие решения;
-- спроектировать архитектуру информационной системы;
-- определить состав данных и пользовательских сценариев;
-- рассчитать показатели эффективности разработанного решения.
-
-При выполнении работы использованы методы системного анализа, объектного моделирования и
-статистической обработки данных [@2]. Требования к оформлению приняты в соответствии с
-действующими стандартами [@3], [@4].
diff --git a/content/02-analysis.md b/content/02-analysis.md
deleted file mode 100644
index 90b0c34..0000000
--- a/content/02-analysis.md
+++ /dev/null
@@ -1,107 +0,0 @@
-# АНАЛИЗ ПРЕДМЕТНОЙ ОБЛАСТИ
-
-Раздел посвящён анализу процесса обработки обращений и обоснованию требований к информационной
-системе. Рассматриваются участники процесса, информационные потоки и ограничения предметной
-области.
-
-## Обзор процесса обработки обращений
-
-Процесс обработки заявки включает регистрацию, классификацию, назначение исполнителя и закрытие.
-Схема процесса приведена на рисунке @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
-Заявка — обращение пользователя, зарегистрированное в информационной системе;
-Исполнитель — сотрудник, назначенный ответственным за обработку заявки;
-Регламент — документ, определяющий порядок и сроки обработки обращений.
-:::
diff --git a/content/03-design.md b/content/03-design.md
deleted file mode 100644
index adb7d69..0000000
--- a/content/03-design.md
+++ /dev/null
@@ -1,126 +0,0 @@
-# ПРОЕКТИРОВАНИЕ ИНФОРМАЦИОННОЙ СИСТЕМЫ
-
-В разделе описаны архитектура, модель данных и пользовательские сценарии разрабатываемой
-информационной системы.
-
-## Архитектура системы
-
-Система построена по трёхуровневой архитектуре. Взаимодействие компонентов показано на
-рисунке @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
-Оценка выполнена для среднего месяца пилотной эксплуатации.
-Отклонение фактических значений не превышает десяти процентов.
-:::
diff --git a/content/04-conclusion.md b/content/04-conclusion.md
deleted file mode 100644
index c6705cf..0000000
--- a/content/04-conclusion.md
+++ /dev/null
@@ -1,12 +0,0 @@
-В ходе работы проанализирована предметная область, спроектирована архитектура информационной
-системы и определены показатели эффективности [@6], [@7].
-
-Основные результаты работы:
-
-- выполнена классификация обращений и определены их приоритеты;
-- разработана модель данных и описаны пользовательские сценарии;
-- рассчитан коэффициент снижения трудозатрат.
-
-Применение информационной системы позволяет сократить среднее время обработки заявки с 46 до
-18 минут, что подтверждает достижение поставленной цели. Единицы измерений приведены в
-соответствии с действующим стандартом [@8].
diff --git a/content/05-bibliography.md b/content/05-bibliography.md
deleted file mode 100644
index ccf5cc5..0000000
--- a/content/05-bibliography.md
+++ /dev/null
@@ -1,8 +0,0 @@
-- ГОСТ 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 с.
diff --git a/content/06-appendix-a.md b/content/06-appendix-a.md
deleted file mode 100644
index 2ca8fbc..0000000
--- a/content/06-appendix-a.md
+++ /dev/null
@@ -1,38 +0,0 @@
-Сводные данные по обращениям за отчётный период приведены в таблице @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 |
-:::
diff --git a/content/07-appendix-b.md b/content/07-appendix-b.md
deleted file mode 100644
index 8e65707..0000000
--- a/content/07-appendix-b.md
+++ /dev/null
@@ -1,18 +0,0 @@
-Схема размещения компонентов системы приведена на рисунке @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 | Сбор и хранение логов |
-:::
diff --git a/content/assets/images/appendix-scheme.png b/content/assets/images/appendix-scheme.png
deleted file mode 100644
index a054567..0000000
Binary files a/content/assets/images/appendix-scheme.png and /dev/null differ
diff --git a/content/assets/images/architecture.png b/content/assets/images/architecture.png
deleted file mode 100644
index 46d031f..0000000
Binary files a/content/assets/images/architecture.png and /dev/null differ
diff --git a/content/assets/images/chart.png b/content/assets/images/chart.png
deleted file mode 100644
index 1f27bf3..0000000
Binary files a/content/assets/images/chart.png and /dev/null differ
diff --git a/content/assets/images/data-flow.png b/content/assets/images/data-flow.png
deleted file mode 100644
index 82da710..0000000
Binary files a/content/assets/images/data-flow.png and /dev/null differ
diff --git a/content/assets/images/interface.png b/content/assets/images/interface.png
deleted file mode 100644
index 5202296..0000000
Binary files a/content/assets/images/interface.png and /dev/null differ
diff --git a/content/assets/title-page.docx b/content/assets/title-page.docx
deleted file mode 100644
index 1edbc2e..0000000
Binary files a/content/assets/title-page.docx and /dev/null differ
diff --git a/AGENTS.md b/docx/AGENTS.md
similarity index 79%
rename from AGENTS.md
rename to docx/AGENTS.md
index e110e93..bd6fdac 100644
--- a/AGENTS.md
+++ b/docx/AGENTS.md
@@ -13,7 +13,8 @@
- Windows (собранный `.exe` под `win-x64`).
- Node.js **только для сборки** `.exe` из исходников. Конечному пользователю Node.js не нужен.
-- Никаких Java/Chrome/интернета: инструмент не рендерит диаграммы, а вставляет готовые PNG.
+- Готовые PNG вставляются без внешних зависимостей. Для рендеринга PlantUML (`.puml`)
+ нужны установленная Java (JRE 11+) и файл `plantuml.jar` (см. п. 4, `plantuml`).
## 2. Состав дистрибутива
@@ -23,14 +24,21 @@ config.yaml # настройки (создаётся автомат
content/ # Markdown-файлы документа и ресурсы
*.md
assets/
- images/ # PNG для рисунков (готовятся заранее)
+ images/ # PNG для рисунков (готовятся заранее) и исходники .puml
title-page.docx # титульный лист (по умолчанию content/assets/title-page.docx)
+tools/
+ plantuml.jar # рендерер PlantUML (для файлов .puml)
report.docx # результат сборки (путь задаётся document.output)
```
Все относительные пути в `config.yaml` считаются **от каталога самого `config.yaml`**.
Сам `config.yaml` и папки ищутся **рядом с `DocxGenerator.exe`**.
+В монорепозитории общий `plantuml.jar` лежит в корне, в `tools/plantuml.jar`,
+поэтому репозиторный `config.yaml` задаёт `plantuml.jar: ../tools/plantuml.jar`.
+Для автономного дистрибутива положите `tools/plantuml.jar` рядом с `.exe`
+(значение по умолчанию при `--init` — `tools/plantuml.jar`).
+
## 3. Командная строка
```
@@ -125,6 +133,24 @@ DocxGenerator [опции]
### figure
`maxWidth` (px; `null` — по ширине полосы), `maxHeight` (px).
+### plantuml
+Генерация рисунков из исходников PlantUML (см. п. 5, «Рисунки»).
+
+| Ключ | Тип | Смысл |
+|------|-----|-------|
+| `enabled` | bool | включить рендеринг `.puml`; `false` — считать ошибкой при наличии `.puml` |
+| `java` | string | исполняемый файл Java; `auto` — `%JAVA_HOME%\bin\java`, иначе `java` из PATH |
+| `jar` | string | путь к `plantuml.jar` (относительно `config.yaml`) |
+| `format` | string | только `png` |
+| `charset` | string | кодировка исходника (по умолчанию `UTF-8`) |
+| `cache` | bool | не перерисовывать неизменённый исходник |
+| `cacheDir` | string | каталог кэша PNG (относительно `config.yaml`) |
+| `timeoutMs` | int | таймаут рендера одной диаграммы, мс |
+
+Ошибки рендера (нет `jar`, нет Java, синтаксическая ошибка) — **фатальные**: сборка
+прекращается с кодом выхода `1`. Требуется установленная Java (JRE 11+) и файл
+`plantuml.jar`; для части типов диаграмм нужен Graphviz либо встроенный Smlayout.
+
### pageNumber
`align`, `format`, `startFrom`.
@@ -214,6 +240,20 @@ sections:
`file` — относительно `config.paths.assets`. Если файла нет — предупреждение, подпись
всё равно выводится.
+**PlantUML.** Если `file` оканчивается на `.puml` (`.plantuml`, `.pu`, `.iuml`), то файл
+считается исходником PlantUML: инструмент рендерит его в PNG через локальный
+`plantuml.jar` (см. `plantuml` в конфиге) и вставляет результат как обычный рисунок.
+Нумерация, ссылки `@fig:id` и примечания работают так же. Пример:
+
+```md
+::: figure "Диаграмма последовательности" id=seq file=images/seq.puml
+а) запрос клиента;
+б) ответ сервера.
+:::
+```
+
+Ошибка рендера — фатальная (код выхода `1`), `--check` тоже рендерит `.puml`.
+
**Подписи по СТО.** Подпись ставится **под** рисунком по центру: `Рисунок N — Наименование`
(с прописной буквы, без точки в конце). Если на рисунке обозначены отдельные детали,
пояснительные данные размещают **над подписью** и оформляют перечислением `а)`, `б)`, `в)` —
@@ -293,10 +333,11 @@ a — коэффициент.
- корректность `config.yaml` (типы, существование файлов, допустимые буквы приложений);
- наличие всех `sections[].file`;
- существование меток в `@fig/@tbl/@eq`;
-- диапазон ссылок `[@n]`.
+- диапазон ссылок `[@n]`;
+- рендеринг всех `::: figure ... file=*.puml` (PlantUML).
Ошибки печатаются списком и возвращают код выхода `1`. Предупреждения (например,
-отсутствующий PNG) не останавливают сборку.
+отсутствующий PNG) не останавливают сборку; ошибки PlantUML — фатальные.
## 7. Сборка `.exe` из исходников
@@ -312,8 +353,11 @@ npm run check # проверка конфига и ссылок
### Подготовка диаграмм
-Инструмент не рендерит диаграммы: PNG готовятся заранее и кладутся в `content/assets/images/`.
-Диаграммы можно строить любым внешним средством (PlantUML, Mermaid, draw.io, редактор).
+Готовые PNG кладутся в `content/assets/images/`. Для исходников PlantUML (`*.puml`)
+инструмент рендерит PNG сам: положите исходник в `content/assets/images/`, укажите его в
+`::: figure ... file=images/<имя>.puml` и поместите `plantuml.jar` в `tools/plantuml.jar`
+(или задайте путь в `plantuml.jar`). Диаграммы других форматов (Mermaid, draw.io) готовятся
+внешними средствами и вставляются как PNG.
## 8. Замечания по дизайну
diff --git a/docx/README.md b/docx/README.md
new file mode 100644
index 0000000..a7784d8
--- /dev/null
+++ b/docx/README.md
@@ -0,0 +1,44 @@
+# DocxGenerator
+
+Автономный генератор отчётов **DOCX** из **Markdown** по дизайну
+[СТО 02068410-004-2018](documentation/STO_SGAU_02068410-004-2018.md).
+Поведение полностью задаётся файлом `config.yaml`; путь выходного файла — ключ
+`document.output`.
+
+Полная документация: **[AGENTS.md](AGENTS.md)** — CLI, схема конфига, формат контента,
+директивы и сборка `.exe`.
+
+## Быстрый старт
+
+```powershell
+.\DocxGenerator.exe --init # создать config.yaml, примеры content/ и content/assets/
+# ... правим config.yaml и content/*.md ...
+.\DocxGenerator.exe --check # проверить ссылки и конфиг
+.\DocxGenerator.exe # собрать report.docx
+```
+
+## Разработка
+
+```powershell
+npm install
+npm run generate # локальная сборка report.docx
+npm run check # проверка конфига и ссылок
+npm run build:exe # собрать DocxGenerator.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/`. Исходники PlantUML (`*.puml`) можно указать
+в `::: figure ... file=images/x.puml` — генератор отрендерит их в PNG через локальный
+`plantuml.jar` (требуется Java 11+).
diff --git a/config.yaml b/docx/config.yaml
similarity index 89%
rename from config.yaml
rename to docx/config.yaml
index b36143e..d36f0d2 100644
--- a/config.yaml
+++ b/docx/config.yaml
@@ -91,6 +91,15 @@ table:
figure:
maxWidth: null
maxHeight: 620
+plantuml:
+ enabled: true
+ java: auto
+ jar: ../tools/plantuml.jar
+ format: png
+ charset: UTF-8
+ cache: true
+ cacheDir: .plantuml-cache
+ timeoutMs: 30000
pageNumber:
align: center
format: decimal
@@ -121,9 +130,9 @@ sections:
- file: content/01-introduction.md
type: structural
title: Введение
- - file: content/02-analysis.md
+ - file: content/02-method.md
type: numbered
- - file: content/03-design.md
+ - file: content/03-spheres.md
type: numbered
- file: content/04-conclusion.md
type: structural
@@ -133,8 +142,4 @@ sections:
- file: content/06-appendix-a.md
type: appendix
letter: А
- title: Сводные таблицы
- - file: content/07-appendix-b.md
- type: appendix
- letter: Б
- title: Вспомогательные материалы
+ title: Сводная таблица применения метода
diff --git a/docx/content/01-introduction.md b/docx/content/01-introduction.md
new file mode 100644
index 0000000..9737791
--- /dev/null
+++ b/docx/content/01-introduction.md
@@ -0,0 +1,41 @@
+Объектом исследования выступает процесс выведения нового товара на рынок,
+предметом — выбор метода реализации проекта по выводу новинки и его обоснование
+в различных сферах деятельности.
+
+Актуальность работы обусловлена тем, что запуск нового товара сопровождается
+высоким риском: значительная доля новинок не достигает устойчивых продаж, а
+неудачный запуск приводит к прямым убыткам, потерям доли рынка и подрыву доверия
+к бренду. По разным оценкам, до семи из десяти новых товаров уходят с рынка в
+первые годы после появления, поэтому цена ошибки при выводе новинки особенно
+велика [@1], [@2]. В этих условиях выбор управляемого и повторяемого метода
+вывода товара на рынок становится практически значимой задачей: он позволяет
+снизить неопределённость, распределить ресурсы проекта и своевременно отказаться
+от бесперспективного направления [@7].
+
+Под новым товаром в работе понимается продукт, услуга или их значимая
+модификация, ранее не предлагавшиеся компанией целевой аудитории. Выведение
+такого товара на рынок охватывает исследование спроса, разработку предложения,
+его продвижение и получение обратной связи от клиентов. Именно комплексность
+этих этапов и их взаимное влияние порождают основные риски: чем позже компания
+узнаёт реакцию рынка, тем дороже исправлять допущенные ошибки [@5], [@7].
+
+Целью работы является выбор метода реализации проекта по выведению нового товара
+на рынок и обоснование его применимости в десяти сферах деятельности. Для
+достижения цели необходимо решить следующие задачи:
+
+- изучить классические и гибкие методы выведения товара на рынок и их основные
+ ограничения;
+- обосновать выбор метода Agile/Scrum применительно к запуску новинки;
+- описать роли, артефакты и события выбранной методологии;
+- раскрыть механизм проверки спроса через спринты и инкременты;
+- обосновать применимость метода в производстве, связи, на транспорте, в
+ строительстве, коммерческой деятельности, юридических услугах, организации
+ праздников, бытовых услугах, IT-услугах и банковской сфере;
+- сформулировать выводы об универсальности выбранного метода и условиях его
+ эффективного применения.
+
+При выполнении работы использованы методы системного анализа, сравнительного
+анализа методов управления проектами, проектного моделирования и обобщения
+практики [@3], [@4]. Структура работы соответствует поставленным задачам: первый
+раздел посвящён обоснованию выбора метода, второй — его проверке в десяти сферах
+деятельности, заключение содержит основные результаты и выводы.
diff --git a/docx/content/02-method.md b/docx/content/02-method.md
new file mode 100644
index 0000000..93622ab
--- /dev/null
+++ b/docx/content/02-method.md
@@ -0,0 +1,154 @@
+# ВЫБОР МЕТОДА ВЫВЕДЕНИЯ НОВОГО ТОВАРА НА РЫНОК
+
+В разделе рассматриваются основные методы реализации проекта по выведению нового
+товара на рынок, формулируются критерии их сравнения и обосновывается выбор
+методологии Agile/Scrum [@1], [@5].
+
+## Обзор методов выведения товара на рынок
+
+В практике управления проектами сложились как классические, так и гибкие методы
+вывода новинки на рынок. Классические подходы (Waterfall, Stage-Gate) строятся на
+последовательном прохождении этапов и предполагают, что требования известны
+заранее, а результат оценивается на завершающей стадии. Гибкие подходы
+(Design Thinking, Lean Startup, Agile/Scrum) переносят проверку спроса внутрь
+процесса и позволяют уточнять требования по мере получения отклика рынка. Краткая
+характеристика методов приведена в таблице @tbl:methods.
+
+::: table "Сравнение методов выведения товара на рынок" id=methods
+| Метод | Сущность | Основное ограничение |
+|---|---|---|
+| Waterfall (водопад) | последовательные этапы от идеи до запуска | поздняя обратная связь рынка |
+| Stage-Gate | этапы с контрольными «воротами» go/kill | высокая стоимость стадии |
+| Design Thinking | старт от потребности клиента, прототип и тест | не задаёт процесс управления |
+| Lean Startup | минимальный продукт (MVP) и цикл «создать — измерить — научиться» | требует зрелой аналитики |
+| Agile/Scrum | короткие итерации — спринты, готовый инкремент | нужна дисциплина команды |
+| 4P (маркетинг-микс) | проработка товара, цены, каналов, продвижения | не покрывает разработку |
+:::
+
+Сравнение методов проводится по четырём критериям: скорость получения обратной
+связи, способность реагировать на изменения, стоимость проверки спроса и
+прозрачность управления. Как видно из таблицы @tbl:methods, классические методы
+предполагают длительный цикл и получают обратную связь рынка лишь на финальной
+стадии. Для запуска нового товара, спрос на который ещё не подтверждён, это
+создаёт повышенный риск: к моменту первых продаж значительная часть ресурсов уже
+израсходована, а изменение требований требует дорогостоящей переработки.
+Наиболее подходящим признан гибкий подход Agile и его методология Scrum [@2],
+[@6]. Ключевые различия классического и гибкого подходов сведены в таблице
+@tbl:compare.
+
+::: table "Сравнение классического и гибкого подходов" id=compare
+| Критерий | Waterfall | Agile/Scrum |
+|---|---|---|
+| Обратная связь рынка | в конце проекта | в каждом спринте |
+| Реакция на изменения | низкая | высокая |
+| Готовый продукт | к концу проекта | в конце каждого спринта |
+| Стоимость проверки спроса | высокая | низкая (пилотный инкремент) |
+| Требования | фиксируются заранее | уточняются итеративно |
+| Риск неудачного запуска | высокий | снижается поэтапно |
+:::
+
+## Сущность методологии Agile/Scrum
+
+Agile — это гибкий подход к управлению проектами, закреплённый в «Манифесте
+гибкой разработки» 2001 года. Манифест ставит в приоритет людей и взаимодействие
+над процессами и инструментами, работающий продукт над исчерпывающей
+документацией, сотрудничество с заказчиком над согласованием условий и готовность
+к изменениям над следованиям первоначальному плану [@1]. На этих ценностях
+строятся двенадцать принципов, важнейшими из которых для вывода нового товара
+являются ранняя и частая поставка ценности, неразрывная работа бизнеса и
+исполнителей и постоянная адаптация к меняющимся условиям рынка.
+
+Scrum — наиболее распространённая методология Agile: работа разбивается на
+короткие итерации — спринты длительностью от одной до четырёх недель, каждая из
+которых завершается готовым приростом продукта (инкрементом) [@2], [@3]. В основе
+Scrum лежат три идеи: прозрачность (все участники видят состояние работы),
+инспекция (результат регулярно проверяется) и адаптация (процесс меняется по
+результатам проверки). Такой цикл «планирование — работа — проверка — улучшение»
+позволяет проверять спрос небольшими порциями и снижать стоимость ошибки, что
+особенно важно при запуске товара на непроверенном рынке [@3], [@4].
+
+## Роли, артефакты и события Scrum
+
+Методология Scrum опирается на три роли, три артефакта и четыре события. Роли
+отвечают за постановку целей и организацию работы, артефакты фиксируют требования
+и прогресс, а события задают ритм и обеспечивают регулярную обратную связь. Роли
+и артефакты приведены в таблице @tbl:scrum.
+
+::: table "Роли и артефакты методологии Scrum" id=scrum
+| Элемент | Назначение |
+|---|---|
+| Product Owner | отвечает за ценность товара и ведёт Product Backlog |
+| Scrum Master | убирает препятствия, следит за процессом |
+| Команда разработки | 5–9 специалистов, создающих продукт |
+| Product Backlog | перечень всех требований к новому товару |
+| Sprint Backlog | план работ текущего спринта |
+| Increment | готовый к показу прирост продукта |
+:::
+
+Product Owner формирует и упорядочивает Product Backlog по ценности, принимает
+результат спринта и представляет интересы заказчика. Scrum Master отвечает за
+соблюдение правил методологии, помогает команде убирать препятствия и защищает
+спринт от незапланированных работ. Команда разработки — самоорганизующаяся
+группа из пяти–девяти специалистов, которая самостоятельно определяет, сколько
+задач войдёт в спринт, и отвечает за качество инкремента. Каждый артефакт имеет
+своё назначение: Product Backlog хранит все требования к товару, Sprint Backlog —
+план текущей итерации, а Increment — готовую к показу часть продукта. Единое
+понимание готовности фиксируется в критериях Definition of Done, что делает
+результат спринта проверяемым.
+
+Цикл работы методологии при выводе товара на рынок приведён на рисунке
+@fig:scrum-cycle.
+
+::: figure "Цикл Scrum при выводе нового товара на рынок" id=scrum-cycle file=images/scrum-cycle.puml
+а) Product Backlog фиксирует требования к товару;
+б) спринт завершается инкрементом;
+в) Sprint Review даёт обратную связь рынка;
+г) по результатам Review выполняется следующий спринт или масштабирование.
+:::
+
+События Scrum задают ритм работы и обеспечивают инспекцию результата. Sprint
+Planning открывает спринт и определяет его цель и состав работ. Daily Scrum —
+короткая ежедневная встреча для синхронизации команды и выявления препятствий.
+Sprint Review завершает спринт демонстрацией инкремента заказчику и рынку, а
+Sprint Retrospective позволяет команде улучшить собственный процесс. Поскольку
+каждый спринт заканчивается инкрементом, рынок получает способ проверить товар
+до полного запуска производства и масштабных вложений.
+
+Результативность спринта оценивается по величине созданного инкремента и
+получаемому эффекту. Скорость команды — суммарный объём задач, выполненных за
+итерацию, — позволяет прогнозировать сроки вывода товара, а ожидаемый эффект
+инкремента помогает сравнивать ценность планируемых улучшений с затратами на
+спринт. Ожидаемый эффект рассчитывается по формуле @eq:effect.
+
+::: formula id=effect
+E = C * V - Z
+:::
+
+::: where
+E — ожидаемый эффект спринта;
+C — ценность инкремента для клиента;
+V — вероятность подтверждения спроса;
+Z — затраты на проведение спринта.
+:::
+
+Положительный ожидаемый эффект служит основанием для развития товара, а
+отрицательный — для корректировки состава требований. Такой расчёт делает
+решение о продолжении или изменении проекта более обоснованным [@5].
+
+## Обоснование выбора Scrum
+
+Выбор Scrum определяется рядом преимуществ применительно к выводу нового товара на
+рынок [@3], [@4]:
+
+- рынок проверяется дешёвым инкрементом, а не дорогим запуском «с нуля»;
+- обратная связь собирается в каждом спринте, что снижает риск ошибки;
+- требования уточняются итеративно, поэтому изменения не приводят к переработке
+ всего проекта;
+- прозрачность процесса обеспечивается ролями, артефактами и событиями;
+- ранние пилотные продажи формируют выручку ещё до полного запуска;
+- метод не зависит от отрасли и применим там, где есть клиент и обратная связь;
+- скорость команды и результаты спринтов делают план развития измеримым.
+
+Таким образом, Agile/Scrum выбран в качестве метода реализации проекта по
+выведению нового товара на рынок. Его применимость в десяти сферах деятельности
+обоснована в следующем разделе.
diff --git a/docx/content/03-spheres.md b/docx/content/03-spheres.md
new file mode 100644
index 0000000..3005438
--- /dev/null
+++ b/docx/content/03-spheres.md
@@ -0,0 +1,105 @@
+# ОБОСНОВАНИЕ МЕТОДА AGILE/SCRUM В СФЕРАХ ДЕЯТЕЛЬНОСТИ
+
+В разделе обосновывается применимость метода Agile/Scrum в десяти сферах
+деятельности. В каждой сфере рассматривается пример нового товара и показывается,
+как его вывод на рынок организуется через спринты, пилотный инкремент и обратную
+связь [@3], [@6]. Общая логика одинакова для всех сфер: сначала создаётся
+ограниченный пробный продукт, затем замеряется результат, и только после этого
+принимается решение о масштабировании (рисунок @fig:funnel).
+
+::: figure "Воронка вывода нового товара на рынок" id=funnel file=images/product-funnel.puml
+а) спринт 0 уточняет требования и сегмент рынка;
+б) спринты 1–2 создают минимальную версию товара;
+в) спринты 3–4 проверяют спрос на ограниченной аудитории;
+г) начиная со спринта 5 товар масштабируется по подтверждённым метрикам.
+:::
+
+## Производство
+
+Новым товаром выступает линейка упаковки. Вместо единовременного перезапуска всех
+линий выпускается пробная партия на одной линии, которая играет роль инкремента.
+В ходе спринта замеряются себестоимость партии, доля брака и скорость переналадки
+оборудования. Sprint Review с оптовыми покупателями показывает востребованность
+новой упаковки, после чего производство масштабируется на остальные линии, а
+замечания клиентов становятся задачами следующего спринта.
+
+## Связь
+
+Новый товар — тариф «Умный дом». Пилотный спринт запускает тариф для ста
+абонентов, в ходе Sprint Review анализируются подключения, средний чек и отток.
+Обратная связь абонентов позволяет скорректировать состав услуг и условия
+подключения. По результатам обратной связи тариф дорабатывается и выводится на
+массовый рынок без риска массового оттока при неудачной конфигурации.
+
+## Транспорт
+
+Новым товаром является сервис каршеринга мини-фургонов. Пилот реализуется пятью
+машинами в одном районе; метрики загрузки, среднего времени аренды и спроса
+оцениваются в конце спринта. Если загрузка подтверждает спрос, инкремент
+признаётся успешным, и автопарк расширяется за счёт новых районов. Такой подход
+позволяет проверить экономику сервиса до крупных капитальных вложений в технику.
+
+## Строительство
+
+Новый товар — модульные дома. Первый спринт предполагает возведение одного
+пилотного модуля и его показ заказчикам. На Sprint Review фиксируются затраты на
+монтаж, сроки и замечания по проекту, после чего они учитываются в следующем
+инкременте — серийном модуле. Итеративная проверка снижает риск ошибок в типовом
+проекте, который затем тиражируется на серию.
+
+## Коммерческая деятельность
+
+Новым товаром выступает подписка на наборы продуктов. Бокс-пилот на пятидесяти
+клиентах позволяет измерить конверсию, средний чек и удержание. Обратная связь
+покупателей формирует Sprint Backlog для доработки ассортимента и условий
+подписки. Если удержание растёт, объём подписки увеличивается, а состав наборов
+уточняется по отклику клиентов.
+
+## Предоставление юридических услуг
+
+Новый товар — пакет «юрфирма на аутсорсе». Пробный месяц для пяти клиентов даёт
+инкремент услуги; на Sprint Review оцениваются востребованность, трудозатраты и
+повторные обращения. На основе отзывов формируются тарифы и состав пакета.
+Поэтапная проверка позволяет выстроить услугу под реальные ожидания клиентов, не
+вкладываясь сразу в расширение штата юристов.
+
+## Организация праздников
+
+Новым товаром является онлайн-конструктор мероприятий. Один сценарий тестируется
+на пробной группе клиентов; метрики вовлечённости, повторных заказов и средней
+стоимости мероприятия анализируются в конце спринта. Успешный сценарий
+становится основой каталога и автоматизации заказов, а непопулярные сценарии
+исключаются без затрат на массовое продвижение.
+
+## Предоставление бытовых услуг
+
+Новый товар — подписка на уборку. Пилот с двадцатью подписчиками в одном районе
+позволяет измерить удержание, среднее число заказов и загрузку исполнителей. По
+результатам спринта сервис дорабатывается и масштабируется на другие районы.
+Пилотный формат помогает наладить логистику и стандарты качества до выхода на
+городской уровень.
+
+## IT-услуги
+
+Новым товаром выступает модуль SaaS-платформы. Бета-версия для десяти клиентов
+является инкрементом; на Sprint Review собираются метрики использования, частоты
+ошибок и NPS. Обратная связь определяет состав следующего релиза и приоритеты
+доработки. Такой цикл соответствует самой природе гибкой разработки и позволяет
+выпускать ценность небольшими, но регулярными порциями.
+
+## Банковские услуги
+
+Новый товар — вклад «Цель» в мобильном приложении. Тест на ограниченной группе
+пользователей позволяет оценить долю открытий, средний срок и удержание. Даже в
+регулируемой сфере спринты обеспечивают безопасную проверку спроса до полного
+внедрения: пилот проводится в контролируемых условиях, а результат проходит
+согласование с подразделениями банка.
+
+## Обобщение по сферам
+
+Рассмотренные примеры показывают, что различия между сферами касаются в первую
+очередь содержания товара и применяемых метрик, тогда как структура процесса
+остаётся общей. Везде выделяются заказчик, готовый участвовать в проверке
+инкремента, и измеримый результат спринта, на основании которого принимается
+решение о продолжении работы. Именно эта общность позволяет считать Agile/Scrum
+универсальным методом выведения нового товара на рынок [@3], [@6].
diff --git a/docx/content/04-conclusion.md b/docx/content/04-conclusion.md
new file mode 100644
index 0000000..6f6bbc1
--- /dev/null
+++ b/docx/content/04-conclusion.md
@@ -0,0 +1,37 @@
+В работе выбран метод реализации проекта по выведению нового товара на рынок и
+обоснована его применимость в десяти сферах деятельности.
+
+В результате сравнительного анализа методов установлено, что классические подходы
+(Waterfall, Stage-Gate) предполагают длительный цикл и позднюю обратную связь
+рынка, что повышает риск запуска новинки. Сравнение по критериям скорости
+обратной связи, реакции на изменения, стоимости проверки спроса и прозрачности
+управления (таблица @tbl:compare) показало, что для непроверенного рынка
+предпочтителен гибкий подход. В качестве метода выбран Agile/Scrum, поскольку он
+проверяет спрос дешёвым инкрементом в каждом спринте и позволяет корректировать
+товар по отклику рынка.
+
+Показано, что методология Scrum опирается на роли (Product Owner, Scrum Master,
+команда разработки), артефакты (Product Backlog, Sprint Backlog, Increment) и
+события (Sprint Planning, Daily Scrum, Sprint Review, Sprint Retrospective), что
+делает процесс прозрачным и управляемым [@2], [@3]. В работе раскрыт механизм
+оценки результативности итераций: ожидаемый эффект спринта рассчитывается по
+формуле @eq:effect, а решение о развитии товара принимается по результатам Sprint
+Review. Такой подход заменяет один дорогой запуск серией последовательных
+проверок и снижает стоимость возможной ошибки.
+
+Обоснована применимость метода в производстве, связи, на транспорте, в
+строительстве, коммерческой деятельности, юридических услугах, организации
+праздников, бытовых услугах, IT-услугах и банковских услугах. В каждой сфере
+вывод товара на рынок строится по единой логике: ограниченный пробный продукт —
+замер результата — масштабирование. Различия касаются содержания товара и набора
+метрик, тогда как структура процесса и роль обратной связи остаются неизменными.
+
+Практическая значимость работы состоит в том, что предложенный метод можно
+применять как типовой алгоритм: выделить сегмент и требования, создать MVP,
+провести пилот, измерить результат и принять решение о масштабировании. Это
+позволяет компании управлять риском запуска новинки и обосновывать вложения
+перед расширением производства или сервиса.
+
+Таким образом, выбранный метод Agile/Scrum является универсальным и позволяет
+снизить риск и стоимость ошибки при выведении нового товара на рынок в различных
+сферах деятельности.
diff --git a/docx/content/05-bibliography.md b/docx/content/05-bibliography.md
new file mode 100644
index 0000000..ead8926
--- /dev/null
+++ b/docx/content/05-bibliography.md
@@ -0,0 +1,7 @@
+- Манифест гибкой разработки программного обеспечения. — 2001. — URL: https://agilemanifesto.org (дата обращения: 01.10.2026).
+- Schwaber K., Sutherland J. The Scrum Guide: The Definitive Guide to Scrum. — 2020. — URL: https://scrumguides.org (дата обращения: 01.10.2026).
+- Сазерленд Дж. Scrum. Революционный метод управления проектами. — М.: Манн, Иванов и Фербер, 2016. — 288 с.
+- ГОСТ Р 54869-2011. Проектный менеджмент. Требования к управлению проектом. — М.: Стандартинформ, 2011. — 9 с.
+- Cooper R. G. Perspective: The Stage-Gate Idea-to-Launch Process. — Journal of Product Innovation Management, 2008. — Vol. 25. — P. 213–232.
+- Рис Э. Бизнес с нуля. Lean Startup для быстрого тестирования идей. — М.: Альпина Паблишер, 2014. — 253 с.
+- Котлер Ф. Основы маркетинга. Краткий курс. — М.: Вильямс, 2019. — 496 с.
diff --git a/docx/content/06-appendix-a.md b/docx/content/06-appendix-a.md
new file mode 100644
index 0000000..d118f10
--- /dev/null
+++ b/docx/content/06-appendix-a.md
@@ -0,0 +1,17 @@
+Сводная характеристика применения метода Agile/Scrum в десяти сферах деятельности
+приведена в таблице @tbl:sphere-summary.
+
+::: table "Применение метода Agile/Scrum в сферах деятельности" id=sphere-summary
+| № | Сфера | Новый товар | Пилотный инкремент спринта |
+|---|---|---|---|
+| 1 | Производство | новая линейка упаковки | пробная партия на одной линии |
+| 2 | Связь | тариф «Умный дом» | пилот на 100 абонентов |
+| 3 | Транспорт | каршеринг мини-фургонов | пять машин в одном районе |
+| 4 | Строительство | модульные дома | один пилотный модуль |
+| 5 | Коммерческая деятельность | подписка на наборы продуктов | бокс-пилот на 50 клиентов |
+| 6 | Юридические услуги | пакет «юрфирма на аутсорсе» | пробный месяц для 5 клиентов |
+| 7 | Организация праздников | онлайн-конструктор мероприятий | один сценарий на пробной группе |
+| 8 | Бытовые услуги | подписка на уборку | 20 подписчиков в одном районе |
+| 9 | IT-услуги | модуль SaaS-платформы | бета на 10 клиентов |
+| 10 | Банковские услуги | вклад «Цель» в приложении | тест на группе пользователей |
+:::
diff --git a/docx/content/assets/images/product-funnel.puml b/docx/content/assets/images/product-funnel.puml
new file mode 100644
index 0000000..8d70ad1
--- /dev/null
+++ b/docx/content/assets/images/product-funnel.puml
@@ -0,0 +1,19 @@
+@startuml
+skinparam defaultFontName "Times New Roman"
+skinparam defaultFontSize 13
+skinparam shadowing false
+skinparam ArrowColor #1B87C9
+skinparam rectangleBorderColor #1B87C9
+skinparam rectangleBackgroundColor #EAF4FB
+
+title Воронка вывода нового товара на рынок
+
+rectangle "Идея и исследование рынка\nСпринт 0" as A
+rectangle "MVP — минимальный товар\nСпринты 1–2" as B
+rectangle "Пилот на ограниченной аудитории\nСпринты 3–4" as C
+rectangle "Масштабирование товара\nСпринты 5 и далее" as D
+
+A --> B : спрос подтверждён
+B --> C : инкремент принят
+C --> D : метрики достигнуты
+@enduml
diff --git a/docx/content/assets/images/scrum-cycle.puml b/docx/content/assets/images/scrum-cycle.puml
new file mode 100644
index 0000000..0791e3e
--- /dev/null
+++ b/docx/content/assets/images/scrum-cycle.puml
@@ -0,0 +1,26 @@
+@startuml
+skinparam defaultFontName "Times New Roman"
+skinparam defaultFontSize 13
+skinparam shadowing false
+skinparam ArrowColor #1B87C9
+skinparam rectangleBorderColor #1B87C9
+skinparam rectangleBackgroundColor #EAF4FB
+
+title Цикл Scrum при выводе нового товара на рынок
+
+rectangle "Product Backlog — требования к товару" as PB
+rectangle "Sprint Planning" as SP
+rectangle "Спринт 1–4 недели" as S
+rectangle "Increment — пробный продукт" as I
+rectangle "Sprint Review — обратная связь рынка" as R
+rectangle "Sprint Retrospective" as RET
+rectangle "Масштабирование товара" as M
+
+PB --> SP
+SP --> S
+S --> I
+I --> R
+R --> RET
+RET --> SP : следующий спринт
+R --> M : спрос подтверждён
+@enduml
diff --git a/docx/content/assets/title-page.docx b/docx/content/assets/title-page.docx
new file mode 100644
index 0000000..ad44a2c
Binary files /dev/null and b/docx/content/assets/title-page.docx differ
diff --git a/documentation/STO_SGAU_02068410-004-2018.md b/docx/documentation/STO_SGAU_02068410-004-2018.md
similarity index 100%
rename from documentation/STO_SGAU_02068410-004-2018.md
rename to docx/documentation/STO_SGAU_02068410-004-2018.md
diff --git a/package-lock.json b/docx/package-lock.json
similarity index 100%
rename from package-lock.json
rename to docx/package-lock.json
diff --git a/package.json b/docx/package.json
similarity index 100%
rename from package.json
rename to docx/package.json
diff --git a/src/build.js b/docx/src/build.js
similarity index 94%
rename from src/build.js
rename to docx/src/build.js
index 527dab6..3852529 100644
--- a/src/build.js
+++ b/docx/src/build.js
@@ -8,6 +8,7 @@ const { buildContext } = require('./render/styles');
const { collectNumbers } = require('./render/references');
const { createState, renderSections, buildDocx } = require('./render/document');
const { mergeDocxBuffers } = require('./render/titlepage');
+const { resolvePlantUml } = require('./plantuml/resolve');
function loadSections(config, md) {
const sections = [];
@@ -33,6 +34,7 @@ async function buildDocument(config, { check = false } = {}) {
const state = createState(md, sections);
collectNumbers(sections, ctx, state);
+ await resolvePlantUml(ctx, sections, state);
const children = renderSections(ctx, state);
if (check) return { state, buffer: null };
diff --git a/src/cli.js b/docx/src/cli.js
similarity index 100%
rename from src/cli.js
rename to docx/src/cli.js
diff --git a/src/config/defaults.js b/docx/src/config/defaults.js
similarity index 93%
rename from src/config/defaults.js
rename to docx/src/config/defaults.js
index bc92a0e..f4d37af 100644
--- a/src/config/defaults.js
+++ b/docx/src/config/defaults.js
@@ -85,6 +85,17 @@ const DEFAULT_CONFIG = {
maxHeight: 620,
},
+ plantuml: {
+ enabled: true,
+ java: 'auto',
+ jar: 'tools/plantuml.jar',
+ format: 'png',
+ charset: 'UTF-8',
+ cache: true,
+ cacheDir: '.plantuml-cache',
+ timeoutMs: 30000,
+ },
+
pageNumber: { align: 'center', format: 'decimal', startFrom: 1 },
toc: { enabled: true, title: 'Содержание', depth: 3 },
diff --git a/src/config/load.js b/docx/src/config/load.js
similarity index 83%
rename from src/config/load.js
rename to docx/src/config/load.js
index 58d6987..a70cbbb 100644
--- a/src/config/load.js
+++ b/docx/src/config/load.js
@@ -19,6 +19,18 @@ function resolvePaths(config, baseDir) {
config.document.titlePage = resolveAgainst(baseDir, config.document.titlePage);
config.paths.assets = resolveAgainst(baseDir, config.paths.assets) || baseDir;
+ if (config.plantuml) {
+ config.plantuml.jar = resolveAgainst(baseDir, config.plantuml.jar);
+ config.plantuml.cacheDir = resolveAgainst(baseDir, config.plantuml.cacheDir);
+ if (
+ config.plantuml.java &&
+ config.plantuml.java !== 'auto' &&
+ /[\\/]/.test(config.plantuml.java)
+ ) {
+ config.plantuml.java = resolveAgainst(baseDir, config.plantuml.java);
+ }
+ }
+
config.sections = (config.sections || []).map((entry) => {
if (!entry || typeof entry !== 'object') return entry;
if (entry.type === 'toc' || !entry.file) return { ...entry };
diff --git a/src/config/template.js b/docx/src/config/template.js
similarity index 100%
rename from src/config/template.js
rename to docx/src/config/template.js
diff --git a/src/config/validate.js b/docx/src/config/validate.js
similarity index 77%
rename from src/config/validate.js
rename to docx/src/config/validate.js
index e48b4ae..6e94084 100644
--- a/src/config/validate.js
+++ b/docx/src/config/validate.js
@@ -89,6 +89,31 @@ function validateConfig(config) {
}
}
+ if (config.plantuml) {
+ const p = config.plantuml;
+ if (typeof p.enabled !== 'boolean') {
+ errors.push('plantuml.enabled: ожидалось true/false');
+ }
+ if (p.format !== 'png') {
+ errors.push(`plantuml.format: поддерживается только "png", получено "${p.format}"`);
+ }
+ if (typeof p.jar !== 'string' || !p.jar) {
+ errors.push('plantuml.jar: ожидался путь к plantuml.jar');
+ }
+ if (typeof p.java !== 'string' || !p.java) {
+ errors.push('plantuml.java: ожидалось имя или путь к java (или "auto")');
+ }
+ if (p.cacheDir != null && typeof p.cacheDir !== 'string') {
+ errors.push('plantuml.cacheDir: ожидалась строка');
+ }
+ if (!isPositiveInt(p.timeoutMs)) {
+ errors.push('plantuml.timeoutMs: ожидалось положительное целое (мс)');
+ }
+ if (typeof p.cache !== 'boolean') {
+ errors.push('plantuml.cache: ожидалось true/false');
+ }
+ }
+
return errors;
}
diff --git a/src/index.js b/docx/src/index.js
similarity index 100%
rename from src/index.js
rename to docx/src/index.js
diff --git a/src/markdown/container.js b/docx/src/markdown/container.js
similarity index 100%
rename from src/markdown/container.js
rename to docx/src/markdown/container.js
diff --git a/src/markdown/inline.js b/docx/src/markdown/inline.js
similarity index 100%
rename from src/markdown/inline.js
rename to docx/src/markdown/inline.js
diff --git a/src/markdown/parse.js b/docx/src/markdown/parse.js
similarity index 100%
rename from src/markdown/parse.js
rename to docx/src/markdown/parse.js
diff --git a/docx/src/plantuml/render.js b/docx/src/plantuml/render.js
new file mode 100644
index 0000000..28026b4
--- /dev/null
+++ b/docx/src/plantuml/render.js
@@ -0,0 +1,148 @@
+'use strict';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const crypto = require('crypto');
+const { spawn } = require('child_process');
+
+const { BuildError } = require('../util/errors');
+
+const SOURCE_EXTENSIONS = new Set(['.puml', '.plantuml', '.pu', '.iuml']);
+
+/** Является ли файл исходником PlantUML (а не готовым изображением). */
+function isPlantUmlSource(filePath) {
+ if (!filePath) return false;
+ return SOURCE_EXTENSIONS.has(path.extname(String(filePath)).toLowerCase());
+}
+
+/**
+ * Определяет исполняемый файл Java. Значение `auto` (по умолчанию) предпочитает
+ * JAVA_HOME/bin/java, иначе — `java` из PATH.
+ */
+function resolveJava(javaCfg) {
+ if (javaCfg && javaCfg !== 'auto') return javaCfg;
+ const home = process.env.JAVA_HOME;
+ if (home) {
+ const exe = process.platform === 'win32' ? 'java.exe' : 'java';
+ const candidate = path.join(home, 'bin', exe);
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ return 'java';
+}
+
+function runProcess(command, args, input, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ let child;
+ try {
+ child = spawn(command, args, { windowsHide: true });
+ } catch (err) {
+ reject(new BuildError(`PlantUML: не удалось запустить "${command}": ${err.message}`));
+ return;
+ }
+
+ const stdout = [];
+ const stderr = [];
+ let settled = false;
+ let timer = null;
+
+ const finish = (fn, arg) => {
+ if (settled) return;
+ settled = true;
+ if (timer) clearTimeout(timer);
+ fn(arg);
+ };
+
+ if (timeoutMs > 0) {
+ timer = setTimeout(() => {
+ child.kill();
+ finish(reject, new BuildError(`PlantUML: превышено время ожидания (${timeoutMs} мс)`));
+ }, timeoutMs);
+ }
+
+ child.on('error', (err) => {
+ finish(
+ reject,
+ new BuildError(`PlantUML: не удалось запустить "${command}": ${err.message}`)
+ );
+ });
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
+ child.on('close', (code) => {
+ finish(resolve, {
+ code,
+ stdout: Buffer.concat(stdout),
+ stderr: Buffer.concat(stderr).toString('utf8').trim(),
+ });
+ });
+
+ child.stdin.end(input == null ? '' : input);
+ });
+}
+
+/** Рендерит исходник PlantUML в PNG через java -jar plantuml.jar -pipe. */
+async function renderPlantUml(sourceBuffer, cfg) {
+ if (!cfg.jar) {
+ throw new BuildError('PlantUML: не задан путь к plantuml.jar (plantuml.jar)');
+ }
+ if (!fs.existsSync(cfg.jar)) {
+ throw new BuildError(`PlantUML: plantuml.jar не найден: ${cfg.jar}`);
+ }
+ const java = resolveJava(cfg.java);
+ const args = [
+ '-jar',
+ cfg.jar,
+ '-pipe',
+ `-t${cfg.format || 'png'}`,
+ '-charset',
+ cfg.charset || 'UTF-8',
+ '-failfast2',
+ '-nometadata',
+ ];
+ const result = await runProcess(java, args, sourceBuffer, cfg.timeoutMs);
+ if (result.code !== 0 || result.stdout.length === 0) {
+ const detail = result.stderr || `код выхода ${result.code}`;
+ throw new BuildError(`PlantUML: ошибка рендеринга: ${detail}`);
+ }
+ return result.stdout;
+}
+
+function jarFingerprint(cfg) {
+ try {
+ const st = fs.statSync(cfg.jar);
+ return `${st.size}:${Math.round(st.mtimeMs)}`;
+ } catch {
+ return 'nojar';
+ }
+}
+
+function cacheFileName(sourceBuffer, cfg) {
+ const hash = crypto
+ .createHash('sha1')
+ .update(sourceBuffer)
+ .update(`|${cfg.format || 'png'}`)
+ .update(`|${jarFingerprint(cfg)}`)
+ .digest('hex');
+ return `${hash}.png`;
+}
+
+/**
+ * Возвращает PNG для исходника. Использует кэш (если cfg.cache) и всегда
+ * оставляет файл на диске, чтобы его можно было вставить синхронно.
+ * @returns {Promise<{buffer: Buffer, cachePath: string}>}
+ */
+async function renderCached(sourceBuffer, cfg) {
+ const dir = cfg.cacheDir || path.join(os.tmpdir(), 'docxgen-plantuml');
+ const target = path.join(dir, cacheFileName(sourceBuffer, cfg));
+
+ if (cfg.cache && fs.existsSync(target)) {
+ return { buffer: fs.readFileSync(target), cachePath: target };
+ }
+
+ const buffer = await renderPlantUml(sourceBuffer, cfg);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(target, buffer);
+ return { buffer, cachePath: target };
+}
+
+module.exports = { isPlantUmlSource, resolveJava, renderPlantUml, renderCached };
diff --git a/docx/src/plantuml/resolve.js b/docx/src/plantuml/resolve.js
new file mode 100644
index 0000000..81e5346
--- /dev/null
+++ b/docx/src/plantuml/resolve.js
@@ -0,0 +1,69 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+const { isPlantUmlSource, renderCached } = require('./render');
+
+function resolveAssetPath(assetsRoot, file) {
+ return path.isAbsolute(file) ? file : path.resolve(assetsRoot, file);
+}
+
+/**
+ * Проход 1.5: рендерит все директивы ::: figure с file=*.puml в PNG и
+ * прикрепляет путь к готовому изображению к атрибутам токена.
+ * Ошибки складываются в state.errors (строгий режим).
+ */
+async function resolvePlantUml(ctx, sections, state) {
+ const cfg = ctx.config.plantuml;
+ const jobs = [];
+
+ for (const section of sections) {
+ if (!section.tokens) continue;
+ for (const token of section.tokens) {
+ if (token.type !== 'directive') continue;
+ const { name, attrs } = token.info;
+ if (name === 'figure' && isPlantUmlSource(attrs.file)) jobs.push({ token, attrs });
+ }
+ }
+
+ if (jobs.length === 0) return;
+
+ if (!cfg || cfg.enabled === false) {
+ for (const { attrs } of jobs) {
+ state.errors.push(
+ `Рисунок "${attrs.title || ''}": PlantUML отключён (plantuml.enabled=false), а file=${attrs.file}`
+ );
+ }
+ return;
+ }
+
+ for (const { token, attrs } of jobs) {
+ const resolved = resolveAssetPath(ctx.assetsRoot, attrs.file);
+ if (!fs.existsSync(resolved)) {
+ state.errors.push(
+ `Рисунок "${attrs.title || ''}": файл PlantUML не найден: ${attrs.file}`
+ );
+ continue;
+ }
+
+ let source;
+ try {
+ source = fs.readFileSync(resolved);
+ } catch (err) {
+ state.errors.push(
+ `Рисунок "${attrs.title || ''}": не удалось прочитать ${attrs.file}: ${err.message}`
+ );
+ continue;
+ }
+
+ try {
+ const { cachePath } = await renderCached(source, cfg);
+ token.info.attrs.__renderedImage = cachePath;
+ } catch (err) {
+ state.errors.push(`Рисунок "${attrs.title || ''}": ${err.message}`);
+ }
+ }
+}
+
+module.exports = { resolvePlantUml, resolveAssetPath };
diff --git a/src/render/document.js b/docx/src/render/document.js
similarity index 98%
rename from src/render/document.js
rename to docx/src/render/document.js
index e97b614..3e7f8c8 100644
--- a/src/render/document.js
+++ b/docx/src/render/document.js
@@ -30,6 +30,7 @@ const {
pageNumberFooter,
REFS,
} = require('./primitives');
+const { isPlantUmlSource } = require('../plantuml/render');
function createState(md, sections) {
return {
@@ -274,7 +275,10 @@ function renderDirective(ctx, token, state) {
const notes = body
? body.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
: [];
- if (attrs.file) {
+ let imagePath = attrs.file;
+ if (isPlantUmlSource(attrs.file)) {
+ imagePath = attrs.__renderedImage || null;
+ } else if (attrs.file) {
const resolved = path.isAbsolute(attrs.file)
? attrs.file
: path.resolve(ctx.assetsRoot, attrs.file);
@@ -285,7 +289,7 @@ function renderDirective(ctx, token, state) {
return figureBlock(ctx, {
label: cap.label,
title: attrs.title || '',
- imagePath: attrs.file,
+ imagePath,
notesLines: notes,
});
}
diff --git a/src/render/primitives.js b/docx/src/render/primitives.js
similarity index 100%
rename from src/render/primitives.js
rename to docx/src/render/primitives.js
diff --git a/src/render/references.js b/docx/src/render/references.js
similarity index 100%
rename from src/render/references.js
rename to docx/src/render/references.js
diff --git a/src/render/sample-titlepage.js b/docx/src/render/sample-titlepage.js
similarity index 100%
rename from src/render/sample-titlepage.js
rename to docx/src/render/sample-titlepage.js
diff --git a/src/render/styles.js b/docx/src/render/styles.js
similarity index 100%
rename from src/render/styles.js
rename to docx/src/render/styles.js
diff --git a/src/render/titlepage.js b/docx/src/render/titlepage.js
similarity index 60%
rename from src/render/titlepage.js
rename to docx/src/render/titlepage.js
index 1cbc1ad..43bd05d 100644
--- a/src/render/titlepage.js
+++ b/docx/src/render/titlepage.js
@@ -5,6 +5,62 @@ const JSZip = require('jszip');
const PARTS_TO_PATCH = ['word/numbering.xml', 'word/styles.xml', 'word/document.xml'];
+const NUMBERING_PART = 'word/numbering.xml';
+const NUMBERING_CONTENT_TYPE =
+ '';
+const NUMBERING_REL_TYPE =
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering';
+
+/** Повторяет патч numId из document.xml (fileIndex=1) для numbering.xml. */
+function patchNumberingNumIds(xml, fileIndex) {
+ return xml.replace(/ ` Number(m[1]));
+ return `rId${(ids.length ? Math.max(...ids) : 0) + 1}`;
+}
+
+/**
+ * docx-merger теряет word/numbering.xml основного документа, из-за чего в Word
+ * пропадает нумерация заголовков и списков. Возвращаем часть и её связи.
+ */
+async function ensureNumberingPart(mergedBuffer, mainBuffer) {
+ const mergedZip = await JSZip.loadAsync(mergedBuffer);
+ if (mergedZip.file(NUMBERING_PART)) return mergedBuffer;
+
+ const mainZip = await JSZip.loadAsync(mainBuffer);
+ const mainNumbering = mainZip.file(NUMBERING_PART);
+ if (!mainNumbering) return mergedBuffer;
+
+ const numberingXml = patchNumberingNumIds(await mainNumbering.async('string'), 1);
+ mergedZip.file(NUMBERING_PART, numberingXml);
+
+ const contentTypes = mergedZip.file('[Content_Types].xml');
+ if (contentTypes) {
+ let xml = await contentTypes.async('string');
+ if (!xml.includes('/word/numbering.xml')) {
+ xml = xml.replace('', `${NUMBERING_CONTENT_TYPE}`);
+ mergedZip.file('[Content_Types].xml', xml);
+ }
+ }
+
+ const rels = mergedZip.file('word/_rels/document.xml.rels');
+ if (rels) {
+ let xml = await rels.async('string');
+ if (!xml.includes(NUMBERING_REL_TYPE)) {
+ const id = nextRelId(xml);
+ xml = xml.replace(
+ '',
+ ``
+ );
+ mergedZip.file('word/_rels/document.xml.rels', xml);
+ }
+ }
+
+ return mergedZip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
+}
+
/** docx-merger переименовывает numId в numbering.xml, но не в document.xml. */
function patchDocumentNumIds(xml, fileIndex) {
return xml.replace(
@@ -117,17 +173,17 @@ async function mergeDocxBuffers(prependBuffer, mainBuffer) {
const mainBin = toBinary(mainPrepared);
const prependBin = toBinary(prependPrepared);
+ const mainBuf = Buffer.isBuffer(mainBuffer) ? mainBuffer : Buffer.from(mainBin, 'binary');
+
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);
+ const patched = await patchDocxNamespaces(merged, mainBuf);
+ const withNumbering = await ensureNumberingPart(patched, mainBuf);
+ resolve(withNumbering);
} catch (err) {
reject(err);
}
@@ -138,4 +194,4 @@ async function mergeDocxBuffers(prependBuffer, mainBuffer) {
});
}
-module.exports = { mergeDocxBuffers };
+module.exports = { mergeDocxBuffers, ensureNumberingPart };
diff --git a/src/util/deepmerge.js b/docx/src/util/deepmerge.js
similarity index 100%
rename from src/util/deepmerge.js
rename to docx/src/util/deepmerge.js
diff --git a/src/util/errors.js b/docx/src/util/errors.js
similarity index 100%
rename from src/util/errors.js
rename to docx/src/util/errors.js
diff --git a/src/util/logger.js b/docx/src/util/logger.js
similarity index 100%
rename from src/util/logger.js
rename to docx/src/util/logger.js
diff --git a/src/util/png.js b/docx/src/util/png.js
similarity index 100%
rename from src/util/png.js
rename to docx/src/util/png.js
diff --git a/pptx/AGENTS.md b/pptx/AGENTS.md
new file mode 100644
index 0000000..9c97910
--- /dev/null
+++ b/pptx/AGENTS.md
@@ -0,0 +1,212 @@
+# AGENTS.md — генератор lab-gen-pptx
+
+`lab-gen-pptx` собирает презентацию **PPTX** из обычных **Markdown**-файлов по
+шаблону Самарского университета. Поведение задаётся файлом `config.yaml`.
+
+## 1. Требования
+
+- Node.js 18+.
+- Зависимости: `pptxgenjs`, `js-yaml`.
+- Для директив `::: image` с файлами `.puml` — Java 11+ (берётся из
+ `JAVA_HOME`, иначе `java` из PATH) и файл `plantuml.jar` (см. `config.plantuml`).
+
+## 2. Состав
+
+```
+config.yaml # настройки (тема, тексты, таблицы, список слайдов)
+content/
+ slides/*.md # по одному Markdown-файлу на слайд
+ assets/ # задники (title-bg.jpeg, content-bg.jpg, final-bg.jpeg),
+ # готовые PNG/JPEG и исходники PlantUML (*.puml)
+presentation.pptx # результат сборки (ключ presentation.output)
+.plantuml-cache/ # кэш отрендеренных PlantUML (ключ plantuml.cacheDir)
+src/ # cli.js, config.js, parse.js, render.js, plantuml.js, build.js
+```
+
+Все относительные пути считаются **от каталога `config.yaml`**.
+
+Общий `plantuml.jar` монорепозитория лежит в корне, в `tools/plantuml.jar`
+(`plantuml.jar: ../tools/plantuml.jar`). Для автономного дистрибутива положите
+`tools/plantuml.jar` рядом с `config.yaml` и задайте путь `tools/plantuml.jar`.
+
+## 3. Командная строка
+
+```
+lab-gen-pptx [config.yaml] [опции]
+
+ -c, --config путь к config.yaml (по умолчанию config.yaml)
+ -o, --output переопределить имя выходного файла
+ --check только проверить конфиг и ссылки
+ -v, --verbose подробный вывод
+ -h, --help справка
+ --version версия
+```
+
+```powershell
+npm run check
+npm run generate
+```
+
+## 4. Схема `config.yaml`
+
+| Раздел | Назначение |
+|---|---|
+| `presentation` | `output`, `author`, `track` |
+| `theme` | `width`/`height` (дюймы), `font`, `colors`, `backgrounds`, `slideNumber` |
+| `text` | макеты текста для типов слайдов `titleSlide`, `section`, `content`, `final` |
+| `table` | размер шрифта, цвета шапки/ячеек/границ |
+| `callout` | плашка-вывод: заливка, рамка, цвета и размеры шрифтов |
+| `metrics` | карточки-метрики: заливка, цвета, размеры значения и подписи |
+| `flow` | нативная схема-цепочка: заливка, рамка, стрелки, размер |
+| `chart` | палитра, размер шрифта, легенда, высота диаграммы |
+| `plantuml` | рендеринг `*.puml` (см. п. 5) |
+| `content.dir` | каталог контента |
+| `slides` | список слайдов: `{ type, file }` |
+
+Типы слайдов: `title`, `section`, `content`, `final`. Каждому типу соответствует
+макет (`slideMaster`) с собственным фоном:
+
+| type | Фон | Оформление |
+|---|---|---|
+| `title` | `title-bg.jpeg` | синий, белый текст в правой половине |
+| `section` | `title-bg.jpeg` | разделитель, крупный белый текст справа |
+| `content` | `content-bg.jpg` | белый лист, заголовок в верхней синей плашке |
+| `final` | `final-bg.jpeg` | синий, белый текст справа |
+
+Номер слайда выводится в правом нижнем углу для `content` и `section`.
+
+## 5. Формат слайда (Markdown)
+
+```md
+# Заголовок слайда -> заголовок
+## Подзаголовок -> подзаголовок (для title/final)
+
+- пункт списка -> маркер
+ - вложенный пункт -> вложенный маркер
+
+Обычный абзац. **Жирный** текст, *курсив*, ==выделение== и `код`
+поддерживаются в абзацах, пунктах и ячейках таблиц.
+
+::: table
+| Столбец A | Столбец B |
+|---|---|
+| 1 | 2 |
+:::
+
+::: image assets/pic.png
+подпись под рисунком
+:::
+
+::: notes
+Заметки докладчика (используются как сценарий выступления).
+:::
+```
+
+### Богатое форматирование текста
+
+В абзацах, пунктах списка и ячейках таблиц работает inline-разметка:
+
+| Разметка | Результат |
+|---|---|
+| `**текст**` | полужирный |
+| `*текст*` | курсив |
+| `==текст==` | акцентный (брендовый) полужирный |
+| `` `текст` `` | моноширинный (Consolas) |
+
+Пункты списка рисуются с висячим отступом, поэтому многострочные пункты с
+форматированием сохраняют маркер и выравнивание.
+
+### Директива `::: callout "Заголовок"`
+
+Плашка-вывод с заливкой и рамкой. Внутри — абзацы и/или пункты списка:
+
+```md
+::: callout "Итог"
+Рынок проверяется ==дешёвым инкрементом==, а не дорогим запуском.
+:::
+```
+
+Параметр `side=left|right` ставит плашку в левую или правую колонку слайда.
+Если у слайда есть блоки с `side`, они верстаются в две колонки (левая/правая),
+а остальные блоки — как обычно. Типовое сочетание: `::: image ... side=left`
+и `::: callout ... side=right`.
+
+### Директива `::: metrics`
+
+Карточки-метрики (до четырёх). Каждая строка — `Значение | Подпись`
+(допускается разделитель `—`):
+
+```md
+::: metrics
+70% | новых товаров уходят с рынка
+10 | сфер применения метода
+:::
+```
+
+### Директива `::: flow`
+
+Нативная схема-цепочка из скруглённых блоков со стрелками. Шаги разделяются
+`->` (или `→`), можно писать по одному на строку:
+
+```md
+::: flow
+Sprint Planning -> Спринт -> Sprint Review -> Ретроспектива
+:::
+```
+
+Если ширина блока становится малой, схема автоматически разворачивается
+вертикально.
+
+### Директива `::: chart <тип> "Заголовок"`
+
+Диаграмма на основе GFM-таблицы: первая колонка — категории, остальные —
+числовые ряды (заголовки колонок = имена рядов). Поддерживаемые типы:
+`bar`, `bar3d`, `line`, `pie`, `doughnut`, `area`, `radar`, `scatter`.
+
+```md
+::: chart doughnut "Новинки, которые не выживают"
+| Итог | Доля, % |
+|---|---|
+| Успешные новинки | 30 |
+| Уходят с рынка | 70 |
+:::
+```
+
+### Директива `::: image` и PlantUML
+
+`file` — относительно `content.dir` (`content/`). Если файл оканчивается на
+`.puml` (`.plantuml`, `.pu`, `.iuml`), он рендерится в PNG через `plantuml.jar`
+(см. `config.plantuml`) и вставляется как обычный рисунок:
+
+```md
+::: image assets/scrum-cycle.puml height=3.2
+Цикл Scrum: от Product Backlog до масштабирования товара
+:::
+```
+
+Подпись (строка под директивой) выводится под рисунком курсивом. Растровые
+PNG/JPEG вставляются как есть. Необязательный параметр `height=<дюймы>`
+(`h=`) ограничивает высоту рисунка, чтобы уместить под ним другие блоки;
+пропорции сохраняются, рисунок центрируется. Параметр `side=left|right`
+ставит рисунок в колонку (см. `::: callout`).
+
+## 6. Задники
+
+Изображения-задники извлечены из `presentation_16.pptx` в
+`content/assets/` (`title-bg.jpeg`, `content-bg.jpg`, `final-bg.jpeg`). При замене
+шаблона положите новые файлы и обновите пути в `theme.backgrounds`.
+
+## 7. Шрифт
+
+В шаблоне используется фирменный `Elektra Text Pro`, который обычно не установлен
+в системе (иначе Word подставляет случайный шрифт). Поэтому по умолчанию задан
+`Segoe UI` — близкий современный sans. Альтернативы: `Calibri`, `Candara`,
+`Corbel`, `Noto Sans`, `Arial`. Смените `theme.font` при необходимости.
+
+## 8. Проверка результата
+
+Презентацию можно отрендерить для визуальной проверки через LibreOffice:
+
+```powershell
+soffice --headless --convert-to pdf --outdir . presentation.pptx
+```
diff --git a/pptx/config.yaml b/pptx/config.yaml
new file mode 100644
index 0000000..31a02df
--- /dev/null
+++ b/pptx/config.yaml
@@ -0,0 +1,130 @@
+# Конфигурация генератора презентаций lab-gen-pptx.
+# Шаблон: Самарский университет, presentation_16.pptx.
+# Все относительные пути считаются от каталога этого файла.
+
+presentation:
+ output: presentation.pptx
+ author: "Степанов П.А."
+ track: "ДОП 11"
+ group: "6403-020302D"
+
+theme:
+ width: 13.333
+ height: 7.5
+ # Шрифт презентации. Elektra Text Pro (фирменный) не установлен в системе,
+ # поэтому используется близкий современный sans. Замените при необходимости.
+ font: "Segoe UI"
+ colors:
+ white: "FFFFFF"
+ ink: "1F2933"
+ brand: "1B87C9"
+ muted: "6B7280"
+ accent: "0E6BA8"
+ tint: "EAF4FB"
+ line: "BFD3DD"
+ backgrounds:
+ title: content/assets/title-bg.jpeg
+ content: content/assets/content-bg.jpg
+ final: content/assets/final-bg.jpeg
+ slideNumber:
+ show: true
+ x: 12.17
+ y: 6.86
+ w: 0.5
+ h: 0.42
+ fontSize: 14
+ # color — для тёмных фонов (разделитель), contentColor — для белых (контент)
+ color: "FFFFFF"
+ contentColor: "FFFFFF"
+ bold: true
+ align: "center"
+
+text:
+ titleSlide:
+ title: { x: 6.7, y: 1.85, w: 5.95, h: 1.9, fontSize: 28, align: center, color: white }
+ subtitle: { x: 6.7, y: 3.75, w: 5.95, h: 0.7, fontSize: 16, align: center, color: white }
+ body: { x: 6.7, y: 4.65, w: 5.95, h: 1.7, fontSize: 14, align: center, color: white }
+ section:
+ title: { x: 6.4, y: 2.55, w: 6.35, h: 2.4, fontSize: 26, bold: true, align: center, color: white }
+ content:
+ title: { x: 0.6, y: 0.20, w: 12.15, h: 0.66, fontSize: 24, align: center, color: white, valign: middle }
+ body: { x: 0.75, y: 0.98, w: 11.85, h: 5.7, fontSize: 18, align: left, color: ink, valign: top }
+ bulletSize: 18
+ subBulletSize: 16
+ final:
+ title: { x: 6.7, y: 2.85, w: 5.95, h: 1.6, fontSize: 28, bold: true, align: center, color: white }
+ body: { x: 6.7, y: 4.65, w: 5.95, h: 1.7, fontSize: 14, align: center, color: white }
+
+table:
+ fontSize: 14
+ headerFill: "1B87C9"
+ headerColor: "FFFFFF"
+ cellColor: "1F2933"
+ borderColor: "BFD3DD"
+
+callout:
+ fill: "EAF4FB"
+ border: "1B87C9"
+ titleColor: "0E6BA8"
+ textColor: "1F2933"
+ titleFontSize: 18
+ fontSize: 15
+ lineWidth: 1.25
+
+metrics:
+ fill: "1B87C9"
+ valueColor: "FFFFFF"
+ labelColor: "EAF4FB"
+ valueFontSize: 30
+ labelFontSize: 13
+ gap: 0.2
+ height: 1.25
+
+flow:
+ fill: "EAF4FB"
+ border: "1B87C9"
+ textColor: "1F2933"
+ arrowColor: "1B87C9"
+ fontSize: 13
+ height: 1.0
+ gap: 0.4
+
+chart:
+ palette: ["1B87C9", "7FB2D9", "0E6BA8", "BFD3DD"]
+ fontSize: 12
+ legend: true
+ height: 3.4
+
+# Рендеринг PlantUML (::: image assets/schema.puml).
+# Требуется Java 11+ (берётся из JAVA_HOME, иначе java из PATH) и plantuml.jar.
+# Общий jar монорепозитория лежит в корне: tools/plantuml.jar.
+plantuml:
+ enabled: true
+ java: auto
+ jar: ../tools/plantuml.jar
+ format: png
+ charset: UTF-8
+ cache: true
+ cacheDir: .plantuml-cache
+ timeoutMs: 30000
+
+content:
+ dir: content
+
+slides:
+ - { type: title, file: slides/00-title.md }
+ - { type: content, file: slides/01-agenda.md }
+ - { type: content, file: slides/01-task.md }
+ - { type: content, file: slides/02-why-scrum.md }
+ - { type: content, file: slides/03-roles.md }
+ - { type: content, file: slides/04-events.md }
+ - { type: content, file: slides/12-scrum-cycle.md }
+ - { type: content, file: slides/05-launch.md }
+ - { type: section, file: slides/06-spheres.md }
+ - { type: content, file: slides/07a-spheres-1-2.md }
+ - { type: content, file: slides/07b-spheres-3-4.md }
+ - { type: content, file: slides/07c-spheres-5-6.md }
+ - { type: content, file: slides/07d-spheres-7-8.md }
+ - { type: content, file: slides/07e-spheres-9-10.md }
+ - { type: content, file: slides/10-conclusion.md }
+ - { type: final, file: slides/11-final.md }
diff --git a/pptx/content/assets/content-bg.jpg b/pptx/content/assets/content-bg.jpg
new file mode 100644
index 0000000..3ffbef6
Binary files /dev/null and b/pptx/content/assets/content-bg.jpg differ
diff --git a/pptx/content/assets/final-bg.jpeg b/pptx/content/assets/final-bg.jpeg
new file mode 100644
index 0000000..625e81a
Binary files /dev/null and b/pptx/content/assets/final-bg.jpeg differ
diff --git a/pptx/content/assets/scrum-cycle.puml b/pptx/content/assets/scrum-cycle.puml
new file mode 100644
index 0000000..cd0600c
--- /dev/null
+++ b/pptx/content/assets/scrum-cycle.puml
@@ -0,0 +1,24 @@
+@startuml
+skinparam defaultFontName "Times New Roman"
+skinparam defaultFontSize 13
+skinparam shadowing false
+skinparam ArrowColor #1B87C9
+skinparam rectangleBorderColor #1B87C9
+skinparam rectangleBackgroundColor #EAF4FB
+
+rectangle "Product Backlog — требования к товару" as PB
+rectangle "Sprint Planning" as SP
+rectangle "Спринт 1–4 недели" as S
+rectangle "Increment — пробный продукт" as I
+rectangle "Sprint Review — обратная связь рынка" as R
+rectangle "Sprint Retrospective" as RET
+rectangle "Масштабирование товара" as M
+
+PB --> SP
+SP --> S
+S --> I
+I --> R
+R --> RET
+RET --> SP : следующий спринт
+R --> M : спрос подтверждён
+@enduml
diff --git a/pptx/content/assets/title-bg.jpeg b/pptx/content/assets/title-bg.jpeg
new file mode 100644
index 0000000..f89fd0d
Binary files /dev/null and b/pptx/content/assets/title-bg.jpeg differ
diff --git a/pptx/content/slides/00-title.md b/pptx/content/slides/00-title.md
new file mode 100644
index 0000000..f7be6c0
--- /dev/null
+++ b/pptx/content/slides/00-title.md
@@ -0,0 +1,13 @@
+# Современные проектные технологии
+
+## Метод выведения нового товара на рынок: Agile / Scrum
+
+Степанов П.А., группа 6403-020302D
+
+Слушатель программы профессиональной переподготовки «Современные проектные технологии»
+
+ИОТ-трек «ДОП 11»
+
+::: notes
+Добрый день, уважаемые члены итоговой аттестационной комиссии. Меня зовут Степанов П.А., я слушатель программы профессиональной переподготовки «Современные проектные технологии», ИОТ-трек «ДОП 11», группа 6403-020302D. Разрешите представить решение итогового задания по теме «Современные проектные технологии» — метод выведения нового товара на рынок.
+:::
diff --git a/pptx/content/slides/01-agenda.md b/pptx/content/slides/01-agenda.md
new file mode 100644
index 0000000..a968cd7
--- /dev/null
+++ b/pptx/content/slides/01-agenda.md
@@ -0,0 +1,12 @@
+# План доклада
+
+- ==Метод== выведения нового товара: Agile / Scrum
+- Почему выбран именно Scrum
+- Роли, артефакты и события методологии
+- Как спринты проверяют спрос на рынке
+- Применение в ==десяти сферах== деятельности
+- Выводы и практический результат
+
+::: notes
+Структура доклада. Сначала я обосную выбор метода — Agile и Scrum. Затем покажу, как устроена методология: роли, артефакты и события. Отдельно объясню, как спринты позволяют проверять спрос на рынке. После этого докажу универсальность метода на десяти сферах деятельности: от производства до банковских услуг. В завершение приведу выводы и практический результат.
+:::
diff --git a/pptx/content/slides/01-task.md b/pptx/content/slides/01-task.md
new file mode 100644
index 0000000..f3fd8b6
--- /dev/null
+++ b/pptx/content/slides/01-task.md
@@ -0,0 +1,19 @@
+# Постановка задачи
+
+- Выбрать метод выведения нового товара на рынок
+- Обосновать его применимость в ==десяти сферах== деятельности
+- Показать, как метод работает в каждой сфере
+
+::: metrics
+70% | новых товаров уходят с рынка
+10 | сфер применения метода
+4 | недели — максимальная длина спринта
+:::
+
+::: callout "Проблема и решение"
+Запуск новинки — это **высокий риск**. Нужен управляемый и повторяемый процесс вывода товара — таким процессом является **Agile / Scrum**.
+:::
+
+::: notes
+Задача итогового задания — выбрать метод выведения нового товара на рынок и обосновать его в десяти сферах: от производства до банковских услуг. Запуск новинки всегда сопряжён с риском: до семи из десяти новых товаров не выживают на рынке. Поэтому нужен управляемый и повторяемый процесс, и в качестве такого метода я выбрал Agile и его самую популярную методологию — Scrum.
+:::
diff --git a/pptx/content/slides/02-why-scrum.md b/pptx/content/slides/02-why-scrum.md
new file mode 100644
index 0000000..d68e907
--- /dev/null
+++ b/pptx/content/slides/02-why-scrum.md
@@ -0,0 +1,18 @@
+# Почему выбран Agile / Scrum
+
+- Agile — гибкий подход к управлению проектами, закреплён в ==манифесте 2001 года==
+- Scrum — самая распространённая Agile-методология в мире
+- Работа идёт короткими итерациями — *спринтами* от одной до четырёх недель
+- Каждый спринт даёт готовый прирост продукта — ==инкремент==
+- Обратная связь с рынком собирается на каждом шаге, а не в конце проекта
+
+::: chart doughnut "Новинки, которые не выживают на рынке"
+| Итог | Доля, % |
+|---|---|
+| Успешные новинки | 30 |
+| Уходят с рынка | 70 |
+:::
+
+::: notes
+Почему именно Agile и Scrum. Agile — это гибкий подход к управлению проектами, закреплённый в манифесте 2001 года. Scrum — самая распространённая Agile-методология. Работа разбивается на короткие спринты длиной от одной до четырёх недель. Каждый спринт завершается готовым приростом продукта. Благодаря этому обратная связь с рынком собирается постоянно, а не только в конце. Именно поэтому гибкий подход снижает риск: как показывают оценки, до семи из десяти новинок не выживают при традиционном запуске.
+:::
diff --git a/pptx/content/slides/03-roles.md b/pptx/content/slides/03-roles.md
new file mode 100644
index 0000000..cd9ddcc
--- /dev/null
+++ b/pptx/content/slides/03-roles.md
@@ -0,0 +1,21 @@
+# Scrum: роли и артефакты
+
+- **Product Owner** — отвечает за ценность товара и ведёт Product Backlog
+- **Scrum Master** — убирает препятствия и следит за соблюдением процесса
+- **Команда разработки** — от пяти до девяти специалистов, создающих продукт
+
+::: table
+| Артефакт Scrum | Назначение |
+|---|---|
+| Product Backlog | все требования к новому товару |
+| Sprint Backlog | план работ текущего спринта |
+| Increment | готовый к показу прирост продукта |
+:::
+
+::: callout "Прозрачность"
+Все артефакты видны команде и заказчику, а критерии готовности зафиксированы в ==Definition of Done==.
+:::
+
+::: notes
+В Scrum три роли. Product Owner отвечает за ценность товара и ведёт Product Backlog — список всех требований. Scrum Master убирает препятствия и следит за соблюдением процесса. Команда разработки из пяти–девяти специалистов создаёт продукт. Три артефакта: Product Backlog со всеми требованиями, Sprint Backlog с планом текущего спринта и Increment — готовый прирост продукта. Единое понимание готовности фиксируется в критериях Definition of Done.
+:::
diff --git a/pptx/content/slides/04-events.md b/pptx/content/slides/04-events.md
new file mode 100644
index 0000000..66d0ee6
--- /dev/null
+++ b/pptx/content/slides/04-events.md
@@ -0,0 +1,18 @@
+# Scrum: события спринта
+
+::: flow
+Sprint Planning -> Спринт -> Sprint Review -> Ретроспектива
+:::
+
+- **Sprint Planning** — планируем, что войдёт в спринт
+- **Daily Scrum** — пятнадцать минут на синхронизацию команды
+- **Sprint Review** — показываем прирост заказчику и рынку
+- **Sprint Retrospective** — улучшаем процесс работы команды
+
+::: callout "Результат спринта"
+Каждый спринт заканчивается рабочим ==инкрементом== — кандидатом на рынок.
+:::
+
+::: notes
+Спринт состоит из четырёх событий. На Sprint Planning команда решает, что войдёт в спринт. Daily Scrum — пятнадцатиминутная синхронизация каждый день. На Sprint Review прирост показывают заказчику и рынку. На Sprint Retrospective команда улучшает свой процесс. Каждый спринт заканчивается рабочим инкрементом, который можно показать рынку. Далее цикл повторяется: планирование, спринт, демонстрация, ретроспектива.
+:::
diff --git a/pptx/content/slides/05-launch.md b/pptx/content/slides/05-launch.md
new file mode 100644
index 0000000..11ee86a
--- /dev/null
+++ b/pptx/content/slides/05-launch.md
@@ -0,0 +1,18 @@
+# Как Scrum выводит товар на рынок
+
+::: flow
+Спринт 0: исследование -> MVP -> Пилот и метрики -> Масштабирование
+:::
+
+- **Спринт 0** — исследование рынка, выбор сегмента, формирование Product Backlog
+- **Спринты 1–2** — создание MVP: минимальной версии товара для первых клиентов
+- **Спринты 3–4** — пилот на ограниченной аудитории и сбор метрик
+- **Спринт 5 и далее** — доработка по обратной связи и масштабирование
+
+::: callout "Итог"
+Рынок проверяется ==дешёвым инкрементом==, а не дорогим запуском «с нуля».
+:::
+
+::: notes
+Как Scrum выводит товар на рынок. Спринт ноль — исследование рынка и формирование списка требований. Спринты один и два — создание минимального продукта для первых клиентов. Спринты три и четыре — пилот на ограниченной аудитории и сбор метрик. Дальше — доработка по обратной связи и масштабирование. Демонстрация реальным клиентам позволяет проверить спрос до крупных вложений. Итог: рынок проверяется дешёвым инкрементом, а не дорогим запуском с нуля.
+:::
diff --git a/pptx/content/slides/06-spheres.md b/pptx/content/slides/06-spheres.md
new file mode 100644
index 0000000..e34fee5
--- /dev/null
+++ b/pptx/content/slides/06-spheres.md
@@ -0,0 +1,7 @@
+# Agile / Scrum в десяти сферах деятельности
+
+Обоснование универсальности выбранного метода
+
+::: notes
+Теперь покажу, что метод Agile и Scrum применим не только в IT, но и в десяти различных сферах деятельности.
+:::
diff --git a/pptx/content/slides/07a-spheres-1-2.md b/pptx/content/slides/07a-spheres-1-2.md
new file mode 100644
index 0000000..56b58d2
--- /dev/null
+++ b/pptx/content/slides/07a-spheres-1-2.md
@@ -0,0 +1,21 @@
+# Сферы 1–2: производство и связь
+
+::: callout "1. Производство — новая линейка упаковки"
+- Новый товар: линейка упаковки для розницы.
+- Пилотный спринт: пробная партия на ==одной линии==.
+- Метрика Sprint Review: себестоимость, доля брака, скорость переналадки.
+- Решение: масштабирование на остальные линии по подтверждённому спросу.
+- Особенность: переналадка одной линии дешевле полного перезапуска.
+:::
+
+::: callout "2. Связь — тариф «Умный дом»"
+- Новый товар: тариф «Умный дом» для абонентов.
+- Пилотный спринт: подключение ==100 абонентов==.
+- Метрика Sprint Review: число подключений, средний чек, отток.
+- Решение: доработка состава услуг и вывод тарифа на массовый рынок.
+- Особенность: пилот ограничен абонентской базой и не грозит массовым оттоком.
+:::
+
+::: notes
+Первые две сферы. В производстве новая линейка упаковки запускается пробной партией на одной линии: замеряют себестоимость, долю брака и скорость переналадки. По результатам Sprint Review производство масштабируется на остальные линии. В связи тариф «Умный дом» тестируется на ста абонентах: анализируют подключения, средний чек и отток, после чего состав услуг уточняется и тариф выводится на массовый рынок. Общая логика: ограниченный пилот, замер метрики, затем масштабирование.
+:::
diff --git a/pptx/content/slides/07b-spheres-3-4.md b/pptx/content/slides/07b-spheres-3-4.md
new file mode 100644
index 0000000..060080a
--- /dev/null
+++ b/pptx/content/slides/07b-spheres-3-4.md
@@ -0,0 +1,21 @@
+# Сферы 3–4: транспорт и строительство
+
+::: callout "3. Транспорт — каршеринг мини-фургонов"
+- Новый товар: сервис каршеринга мини-фургонов.
+- Пилотный спринт: ==пять машин== в одном районе.
+- Метрика Sprint Review: загрузка, среднее время аренды, спрос.
+- Решение: расширение автопарка и запуск в новых районах.
+- Особенность: экономика сервиса проверяется до закупки техники.
+:::
+
+::: callout "4. Строительство — модульные дома"
+- Новый товар: модульные дома для застройки.
+- Пилотный спринт: возведение ==одного пилотного модуля== и показ заказчикам.
+- Метрика Sprint Review: затраты на монтаж, сроки, замечания.
+- Решение: серийный модуль с учётом правок и тиражирование.
+- Особенность: типовой проект снижает стоимость серии.
+:::
+
+::: notes
+Транспорт и строительство. В транспорте сервис каршеринга мини-фургонов стартует с пяти машин в одном районе: оценивают загрузку, среднее время аренды и спрос. Если экономика подтверждается, автопарк расширяется. В строительстве сначала возводится один пилотный модульный дом и показывается заказчикам: фиксируют затраты, сроки и замечания, после чего серийный модуль тиражируется. Итеративная проверка снижает риск ошибок в типовом проекте.
+:::
diff --git a/pptx/content/slides/07c-spheres-5-6.md b/pptx/content/slides/07c-spheres-5-6.md
new file mode 100644
index 0000000..d076692
--- /dev/null
+++ b/pptx/content/slides/07c-spheres-5-6.md
@@ -0,0 +1,21 @@
+# Сферы 5–6: коммерция и юридические услуги
+
+::: callout "5. Коммерция — подписка на наборы продуктов"
+- Новый товар: подписка на наборы продуктов.
+- Пилотный спринт: бокс-пилот на ==50 клиентах==.
+- Метрика Sprint Review: конверсия, средний чек, удержание.
+- Решение: доработка ассортимента и условий подписки.
+- Особенность: состав набора гибко меняется по спросу.
+:::
+
+::: callout "6. Юридические услуги — пакет «юрфирма на аутсорсе»"
+- Новый товар: пакет «юрфирма на аутсорсе».
+- Пилотный спринт: пробный месяц для ==5 клиентов==.
+- Метрика Sprint Review: востребованность, трудозатраты, повторные обращения.
+- Решение: тарифы и состав пакета формируются по отзывам.
+- Особенность: расширение штата юристов только после подтверждения спроса.
+:::
+
+::: notes
+Коммерция и юридические услуги. В коммерции подписка на продуктовые наборы запускается бокс-пилотом на пятидесяти клиентах: измеряют конверсию, средний чек и удержание, после чего уточняют ассортимент и условия. В юридических услугах пакет «юрфирма на аутсорсе» предлагается пяти клиентам на пробный месяц: оценивают востребованность и трудозатраты. Пилот позволяет выстроить услугу под реальные ожидания клиентов, не вкладываясь сразу в расширение штата.
+:::
diff --git a/pptx/content/slides/07d-spheres-7-8.md b/pptx/content/slides/07d-spheres-7-8.md
new file mode 100644
index 0000000..d498e7f
--- /dev/null
+++ b/pptx/content/slides/07d-spheres-7-8.md
@@ -0,0 +1,21 @@
+# Сферы 7–8: организация праздников и бытовые услуги
+
+::: callout "7. Организация праздников — онлайн-конструктор мероприятий"
+- Новый товар: онлайн-конструктор мероприятий.
+- Пилотный спринт: ==один сценарий== на пробной группе.
+- Метрика Sprint Review: вовлечённость, повторные заказы, средняя стоимость.
+- Решение: каталог успешных сценариев и автоматизация заказов.
+- Особенность: непопулярные сценарии не масштабируются и не продвигаются.
+:::
+
+::: callout "8. Бытовые услуги — подписка на уборку"
+- Новый товар: подписка на уборку.
+- Пилотный спринт: ==20 подписчиков== в одном районе.
+- Метрика Sprint Review: удержание, число заказов, загрузка исполнителей.
+- Решение: стандарты качества и масштабирование на другие районы.
+- Особенность: логистика и качество отрабатываются в пилоте.
+:::
+
+::: notes
+Организация праздников и бытовые услуги. Онлайн-конструктор мероприятий тестируется на одной пробной группе: смотрят вовлечённость, повторные заказы и среднюю стоимость. Успешный сценарий становится основой каталога. Подписка на уборку стартует с двадцати подписчиков в одном районе: измеряют удержание, число заказов и загрузку исполнителей, после чего налаживают логистику и стандарты качества и выходят на другие районы.
+:::
diff --git a/pptx/content/slides/07e-spheres-9-10.md b/pptx/content/slides/07e-spheres-9-10.md
new file mode 100644
index 0000000..9d9c9c2
--- /dev/null
+++ b/pptx/content/slides/07e-spheres-9-10.md
@@ -0,0 +1,21 @@
+# Сферы 9–10: IT-услуги и банки
+
+::: callout "9. IT-услуги — модуль SaaS-платформы"
+- Новый товар: новый модуль SaaS-платформы.
+- Пилотный спринт: ==бета на 10 клиентов==.
+- Метрика Sprint Review: использование, частота ошибок, NPS.
+- Решение: состав следующего релиза по обратной связи.
+- Особенность: регулярные релизы — естественный режим гибкой разработки.
+:::
+
+::: callout "10. Банковские услуги — вклад «Цель»"
+- Новый товар: вклад «Цель» в мобильном приложении.
+- Пилотный спринт: тест на ==ограниченной группе пользователей==.
+- Метрика Sprint Review: доля открытий, средний срок, удержание.
+- Решение: полное внедрение после согласования с подразделениями банка.
+- Особенность: пилот проводится в контролируемых регулируемых условиях.
+:::
+
+::: notes
+Последние две сферы. В IT новый модуль SaaS-платформы выходит в бету на десять клиентов: измеряют использование, частоту ошибок и NPS, а обратная связь определяет состав следующего релиза — это естественный режим гибкой разработки. В банковских услугах вклад «Цель» тестируется на ограниченной группе пользователей приложения: оценивают долю открытий, срок и удержание. Даже в регулируемой сфере пилот в контролируемых условиях позволяет безопасно проверить спрос до полного внедрения.
+:::
diff --git a/pptx/content/slides/10-conclusion.md b/pptx/content/slides/10-conclusion.md
new file mode 100644
index 0000000..a052590
--- /dev/null
+++ b/pptx/content/slides/10-conclusion.md
@@ -0,0 +1,17 @@
+# Выводы
+
+- Agile/Scrum — ==универсальный метод== выведения товара на рынок
+- Спринты заменяют один дорогой запуск серией дешёвых проверок
+- Роли, артефакты и события делают процесс прозрачным и управляемым
+- Метод работает везде, где есть клиент и обратная связь — от производства до банков
+- Результат — снижение риска и стоимости ошибки при запуске новинки
+
+::: metrics
+10 | сфер применения
+30% | новинок выживают
+4 | недели — длина спринта
+:::
+
+::: notes
+Таким образом, Agile и Scrum — универсальный метод выведения товара на рынок. Спринты заменяют один дорогой запуск серией дешёвых проверок. Роли, артефакты и события делают процесс прозрачным и управляемым. Метод работает везде, где есть клиент и обратная связь: от производства до банковских услуг. Результат — снижение риска и стоимости ошибки при запуске новинки.
+:::
diff --git a/pptx/content/slides/11-final.md b/pptx/content/slides/11-final.md
new file mode 100644
index 0000000..561018e
--- /dev/null
+++ b/pptx/content/slides/11-final.md
@@ -0,0 +1,9 @@
+# Спасибо за внимание
+
+Степанов П.А., группа 6403-020302D
+
+Слушатель программы профессиональной переподготовки «Современные проектные технологии», ИОТ-трек «ДОП 11»
+
+::: notes
+На этом мой доклад закончен, спасибо за внимание. Готов ответить на ваши вопросы.
+:::
diff --git a/pptx/content/slides/12-scrum-cycle.md b/pptx/content/slides/12-scrum-cycle.md
new file mode 100644
index 0000000..394dfd5
--- /dev/null
+++ b/pptx/content/slides/12-scrum-cycle.md
@@ -0,0 +1,17 @@
+# Жизненный цикл Scrum
+
+::: image assets/scrum-cycle.puml side=left
+Цикл Scrum: от Product Backlog до масштабирования товара
+:::
+
+::: callout "Как замкнут цикл" side=right
+- Product Backlog фиксирует требования к товару.
+- Спринт длится 1–4 недели и завершается готовым ==инкрементом==.
+- Sprint Review показывает инкремент рынку, ретроспектива улучшает процесс.
+- Подтверждённый спрос ведёт к масштабированию, иначе цикл повторяется.
+- Каждый виток спирали снижает неопределённость и стоимость ошибки.
+:::
+
+::: notes
+На этой схеме слева показан цикл Scrum. Из Product Backlog формируется план спринта. Спринт длится от одной до четырёх недель и завершается инкрементом — готовым пробным продуктом. На Sprint Review рынок даёт обратную связь, после чего команда проводит ретроспективу. Если спрос подтверждён, товар масштабируется; если нет — цикл повторяется с уточнёнными требованиями. Каждый виток снижает неопределённость.
+:::
diff --git a/pptx/package-lock.json b/pptx/package-lock.json
new file mode 100644
index 0000000..e101935
--- /dev/null
+++ b/pptx/package-lock.json
@@ -0,0 +1,203 @@
+{
+ "name": "lab-gen-pptx",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "lab-gen-pptx",
+ "version": "1.0.0",
+ "dependencies": {
+ "js-yaml": "^4.1.0",
+ "pptxgenjs": "^3.12.0"
+ },
+ "bin": {
+ "lab-gen-pptx": "src/cli.js"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "18.19.130",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/https": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
+ "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
+ "license": "ISC"
+ },
+ "node_modules/image-size": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
+ "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
+ "license": "MIT",
+ "dependencies": {
+ "queue": "6.0.2"
+ },
+ "bin": {
+ "image-size": "bin/image-size.js"
+ },
+ "engines": {
+ "node": ">=16.x"
+ }
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jszip": {
+ "version": "3.10.2",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+ "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/pptxgenjs": {
+ "version": "3.12.0",
+ "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-3.12.0.tgz",
+ "integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^18.7.3",
+ "https": "^1.0.0",
+ "image-size": "^1.0.0",
+ "jszip": "^3.7.1"
+ }
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/queue": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "~2.0.3"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "license": "MIT"
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/pptx/package.json b/pptx/package.json
new file mode 100644
index 0000000..9e7f179
--- /dev/null
+++ b/pptx/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "lab-gen-pptx",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Генератор презентаций PPTX из Markdown по шаблону Самарского университета",
+ "bin": {
+ "lab-gen-pptx": "src/cli.js"
+ },
+ "main": "src/index.js",
+ "scripts": {
+ "generate": "node src/cli.js",
+ "check": "node src/cli.js --check",
+ "init": "node src/cli.js --init"
+ },
+ "dependencies": {
+ "js-yaml": "^4.1.0",
+ "pptxgenjs": "^3.12.0"
+ }
+}
diff --git a/pptx/src/build.js b/pptx/src/build.js
new file mode 100644
index 0000000..d361ca8
--- /dev/null
+++ b/pptx/src/build.js
@@ -0,0 +1,105 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const { loadConfig, validateConfig } = require("./config");
+const { parseSlide } = require("./parse");
+const { renderDeck } = require("./render");
+const { resolveImages, isPlantUmlSource } = require("./plantuml");
+
+function resolveSlideFile(slide, config) {
+ if (path.isAbsolute(slide.file)) return slide.file;
+ return path.resolve(config.content.dir, slide.file);
+}
+
+function resolveAssetFile(config, file) {
+ if (path.isAbsolute(file)) return file;
+ return path.resolve(config.content.dir, file);
+}
+
+function collectIssues(config) {
+ const issues = [...validateConfig(config)];
+
+ for (const [name, file] of Object.entries(config.theme.backgrounds)) {
+ if (file && !fs.existsSync(file)) {
+ issues.push(`Не найден фон "${name}": ${file}`);
+ }
+ }
+
+ (config.slides || []).forEach((slide, i) => {
+ if (!slide || !slide.file) return;
+ const file = resolveSlideFile(slide, config);
+ if (!fs.existsSync(file)) {
+ issues.push(`Слайд #${i + 1}: не найден файл ${file}`);
+ }
+ });
+
+ return issues;
+}
+
+function checkImages(config, deck) {
+ const issues = [];
+ for (const entry of deck) {
+ for (const block of entry.parsed.blocks) {
+ if (block.kind !== "image" || !block.file) continue;
+ const resolved = resolveAssetFile(config, block.file);
+ if (!fs.existsSync(resolved)) {
+ const kind = isPlantUmlSource(block.file) ? "исходник PlantUML" : "изображение";
+ issues.push(`Слайд #${entry.index}: не найден ${kind}: ${block.file}`);
+ }
+ }
+ }
+ return issues;
+}
+
+function buildDeck(config) {
+ return config.slides.map((slide, i) => {
+ const file = resolveSlideFile(slide, config);
+ const markdown = fs.readFileSync(file, "utf8");
+ return {
+ index: i + 1,
+ file,
+ type: slide.type || "content",
+ parsed: parseSlide(markdown),
+ };
+ });
+}
+
+async function build(options = {}) {
+ const configPath = options.config || "config.yaml";
+ const { config, configDir } = loadConfig(configPath);
+ const issues = collectIssues(config);
+
+ let deck = null;
+ if (issues.length === 0) {
+ deck = buildDeck(config);
+ issues.push(...checkImages(config, deck));
+ }
+
+ if (options.check || options.dryRun) {
+ return { config, configDir, issues, output: null, written: false };
+ }
+
+ if (issues.length) {
+ const error = new Error("Конфигурация содержит ошибки:\n - " + issues.join("\n - "));
+ error.issues = issues;
+ throw error;
+ }
+
+ const renderIssues = await resolveImages(config, deck);
+ if (renderIssues.length) {
+ const error = new Error("Не удалось подготовить изображения:\n - " + renderIssues.join("\n - "));
+ error.issues = renderIssues;
+ throw error;
+ }
+
+ const pptx = renderDeck(config, deck);
+ const output = options.output
+ ? path.resolve(options.output)
+ : path.resolve(configDir, config.presentation.output);
+ await pptx.writeFile({ fileName: output });
+
+ return { config, configDir, issues, deck, output, written: true };
+}
+
+module.exports = { build, buildDeck, collectIssues, checkImages, resolveSlideFile };
diff --git a/pptx/src/cli.js b/pptx/src/cli.js
new file mode 100644
index 0000000..24f968b
--- /dev/null
+++ b/pptx/src/cli.js
@@ -0,0 +1,96 @@
+#!/usr/bin/env node
+"use strict";
+
+const path = require("path");
+const { build } = require("./build");
+
+function parseArgs(argv) {
+ const options = { config: "config.yaml" };
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i];
+ switch (arg) {
+ case "--config":
+ case "-c":
+ options.config = argv[++i];
+ break;
+ case "--output":
+ case "-o":
+ options.output = argv[++i];
+ break;
+ case "--check":
+ options.check = true;
+ break;
+ case "--verbose":
+ case "-v":
+ options.verbose = true;
+ break;
+ case "--help":
+ case "-h":
+ options.help = true;
+ break;
+ case "--version":
+ options.version = true;
+ break;
+ default:
+ if (!arg.startsWith("-")) {
+ options.config = arg;
+ } else {
+ console.warn(`Неизвестный аргумент: ${arg}`);
+ }
+ }
+ }
+ return options;
+}
+
+function printHelp() {
+ console.log(`lab-gen-pptx — генератор презентаций PPTX из Markdown
+
+Использование:
+ lab-gen-pptx [config.yaml] [опции]
+
+Опции:
+ -c, --config путь к config.yaml (по умолчанию config.yaml)
+ -o, --output переопределить имя выходного файла
+ --check только проверить конфигурацию и ссылки
+ -v, --verbose подробный вывод
+ -h, --help показать справку
+ --version показать версию
+`);
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) return printHelp();
+ if (options.version) {
+ const pkg = require("../package.json");
+ console.log(pkg.version);
+ return undefined;
+ }
+
+ const configPath = path.resolve(options.config);
+ try {
+ const result = await build({ config: configPath, check: options.check, output: options.output });
+ const { config, issues } = result;
+
+ if (issues.length) {
+ console.log("Проверка конфигурации:");
+ issues.forEach((issue) => console.log(` - ${issue}`));
+ } else {
+ console.log("Проверка конфигурации: ошибок нет.");
+ }
+
+ if (options.check) {
+ console.log(`Слайдов в конфигурации: ${(config.slides || []).length}`);
+ return undefined;
+ }
+
+ if (issues.length) process.exitCode = 1;
+ else console.log(`Готово: ${result.output}`);
+ } catch (error) {
+ console.error(error.message);
+ process.exitCode = 1;
+ }
+ return undefined;
+}
+
+main();
diff --git a/pptx/src/config.js b/pptx/src/config.js
new file mode 100644
index 0000000..448a3d4
--- /dev/null
+++ b/pptx/src/config.js
@@ -0,0 +1,195 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const yaml = require("js-yaml");
+
+const DEFAULTS = {
+ presentation: {
+ output: "presentation.pptx",
+ author: "Иванов И.И.",
+ track: "Название ИОТ-трека",
+ },
+ theme: {
+ width: 13.333,
+ height: 7.5,
+ font: "Elektra Text Pro",
+ colors: {
+ white: "FFFFFF",
+ ink: "1F2933",
+ brand: "1B87C9",
+ muted: "6B7280",
+ accent: "0E6BA8",
+ tint: "EAF4FB",
+ line: "BFD3DD",
+ },
+ backgrounds: {
+ title: "content/assets/title-bg.jpeg",
+ content: "content/assets/content-bg.jpg",
+ final: "content/assets/final-bg.jpeg",
+ },
+ slideNumber: {
+ show: true,
+ x: 12.05,
+ y: 6.86,
+ w: 1.15,
+ h: 0.42,
+ fontSize: 12,
+ color: "FFFFFF",
+ align: "right",
+ },
+ },
+ text: {
+ titleSlide: {
+ title: { x: 6.7, y: 1.85, w: 5.95, h: 1.9, fontSize: 28, bold: false, align: "center", color: "white" },
+ subtitle: { x: 6.7, y: 3.75, w: 5.95, h: 0.7, fontSize: 16, align: "center", color: "white" },
+ body: { x: 6.7, y: 4.65, w: 5.95, h: 1.7, fontSize: 14, align: "center", color: "white" },
+ },
+ section: {
+ title: { x: 2.0, y: 2.85, w: 9.35, h: 1.8, fontSize: 32, bold: true, align: "center", color: "white" },
+ },
+ content: {
+ title: { x: 0.6, y: 0.06, w: 12.15, h: 0.66, fontSize: 20, bold: false, align: "center", color: "white" },
+ body: { x: 0.75, y: 1.0, w: 11.85, h: 5.55, fontSize: 18, align: "left", color: "ink", valign: "top" },
+ bulletSize: 18,
+ subBulletSize: 16,
+ },
+ final: {
+ title: { x: 6.7, y: 2.85, w: 5.95, h: 1.6, fontSize: 28, bold: true, align: "center", color: "white" },
+ body: { x: 6.7, y: 4.65, w: 5.95, h: 1.7, fontSize: 14, align: "center", color: "white" },
+ },
+ },
+ table: {
+ fontSize: 12,
+ headerFill: "1B87C9",
+ headerColor: "FFFFFF",
+ cellColor: "1F2933",
+ borderColor: "BFD3DD",
+ },
+ callout: {
+ fill: "EAF4FB",
+ border: "1B87C9",
+ titleColor: "0E6BA8",
+ textColor: "1F2933",
+ titleFontSize: 18,
+ fontSize: 15,
+ lineWidth: 1.25,
+ },
+ metrics: {
+ fill: "1B87C9",
+ valueColor: "FFFFFF",
+ labelColor: "EAF4FB",
+ valueFontSize: 30,
+ labelFontSize: 13,
+ gap: 0.2,
+ height: 1.25,
+ },
+ flow: {
+ fill: "EAF4FB",
+ border: "1B87C9",
+ textColor: "1F2933",
+ arrowColor: "1B87C9",
+ fontSize: 13,
+ height: 1.0,
+ gap: 0.4,
+ },
+ chart: {
+ palette: ["1B87C9", "7FB2D9", "0E6BA8", "BFD3DD"],
+ fontSize: 12,
+ legend: true,
+ height: 3.4,
+ },
+ plantuml: {
+ enabled: true,
+ java: "auto",
+ jar: "tools/plantuml.jar",
+ format: "png",
+ charset: "UTF-8",
+ cache: true,
+ cacheDir: ".plantuml-cache",
+ timeoutMs: 30000,
+ },
+ content: {
+ dir: "content",
+ },
+ slides: [],
+};
+
+function isPlainObject(value) {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function deepMerge(base, override) {
+ if (!isPlainObject(base)) return override;
+ const out = Array.isArray(base) ? base.slice() : { ...base };
+ if (!isPlainObject(override)) return out;
+ for (const [key, value] of Object.entries(override)) {
+ if (isPlainObject(value) && isPlainObject(out[key])) {
+ out[key] = deepMerge(out[key], value);
+ } else {
+ out[key] = value;
+ }
+ }
+ return out;
+}
+
+function resolveColor(value, colors) {
+ if (typeof value !== "string") return value;
+ if (colors && Object.prototype.hasOwnProperty.call(colors, value)) return colors[value];
+ return value.replace(/^#/, "");
+}
+
+function resolveConfigPaths(config, configDir) {
+ const backgrounds = config.theme.backgrounds;
+ for (const key of Object.keys(backgrounds)) {
+ const value = backgrounds[key];
+ if (typeof value === "string" && value.trim() && !path.isAbsolute(value)) {
+ backgrounds[key] = path.resolve(configDir, value);
+ }
+ }
+ const plantuml = config.plantuml;
+ if (plantuml) {
+ for (const key of ["jar", "cacheDir"]) {
+ const value = plantuml[key];
+ if (typeof value === "string" && value.trim() && !path.isAbsolute(value)) {
+ plantuml[key] = path.resolve(configDir, value);
+ }
+ }
+ }
+ return config;
+}
+
+function loadConfig(configPath) {
+ const abs = path.resolve(configPath);
+ if (!fs.existsSync(abs)) {
+ throw new Error(`Не найден файл конфигурации: ${abs}`);
+ }
+ const configDir = path.dirname(abs);
+ const parsed = yaml.load(fs.readFileSync(abs, "utf8")) || {};
+ const config = deepMerge(DEFAULTS, parsed);
+ config.content.dir = path.resolve(configDir, config.content.dir);
+ resolveConfigPaths(config, configDir);
+ return { config, configDir };
+}
+
+function validateConfig(config) {
+ const errors = [];
+ if (!Array.isArray(config.slides) || config.slides.length === 0) {
+ errors.push("Не задан список слайдов (slides) в config.yaml");
+ } else {
+ config.slides.forEach((slide, i) => {
+ if (!slide || typeof slide !== "object") {
+ errors.push(`Слайд #${i + 1}: некорректная запись`);
+ return;
+ }
+ if (!slide.file) errors.push(`Слайд #${i + 1}: не указан файл (file)`);
+ const type = slide.type || "content";
+ if (!["title", "section", "content", "final"].includes(type)) {
+ errors.push(`Слайд #${i + 1}: неизвестный тип "${type}"`);
+ }
+ });
+ }
+ return errors;
+}
+
+module.exports = { DEFAULTS, deepMerge, loadConfig, validateConfig, resolveColor };
diff --git a/pptx/src/index.js b/pptx/src/index.js
new file mode 100644
index 0000000..f4bfb4a
--- /dev/null
+++ b/pptx/src/index.js
@@ -0,0 +1,6 @@
+"use strict";
+
+const { build } = require("./build");
+const { parseSlide } = require("./parse");
+
+module.exports = { build, parseSlide };
diff --git a/pptx/src/parse.js b/pptx/src/parse.js
new file mode 100644
index 0000000..8fb895f
--- /dev/null
+++ b/pptx/src/parse.js
@@ -0,0 +1,230 @@
+"use strict";
+
+function parseTable(lines) {
+ const rows = lines
+ .map((line) => line.trim())
+ .filter((line) => line.startsWith("|"))
+ .map((line) => line.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()))
+ .filter((cells) => !cells.every((c) => /^:?-{2,}:?$/.test(c)));
+ return rows;
+}
+
+function unquote(value) {
+ const trimmed = String(value || "").trim();
+ const match = trimmed.match(/^"([\s\S]*)"$/);
+ return match ? match[1].trim() : trimmed;
+}
+
+function parseMetrics(lines) {
+ const items = [];
+ for (const raw of lines) {
+ const line = raw.trim();
+ if (!line) continue;
+ let value = "";
+ let label = line;
+ if (line.includes("|")) {
+ const parts = line.split("|");
+ value = parts[0];
+ label = parts.slice(1).join("|");
+ } else {
+ const match = line.match(/^(.*?)\s+[—–-]\s+(.*)$/);
+ if (match) {
+ value = match[1];
+ label = match[2];
+ }
+ }
+ items.push({ value: String(value).trim(), label: String(label).trim() });
+ }
+ return items;
+}
+
+function parseFlow(lines) {
+ const steps = [];
+ for (const raw of lines) {
+ const line = raw.replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").trim();
+ if (!line) continue;
+ for (const part of line.split(/->|→/)) {
+ const step = part.trim();
+ if (step) steps.push(step);
+ }
+ }
+ return steps;
+}
+
+function parseTextBlocks(lines) {
+ const blocks = [];
+ let paragraph = [];
+ let bullets = [];
+ const flushParagraph = () => {
+ const text = paragraph.join(" ").trim();
+ if (text) blocks.push({ kind: "paragraph", text });
+ paragraph = [];
+ };
+ const flushBullets = () => {
+ if (bullets.length) blocks.push({ kind: "bullets", items: bullets });
+ bullets = [];
+ };
+
+ for (const raw of lines) {
+ const line = raw.replace(/\s+$/, "");
+ const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
+ if (bullet) {
+ flushParagraph();
+ bullets.push({ level: Math.min(3, Math.floor(bullet[1].length / 2)), text: bullet[2].trim() });
+ continue;
+ }
+ if (line.trim() === "") {
+ flushParagraph();
+ flushBullets();
+ continue;
+ }
+ flushBullets();
+ paragraph.push(line.trim());
+ }
+
+ flushParagraph();
+ flushBullets();
+ return blocks;
+}
+
+const CHART_TYPES = new Set(["bar", "bar3d", "line", "pie", "doughnut", "area", "radar", "scatter", "bubble"]);
+
+function buildChartBlock(rest, buf) {
+ const typeMatch = rest.match(/\b(bar3d|bar|line|pie|doughnut|area|radar|scatter|bubble)\b/i);
+ const titleMatch = rest.match(/"([^"]*)"/);
+ return {
+ kind: "chart",
+ chartType: (typeMatch ? typeMatch[1] : "bar").toLowerCase(),
+ title: titleMatch ? titleMatch[1] : "",
+ rows: parseTable(buf),
+ };
+}
+
+function parseSlide(markdown) {
+ const lines = String(markdown).replace(/\r\n?/g, "\n").split("\n");
+ const slide = { title: null, subtitle: null, blocks: [], notes: null };
+ let paragraph = [];
+ let bullets = [];
+ let i = 0;
+
+ const flushParagraph = () => {
+ const text = paragraph.join(" ").trim();
+ if (text) slide.blocks.push({ kind: "paragraph", text });
+ paragraph = [];
+ };
+ const flushBullets = () => {
+ if (bullets.length) slide.blocks.push({ kind: "bullets", items: bullets });
+ bullets = [];
+ };
+ const flushAll = () => {
+ flushParagraph();
+ flushBullets();
+ };
+
+ const pushDirective = (name, rest, buf) => {
+ switch (name) {
+ case "notes":
+ slide.notes = buf.join("\n").trim();
+ return;
+ case "table": {
+ const rows = parseTable(buf);
+ if (rows.length) slide.blocks.push({ kind: "table", rows });
+ return;
+ }
+ case "image": {
+ const rest2 = String(rest || "").trim();
+ const fileMatch = rest2.match(/^("[^"]*"|\S+)/);
+ const heightMatch = rest2.match(/\b(?:h|height)=([0-9.]+)/i);
+ const sideMatch = rest2.match(/\bside=(left|right)\b/i);
+ slide.blocks.push({
+ kind: "image",
+ file: fileMatch ? unquote(fileMatch[1]) : rest2,
+ height: heightMatch ? Number(heightMatch[1]) : null,
+ side: sideMatch ? sideMatch[1].toLowerCase() : null,
+ caption: buf.join(" ").trim(),
+ });
+ return;
+ }
+ case "callout": {
+ const sideMatch = rest.match(/\bside=(left|right)\b/i);
+ const title = unquote(rest.replace(/\bside=(left|right)\b/i, "").trim());
+ slide.blocks.push({
+ kind: "callout",
+ title,
+ side: sideMatch ? sideMatch[1].toLowerCase() : null,
+ blocks: parseTextBlocks(buf),
+ });
+ return;
+ }
+ case "metrics":
+ slide.blocks.push({ kind: "metrics", items: parseMetrics(buf) });
+ return;
+ case "flow":
+ slide.blocks.push({ kind: "flow", steps: parseFlow(buf) });
+ return;
+ case "chart":
+ slide.blocks.push(buildChartBlock(rest, buf));
+ return;
+ default:
+ for (const block of parseTextBlocks(buf)) slide.blocks.push(block);
+ }
+ };
+
+ while (i < lines.length) {
+ const raw = lines[i];
+ const line = raw.replace(/\s+$/, "");
+
+ const directive = line.match(/^:::\s*([A-Za-z]+)\b\s*(.*)$/);
+ if (directive) {
+ const name = directive[1].toLowerCase();
+ const rest = directive[2] || "";
+ const buf = [];
+ i += 1;
+ while (i < lines.length && !/^:::\s*$/.test(lines[i])) {
+ buf.push(lines[i]);
+ i += 1;
+ }
+ i += 1;
+ flushAll();
+ pushDirective(name, rest, buf);
+ continue;
+ }
+
+ if (/^#\s+/.test(line) && slide.title === null) {
+ flushAll();
+ slide.title = line.replace(/^#\s+/, "").trim();
+ i += 1;
+ continue;
+ }
+
+ if (/^##\s+/.test(line) && slide.subtitle === null) {
+ flushAll();
+ slide.subtitle = line.replace(/^##\s+/, "").trim();
+ i += 1;
+ continue;
+ }
+
+ const bullet = line.match(/^(\s*)[-*]\s+(.*)$/);
+ if (bullet) {
+ flushParagraph();
+ bullets.push({ level: Math.min(3, Math.floor(bullet[1].length / 2)), text: bullet[2].trim() });
+ i += 1;
+ continue;
+ }
+
+ if (line.trim() === "") {
+ flushAll();
+ i += 1;
+ continue;
+ }
+
+ flushBullets();
+ paragraph.push(line.trim());
+ i += 1;
+ }
+
+ flushAll();
+ return slide;
+}
+
+module.exports = { parseSlide, parseTable, parseTextBlocks, parseMetrics, parseFlow };
diff --git a/pptx/src/plantuml.js b/pptx/src/plantuml.js
new file mode 100644
index 0000000..2d79d4b
--- /dev/null
+++ b/pptx/src/plantuml.js
@@ -0,0 +1,174 @@
+"use strict";
+
+const fs = require("fs");
+const os = require("os");
+const path = require("path");
+const crypto = require("crypto");
+const { spawn } = require("child_process");
+
+const SOURCE_EXTENSIONS = new Set([".puml", ".plantuml", ".pu", ".iuml"]);
+
+function isPlantUmlSource(filePath) {
+ if (!filePath) return false;
+ return SOURCE_EXTENSIONS.has(path.extname(String(filePath)).toLowerCase());
+}
+
+function resolveJava(javaCfg) {
+ if (javaCfg && javaCfg !== "auto") return javaCfg;
+ const home = process.env.JAVA_HOME;
+ if (home) {
+ const exe = process.platform === "win32" ? "java.exe" : "java";
+ const candidate = path.join(home, "bin", exe);
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ return "java";
+}
+
+function runProcess(command, args, input, timeoutMs) {
+ return new Promise((resolve, reject) => {
+ let child;
+ try {
+ child = spawn(command, args, { windowsHide: true });
+ } catch (err) {
+ reject(new Error(`PlantUML: не удалось запустить "${command}": ${err.message}`));
+ return;
+ }
+
+ const stdout = [];
+ const stderr = [];
+ let settled = false;
+ let timer = null;
+
+ const finish = (fn, arg) => {
+ if (settled) return;
+ settled = true;
+ if (timer) clearTimeout(timer);
+ fn(arg);
+ };
+
+ if (timeoutMs > 0) {
+ timer = setTimeout(() => {
+ child.kill();
+ finish(reject, new Error(`PlantUML: превышено время ожидания (${timeoutMs} мс)`));
+ }, timeoutMs);
+ }
+
+ child.on("error", (err) => {
+ finish(reject, new Error(`PlantUML: не удалось запустить "${command}": ${err.message}`));
+ });
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
+ child.on("close", (code) => {
+ finish(resolve, {
+ code,
+ stdout: Buffer.concat(stdout),
+ stderr: Buffer.concat(stderr).toString("utf8").trim(),
+ });
+ });
+
+ child.stdin.end(input == null ? "" : input);
+ });
+}
+
+async function renderPlantUml(sourceBuffer, cfg) {
+ if (!cfg.jar) throw new Error("PlantUML: не задан путь к plantuml.jar");
+ if (!fs.existsSync(cfg.jar)) {
+ throw new Error(`PlantUML: plantuml.jar не найден: ${cfg.jar}`);
+ }
+ const java = resolveJava(cfg.java);
+ const args = [
+ "-jar",
+ cfg.jar,
+ "-pipe",
+ `-t${cfg.format || "png"}`,
+ "-charset",
+ cfg.charset || "UTF-8",
+ "-failfast2",
+ "-nometadata",
+ ];
+ const result = await runProcess(java, args, sourceBuffer, cfg.timeoutMs);
+ if (result.code !== 0 || result.stdout.length === 0) {
+ const detail = result.stderr || `код выхода ${result.code}`;
+ throw new Error(`PlantUML: ошибка рендеринга: ${detail}`);
+ }
+ return result.stdout;
+}
+
+function jarFingerprint(cfg) {
+ try {
+ const st = fs.statSync(cfg.jar);
+ return `${st.size}:${Math.round(st.mtimeMs)}`;
+ } catch {
+ return "nojar";
+ }
+}
+
+function cacheFileName(sourceBuffer, cfg) {
+ const hash = crypto
+ .createHash("sha1")
+ .update(sourceBuffer)
+ .update(`|${cfg.format || "png"}`)
+ .update(`|${jarFingerprint(cfg)}`)
+ .digest("hex");
+ return `${hash}.png`;
+}
+
+async function renderCached(sourceBuffer, cfg) {
+ const dir = cfg.cacheDir || path.join(os.tmpdir(), "lab-gen-pptx-plantuml");
+ const target = path.join(dir, cacheFileName(sourceBuffer, cfg));
+
+ if (cfg.cache && fs.existsSync(target)) {
+ return { buffer: fs.readFileSync(target), cachePath: target };
+ }
+
+ const buffer = await renderPlantUml(sourceBuffer, cfg);
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(target, buffer);
+ return { buffer, cachePath: target };
+}
+
+/**
+ * Проставляет каждому блоку ::: image путь к готовому изображению.
+ * Для исходников PlantUML рендерит PNG; для растровых — проверяет наличие.
+ * @returns {Promise} список проблем (ошибок/предупреждений)
+ */
+async function resolveImages(config, deck) {
+ const issues = [];
+ const cfg = config.plantuml || {};
+ const contentDir = config.content.dir;
+
+ for (const entry of deck) {
+ for (const block of entry.parsed.blocks) {
+ if (block.kind !== "image" || !block.file) continue;
+ const source = path.isAbsolute(block.file) ? block.file : path.resolve(contentDir, block.file);
+ if (isPlantUmlSource(source)) {
+ if (cfg.enabled === false) {
+ issues.push(`PlantUML отключён, а файл требует рендеринга: ${block.file}`);
+ continue;
+ }
+ if (!fs.existsSync(source)) {
+ issues.push(`Не найден исходник PlantUML: ${block.file}`);
+ continue;
+ }
+ try {
+ const { cachePath } = await renderCached(fs.readFileSync(source), {
+ ...cfg,
+ jar: cfg.jar,
+ });
+ block.renderFile = cachePath;
+ } catch (err) {
+ issues.push(`Рисунок "${block.file}": ${err.message}`);
+ }
+ } else {
+ if (!fs.existsSync(source)) {
+ issues.push(`Не найден файл изображения: ${block.file}`);
+ continue;
+ }
+ block.renderFile = source;
+ }
+ }
+ }
+ return issues;
+}
+
+module.exports = { isPlantUmlSource, resolveJava, renderPlantUml, renderCached, resolveImages };
diff --git a/pptx/src/render.js b/pptx/src/render.js
new file mode 100644
index 0000000..3f804d9
--- /dev/null
+++ b/pptx/src/render.js
@@ -0,0 +1,778 @@
+"use strict";
+
+const fs = require("fs");
+const { resolveColor } = require("./config");
+
+const MASTER_BY_TYPE = {
+ title: "TITLE",
+ section: "SECTION",
+ content: "CONTENT",
+ final: "FINAL",
+};
+
+const MEDIA_KINDS = new Set(["table", "image", "chart", "flow", "metrics", "callout"]);
+
+function fontInches(fontSize) {
+ return (fontSize / 72) * 1.25;
+}
+
+function estimateLines(text, widthIn, fontSize) {
+ const charWidth = (fontSize / 72) * 0.52;
+ const perLine = Math.max(8, Math.floor(widthIn / charWidth));
+ return Math.max(1, Math.ceil(String(text).length / perLine));
+}
+
+/**
+ * Разбирает inline-разметку: **жирный**, *курсив*, ==выделение==, `код`.
+ * Возвращает массив runs для pptxgenjs.
+ */
+function inlineRuns(text, base, colors) {
+ const runs = [];
+ const pattern = /(\*\*[^*]+\*\*|==[^=]+==|\*[^*]+\*|`[^`]+`)/g;
+ const src = String(text);
+ let last = 0;
+ let match;
+ while ((match = pattern.exec(src)) !== null) {
+ if (match.index > last) runs.push({ text: src.slice(last, match.index), options: { ...base } });
+ const token = match[0];
+ if (token.startsWith("**")) {
+ runs.push({ text: token.slice(2, -2), options: { ...base, bold: true } });
+ } else if (token.startsWith("==")) {
+ runs.push({
+ text: token.slice(2, -2),
+ options: { ...base, bold: true, color: colors.highlight },
+ });
+ } else if (token.startsWith("`")) {
+ runs.push({
+ text: token.slice(1, -1),
+ options: { ...base, fontFace: "Consolas", color: colors.muted },
+ });
+ } else {
+ runs.push({ text: token.slice(1, -1), options: { ...base, italic: true } });
+ }
+ last = pattern.lastIndex;
+ }
+ if (last < src.length) runs.push({ text: src.slice(last), options: { ...base } });
+ if (!runs.length) runs.push({ text: src, options: { ...base } });
+ return runs;
+}
+
+function colorScheme(config) {
+ const c = config.theme.colors;
+ return {
+ ink: resolveColor(c.ink, c),
+ brand: resolveColor(c.brand, c),
+ highlight: resolveColor(c.accent || c.brand, c),
+ muted: resolveColor(c.muted, c),
+ tint: resolveColor(c.tint || c.brand, c),
+ line: resolveColor(c.line || c.brand, c),
+ };
+}
+
+function defineMasters(pptx, config) {
+ const { theme } = config;
+ const master = (title, background) =>
+ pptx.defineSlideMaster({
+ title,
+ background: { path: background },
+ objects: [],
+ });
+
+ master("TITLE", theme.backgrounds.title);
+ master("SECTION", theme.backgrounds.title);
+ master("CONTENT", theme.backgrounds.content);
+ master("FINAL", theme.backgrounds.final);
+}
+
+function addSlideNumber(slide, config, number, type) {
+ const spec = config.theme.slideNumber;
+ if (!spec.show) return;
+ const colorKey = type === "content" ? spec.contentColor || spec.color : spec.color;
+ slide.addText(String(number), {
+ x: spec.x,
+ y: spec.y,
+ w: spec.w,
+ h: spec.h,
+ fontSize: spec.fontSize,
+ color: resolveColor(colorKey, config.theme.colors),
+ fontFace: config.theme.font,
+ bold: !!spec.bold,
+ align: spec.align,
+ valign: "middle",
+ margin: 0,
+ });
+}
+
+function style(spec, config, extra = {}) {
+ return {
+ x: spec.x,
+ y: spec.y,
+ w: spec.w,
+ h: spec.h,
+ fontSize: spec.fontSize,
+ bold: !!spec.bold,
+ align: spec.align || "left",
+ valign: spec.valign || "top",
+ color: resolveColor(spec.color, config.theme.colors),
+ fontFace: config.theme.font,
+ ...extra,
+ };
+}
+
+// ── титульный, разделительный, финальный слайды ───────────────────
+
+function renderTitleSlide(slide, parsed, config) {
+ const spec = config.text.titleSlide;
+ if (parsed.title) slide.addText(parsed.title, style(spec.title, config));
+ if (parsed.subtitle) slide.addText(parsed.subtitle, style(spec.subtitle, config));
+
+ const bodyLines = parsed.blocks
+ .filter((b) => b.kind === "paragraph")
+ .map((b) => b.text);
+ if (!bodyLines.length) {
+ if (config.presentation.author) bodyLines.push(config.presentation.author);
+ if (config.presentation.track) bodyLines.push(config.presentation.track);
+ }
+ if (bodyLines.length) {
+ slide.addText(bodyLines.join("\n"), style(spec.body, config));
+ }
+}
+
+function renderSectionSlide(slide, parsed, config) {
+ const spec = config.text.section;
+ const title = parsed.title || "";
+ slide.addText(title, style(spec.title, config, { fit: "shrink" }));
+ if (parsed.subtitle) {
+ slide.addText(
+ parsed.subtitle,
+ style(spec.title, config, {
+ y: spec.title.y + spec.title.h,
+ fontSize: spec.title.fontSize * 0.55,
+ })
+ );
+ }
+}
+
+function renderFinalSlide(slide, parsed, config) {
+ const spec = config.text.final;
+ slide.addText(parsed.title || "Спасибо за внимание", style(spec.title, config));
+ const bodyLines = parsed.blocks.filter((b) => b.kind === "paragraph").map((b) => b.text);
+ if (bodyLines.length) slide.addText(bodyLines.join("\n"), style(spec.body, config));
+}
+
+// ── текстовые блоки ───────────────────────────────────────────────
+//
+// pptxgenjs генерирует свойства абзаца для каждого run, поэтому список с
+// inline-форматированием внутри пункта рисуется вручную: маркер отдельным
+// run'ом, текст — с отступом и висячим выравниванием.
+
+function renderParagraph(slide, block, config, spec, colors, cursor) {
+ const base = {
+ color: colors.ink,
+ fontFace: config.theme.font,
+ fontSize: spec.bulletSize,
+ };
+ const runs = inlineRuns(block.text, base, colors);
+ const height = estimateLines(block.text, spec.body.w, spec.bulletSize) * fontInches(spec.bulletSize) + 0.06;
+ slide.addText(runs, {
+ x: spec.body.x,
+ y: cursor.y,
+ w: spec.body.w,
+ h: height,
+ align: "left",
+ valign: "top",
+ fontFace: config.theme.font,
+ margin: 0,
+ });
+ cursor.y += height + 0.04;
+}
+
+function bulletGeometry(item, spec) {
+ const size = item.level === 0 ? spec.bulletSize : spec.subBulletSize;
+ const indent = 0.3 + item.level * 0.3;
+ const textWidth = Math.max(1, spec.body.w - indent);
+ return { size, indent, textWidth };
+}
+
+function renderBullets(slide, items, config, spec, colors, cursor) {
+ for (const item of items) {
+ const { size, indent, textWidth } = bulletGeometry(item, spec);
+ const glyph = item.level === 0 ? "•" : "–";
+ const lines = estimateLines(item.text, textWidth, size);
+ const height = lines * fontInches(size) + 0.03;
+
+ slide.addText(glyph, {
+ x: spec.body.x + item.level * 0.3,
+ y: cursor.y,
+ w: indent,
+ h: height,
+ align: "left",
+ valign: "top",
+ color: colors.ink,
+ fontFace: config.theme.font,
+ fontSize: size,
+ margin: 0,
+ });
+
+ const base = {
+ color: colors.ink,
+ fontFace: config.theme.font,
+ fontSize: size,
+ };
+ const runs = inlineRuns(item.text, base, colors);
+ slide.addText(runs, {
+ x: spec.body.x + indent,
+ y: cursor.y,
+ w: textWidth,
+ h: height,
+ align: "left",
+ valign: "top",
+ fontFace: config.theme.font,
+ margin: 0,
+ });
+
+ cursor.y += height + 0.07;
+ }
+ cursor.y += 0.05;
+}
+
+function estimateBlocksHeight(blocks, config, spec) {
+ let y = 0;
+ for (const block of blocks) {
+ if (block.kind === "paragraph") {
+ y += estimateLines(block.text, spec.body.w, spec.bulletSize) * fontInches(spec.bulletSize) + 0.1;
+ } else if (block.kind === "bullets") {
+ for (const item of block.items) {
+ const { size, textWidth } = bulletGeometry(item, spec);
+ y += estimateLines(item.text, textWidth, size) * fontInches(size) + 0.1;
+ }
+ y += 0.08;
+ }
+ }
+ return y;
+}
+
+function renderTextBlocks(slide, blocks, config, cursor) {
+ const spec = config.text.content;
+ const colors = colorScheme(config);
+ let rendered = false;
+
+ for (const block of blocks) {
+ if (block.kind === "paragraph") {
+ renderParagraph(slide, block, config, spec, colors, cursor);
+ rendered = true;
+ } else if (block.kind === "bullets") {
+ renderBullets(slide, block.items, config, spec, colors, cursor);
+ rendered = true;
+ }
+ }
+
+ if (rendered) cursor.y += 0.06;
+}
+
+// ── плашка-вывод ──────────────────────────────────────────────────
+
+function calloutRuns(block, config, colors) {
+ const spec = config.callout;
+ const runs = [];
+ if (block.title) {
+ runs.push({
+ text: block.title,
+ options: {
+ bold: true,
+ breakLine: true,
+ paraSpaceBefore: 0,
+ color: resolveColor(spec.titleColor, config.theme.colors),
+ fontFace: config.theme.font,
+ fontSize: spec.titleFontSize,
+ },
+ });
+ }
+ for (const inner of block.blocks) {
+ if (inner.kind === "paragraph") {
+ const base = {
+ color: resolveColor(spec.textColor, config.theme.colors),
+ fontFace: config.theme.font,
+ fontSize: spec.fontSize,
+ };
+ const line = inlineRuns(inner.text, base, colors);
+ line[line.length - 1].options.breakLine = true;
+ runs.push(...line);
+ } else if (inner.kind === "bullets") {
+ for (const item of inner.items) {
+ const base = {
+ color: resolveColor(spec.textColor, config.theme.colors),
+ fontFace: config.theme.font,
+ fontSize: spec.fontSize,
+ };
+ const prefix = `${" ".repeat(item.level)}• `;
+ const line = inlineRuns(item.text, base, colors);
+ line[0].text = prefix + line[0].text;
+ line[line.length - 1].options.breakLine = true;
+ runs.push(...line);
+ }
+ }
+ }
+ return runs;
+}
+
+function calloutHeight(block, config) {
+ const spec = config.callout;
+ const area = config.text.content.body;
+ const textWidth = area.w - 1.0;
+ const titleLines = block.title ? estimateLines(block.title, textWidth, spec.titleFontSize) : 0;
+ let contentHeight = 0;
+ for (const inner of block.blocks) {
+ if (inner.kind === "paragraph") {
+ contentHeight += estimateLines(inner.text, textWidth, spec.fontSize) * fontInches(spec.fontSize) + 0.06;
+ } else if (inner.kind === "bullets") {
+ for (const item of inner.items) {
+ contentHeight += estimateLines(item.text, textWidth - 0.4, spec.fontSize) * fontInches(spec.fontSize) + 0.05;
+ }
+ }
+ }
+ return Math.max(0.9, titleLines * fontInches(spec.titleFontSize) + (titleLines ? 0.1 : 0) + contentHeight + 0.34);
+}
+
+function renderCallout(pptx, slide, block, config, cursor) {
+ const area = config.text.content.body;
+ const spec = config.callout;
+ const colors = colorScheme(config);
+ const runs = calloutRuns(block, config, colors);
+ const height = calloutHeight(block, config);
+
+ slide.addText(runs, {
+ x: area.x,
+ y: cursor.y,
+ w: area.w,
+ h: height,
+ shape: pptx.ShapeType.roundRect,
+ fill: { color: resolveColor(spec.fill, config.theme.colors) },
+ line: { color: resolveColor(spec.border, config.theme.colors), width: spec.lineWidth },
+ align: "left",
+ valign: "middle",
+ margin: [10, 14, 10, 14],
+ });
+ cursor.y += height + 0.2;
+}
+
+// ── карточки-метрики ──────────────────────────────────────────────
+
+function renderMetrics(pptx, slide, block, config, cursor) {
+ const items = (block.items || []).slice(0, 4);
+ if (!items.length) return;
+ const area = config.text.content.body;
+ const spec = config.metrics;
+ const n = items.length;
+ const gap = spec.gap;
+ const cardW = (area.w - gap * (n - 1)) / n;
+
+ items.forEach((item, i) => {
+ const x = area.x + i * (cardW + gap);
+ const runs = [
+ {
+ text: item.value || "—",
+ options: {
+ bold: true,
+ breakLine: true,
+ align: "center",
+ color: resolveColor(spec.valueColor, config.theme.colors),
+ fontFace: config.theme.font,
+ fontSize: spec.valueFontSize,
+ },
+ },
+ {
+ text: item.label,
+ options: {
+ align: "center",
+ color: resolveColor(spec.labelColor, config.theme.colors),
+ fontFace: config.theme.font,
+ fontSize: spec.labelFontSize,
+ },
+ },
+ ];
+ slide.addText(runs, {
+ x,
+ y: cursor.y,
+ w: cardW,
+ h: spec.height,
+ shape: pptx.ShapeType.roundRect,
+ fill: { color: resolveColor(spec.fill, config.theme.colors) },
+ line: { type: "none" },
+ align: "center",
+ valign: "middle",
+ margin: [6, 8, 6, 8],
+ });
+ });
+
+ cursor.y += spec.height + 0.2;
+}
+
+// ── нативная схема-цепочка ────────────────────────────────────────
+
+function renderFlow(pptx, slide, block, config, cursor) {
+ const steps = block.steps || [];
+ if (!steps.length) return;
+ const area = config.text.content.body;
+ const spec = config.flow;
+ const n = steps.length;
+ const gap = spec.gap;
+ const boxW = (area.w - gap * (n - 1)) / n;
+
+ if (boxW < 0.9) {
+ renderFlowVertical(pptx, slide, steps, config, cursor);
+ return;
+ }
+
+ const h = spec.height;
+ const arrowH = 0.24;
+ steps.forEach((step, i) => {
+ const x = area.x + i * (boxW + gap);
+ slide.addText(step, {
+ x,
+ y: cursor.y,
+ w: boxW,
+ h,
+ shape: pptx.ShapeType.roundRect,
+ fill: { color: resolveColor(spec.fill, config.theme.colors) },
+ line: { color: resolveColor(spec.border, config.theme.colors), width: 1 },
+ align: "center",
+ valign: "middle",
+ color: resolveColor(spec.textColor, config.theme.colors),
+ fontFace: config.theme.font,
+ fontSize: spec.fontSize,
+ margin: [3, 6, 3, 6],
+ });
+ if (i < n - 1) {
+ slide.addShape(pptx.ShapeType.rightArrow, {
+ x: x + boxW,
+ y: cursor.y + h / 2 - arrowH / 2,
+ w: gap,
+ h: arrowH,
+ fill: { color: resolveColor(spec.arrowColor, config.theme.colors) },
+ line: { type: "none" },
+ });
+ }
+ });
+
+ cursor.y += h + 0.2;
+}
+
+function renderFlowVertical(pptx, slide, steps, config, cursor) {
+ const area = config.text.content.body;
+ const spec = config.flow;
+ const h = 0.55;
+ const arrowH = 0.22;
+ for (let i = 0; i < steps.length; i++) {
+ slide.addText(steps[i], {
+ x: area.x + area.w * 0.1,
+ y: cursor.y,
+ w: area.w * 0.8,
+ h,
+ shape: pptx.ShapeType.roundRect,
+ fill: { color: resolveColor(spec.fill, config.theme.colors) },
+ line: { color: resolveColor(spec.border, config.theme.colors), width: 1 },
+ align: "center",
+ valign: "middle",
+ color: resolveColor(spec.textColor, config.theme.colors),
+ fontFace: config.theme.font,
+ fontSize: spec.fontSize,
+ margin: [3, 6, 3, 6],
+ });
+ if (i < steps.length - 1) {
+ slide.addShape(pptx.ShapeType.downArrow, {
+ x: area.x + area.w / 2 - arrowH / 2,
+ y: cursor.y + h,
+ w: arrowH,
+ h: arrowH,
+ fill: { color: resolveColor(spec.arrowColor, config.theme.colors) },
+ line: { type: "none" },
+ });
+ }
+ cursor.y += h + arrowH;
+ }
+ cursor.y += 0.05;
+}
+
+// ── таблицы ───────────────────────────────────────────────────────
+
+function colRatios(cols) {
+ if (cols === 3) return [0.24, 0.34, 0.42];
+ if (cols === 4) return [0.19, 0.28, 0.3, 0.23];
+ return new Array(cols).fill(1 / cols);
+}
+
+function renderTable(slide, block, config, cursor) {
+ const t = config.table;
+ const colors = colorScheme(config);
+ const rows = block.rows.map((cells, r) =>
+ cells.map((text) => {
+ const base = {
+ color: resolveColor(r === 0 ? t.headerColor : t.cellColor, config.theme.colors),
+ fontFace: config.theme.font,
+ fontSize: t.fontSize,
+ bold: r === 0,
+ };
+ return {
+ text: inlineRuns(String(text), base, colors),
+ options: {
+ fill: r === 0 ? resolveColor(t.headerFill, config.theme.colors) : { color: "FFFFFF" },
+ valign: "middle",
+ },
+ };
+ }),
+ );
+ const area = config.text.content.body;
+ const cols = block.rows[0].length;
+ const rowH = cols >= 4 ? 0.5 : 0.42;
+ const height = Math.min(area.y + area.h - cursor.y, block.rows.length * rowH);
+ slide.addTable(rows, {
+ x: area.x,
+ y: cursor.y,
+ w: area.w,
+ colW: colRatios(cols).map((r) => r * area.w),
+ rowH,
+ border: { type: "solid", color: resolveColor(t.borderColor, config.theme.colors), pt: 1 },
+ fontFace: config.theme.font,
+ fontSize: t.fontSize,
+ autoPage: false,
+ });
+ cursor.y += height + 0.15;
+}
+
+// ── изображения ───────────────────────────────────────────────────
+
+function pngDimensions(buffer) {
+ if (buffer.length > 24 && buffer.toString("ascii", 1, 4) === "PNG") {
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
+ }
+ return null;
+}
+
+function jpegDimensions(buffer) {
+ let i = 2;
+ while (i < buffer.length - 8) {
+ if (buffer[i] !== 0xff) {
+ i += 1;
+ continue;
+ }
+ const marker = buffer[i + 1];
+ if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) {
+ return { width: buffer.readUInt16BE(i + 7), height: buffer.readUInt16BE(i + 5) };
+ }
+ if (marker >= 0xd0 && marker <= 0xd9) {
+ i += 2;
+ continue;
+ }
+ const len = buffer.readUInt16BE(i + 2);
+ i += 2 + len;
+ }
+ return null;
+}
+
+function imageDimensions(file) {
+ try {
+ const buffer = fs.readFileSync(file);
+ return pngDimensions(buffer) || jpegDimensions(buffer);
+ } catch {
+ return null;
+ }
+}
+
+function renderImage(slide, block, config, cursor) {
+ const area = config.text.content.body;
+ const file = block.renderFile || block.file;
+ const reserve = block.caption ? 0.5 : 0.1;
+ const maxW = area.w;
+ let maxH = Math.max(0.8, area.y + area.h - cursor.y - reserve);
+ if (block.height) maxH = Math.min(maxH, block.height);
+ const dims = imageDimensions(file);
+
+ let w = maxW;
+ let h = Math.min(maxH, maxW * 0.6);
+ if (dims && dims.width && dims.height) {
+ const scale = Math.min(maxW / dims.width, maxH / dims.height);
+ w = dims.width * scale;
+ h = dims.height * scale;
+ }
+ const x = area.x + (maxW - w) / 2;
+
+ slide.addImage({ path: file, x, y: cursor.y, w, h });
+ cursor.y += h + 0.12;
+ if (block.caption) {
+ slide.addText(block.caption, {
+ x: area.x,
+ y: cursor.y,
+ w: area.w,
+ h: 0.4,
+ fontSize: config.text.content.subBulletSize,
+ italic: true,
+ align: "center",
+ color: resolveColor(config.theme.colors.muted, config.theme.colors),
+ fontFace: config.theme.font,
+ });
+ cursor.y += 0.45;
+ }
+}
+
+// ── графики ───────────────────────────────────────────────────────
+
+function renderChart(pptx, slide, block, config, cursor) {
+ const rows = block.rows || [];
+ if (rows.length < 2 || rows[0].length < 2) return;
+ const area = config.text.content.body;
+ const spec = config.chart;
+
+ const header = rows[0];
+ const body = rows.slice(1);
+ const labels = body.map((r) => r[0]);
+ const series = [];
+ for (let j = 1; j < header.length; j++) {
+ series.push({
+ name: header[j],
+ labels,
+ values: body.map((r) => {
+ const value = Number(String(r[j] ?? "").replace(/\s/g, "").replace(",", "."));
+ return Number.isFinite(value) ? value : 0;
+ }),
+ });
+ }
+
+ let titleHeight = 0;
+ if (block.title) {
+ slide.addText(block.title, {
+ x: area.x,
+ y: cursor.y,
+ w: area.w,
+ h: 0.4,
+ align: "left",
+ valign: "middle",
+ color: resolveColor(config.theme.colors.ink, config.theme.colors),
+ fontFace: config.theme.font,
+ fontSize: config.text.content.subBulletSize,
+ bold: true,
+ });
+ titleHeight = 0.45;
+ }
+
+ const type = pptx.ChartType[block.chartType] || pptx.ChartType.bar;
+ const isPie = block.chartType === "pie" || block.chartType === "doughnut";
+ const height = Math.min(area.y + area.h - cursor.y - titleHeight, spec.height);
+
+ slide.addChart(type, series, {
+ x: area.x,
+ y: cursor.y + titleHeight,
+ w: area.w,
+ h: height,
+ showLegend: isPie ? true : series.length > 1 && spec.legend,
+ legendPos: "b",
+ legendFontFace: config.theme.font,
+ legendFontSize: spec.fontSize,
+ chartColors: spec.palette.map((c) => resolveColor(c, config.theme.colors)),
+ catAxisLabelFontFace: config.theme.font,
+ catAxisLabelFontSize: spec.fontSize,
+ catAxisLabelColor: resolveColor(config.theme.colors.ink, config.theme.colors),
+ valAxisLabelFontFace: config.theme.font,
+ valAxisLabelFontSize: spec.fontSize,
+ valAxisLabelColor: resolveColor(config.theme.colors.muted, config.theme.colors),
+ dataLabelFontFace: config.theme.font,
+ showValue: false,
+ dataLabelColor: resolveColor(config.theme.colors.ink, config.theme.colors),
+ showTitle: false,
+ });
+
+ cursor.y += titleHeight + height + 0.2;
+}
+
+// ── сборка слайда ─────────────────────────────────────────────────
+
+function renderMediaBlock(pptx, slide, block, config, cursor) {
+ if (block.kind === "table") renderTable(slide, block, config, cursor);
+ else if (block.kind === "image") renderImage(slide, block, config, cursor);
+ else if (block.kind === "chart") renderChart(pptx, slide, block, config, cursor);
+ else if (block.kind === "flow") renderFlow(pptx, slide, block, config, cursor);
+ else if (block.kind === "metrics") renderMetrics(pptx, slide, block, config, cursor);
+ else if (block.kind === "callout") renderCallout(pptx, slide, block, config, cursor);
+}
+
+function renderSideColumns(pptx, slide, blocks, config) {
+ const area = config.text.content.body;
+ const gap = 0.5;
+ const leftW = area.w * 0.5 - gap / 2;
+ const rightW = area.w - leftW - gap;
+ const columns = {
+ left: { x: area.x, y: area.y, w: leftW, h: area.h },
+ right: { x: area.x + leftW + gap, y: area.y, w: rightW, h: area.h },
+ };
+
+ for (const name of ["left", "right"]) {
+ const colBlocks = blocks.filter((b) => b.side === name);
+ if (!colBlocks.length) continue;
+ const colConfig = {
+ ...config,
+ text: { ...config.text, content: { ...config.text.content, body: columns[name] } },
+ };
+ const cursor = { y: columns[name].y };
+ for (const block of colBlocks) renderMediaBlock(pptx, slide, block, colConfig, cursor);
+ }
+}
+
+function renderContentSlide(pptx, slide, parsed, config) {
+ const spec = config.text.content;
+ if (parsed.title) slide.addText(parsed.title, style(spec.title, config));
+
+ const cursor = { y: spec.body.y };
+ const sideBlocks = parsed.blocks.filter(
+ (b) => (b.kind === "image" || b.kind === "callout") && b.side
+ );
+ const normalBlocks = parsed.blocks.filter((b) => !sideBlocks.includes(b));
+ const mediaBlocks = normalBlocks.filter((b) => MEDIA_KINDS.has(b.kind));
+ const textBlocks = normalBlocks.filter((b) => !MEDIA_KINDS.has(b.kind));
+
+ if (textBlocks.length && mediaBlocks.length) {
+ const compressed = { ...spec.body, h: spec.body.h * 0.42 };
+ const compressedConfig = {
+ ...config,
+ text: { ...config.text, content: { ...spec, body: compressed } },
+ };
+ renderTextBlocks(slide, textBlocks, compressedConfig, cursor);
+ cursor.y = compressed.y + compressed.h + 0.1;
+ } else if (textBlocks.length) {
+ renderTextBlocks(slide, textBlocks, config, cursor);
+ }
+
+ for (const block of mediaBlocks) renderMediaBlock(pptx, slide, block, config, cursor);
+
+ if (sideBlocks.length) renderSideColumns(pptx, slide, sideBlocks, config);
+}
+
+function renderDeck(config, deck) {
+ const PptxGenJS = require("pptxgenjs");
+ const pptx = new PptxGenJS();
+ pptx.defineLayout({ name: "SSAU", width: config.theme.width, height: config.theme.height });
+ pptx.layout = "SSAU";
+ pptx.author = config.presentation.author || "";
+ pptx.title = config.presentation.output;
+ defineMasters(pptx, config);
+
+ deck.forEach((entry, index) => {
+ const type = entry.type || "content";
+ const slide = pptx.addSlide({ masterName: MASTER_BY_TYPE[type] });
+ const parsed = entry.parsed;
+
+ if (type === "title") renderTitleSlide(slide, parsed, config);
+ else if (type === "section") renderSectionSlide(slide, parsed, config);
+ else if (type === "final") renderFinalSlide(slide, parsed, config);
+ else renderContentSlide(pptx, slide, parsed, config);
+
+ if (type === "content" || type === "section") {
+ addSlideNumber(slide, config, index + 1, type);
+ }
+
+ if (parsed.notes) slide.addNotes(parsed.notes);
+ });
+
+ return pptx;
+}
+
+module.exports = { renderDeck, MASTER_BY_TYPE };
diff --git a/video-script.md b/video-script.md
new file mode 100644
index 0000000..e4d9f8f
--- /dev/null
+++ b/video-script.md
@@ -0,0 +1,98 @@
+# Сценарий видеодоклада (≈5 минут)
+
+Докладчик: **Степанов П.А.**, группа **6403-020302D**, ИОТ-трек **«ДОП 11»**,
+программа **«Современные проектные технологии»**.
+Слайды — файл `pptx/presentation.pptx` (заметки докладчика дублируют этот текст).
+
+---
+
+## 0:00–0:20. Приветствие (с паспортом)
+
+> Добрый день, уважаемые члены итоговой аттестационной комиссии. Меня зовут
+> Степанов П.А. *(показать в камеру разворот паспорта)*, группа 6403-020302D, я
+> являюсь слушателем программы профессиональной переподготовки «Современные
+> проектные технологии», ИОТ-трек «ДОП 11». Разрешите представить решение
+> итогового задания по теме «Современные проектные технологии».
+
+*(Слайд 1 — титульный.)*
+
+## 0:20–0:45. План доклада
+
+Сначала обосную выбор метода, затем покажу устройство методологии, проверку
+спроса через спринты и применение в десяти сферах деятельности. *(Слайд 2.)*
+
+## 0:45–1:20. Постановка задачи
+
+Задача итогового задания — выбрать метод выведения нового товара на рынок и
+обосновать его применимость в десяти сферах деятельности. Запуск новинки всегда
+сопряжён с риском: до семи из десяти новых товаров не выживают на рынке. Поэтому
+нужен управляемый и повторяемый процесс. В качестве такого метода я выбрал Agile
+и его самую популярную методологию — Scrum. *(Слайд 3.)*
+
+## 1:20–2:30. Метод: Agile и Scrum
+
+Agile — это гибкий подход к управлению проектами, закреплённый в манифесте
+2001 года. Scrum — самая распространённая Agile-методология. Работа разбивается на
+короткие спринты длиной от одной до четырёх недель, каждый из которых завершается
+готовым приростом продукта. *(Слайд 4.)*
+
+В Scrum три роли: Product Owner отвечает за ценность товара, Scrum Master убирает
+препятствия, команда разработки создаёт продукт. Три артефакта — Product Backlog,
+Sprint Backlog и Increment. Четыре события — планирование, ежедневная встреча,
+обзор спринта и ретроспектива. *(Слайды 5–6.)*
+
+Весь цикл замкнут: из Product Backlog формируется спринт, который завершается
+инкрементом; Sprint Review показывает результат рынку, а подтверждённый спрос ведёт
+к масштабированию. *(Слайд 7.)*
+
+Ключевое преимущество: рынок проверяется дешёвым инкрементом — сначала минимальный
+продукт, затем развитие по отклику реальных клиентов. *(Слайд 8.)*
+
+## 2:30–4:10. Применение в десяти сферах
+
+Покажу, что метод работает не только в IT. *(Слайд 9.)* В каждой сфере — свой
+пилотный инкремент и своя метрика спринта.
+
+- **Производство** — линейка упаковки запускается пробной партией на одной линии;
+ метрики — себестоимость и доля брака. **Связь** — тариф «Умный дом» тестируется
+ на ста абонентах; смотрят подключения и отток. *(Слайд 10.)*
+- **Транспорт** — каршеринг мини-фургонов стартует с пяти машин в одном районе;
+ метрики — загрузка и спрос. **Строительство** — сначала возводится один пилотный
+ модуль; оценивают затраты и сроки монтажа. *(Слайд 11.)*
+- **Коммерция** — подписка на продуктовые наборы запускается бокс-пилотом на
+ пятидесяти клиентах; метрики — конверсия и удержание. **Юридические услуги** —
+ пакет «юрфирма на аутсорсе» предлагается пяти клиентам на пробный месяц.
+ *(Слайд 12.)*
+- **Организация праздников** — онлайн-конструктор тестируется на одной группе по
+ вовлечённости и заказам. **Бытовые услуги** — подписка на уборку стартует с
+ двадцати подписчиков; важны удержание и загрузка. *(Слайд 13.)*
+- **IT-услуги** — новый модуль SaaS выходит в бету на десять клиентов; метрики —
+ использование и NPS. **Банковские услуги** — вклад «Цель» тестируется на
+ ограниченной группе приложения. *(Слайд 14.)*
+
+Везде логика одна: ограниченный пробный продукт — замер метрики на Sprint Review —
+масштабирование.
+
+## 4:10–4:45. Выводы
+
+Agile и Scrum — универсальный метод выведения товара на рынок. Спринты заменяют
+один дорогой запуск серией дешёвых проверок. Метод работает везде, где есть клиент
+и обратная связь: от производства до банковских услуг. Результат — снижение риска
+и стоимости ошибки при запуске новинки. *(Слайд 15.)*
+
+## 4:45–5:00. Заключение
+
+> На этом мой доклад закончен, спасибо за внимание.
+
+*(Слайд 16.)*
+
+---
+
+## Чек-лист записи
+
+- [ ] В начале доклада — **демонстрация паспорта** в камеру.
+- [ ] В кадре одновременно видны **вы и презентация**.
+- [ ] Приветствие, основная часть и заключение — по формулировкам выше.
+- [ ] Формат файла — **mp4**, имя: `Видео защита Фамилия И.О.mp4`.
+- [ ] Загрузить в облако: https://disk.ssau.ru/s/s8dSapsJ7bbn8yb
+- [ ] PDF ответа: `Итоговое задание Фамилия И.О.pdf` — в «Материалы итогового задания».