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` — в «Материалы итогового задания».