mirror of
https://github.com/StepanovPlaton/SSAUGenerator.git
synced 2026-11-01 10:49:08 +04:00
add pptx
This commit is contained in:
212
pptx/AGENTS.md
Normal file
212
pptx/AGENTS.md
Normal 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
130
pptx/config.yaml
Normal 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 }
|
||||
BIN
pptx/content/assets/content-bg.jpg
Normal file
BIN
pptx/content/assets/content-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 377 KiB |
BIN
pptx/content/assets/final-bg.jpeg
Normal file
BIN
pptx/content/assets/final-bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 365 KiB |
24
pptx/content/assets/scrum-cycle.puml
Normal file
24
pptx/content/assets/scrum-cycle.puml
Normal 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
|
||||
BIN
pptx/content/assets/title-bg.jpeg
Normal file
BIN
pptx/content/assets/title-bg.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
13
pptx/content/slides/00-title.md
Normal file
13
pptx/content/slides/00-title.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Современные проектные технологии
|
||||
|
||||
## Метод выведения нового товара на рынок: Agile / Scrum
|
||||
|
||||
Степанов П.А., группа 6403-020302D
|
||||
|
||||
Слушатель программы профессиональной переподготовки «Современные проектные технологии»
|
||||
|
||||
ИОТ-трек «ДОП 11»
|
||||
|
||||
::: notes
|
||||
Добрый день, уважаемые члены итоговой аттестационной комиссии. Меня зовут Степанов П.А., я слушатель программы профессиональной переподготовки «Современные проектные технологии», ИОТ-трек «ДОП 11», группа 6403-020302D. Разрешите представить решение итогового задания по теме «Современные проектные технологии» — метод выведения нового товара на рынок.
|
||||
:::
|
||||
12
pptx/content/slides/01-agenda.md
Normal file
12
pptx/content/slides/01-agenda.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# План доклада
|
||||
|
||||
- ==Метод== выведения нового товара: Agile / Scrum
|
||||
- Почему выбран именно Scrum
|
||||
- Роли, артефакты и события методологии
|
||||
- Как спринты проверяют спрос на рынке
|
||||
- Применение в ==десяти сферах== деятельности
|
||||
- Выводы и практический результат
|
||||
|
||||
::: notes
|
||||
Структура доклада. Сначала я обосную выбор метода — Agile и Scrum. Затем покажу, как устроена методология: роли, артефакты и события. Отдельно объясню, как спринты позволяют проверять спрос на рынке. После этого докажу универсальность метода на десяти сферах деятельности: от производства до банковских услуг. В завершение приведу выводы и практический результат.
|
||||
:::
|
||||
19
pptx/content/slides/01-task.md
Normal file
19
pptx/content/slides/01-task.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Постановка задачи
|
||||
|
||||
- Выбрать метод выведения нового товара на рынок
|
||||
- Обосновать его применимость в ==десяти сферах== деятельности
|
||||
- Показать, как метод работает в каждой сфере
|
||||
|
||||
::: metrics
|
||||
70% | новых товаров уходят с рынка
|
||||
10 | сфер применения метода
|
||||
4 | недели — максимальная длина спринта
|
||||
:::
|
||||
|
||||
::: callout "Проблема и решение"
|
||||
Запуск новинки — это **высокий риск**. Нужен управляемый и повторяемый процесс вывода товара — таким процессом является **Agile / Scrum**.
|
||||
:::
|
||||
|
||||
::: notes
|
||||
Задача итогового задания — выбрать метод выведения нового товара на рынок и обосновать его в десяти сферах: от производства до банковских услуг. Запуск новинки всегда сопряжён с риском: до семи из десяти новых товаров не выживают на рынке. Поэтому нужен управляемый и повторяемый процесс, и в качестве такого метода я выбрал Agile и его самую популярную методологию — Scrum.
|
||||
:::
|
||||
18
pptx/content/slides/02-why-scrum.md
Normal file
18
pptx/content/slides/02-why-scrum.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Почему выбран Agile / Scrum
|
||||
|
||||
- Agile — гибкий подход к управлению проектами, закреплён в ==манифесте 2001 года==
|
||||
- Scrum — самая распространённая Agile-методология в мире
|
||||
- Работа идёт короткими итерациями — *спринтами* от одной до четырёх недель
|
||||
- Каждый спринт даёт готовый прирост продукта — ==инкремент==
|
||||
- Обратная связь с рынком собирается на каждом шаге, а не в конце проекта
|
||||
|
||||
::: chart doughnut "Новинки, которые не выживают на рынке"
|
||||
| Итог | Доля, % |
|
||||
|---|---|
|
||||
| Успешные новинки | 30 |
|
||||
| Уходят с рынка | 70 |
|
||||
:::
|
||||
|
||||
::: notes
|
||||
Почему именно Agile и Scrum. Agile — это гибкий подход к управлению проектами, закреплённый в манифесте 2001 года. Scrum — самая распространённая Agile-методология. Работа разбивается на короткие спринты длиной от одной до четырёх недель. Каждый спринт завершается готовым приростом продукта. Благодаря этому обратная связь с рынком собирается постоянно, а не только в конце. Именно поэтому гибкий подход снижает риск: как показывают оценки, до семи из десяти новинок не выживают при традиционном запуске.
|
||||
:::
|
||||
21
pptx/content/slides/03-roles.md
Normal file
21
pptx/content/slides/03-roles.md
Normal 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.
|
||||
:::
|
||||
18
pptx/content/slides/04-events.md
Normal file
18
pptx/content/slides/04-events.md
Normal 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 команда улучшает свой процесс. Каждый спринт заканчивается рабочим инкрементом, который можно показать рынку. Далее цикл повторяется: планирование, спринт, демонстрация, ретроспектива.
|
||||
:::
|
||||
18
pptx/content/slides/05-launch.md
Normal file
18
pptx/content/slides/05-launch.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Как Scrum выводит товар на рынок
|
||||
|
||||
::: flow
|
||||
Спринт 0: исследование -> MVP -> Пилот и метрики -> Масштабирование
|
||||
:::
|
||||
|
||||
- **Спринт 0** — исследование рынка, выбор сегмента, формирование Product Backlog
|
||||
- **Спринты 1–2** — создание MVP: минимальной версии товара для первых клиентов
|
||||
- **Спринты 3–4** — пилот на ограниченной аудитории и сбор метрик
|
||||
- **Спринт 5 и далее** — доработка по обратной связи и масштабирование
|
||||
|
||||
::: callout "Итог"
|
||||
Рынок проверяется ==дешёвым инкрементом==, а не дорогим запуском «с нуля».
|
||||
:::
|
||||
|
||||
::: notes
|
||||
Как Scrum выводит товар на рынок. Спринт ноль — исследование рынка и формирование списка требований. Спринты один и два — создание минимального продукта для первых клиентов. Спринты три и четыре — пилот на ограниченной аудитории и сбор метрик. Дальше — доработка по обратной связи и масштабирование. Демонстрация реальным клиентам позволяет проверить спрос до крупных вложений. Итог: рынок проверяется дешёвым инкрементом, а не дорогим запуском с нуля.
|
||||
:::
|
||||
7
pptx/content/slides/06-spheres.md
Normal file
7
pptx/content/slides/06-spheres.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Agile / Scrum в десяти сферах деятельности
|
||||
|
||||
Обоснование универсальности выбранного метода
|
||||
|
||||
::: notes
|
||||
Теперь покажу, что метод Agile и Scrum применим не только в IT, но и в десяти различных сферах деятельности.
|
||||
:::
|
||||
21
pptx/content/slides/07a-spheres-1-2.md
Normal file
21
pptx/content/slides/07a-spheres-1-2.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Сферы 1–2: производство и связь
|
||||
|
||||
::: callout "1. Производство — новая линейка упаковки"
|
||||
- Новый товар: линейка упаковки для розницы.
|
||||
- Пилотный спринт: пробная партия на ==одной линии==.
|
||||
- Метрика Sprint Review: себестоимость, доля брака, скорость переналадки.
|
||||
- Решение: масштабирование на остальные линии по подтверждённому спросу.
|
||||
- Особенность: переналадка одной линии дешевле полного перезапуска.
|
||||
:::
|
||||
|
||||
::: callout "2. Связь — тариф «Умный дом»"
|
||||
- Новый товар: тариф «Умный дом» для абонентов.
|
||||
- Пилотный спринт: подключение ==100 абонентов==.
|
||||
- Метрика Sprint Review: число подключений, средний чек, отток.
|
||||
- Решение: доработка состава услуг и вывод тарифа на массовый рынок.
|
||||
- Особенность: пилот ограничен абонентской базой и не грозит массовым оттоком.
|
||||
:::
|
||||
|
||||
::: notes
|
||||
Первые две сферы. В производстве новая линейка упаковки запускается пробной партией на одной линии: замеряют себестоимость, долю брака и скорость переналадки. По результатам Sprint Review производство масштабируется на остальные линии. В связи тариф «Умный дом» тестируется на ста абонентах: анализируют подключения, средний чек и отток, после чего состав услуг уточняется и тариф выводится на массовый рынок. Общая логика: ограниченный пилот, замер метрики, затем масштабирование.
|
||||
:::
|
||||
21
pptx/content/slides/07b-spheres-3-4.md
Normal file
21
pptx/content/slides/07b-spheres-3-4.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Сферы 3–4: транспорт и строительство
|
||||
|
||||
::: callout "3. Транспорт — каршеринг мини-фургонов"
|
||||
- Новый товар: сервис каршеринга мини-фургонов.
|
||||
- Пилотный спринт: ==пять машин== в одном районе.
|
||||
- Метрика Sprint Review: загрузка, среднее время аренды, спрос.
|
||||
- Решение: расширение автопарка и запуск в новых районах.
|
||||
- Особенность: экономика сервиса проверяется до закупки техники.
|
||||
:::
|
||||
|
||||
::: callout "4. Строительство — модульные дома"
|
||||
- Новый товар: модульные дома для застройки.
|
||||
- Пилотный спринт: возведение ==одного пилотного модуля== и показ заказчикам.
|
||||
- Метрика Sprint Review: затраты на монтаж, сроки, замечания.
|
||||
- Решение: серийный модуль с учётом правок и тиражирование.
|
||||
- Особенность: типовой проект снижает стоимость серии.
|
||||
:::
|
||||
|
||||
::: notes
|
||||
Транспорт и строительство. В транспорте сервис каршеринга мини-фургонов стартует с пяти машин в одном районе: оценивают загрузку, среднее время аренды и спрос. Если экономика подтверждается, автопарк расширяется. В строительстве сначала возводится один пилотный модульный дом и показывается заказчикам: фиксируют затраты, сроки и замечания, после чего серийный модуль тиражируется. Итеративная проверка снижает риск ошибок в типовом проекте.
|
||||
:::
|
||||
21
pptx/content/slides/07c-spheres-5-6.md
Normal file
21
pptx/content/slides/07c-spheres-5-6.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Сферы 5–6: коммерция и юридические услуги
|
||||
|
||||
::: callout "5. Коммерция — подписка на наборы продуктов"
|
||||
- Новый товар: подписка на наборы продуктов.
|
||||
- Пилотный спринт: бокс-пилот на ==50 клиентах==.
|
||||
- Метрика Sprint Review: конверсия, средний чек, удержание.
|
||||
- Решение: доработка ассортимента и условий подписки.
|
||||
- Особенность: состав набора гибко меняется по спросу.
|
||||
:::
|
||||
|
||||
::: callout "6. Юридические услуги — пакет «юрфирма на аутсорсе»"
|
||||
- Новый товар: пакет «юрфирма на аутсорсе».
|
||||
- Пилотный спринт: пробный месяц для ==5 клиентов==.
|
||||
- Метрика Sprint Review: востребованность, трудозатраты, повторные обращения.
|
||||
- Решение: тарифы и состав пакета формируются по отзывам.
|
||||
- Особенность: расширение штата юристов только после подтверждения спроса.
|
||||
:::
|
||||
|
||||
::: notes
|
||||
Коммерция и юридические услуги. В коммерции подписка на продуктовые наборы запускается бокс-пилотом на пятидесяти клиентах: измеряют конверсию, средний чек и удержание, после чего уточняют ассортимент и условия. В юридических услугах пакет «юрфирма на аутсорсе» предлагается пяти клиентам на пробный месяц: оценивают востребованность и трудозатраты. Пилот позволяет выстроить услугу под реальные ожидания клиентов, не вкладываясь сразу в расширение штата.
|
||||
:::
|
||||
21
pptx/content/slides/07d-spheres-7-8.md
Normal file
21
pptx/content/slides/07d-spheres-7-8.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Сферы 7–8: организация праздников и бытовые услуги
|
||||
|
||||
::: callout "7. Организация праздников — онлайн-конструктор мероприятий"
|
||||
- Новый товар: онлайн-конструктор мероприятий.
|
||||
- Пилотный спринт: ==один сценарий== на пробной группе.
|
||||
- Метрика Sprint Review: вовлечённость, повторные заказы, средняя стоимость.
|
||||
- Решение: каталог успешных сценариев и автоматизация заказов.
|
||||
- Особенность: непопулярные сценарии не масштабируются и не продвигаются.
|
||||
:::
|
||||
|
||||
::: callout "8. Бытовые услуги — подписка на уборку"
|
||||
- Новый товар: подписка на уборку.
|
||||
- Пилотный спринт: ==20 подписчиков== в одном районе.
|
||||
- Метрика Sprint Review: удержание, число заказов, загрузка исполнителей.
|
||||
- Решение: стандарты качества и масштабирование на другие районы.
|
||||
- Особенность: логистика и качество отрабатываются в пилоте.
|
||||
:::
|
||||
|
||||
::: notes
|
||||
Организация праздников и бытовые услуги. Онлайн-конструктор мероприятий тестируется на одной пробной группе: смотрят вовлечённость, повторные заказы и среднюю стоимость. Успешный сценарий становится основой каталога. Подписка на уборку стартует с двадцати подписчиков в одном районе: измеряют удержание, число заказов и загрузку исполнителей, после чего налаживают логистику и стандарты качества и выходят на другие районы.
|
||||
:::
|
||||
21
pptx/content/slides/07e-spheres-9-10.md
Normal file
21
pptx/content/slides/07e-spheres-9-10.md
Normal 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, а обратная связь определяет состав следующего релиза — это естественный режим гибкой разработки. В банковских услугах вклад «Цель» тестируется на ограниченной группе пользователей приложения: оценивают долю открытий, срок и удержание. Даже в регулируемой сфере пилот в контролируемых условиях позволяет безопасно проверить спрос до полного внедрения.
|
||||
:::
|
||||
17
pptx/content/slides/10-conclusion.md
Normal file
17
pptx/content/slides/10-conclusion.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Выводы
|
||||
|
||||
- Agile/Scrum — ==универсальный метод== выведения товара на рынок
|
||||
- Спринты заменяют один дорогой запуск серией дешёвых проверок
|
||||
- Роли, артефакты и события делают процесс прозрачным и управляемым
|
||||
- Метод работает везде, где есть клиент и обратная связь — от производства до банков
|
||||
- Результат — снижение риска и стоимости ошибки при запуске новинки
|
||||
|
||||
::: metrics
|
||||
10 | сфер применения
|
||||
30% | новинок выживают
|
||||
4 | недели — длина спринта
|
||||
:::
|
||||
|
||||
::: notes
|
||||
Таким образом, Agile и Scrum — универсальный метод выведения товара на рынок. Спринты заменяют один дорогой запуск серией дешёвых проверок. Роли, артефакты и события делают процесс прозрачным и управляемым. Метод работает везде, где есть клиент и обратная связь: от производства до банковских услуг. Результат — снижение риска и стоимости ошибки при запуске новинки.
|
||||
:::
|
||||
9
pptx/content/slides/11-final.md
Normal file
9
pptx/content/slides/11-final.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Спасибо за внимание
|
||||
|
||||
Степанов П.А., группа 6403-020302D
|
||||
|
||||
Слушатель программы профессиональной переподготовки «Современные проектные технологии», ИОТ-трек «ДОП 11»
|
||||
|
||||
::: notes
|
||||
На этом мой доклад закончен, спасибо за внимание. Готов ответить на ваши вопросы.
|
||||
:::
|
||||
17
pptx/content/slides/12-scrum-cycle.md
Normal file
17
pptx/content/slides/12-scrum-cycle.md
Normal 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
203
pptx/package-lock.json
generated
Normal 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
19
pptx/package.json
Normal 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
105
pptx/src/build.js
Normal 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
96
pptx/src/cli.js
Normal 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
195
pptx/src/config.js
Normal 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
6
pptx/src/index.js
Normal 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
230
pptx/src/parse.js
Normal 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
174
pptx/src/plantuml.js
Normal 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
778
pptx/src/render.js
Normal 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 };
|
||||
Reference in New Issue
Block a user