This commit is contained in:
2026-10-08 06:49:13 +04:00
parent 41d434ef97
commit 35611e2a91
84 changed files with 3430 additions and 375 deletions

212
pptx/AGENTS.md Normal file
View File

@@ -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 <file> путь к config.yaml (по умолчанию config.yaml)
-o, --output <file> переопределить имя выходного файла
--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
```

130
pptx/config.yaml Normal file
View File

@@ -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 }

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

View File

@@ -0,0 +1,13 @@
# Современные проектные технологии
## Метод выведения нового товара на рынок: Agile / Scrum
Степанов П.А., группа 6403-020302D
Слушатель программы профессиональной переподготовки «Современные проектные технологии»
ИОТ-трек «ДОП 11»
::: notes
Добрый день, уважаемые члены итоговой аттестационной комиссии. Меня зовут Степанов П.А., я слушатель программы профессиональной переподготовки «Современные проектные технологии», ИОТ-трек «ДОП 11», группа 6403-020302D. Разрешите представить решение итогового задания по теме «Современные проектные технологии» — метод выведения нового товара на рынок.
:::

View File

@@ -0,0 +1,12 @@
# План доклада
- ==Метод== выведения нового товара: Agile / Scrum
- Почему выбран именно Scrum
- Роли, артефакты и события методологии
- Как спринты проверяют спрос на рынке
- Применение в ==десяти сферах== деятельности
- Выводы и практический результат
::: notes
Структура доклада. Сначала я обосную выбор метода — Agile и Scrum. Затем покажу, как устроена методология: роли, артефакты и события. Отдельно объясню, как спринты позволяют проверять спрос на рынке. После этого докажу универсальность метода на десяти сферах деятельности: от производства до банковских услуг. В завершение приведу выводы и практический результат.
:::

View File

@@ -0,0 +1,19 @@
# Постановка задачи
- Выбрать метод выведения нового товара на рынок
- Обосновать его применимость в ==десяти сферах== деятельности
- Показать, как метод работает в каждой сфере
::: metrics
70% | новых товаров уходят с рынка
10 | сфер применения метода
4 | недели — максимальная длина спринта
:::
::: callout "Проблема и решение"
Запуск новинки — это **высокий риск**. Нужен управляемый и повторяемый процесс вывода товара — таким процессом является **Agile / Scrum**.
:::
::: notes
Задача итогового задания — выбрать метод выведения нового товара на рынок и обосновать его в десяти сферах: от производства до банковских услуг. Запуск новинки всегда сопряжён с риском: до семи из десяти новых товаров не выживают на рынке. Поэтому нужен управляемый и повторяемый процесс, и в качестве такого метода я выбрал Agile и его самую популярную методологию — Scrum.
:::

View File

@@ -0,0 +1,18 @@
# Почему выбран Agile / Scrum
- Agile — гибкий подход к управлению проектами, закреплён в ==манифесте 2001 года==
- Scrum — самая распространённая Agile-методология в мире
- Работа идёт короткими итерациями — *спринтами* от одной до четырёх недель
- Каждый спринт даёт готовый прирост продукта — ==инкремент==
- Обратная связь с рынком собирается на каждом шаге, а не в конце проекта
::: chart doughnut "Новинки, которые не выживают на рынке"
| Итог | Доля, % |
|---|---|
| Успешные новинки | 30 |
| Уходят с рынка | 70 |
:::
::: notes
Почему именно Agile и Scrum. Agile — это гибкий подход к управлению проектами, закреплённый в манифесте 2001 года. Scrum — самая распространённая Agile-методология. Работа разбивается на короткие спринты длиной от одной до четырёх недель. Каждый спринт завершается готовым приростом продукта. Благодаря этому обратная связь с рынком собирается постоянно, а не только в конце. Именно поэтому гибкий подход снижает риск: как показывают оценки, до семи из десяти новинок не выживают при традиционном запуске.
:::

View File

@@ -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.
:::

View File

@@ -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 команда улучшает свой процесс. Каждый спринт заканчивается рабочим инкрементом, который можно показать рынку. Далее цикл повторяется: планирование, спринт, демонстрация, ретроспектива.
:::

View File

@@ -0,0 +1,18 @@
# Как Scrum выводит товар на рынок
::: flow
Спринт 0: исследование -> MVP -> Пилот и метрики -> Масштабирование
:::
- **Спринт 0** — исследование рынка, выбор сегмента, формирование Product Backlog
- **Спринты 1–2** — создание MVP: минимальной версии товара для первых клиентов
- **Спринты 3–4** — пилот на ограниченной аудитории и сбор метрик
- **Спринт 5 и далее** — доработка по обратной связи и масштабирование
::: callout "Итог"
Рынок проверяется ==дешёвым инкрементом==, а не дорогим запуском «с нуля».
:::
::: notes
Как Scrum выводит товар на рынок. Спринт ноль — исследование рынка и формирование списка требований. Спринты один и два — создание минимального продукта для первых клиентов. Спринты три и четыре — пилот на ограниченной аудитории и сбор метрик. Дальше — доработка по обратной связи и масштабирование. Демонстрация реальным клиентам позволяет проверить спрос до крупных вложений. Итог: рынок проверяется дешёвым инкрементом, а не дорогим запуском с нуля.
:::

View File

@@ -0,0 +1,7 @@
# Agile / Scrum в десяти сферах деятельности
Обоснование универсальности выбранного метода
::: notes
Теперь покажу, что метод Agile и Scrum применим не только в IT, но и в десяти различных сферах деятельности.
:::

View File

@@ -0,0 +1,21 @@
# Сферы 1–2: производство и связь
::: callout "1. Производство — новая линейка упаковки"
- Новый товар: линейка упаковки для розницы.
- Пилотный спринт: пробная партия на ==одной линии==.
- Метрика Sprint Review: себестоимость, доля брака, скорость переналадки.
- Решение: масштабирование на остальные линии по подтверждённому спросу.
- Особенность: переналадка одной линии дешевле полного перезапуска.
:::
::: callout "2. Связь — тариф «Умный дом»"
- Новый товар: тариф «Умный дом» для абонентов.
- Пилотный спринт: подключение ==100 абонентов==.
- Метрика Sprint Review: число подключений, средний чек, отток.
- Решение: доработка состава услуг и вывод тарифа на массовый рынок.
- Особенность: пилот ограничен абонентской базой и не грозит массовым оттоком.
:::
::: notes
Первые две сферы. В производстве новая линейка упаковки запускается пробной партией на одной линии: замеряют себестоимость, долю брака и скорость переналадки. По результатам Sprint Review производство масштабируется на остальные линии. В связи тариф «Умный дом» тестируется на ста абонентах: анализируют подключения, средний чек и отток, после чего состав услуг уточняется и тариф выводится на массовый рынок. Общая логика: ограниченный пилот, замер метрики, затем масштабирование.
:::

View File

@@ -0,0 +1,21 @@
# Сферы 3–4: транспорт и строительство
::: callout "3. Транспорт — каршеринг мини-фургонов"
- Новый товар: сервис каршеринга мини-фургонов.
- Пилотный спринт: ==пять машин== в одном районе.
- Метрика Sprint Review: загрузка, среднее время аренды, спрос.
- Решение: расширение автопарка и запуск в новых районах.
- Особенность: экономика сервиса проверяется до закупки техники.
:::
::: callout "4. Строительство — модульные дома"
- Новый товар: модульные дома для застройки.
- Пилотный спринт: возведение ==одного пилотного модуля== и показ заказчикам.
- Метрика Sprint Review: затраты на монтаж, сроки, замечания.
- Решение: серийный модуль с учётом правок и тиражирование.
- Особенность: типовой проект снижает стоимость серии.
:::
::: notes
Транспорт и строительство. В транспорте сервис каршеринга мини-фургонов стартует с пяти машин в одном районе: оценивают загрузку, среднее время аренды и спрос. Если экономика подтверждается, автопарк расширяется. В строительстве сначала возводится один пилотный модульный дом и показывается заказчикам: фиксируют затраты, сроки и замечания, после чего серийный модуль тиражируется. Итеративная проверка снижает риск ошибок в типовом проекте.
:::

View File

@@ -0,0 +1,21 @@
# Сферы 5–6: коммерция и юридические услуги
::: callout "5. Коммерция — подписка на наборы продуктов"
- Новый товар: подписка на наборы продуктов.
- Пилотный спринт: бокс-пилот на ==50 клиентах==.
- Метрика Sprint Review: конверсия, средний чек, удержание.
- Решение: доработка ассортимента и условий подписки.
- Особенность: состав набора гибко меняется по спросу.
:::
::: callout "6. Юридические услуги — пакет «юрфирма на аутсорсе»"
- Новый товар: пакет «юрфирма на аутсорсе».
- Пилотный спринт: пробный месяц для ==5 клиентов==.
- Метрика Sprint Review: востребованность, трудозатраты, повторные обращения.
- Решение: тарифы и состав пакета формируются по отзывам.
- Особенность: расширение штата юристов только после подтверждения спроса.
:::
::: notes
Коммерция и юридические услуги. В коммерции подписка на продуктовые наборы запускается бокс-пилотом на пятидесяти клиентах: измеряют конверсию, средний чек и удержание, после чего уточняют ассортимент и условия. В юридических услугах пакет «юрфирма на аутсорсе» предлагается пяти клиентам на пробный месяц: оценивают востребованность и трудозатраты. Пилот позволяет выстроить услугу под реальные ожидания клиентов, не вкладываясь сразу в расширение штата.
:::

View File

@@ -0,0 +1,21 @@
# Сферы 7–8: организация праздников и бытовые услуги
::: callout "7. Организация праздников — онлайн-конструктор мероприятий"
- Новый товар: онлайн-конструктор мероприятий.
- Пилотный спринт: ==один сценарий== на пробной группе.
- Метрика Sprint Review: вовлечённость, повторные заказы, средняя стоимость.
- Решение: каталог успешных сценариев и автоматизация заказов.
- Особенность: непопулярные сценарии не масштабируются и не продвигаются.
:::
::: callout "8. Бытовые услуги — подписка на уборку"
- Новый товар: подписка на уборку.
- Пилотный спринт: ==20 подписчиков== в одном районе.
- Метрика Sprint Review: удержание, число заказов, загрузка исполнителей.
- Решение: стандарты качества и масштабирование на другие районы.
- Особенность: логистика и качество отрабатываются в пилоте.
:::
::: notes
Организация праздников и бытовые услуги. Онлайн-конструктор мероприятий тестируется на одной пробной группе: смотрят вовлечённость, повторные заказы и среднюю стоимость. Успешный сценарий становится основой каталога. Подписка на уборку стартует с двадцати подписчиков в одном районе: измеряют удержание, число заказов и загрузку исполнителей, после чего налаживают логистику и стандарты качества и выходят на другие районы.
:::

View File

@@ -0,0 +1,21 @@
# Сферы 9–10: IT-услуги и банки
::: callout "9. IT-услуги — модуль SaaS-платформы"
- Новый товар: новый модуль SaaS-платформы.
- Пилотный спринт: ==бета на 10 клиентов==.
- Метрика Sprint Review: использование, частота ошибок, NPS.
- Решение: состав следующего релиза по обратной связи.
- Особенность: регулярные релизы — естественный режим гибкой разработки.
:::
::: callout "10. Банковские услуги — вклад «Цель»"
- Новый товар: вклад «Цель» в мобильном приложении.
- Пилотный спринт: тест на ==ограниченной группе пользователей==.
- Метрика Sprint Review: доля открытий, средний срок, удержание.
- Решение: полное внедрение после согласования с подразделениями банка.
- Особенность: пилот проводится в контролируемых регулируемых условиях.
:::
::: notes
Последние две сферы. В IT новый модуль SaaS-платформы выходит в бету на десять клиентов: измеряют использование, частоту ошибок и NPS, а обратная связь определяет состав следующего релиза — это естественный режим гибкой разработки. В банковских услугах вклад «Цель» тестируется на ограниченной группе пользователей приложения: оценивают долю открытий, срок и удержание. Даже в регулируемой сфере пилот в контролируемых условиях позволяет безопасно проверить спрос до полного внедрения.
:::

View File

@@ -0,0 +1,17 @@
# Выводы
- Agile/Scrum — ==универсальный метод== выведения товара на рынок
- Спринты заменяют один дорогой запуск серией дешёвых проверок
- Роли, артефакты и события делают процесс прозрачным и управляемым
- Метод работает везде, где есть клиент и обратная связь — от производства до банков
- Результат — снижение риска и стоимости ошибки при запуске новинки
::: metrics
10 | сфер применения
30% | новинок выживают
4 | недели — длина спринта
:::
::: notes
Таким образом, Agile и Scrum — универсальный метод выведения товара на рынок. Спринты заменяют один дорогой запуск серией дешёвых проверок. Роли, артефакты и события делают процесс прозрачным и управляемым. Метод работает везде, где есть клиент и обратная связь: от производства до банковских услуг. Результат — снижение риска и стоимости ошибки при запуске новинки.
:::

View File

@@ -0,0 +1,9 @@
# Спасибо за внимание
Степанов П.А., группа 6403-020302D
Слушатель программы профессиональной переподготовки «Современные проектные технологии», ИОТ-трек «ДОП 11»
::: notes
На этом мой доклад закончен, спасибо за внимание. Готов ответить на ваши вопросы.
:::

View File

@@ -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 рынок даёт обратную связь, после чего команда проводит ретроспективу. Если спрос подтверждён, товар масштабируется; если нет — цикл повторяется с уточнёнными требованиями. Каждый виток снижает неопределённость.
:::

203
pptx/package-lock.json generated Normal file
View File

@@ -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"
}
}
}

19
pptx/package.json Normal file
View File

@@ -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"
}
}

105
pptx/src/build.js Normal file
View File

@@ -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 };

96
pptx/src/cli.js Normal file
View File

@@ -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 <file> путь к config.yaml (по умолчанию config.yaml)
-o, --output <file> переопределить имя выходного файла
--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();

195
pptx/src/config.js Normal file
View File

@@ -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 };

6
pptx/src/index.js Normal file
View File

@@ -0,0 +1,6 @@
"use strict";
const { build } = require("./build");
const { parseSlide } = require("./parse");
module.exports = { build, parseSlide };

230
pptx/src/parse.js Normal file
View File

@@ -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 };

174
pptx/src/plantuml.js Normal file
View File

@@ -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<string[]>} список проблем (ошибок/предупреждений)
*/
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 };

778
pptx/src/render.js Normal file
View File

@@ -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 };