Единая точка входа в документацию платформы (ADR-001 D1–D9): - docs/overview/ — 10 файлов: индекс (маршруты «Я заказчик / Я менеджер / Я разработчик» + норматив «изменил функциональность → обнови витрину в том же PR»), business.md (без жаргона, 6 сценариев), 7 тех-блоков (link-first), presentation.md (16 слайдов + процедура сборки «команда + Проверка:»). - scripts/build_presentation.py — генератор .pptx в тёмном дизайне (python-pptx; чистый stdlib-парсер parse_slides + ленивый import pptx; бинарь не коммитится, build/ в .gitignore; зависимость НЕ в прод-образе — машинный гард TC-09). - tests/test_system_docs.py — структурный анти-дрейф: derive-сверки стадий/ гейтов/агентов импортом STAGE_TRANSITIONS/QG_CHECKS/glob промптов/config, валидность ссылок, FORBIDDEN-скан + секрет-эвристика, слайды каноническим парсером, NFR-2, указатели. - reviewer.md — ось обзорных доков ORCH-079 расширена на витрину (D7; канон 52d байт-в-байт, только текст внутри секций) + анти-регресс ассерт в test_agent_prompts_canon.py. - Указатели: README.md, CLAUDE.md (правила №2/№6, «Структура»), PRODUCT_VISION.md (врезка-ссылка), CHANGELOG.md. Рантайм байт-в-байт: src/**, docker-compose.yml, Dockerfile, requirements* — ноль изменений (docs+tests+dev-скрипт, паттерн ORCH-102/103). pytest: 1873 passed. Refs: ORCH-011 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
193 lines
8.3 KiB
Python
193 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
||
"""ORCH-011 (ADR-001 D4): сборка `.pptx` из слайдо-источника витрины.
|
||
|
||
Источник истины — `docs/overview/presentation.md` (машинно-парсимая
|
||
слайдо-структура: `## Слайд N: Заголовок` + тезисы `- ...` + опциональная
|
||
строка `> Визуал: ...`). Скрипт собирает редактируемую PowerPoint-презентацию
|
||
в тёмном дизайне (D-1 Владельца): тёмный фон, светлый текст, один акцентный
|
||
цвет, системные шрифты с полной кириллицей.
|
||
|
||
Канон (D4/D5):
|
||
- запуск ТОЛЬКО вне рантайма конвейера (host/dev venv, явный запуск человеком —
|
||
паттерн ORCH-009); `python-pptx` НЕ входит в requirements*/Dockerfile (NFR-2);
|
||
- `parse_slides` — чистая stdlib-функция БЕЗ импорта pptx: её импортирует
|
||
`tests/test_system_docs.py` (один парсер = один источник истины о формате);
|
||
- рендерер импортирует pptx ЛЕНИВО внутри `build_pptx`;
|
||
- дефолтный выход — `build/orchestrator-overview.pptx` (в `.gitignore`;
|
||
собранный бинарь в git НЕ коммитится — D5).
|
||
|
||
Процедура запуска (канон «команда + Проверка:») — `docs/overview/presentation.md`,
|
||
раздел «Как собрать .pptx».
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
DEFAULT_SOURCE = REPO_ROOT / "docs" / "overview" / "presentation.md"
|
||
DEFAULT_OUTPUT = REPO_ROOT / "build" / "orchestrator-overview.pptx"
|
||
|
||
# Тёмная тема (D4): фон ~#1F1F2E, светлый текст, один акцент, приглушённый серый.
|
||
DARK_BG = "1F1F2E"
|
||
TEXT_MAIN = "F2F2F7"
|
||
ACCENT = "8AB4F8"
|
||
TEXT_MUTED = "9A9AAD"
|
||
FONT_NAME = "Calibri" # системный шрифт с полной кириллицей (D4)
|
||
|
||
_SLIDE_RE = re.compile(r"^##\s+Слайд\s+(\d+)\s*:\s*(.+?)\s*$")
|
||
_BULLET_RE = re.compile(r"^-\s+(.+?)\s*$")
|
||
_VISUAL_RE = re.compile(r"^>\s*Визуал\s*:\s*(.+?)\s*$")
|
||
_ANY_HEADING_RE = re.compile(r"^#{1,6}\s+")
|
||
|
||
|
||
@dataclass
|
||
class Slide:
|
||
"""Один слайд источника: номер, заголовок, тезисы, подпись визуала."""
|
||
|
||
number: int
|
||
title: str
|
||
bullets: list[str] = field(default_factory=list)
|
||
visual: str | None = None
|
||
|
||
|
||
def parse_slides(text: str) -> list[Slide]:
|
||
"""Разобрать слайдо-источник в список :class:`Slide` (чистая, stdlib-only).
|
||
|
||
Формат (D4): слайд открывается строкой ``## Слайд N: Заголовок``; его тезисы —
|
||
строки ``- ...``; опциональная подпись визуала — ``> Визуал: ...``. Любой
|
||
другой markdown-заголовок (например, раздел «Как собрать .pptx») завершает
|
||
текущий слайд — служебные разделы источника в слайды не попадают.
|
||
"""
|
||
slides: list[Slide] = []
|
||
current: Slide | None = None
|
||
for line in text.splitlines():
|
||
m = _SLIDE_RE.match(line)
|
||
if m:
|
||
current = Slide(number=int(m.group(1)), title=m.group(2))
|
||
slides.append(current)
|
||
continue
|
||
if _ANY_HEADING_RE.match(line):
|
||
current = None # служебный раздел — не слайд
|
||
continue
|
||
if current is None:
|
||
continue
|
||
bullet = _BULLET_RE.match(line)
|
||
if bullet:
|
||
current.bullets.append(bullet.group(1))
|
||
continue
|
||
visual = _VISUAL_RE.match(line)
|
||
if visual:
|
||
current.visual = visual.group(1)
|
||
return slides
|
||
|
||
|
||
def build_pptx(slides: list[Slide], output: Path) -> None:
|
||
"""Собрать `.pptx` в тёмном дизайне из распарсенных слайдов.
|
||
|
||
Импорт `pptx` — ленивый (D4): без установленного `python-pptx` модуль
|
||
остаётся импортируемым (нужно тестам), а сборка честно подсказывает
|
||
`pip install python-pptx`. Текст пишется настоящими редактируемыми
|
||
run'ами — кириллица не растрируется, слайды правятся руками.
|
||
"""
|
||
from pptx import Presentation
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.util import Inches, Pt
|
||
|
||
prs = Presentation()
|
||
prs.slide_width = Inches(13.333) # 16:9
|
||
prs.slide_height = Inches(7.5)
|
||
blank_layout = prs.slide_layouts[6]
|
||
|
||
for slide_def in slides:
|
||
slide = prs.slides.add_slide(blank_layout)
|
||
slide.background.fill.solid()
|
||
slide.background.fill.fore_color.rgb = RGBColor.from_string(DARK_BG)
|
||
|
||
title_box = slide.shapes.add_textbox(
|
||
Inches(0.6), Inches(0.45), prs.slide_width - Inches(1.2), Inches(1.2)
|
||
)
|
||
title_tf = title_box.text_frame
|
||
title_tf.word_wrap = True
|
||
run = title_tf.paragraphs[0].add_run()
|
||
run.text = slide_def.title
|
||
run.font.size = Pt(34)
|
||
run.font.bold = True
|
||
run.font.name = FONT_NAME
|
||
run.font.color.rgb = RGBColor.from_string(ACCENT)
|
||
|
||
body_box = slide.shapes.add_textbox(
|
||
Inches(0.8), Inches(1.85), prs.slide_width - Inches(1.6), Inches(4.4)
|
||
)
|
||
body_tf = body_box.text_frame
|
||
body_tf.word_wrap = True
|
||
for i, bullet in enumerate(slide_def.bullets):
|
||
para = body_tf.paragraphs[0] if i == 0 else body_tf.add_paragraph()
|
||
run = para.add_run()
|
||
run.text = f"• {bullet}"
|
||
run.font.size = Pt(20)
|
||
run.font.name = FONT_NAME
|
||
run.font.color.rgb = RGBColor.from_string(TEXT_MAIN)
|
||
para.space_after = Pt(10)
|
||
|
||
if slide_def.visual:
|
||
cap_box = slide.shapes.add_textbox(
|
||
Inches(0.8), Inches(6.55), prs.slide_width - Inches(1.6), Inches(0.6)
|
||
)
|
||
cap_tf = cap_box.text_frame
|
||
cap_tf.word_wrap = True
|
||
run = cap_tf.paragraphs[0].add_run()
|
||
run.text = f"Визуал: {slide_def.visual}"
|
||
run.font.size = Pt(13)
|
||
run.font.italic = True
|
||
run.font.name = FONT_NAME
|
||
run.font.color.rgb = RGBColor.from_string(TEXT_MUTED)
|
||
|
||
output.parent.mkdir(parents=True, exist_ok=True)
|
||
prs.save(str(output))
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
"""CLI: распарсить источник, собрать `.pptx`, напечатать число слайдов."""
|
||
parser = argparse.ArgumentParser(
|
||
description="Сборка docs/overview/presentation.md -> .pptx (тёмный дизайн, ORCH-011 D4)."
|
||
)
|
||
parser.add_argument(
|
||
"--source",
|
||
type=Path,
|
||
default=DEFAULT_SOURCE,
|
||
help=f"слайдо-источник (default: {DEFAULT_SOURCE.relative_to(REPO_ROOT)})",
|
||
)
|
||
parser.add_argument(
|
||
"--out",
|
||
type=Path,
|
||
default=DEFAULT_OUTPUT,
|
||
help=f"выходной .pptx (default: {DEFAULT_OUTPUT.relative_to(REPO_ROOT)})",
|
||
)
|
||
args = parser.parse_args(argv)
|
||
|
||
if not args.source.is_file():
|
||
print(f"ОШИБКА: источник не найден: {args.source}")
|
||
return 1
|
||
slides = parse_slides(args.source.read_text(encoding="utf-8"))
|
||
if not slides:
|
||
print(f"ОШИБКА: в {args.source} не найдено ни одного слайда (формат: '## Слайд N: ...')")
|
||
return 1
|
||
try:
|
||
build_pptx(slides, args.out)
|
||
except ImportError:
|
||
print(
|
||
"ОШИБКА: python-pptx не установлен. Сборка выполняется в одноразовом "
|
||
"dev-venv ВНЕ прод-образа (NFR-2): pip install python-pptx"
|
||
)
|
||
return 1
|
||
print(f"Собрано слайдов: {len(slides)} → {args.out}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|