/
logvinokda
/
pptx_skill
Обзор
Документация
Войти
/
logvinokda
/
pptx_skill
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
deck_kit.py
672 строки
32 KB
Dmitry Logvinok
feat: deck_kit v2 — charts, tables, 8 new layouts, 3 new palettes, infrastructure
09 июн 2026, 06:55
09 июн 2026, 06:55
34c8d31
Код
Авторство
О чём код?
""" deck_kit.py — расширенный мини-фреймворк для сборки презентаций на python-pptx. Дизайн-токены (палитры, шрифты, размеры, поля) + готовые раскладки слайдов + чарты + таблицы + инфраструктура (авто-размер шрифта, авто-сетка, speaker notes, нумерация слайдов, фоновое изображение). Работает полностью офлайн. Для рендера превью нужны LibreOffice + poppler. """ import os import glob import shutil import subprocess import math from dataclasses import dataclass, field from pptx import Presentation from pptx.util import Inches, Pt, Emu from pptx.dml.color import RGBColor from pptx.enum.text import PP_ALIGN, MSO_ANCHOR from pptx.enum.shapes import MSO_SHAPE from pptx.enum.shapes import MSO_CONNECTOR_TYPE from pptx.chart.data import CategoryChartData from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION SLIDE_W, SLIDE_H = Inches(13.333), Inches(7.5) # 16:9 MARGIN = 0.9 # ── Дизайн-токены ─────────────────────────────────────────────────────────── @dataclass class Palette: """Доминанта (primary/bg_dark) + поддержка + акцент + цвета для чартов.""" bg_dark: str = "1E2761" bg_light: str = "FFFFFF" primary: str = "1E2761" accent: str = "6AA0FF" ink: str = "1A1A1A" ink_inv: str = "FFFFFF" muted: str = "8A93B2" chart_colors: tuple = ("6AA0FF", "02C39A", "E07A5F", "E8A0BF", "97BC62") accent_light: str = "D6E4FF" # светлая версия акцента для плашек на светлом фоне @dataclass class Typography: head: str = "Georgia" body: str = "Calibri" PALETTES = { "midnight": Palette( bg_dark="1E2761", primary="1E2761", accent="6AA0FF", muted="8A93B2", accent_light="D6E4FF", chart_colors=("6AA0FF", "02C39A", "E07A5F", "E8A0BF", "97BC62"), ), "forest": Palette( bg_dark="1F3D2B", primary="2C5F2D", accent="97BC62", muted="6F8A6F", accent_light="D4EDDA", chart_colors=("97BC62", "6AA0FF", "E07A5F", "02C39A", "E8A0BF"), ), "charcoal": Palette( bg_dark="222831", primary="36454F", accent="E07A5F", muted="9AA3AB", accent_light="F5D6CC", chart_colors=("E07A5F", "6AA0FF", "02C39A", "97BC62", "E8A0BF"), ), "teal": Palette( bg_dark="023C40", primary="028090", accent="02C39A", muted="5E8B8E", accent_light="CCF5EB", chart_colors=("02C39A", "6AA0FF", "E07A5F", "97BC62", "E8A0BF"), ), "berry": Palette( bg_dark="3A1A2A", primary="6D2E46", accent="E8A0BF", muted="A78A93", accent_light="FADDE8", chart_colors=("E8A0BF", "6D2E46", "97BC62", "6AA0FF", "02C39A"), ), "sunset": Palette( bg_dark="4A1D1D", primary="C1440E", accent="F4A236", muted="C49A7A", accent_light="FDE8D0", chart_colors=("F4A236", "C1440E", "02C39A", "6AA0FF", "97BC62"), ), "ocean": Palette( bg_dark="0B2B40", primary="0E4D6E", accent="5DADE2", muted="6E99AD", accent_light="D4ECFA", chart_colors=("5DADE2", "02C39A", "F4A236", "E07A5F", "97BC62"), ), "slate": Palette( bg_dark="252A34", primary="3D4655", accent="A8D8B9", muted="8E99A4", accent_light="E0F5E8", chart_colors=("A8D8B9", "5DADE2", "E07A5F", "F4A236", "6AA0FF"), ), } _ALIGN = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT} _ANCHOR = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM} # ── Хелперы ────────────────────────────────────────────────────────────────── def hexc(h): """'FF8800' | '#FF8800' → RGBColor""" return RGBColor.from_string(h.replace("#", "")) def _luminance(hex_color): """Относительная яркость hex-цвета (0–1). Для выбора контрастного текста.""" h = hex_color.lstrip("#") r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) return (0.299 * r + 0.587 * g + 0.114 * b) / 255 def contrast_text(hex_bg): """Чёрный или белый текст — что контрастнее на заданном фоне.""" return "1A1A1A" if _luminance(hex_bg) > 0.55 else "FFFFFF" # ── Основной класс ─────────────────────────────────────────────────────────── class Deck: def __init__(self, palette="midnight", typography=None, template=None): self.prs = Presentation(template) if template else Presentation() if not template: self.prs.slide_width = SLIDE_W self.prs.slide_height = SLIDE_H self.pal = palette if isinstance(palette, Palette) else PALETTES.get(palette, Palette()) self.type = typography or Typography() self._blank = self.prs.slide_layouts[6 if len(self.prs.slide_layouts) > 6 else -1] self._slide_count = 0 # ── Низкоуровневые примитивы ───────────────────────────────────────── def _slide(self, bg): self._slide_count += 1 s = self.prs.slides.add_slide(self._blank) s.background.fill.solid() s.background.fill.fore_color.rgb = hexc(bg) return s def text(self, slide, txt, x, y, w, h, size=16, bold=False, color=None, font=None, align="left", anchor="top", line_spacing=1.1): tb = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) tf = tb.text_frame tf.word_wrap = True for m in ("margin_left", "margin_right", "margin_top", "margin_bottom"): setattr(tf, m, 0) tf.vertical_anchor = _ANCHOR[anchor] first = True for line in str(txt).split("\n"): p = tf.paragraphs[0] if first else tf.add_paragraph() first = False p.alignment = _ALIGN[align] p.line_spacing = line_spacing r = p.add_run() r.text = line r.font.size = Pt(size) r.font.bold = bold r.font.name = font or self.type.body r.font.color.rgb = hexc(color or self.pal.ink) return tb def circle(self, slide, x, y, d, color): sh = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(x), Inches(y), Inches(d), Inches(d)) sh.fill.solid() sh.fill.fore_color.rgb = hexc(color) sh.line.fill.background() return sh def rect(self, slide, x, y, w, h, color, rounded=True): shp = MSO_SHAPE.ROUNDED_RECTANGLE if rounded else MSO_SHAPE.RECTANGLE sh = slide.shapes.add_shape(shp, Inches(x), Inches(y), Inches(w), Inches(h)) sh.fill.solid() sh.fill.fore_color.rgb = hexc(color) sh.line.fill.background() return sh def _line(self, slide, x1, y1, x2, y2, color, width=2): """Тонкая соединительная линия (для таймлайна).""" conn = slide.shapes.add_connector( MSO_CONNECTOR_TYPE.STRAIGHT, Inches(x1), Inches(y1), Inches(x2), Inches(y2)) conn.line.color.rgb = hexc(color) conn.line.width = Pt(width) return conn def _pill(self, slide, x, y, w, h, color): """Скруглённая плашка-пилюля (для бейджей/номеров).""" sh = slide.shapes.add_shape( MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h)) sh.fill.solid() sh.fill.fore_color.rgb = hexc(color) sh.line.fill.background() return sh # ── Готовые раскладки (базовые, были в v1) ─────────────────────────── def title(self, title_text, subtitle=""): s = self._slide(self.pal.bg_dark) self.circle(s, MARGIN, 2.05, 0.28, self.pal.accent) self.text(s, title_text, MARGIN, 2.6, 11.5, 2.0, size=44, bold=True, color=self.pal.ink_inv, font=self.type.head) if subtitle: self.text(s, subtitle, MARGIN, 4.55, 11.5, 1.0, size=18, color=self.pal.accent, font=self.type.body) return s def section(self, label, number=None): s = self._slide(self.pal.primary) if number is not None: self.text(s, f"{int(number):02d}", MARGIN, 1.7, 3, 1.6, size=72, bold=True, color=self.pal.accent, font=self.type.head) self.text(s, label, MARGIN, 3.2, 11.5, 1.8, size=34, bold=True, color=self.pal.ink_inv, font=self.type.head) return s def content(self, title_text, bullets): s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 11.5, 1.0, size=32, bold=True, color=self.pal.primary, font=self.type.head) body = "\n".join(f"• {b}" for b in bullets) self.text(s, body, MARGIN, 2.0, 11.5, 4.6, size=16, color=self.pal.ink, line_spacing=1.35) return s def stat(self, big, label, footnote=""): s = self._slide(self.pal.bg_dark) self.text(s, big, MARGIN, 2.1, 11.5, 2.2, size=96, bold=True, color=self.pal.ink_inv, font=self.type.head) self.text(s, label, MARGIN + 0.05, 4.4, 11.5, 1.0, size=22, color=self.pal.accent, font=self.type.body) if footnote: self.text(s, footnote, MARGIN + 0.05, 6.7, 11.5, 0.4, size=11, color=self.pal.muted) return s def two_col(self, title_text, left_bullets, image_path=None, right_bullets=None): s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 11.5, 1.0, size=32, bold=True, color=self.pal.primary, font=self.type.head) self.text(s, "\n".join(f"• {b}" for b in left_bullets), MARGIN, 2.0, 5.5, 4.6, size=16, color=self.pal.ink, line_spacing=1.35) if image_path and os.path.exists(image_path): s.shapes.add_picture(image_path, Inches(6.9), Inches(2.0), width=Inches(5.5)) elif right_bullets: self.rect(s, 6.9, 2.0, 5.5, 4.6, self.pal.primary) self.text(s, "\n".join(f"• {b}" for b in right_bullets), 7.25, 2.35, 4.85, 4.0, size=16, color=self.pal.ink_inv, line_spacing=1.35) return s def cards(self, title_text, items): """items: список (заголовок, описание). Сетка до 3 карточек в ряд.""" s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 11.5, 1.0, size=32, bold=True, color=self.pal.primary, font=self.type.head) n = min(len(items), 3) gap, total = 0.4, 11.5 cw = (total - gap * (n - 1)) / n for i, (h, d) in enumerate(items[:3]): x = MARGIN + i * (cw + gap) self.rect(s, x, 2.2, cw, 4.2, self.pal.primary) self.circle(s, x + 0.4, 2.6, 0.5, self.pal.accent) self.text(s, h, x + 0.4, 3.4, cw - 0.8, 0.8, size=18, bold=True, color=self.pal.ink_inv, font=self.type.head) self.text(s, d, x + 0.4, 4.3, cw - 0.8, 1.9, size=13, color=self.pal.ink_inv, line_spacing=1.3) return s # ── Новые контентные раскладки ──────────────────────────────────────── def bullets_two_col(self, title_text, left_title, left_items, right_title, right_items): """Две независимые колонки списков — каждая со своим подзаголовком.""" s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 11.5, 1.0, size=32, bold=True, color=self.pal.primary, font=self.type.head) col_w = 5.5 # Левая колонка self.text(s, left_title, MARGIN, 2.0, col_w, 0.6, size=20, bold=True, color=self.pal.primary, font=self.type.head) self.text(s, "\n".join(f"• {b}" for b in left_items), MARGIN, 2.7, col_w, 3.8, size=15, color=self.pal.ink, line_spacing=1.35) # Правая колонка rx = MARGIN + col_w + 0.5 self.text(s, right_title, rx, 2.0, col_w, 0.6, size=20, bold=True, color=self.pal.primary, font=self.type.head) self.text(s, "\n".join(f"• {b}" for b in right_items), rx, 2.7, col_w, 3.8, size=15, color=self.pal.ink, line_spacing=1.35) return s def quote(self, quote_text, attribution="", context=""): """Крупная цитата на тёмном фоне. context — роль/компания (над цитатой).""" s = self._slide(self.pal.bg_dark) if context: self.text(s, context, MARGIN, 1.5, 11.5, 0.6, size=14, color=self.pal.accent, font=self.type.body) self.circle(s, MARGIN, 2.15 if context else 1.8, 0.28, self.pal.accent) self.text(s, f'"{quote_text}"', MARGIN, 2.7 if context else 2.35, 11.5, 3.0, size=30, bold=False, color=self.pal.ink_inv, font=self.type.head, line_spacing=1.4) if attribution: self.text(s, f"— {attribution}", MARGIN, 5.8, 11.5, 0.6, size=18, color=self.pal.accent, font=self.type.body) return s def timeline(self, title_text, steps): """Горизонтальный таймлайн. steps: список (год/дата, описание).""" s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 11.5, 1.0, size=32, bold=True, color=self.pal.primary, font=self.type.head) n = len(steps) step_w = 11.5 / n y_line = 3.8 # Соединительная линия self._line(s, MARGIN, y_line, MARGIN + 11.5, y_line, self.pal.accent, width=2) for i, (date, desc) in enumerate(steps): cx = MARGIN + step_w * i + step_w / 2 # Точка на линии self.circle(s, cx - 0.15, y_line - 0.15, 0.3, self.pal.accent) # Дата/год над линией self.text(s, date, cx - step_w / 2 + 0.2, 1.5, step_w - 0.4, 0.6, size=22, bold=True, color=self.pal.primary, font=self.type.head, align="center") # Описание под линией self.text(s, desc, cx - step_w / 2 + 0.2, 4.3, step_w - 0.4, 2.0, size=14, color=self.pal.ink, align="center", line_spacing=1.3) return s def comparison(self, title_text, left_label, left_items, right_label, right_items): """Сравнение «до/после», «за/против» — два блока рядом.""" s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 11.5, 1.0, size=32, bold=True, color=self.pal.primary, font=self.type.head) col_w = 5.5 # Левая метка self._pill(s, MARGIN, 2.0, 2.2, 0.5, self.pal.muted) self.text(s, left_label, MARGIN + 0.3, 2.02, 1.8, 0.45, size=16, bold=True, color=self.pal.ink_inv, font=self.type.body, align="center", anchor="middle") self.text(s, "\n".join(f"• {b}" for b in left_items), MARGIN, 2.8, col_w, 3.8, size=15, color=self.pal.ink, line_spacing=1.35) # Правая метка rx = MARGIN + col_w + 0.5 self._pill(s, rx, 2.0, 2.2, 0.5, self.pal.accent) self.text(s, right_label, rx + 0.3, 2.02, 1.8, 0.45, size=16, bold=True, color=contrast_text(self.pal.accent), font=self.type.body, align="center", anchor="middle") self.text(s, "\n".join(f"• {b}" for b in right_items), rx, 2.8, col_w, 3.8, size=15, color=self.pal.ink, line_spacing=1.35) return s def kpi_row(self, title_text, metrics): """3–4 метрики в ряд. metrics: список (значение, подпись).""" s = self._slide(self.pal.bg_dark) self.text(s, title_text, MARGIN, 0.7, 11.5, 1.0, size=32, bold=True, color=self.pal.ink_inv, font=self.type.head) n = min(len(metrics), 4) gap = 0.4 total = 11.5 cw = (total - gap * (n - 1)) / n for i, (val, desc) in enumerate(metrics[:4]): x = MARGIN + i * (cw + gap) self.rect(s, x, 2.3, cw, 3.8, self.pal.primary) self.text(s, val, x + 0.3, 2.6, cw - 0.6, 1.5, size=42, bold=True, color=self.pal.accent, font=self.type.head, align="center", anchor="middle") self.text(s, desc, x + 0.3, 4.3, cw - 0.6, 1.5, size=15, color=self.pal.ink_inv, align="center", line_spacing=1.3) return s def image_full(self, title_text, image_path, caption="", overlay=True): """Изображение на правую половину слайда, текст слева.""" s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 5.5, 1.0, size=32, bold=True, color=self.pal.primary, font=self.type.head) if caption: self.text(s, caption, MARGIN, 1.9, 5.5, 4.5, size=16, color=self.pal.ink, line_spacing=1.35) if os.path.exists(image_path): s.shapes.add_picture(image_path, Inches(6.9), Inches(0.9), width=Inches(5.5)) if overlay: self.rect(s, 6.9, 0.9, 5.5, 5.7, self.pal.primary) # Прозрачность в python-pptx ограничена; плашка-подложка с текстом поверх # сделана через отдельный вызов text поверх rect — см. пример return s def agenda(self, items): """Оглавление/повестка. items: список (номер, заголовок, описание).""" s = self._slide(self.pal.bg_dark) self.text(s, "Повестка", MARGIN, 0.7, 11.5, 1.0, size=32, bold=True, color=self.pal.ink_inv, font=self.type.head) n = len(items) row_h = 4.8 / max(n, 1) for i, (num, title_text, desc) in enumerate(items): y = 2.0 + i * row_h # Номер в акцентном круге self.circle(s, MARGIN, y + 0.1, 0.55, self.pal.accent) self.text(s, str(num), MARGIN, y + 0.1, 0.55, 0.55, size=22, bold=True, color=contrast_text(self.pal.accent), font=self.type.head, align="center", anchor="middle") # Заголовок и описание self.text(s, title_text, MARGIN + 0.85, y, 10.5, 0.55, size=22, bold=True, color=self.pal.ink_inv, font=self.type.body) self.text(s, desc, MARGIN + 0.85, y + 0.55, 10.5, row_h - 0.7, size=14, color=self.pal.muted, line_spacing=1.2) return s # ── Чарты и таблицы ────────────────────────────────────────────────── def bar_chart(self, title_text, categories, series_data, legend=True): """Горизонтальный/вертикальный бар-чарт. series_data: [(имя_серии, [значения]), ...] или один список значений.""" s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 11.5, 0.8, size=28, bold=True, color=self.pal.primary, font=self.type.head) chart_data = CategoryChartData() chart_data.categories = categories if isinstance(series_data, list) and series_data and isinstance(series_data[0], (list, tuple)): for name, vals in series_data: chart_data.add_series(name, vals) else: chart_data.add_series("", series_data) chart = s.shapes.add_chart( XL_CHART_TYPE.COLUMN_CLUSTERED, Inches(MARGIN), Inches(1.8), Inches(11.5), Inches(5.0), chart_data) c = chart.chart c.has_legend = legend if legend: c.legend.position = XL_LEGEND_POSITION.BOTTOM # Раскраска серий в цвета палитры for i, series in enumerate(c.series): color = self.pal.chart_colors[i % len(self.pal.chart_colors)] series.format.fill.solid() series.format.fill.fore_color.rgb = hexc(color) return s def line_chart(self, title_text, categories, series_data, legend=True): """Линейный чарт. series_data: [(имя_серии, [значения]), ...].""" s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 11.5, 0.8, size=28, bold=True, color=self.pal.primary, font=self.type.head) chart_data = CategoryChartData() chart_data.categories = categories for name, vals in series_data: chart_data.add_series(name, vals) chart = s.shapes.add_chart( XL_CHART_TYPE.LINE_MARKERS, Inches(MARGIN), Inches(1.8), Inches(11.5), Inches(5.0), chart_data) c = chart.chart c.has_legend = legend if legend: c.legend.position = XL_LEGEND_POSITION.BOTTOM for i, series in enumerate(c.series): color = self.pal.chart_colors[i % len(self.pal.chart_colors)] series.format.line.color.rgb = hexc(color) series.format.line.width = Pt(2.5) return s def pie_chart(self, title_text, categories, values): """Круговая диаграмма.""" s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 11.5, 0.8, size=28, bold=True, color=self.pal.primary, font=self.type.head) chart_data = CategoryChartData() chart_data.categories = categories chart_data.add_series("", values) chart = s.shapes.add_chart( XL_CHART_TYPE.PIE, Inches(MARGIN + 1.5), Inches(1.8), Inches(8.5), Inches(5.0), chart_data) c = chart.chart c.has_legend = True c.legend.position = XL_LEGEND_POSITION.BOTTOM for i, point in enumerate(c.series[0].points): color = self.pal.chart_colors[i % len(self.pal.chart_colors)] point.format.fill.solid() point.format.fill.fore_color.rgb = hexc(color) return s def table(self, title_text, headers, rows, col_widths=None): """Таблица с зебра-полосой и заголовком в цвете primary. col_widths: список ширин в дюймах (если None — равномерно).""" s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.5, 11.5, 0.7, size=28, bold=True, color=self.pal.primary, font=self.type.head) n_cols = len(headers) n_rows = len(rows) + 1 # + заголовок tbl_w = 11.5 tbl_h = min(n_rows * 0.45, 5.5) tbl_shape = s.shapes.add_table(n_rows, n_cols, Inches(MARGIN), Inches(1.5), Inches(tbl_w), Inches(tbl_h)) tbl = tbl_shape.table if col_widths: for i, w in enumerate(col_widths): tbl.columns[i].width = Inches(w) # Заголовок for ci, h in enumerate(headers): cell = tbl.cell(0, ci) cell.text = h cell.fill.solid() cell.fill.fore_color.rgb = hexc(self.pal.primary) for p in cell.text_frame.paragraphs: p.alignment = PP_ALIGN.LEFT for r in p.runs: r.font.size = Pt(14) r.font.bold = True r.font.name = self.type.body r.font.color.rgb = hexc(self.pal.ink_inv) # Данные for ri, row in enumerate(rows): for ci, val in enumerate(row): cell = tbl.cell(ri + 1, ci) cell.text = str(val) # Зебра if ri % 2 == 1: cell.fill.solid() cell.fill.fore_color.rgb = hexc(self.pal.accent_light) for p in cell.text_frame.paragraphs: p.alignment = PP_ALIGN.LEFT for r in p.runs: r.font.size = Pt(13) r.font.name = self.type.body r.font.color.rgb = hexc(self.pal.ink) return s # ── Инфраструктура ─────────────────────────────────────────────────── def auto_grid(self, title_text, items, max_cols=3): """Авто-раскладка N карточек в ��етку (2×2, 2×3, 3×3, ...). items: список (заголовок, описание).""" n = len(items) if n <= 0: return None cols = min(n, max_cols, 3) rows = math.ceil(n / cols) s = self._slide(self.pal.bg_light) self.text(s, title_text, MARGIN, 0.7, 11.5, 0.9, size=32, bold=True, color=self.pal.primary, font=self.type.head) gap = 0.35 total_w = 11.5 cw = (total_w - gap * (cols - 1)) / cols # От 2.0 до 6.8 — 4.8 дюйма высоты на сетку avail_h = 4.8 rh = (avail_h - gap * (rows - 1)) / rows for idx, (h, d) in enumerate(items): ri, ci = divmod(idx, cols) x = MARGIN + ci * (cw + gap) y = 2.0 + ri * (rh + gap) self.rect(s, x, y, cw, rh, self.pal.primary) self.circle(s, x + 0.3, y + 0.25, 0.4, self.pal.accent) self.text(s, h, x + 0.85, y + 0.2, cw - 1.15, 0.55, size=16, bold=True, color=self.pal.ink_inv, font=self.type.head) self.text(s, d, x + 0.3, y + 0.85, cw - 0.6, rh - 1.1, size=12, color=self.pal.ink_inv, line_spacing=1.25) return s def auto_fit_text(self, slide, txt, x, y, w, h, min_size=8, max_size=44, bold=False, color=None, font=None, align="left", anchor="top", line_spacing=1.1): """Бинарный поиск размера шрифта, чтобы текст влез в бокс. Эвристика: ~0.55 * font_size * len(строка) = ширина в pt. Возвращает итоговый размер.""" lines = str(txt).split("\n") lo, hi = min_size, max_size best = min_size for _ in range(12): mid = (lo + hi) // 2 # Оценка: самая широкая строка max_line_w = max((len(line) for line in lines), default=0) * mid * 0.55 total_h = len(lines) * mid * line_spacing * 1.35 if max_line_w <= w * 72 and total_h <= h * 72: best = mid lo = mid + 1 else: hi = mid - 1 self.text(slide, txt, x, y, w, h, size=best, bold=bold, color=color, font=font, align=align, anchor=anchor, line_spacing=line_spacing) return best def add_speaker_notes(self, slide, notes_text): """Добавить заметки докладчика к слайду.""" if slide.has_notes_slide: notes = slide.notes_slide else: notes = slide.notes_slide # создастся при первом обращении tf = notes.notes_text_frame tf.clear() tf.text = notes_text return notes def add_slide_number(self, slide, number): """Номер слайда в правом нижнем углу.""" self.text(slide, str(number), 11.9, 7.05, 0.8, 0.3, size=10, color=self.pal.muted, align="right") return slide def number_slides(self, start=1): """Пронумеровать все слайды (начиная с start).""" for i, slide in enumerate(self.prs.slides, start): self.add_slide_number(slide, i) def bg_image(self, slide, image_path, darken=0.35): """Фоновое изображение на весь слайд с затемняющей плашкой. darken: 0.0–1.0, насколько затемнить (0 = без затемнения).""" if not os.path.exists(image_path): return slide # Картинка на полный слайд slide.shapes.add_picture(image_path, Inches(0), Inches(0), width=SLIDE_W, height=SLIDE_H) if darken > 0: # Полупрозрачная чёрная плашка (реальная прозрачность в python-pptx # ограничена — используем тёмный цвет с альфа через XML) sh = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H) sh.fill.solid() sh.fill.fore_color.rgb = hexc("000000") sh.line.fill.background() # Установка прозрачности через XML from pptx.oxml.ns import qn solid = sh.fill._fill srgb = solid.find(qn('a:solidFill')).find(qn('a:srgbClr')) if srgb is not None: alpha = srgb.makeelement(qn('a:alpha'), {'val': str(int((1 - darken) * 100000))}) srgb.append(alpha) return slide # ── Сохранение ─────────────────────────────────────────────────────── def save(self, path): self.prs.save(path) return path # ── Рендер превью ──────────────────────────────────────────────────────────── def render_png(pptx_path, out_dir="_preview", dpi=150): """Рендер слайдов в PNG для визуального QA. Нужны LibreOffice (soffice) и poppler (pdftoppm).""" os.makedirs(out_dir, exist_ok=True) soffice = shutil.which("soffice") or shutil.which("libreoffice") if not soffice: return "LibreOffice не найден — установи его в контуре для рендера превью." subprocess.run([soffice, "--headless", "--convert-to", "pdf", "--outdir", out_dir, pptx_path], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) pdf = os.path.join(out_dir, os.path.splitext(os.path.basename(pptx_path))[0] + ".pdf") for f in glob.glob(os.path.join(out_dir, "slide-*.png")): os.remove(f) if not shutil.which("pdftoppm"): return f"PDF готов: {pdf}; для PNG установи poppler (pdftoppm)." subprocess.run(["pdftoppm", "-png", "-r", str(dpi), pdf, os.path.join(out_dir, "slide")], check=True) return sorted(glob.glob(os.path.join(out_dir, "slide-*.png")))