#!/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())