Если бы меня спросили, какой мой любимый векторный редактор, я бы, не задумываясь, ответил: PowerPoint.

Да, этот котик создан с помощью этой библиотеки
Да, этот котик создан с помощью этой библиотеки

Звучит неожиданно, но позвольте объяснить.

Я всегда любил делать презентации. Хоть я и не дизайнер, мне всегда хотелось, чтобы слайды были не только информативными, но и визуально приятными. Зачастую в чужих докладах я видел одно и то же: шакальные картинки, даже там, где их легко можно было заменить чистыми и векторными иллюстрациями (впрочем, я сейчас пользуюсь версией, которая, к сожалению, не поддерживает работу с svg, так что вообще не осуждаю).

В какой-то момент я решил, что в своих презентациях я буду рисовать всё сам – прямо в PowerPoint, фигурами. Прямоугольники, стрелки, круги, надписи – всё, что нужно для простых схем и иллюстраций уже заботливо подготовлено разработчиками из Microsoft.

Такой подход особенно пригодился мне в мини-семинарах для команды, где я рассказывал про различные архитектуры моделей машинного обучения. Зачастую мне нужно было добавлять на свои слайды схемы пайплайнов, визуализации блоков и концептуальные диаграммы из разбираемых к семинару статей. К сожалению, эти иллюстрации были слишком мелкими и расплывались при масштабировании. Поэтому я просто перерисовывал их руками, фигура за фигурой, прямо в PowerPoint.

Пример перерисованной диаграммы из статьи
Пример перерисованной диаграммы из статьи

Но однажды мне понадобилось нечто большее, чем десяток фигур. Нужно было вставить парочку scatter-графиков из тысячи точек и нарисовать модели нескольких полносвязных нейронных сетей впридачу, причём весовые коэффициенты хотелось нарисовать не просто одинаковыми линиями, а линиями с разной толщиной и цветом в зависимости от значений соответствующих весов. Вставлять PNG не хотелось (даже отрендеренный в 8К), а вручную расставлять тысячу точек стало слишком уж лениво (честно признаться, сначала я даже был почти готов пойти на это).

И тут мне пришла идея: а что, если программно управлять PowerPoint, как векторным движком? Я же всё-таки программист! Так появилась pptx-shapes – небольшая Python-библиотека, которая позволяет добавлять фигуры (и не только) прямо в .pptx, полностью автоматически, не используя при этом ничего кроме обработки XML с помощью lxml библиотеки.

Что такое pptx-shapes?

pptx-shapes – это небольшая Python-библиотека, которая позволяет добавлять фигуры прямо в PowerPoint-презентацию, оперируя на уровне XML. Ей не требуется Windows, PowerPoint или Office. Всё работает через редактировние XML-файлов, лежащих внутри .pptx-архива.

Файл презентации, на который хочется добавить фигуры, – это всё, что нужно, чтобы начать работать с библиотекой.

Пример использования

Проще один раз увидеть, как пользоваться инструментом, чем сразу бросаться читать документацию, не так ли? Давайте посмотрим, как создать несколько простых фигур и добавить их на слайд:

from pptx_shapes import Presentation
from pptx_shapes.shapes import Ellipse, Rectangle, TextBox
from pptx_shapes.style import FillStyle, FontFormat, FontStyle, StrokeStyle


with Presentation(presentation_path="empty.pptx") as presentation:
    presentation.add(shape=TextBox(
        x=23, y=4,
        width=12, height=2,
        angle=45,
        text="Hello from pptx-shapes!",
        style=FontStyle(size=32),
        formatting=FontFormat(bold=True)
    ))

    presentation.add(shape=Ellipse(
        x=18, y=7,
        width=9, height=9,
        fill=FillStyle(color="#7699d4")
    ), slide="slide1")

    presentation.add(shape=Rectangle(
        x=4, y=2,
        width=8, height=16,
        radius=0.25,
        angle=30,
        fill=FillStyle(color="#dd7373"),
        stroke=StrokeStyle(color="magenta", thickness=3)
    ), slide="slide1")

    presentation.save("result.pptx")

Давайте разберёмся, что именно здесь происходит:

  • редактируется презентация empty.pptx (содержащая как минимум один слайд);

  • в правый верхний угол добавляется текстовый блок с жирным текстом "Hello from pptx-shapes!", повёрнутый на 45 градусов;

  • добавляется синий круг размером 9x9 сантиметров;

  • добавляется повёрнутый на 30 градусов прямоугольник красного цвета с закруглёнными углами и толстой пурпурной обводкой;

  • изменённая презентация сохраняется с именем result.pptx.

А вот, как это выглядит на слайде (хороший дизайн, ничего не скажешь):

Слайд, полученный в результате выполнения примера
Слайд, полученный в результате выполнения примера

Всё, что вам нужно – это для каждой созданной фигуры вызвать метод add, передав фигуру и опционально номер слайда (по умолчанию фигура добавляется на первый слайд). А когда закончите, вызвать метод save.

Как это работает изнутри

  • .pptx распаковывается как ZIP-архив во временную папку (именно поэтому используется механизм контекстного менежера).

  • При добавлении фигур на слайд ищется соответствующий XML файл в папке ppt/slides (например, для первого слайда – это обычно ppt/slides/slide1.xml).

  • В XML этого слайда внутри spTree добавляются новые элементы с тегами вроде <p:sp>, <p:cxnSp> и <a:prstGeom>.

  • При сохранении изменений модифицированные XML файлы заменяются, и все файлы внутри временной папки запаковываются обратно в .pptx.

Такой низкоуровневый подход особенно хорош, когда требуется автоматически генерировать слайды, визуализировать данные или строить сложные геометрические схемы, например архитектуры нейросетей, которые вручную рисовать слишком долго. Если нужно создать десятки или сотни фигур, разместить их по сетке, закодировать цвет, прозрачность или толщину линий — всё это можно сделать программно с помощью pptx-shapes.

Что можно добавлять?

Сейчас можно добавить одну из следующих фигур, доступных в модуле shapes:

  • прямую линию (Line);

  • стрелку с указанием типа наконечника как в начале, так и конце стрелки (Arrow);

  • дугу окружности (Arc);

  • арку (та же дуга, только имеющая толщину, Arch);

  • эллипс (Elipse);

  • прямоугольник, в т.ч. с закруглёнными углами (Rectangle);

  • сектор окруности (Pie);

  • полигон (Polygon);

  • текстовое поле (TextBox).

Схематичное изображение поддерживаемых фигур и основных параметров
Схематичное изображение поддерживаемых фигур и основных параметров

А если нужно сгруппировать две и более фигуры в единую сущность, то существует фигура Group.

Стилизация

Каждая фигура поддерживает собственные параметры стилизации: заливку (FillStyle), обводку (StrokeStyle), шрифт (FontStyle) и форматирование текста (FontFormat). Доступность этих параметров зависит от типа фигуры. Например, линии и стрелки не имеют заливки, а настройки шрифта и форматирования применимы только к текстовым полям.

Стилизация текстовых блоков
Стилизация текстовых блоков

Диаграммы

Если добавлять фигуры для вас слишком скучно и хочется чего-то посложнее, например, визуализировать данные, то pptx-shapes тоже может быть полезен. В библиотеке доступны несколько базовых типов диаграмм, реализованных в виде составных фигур внутри модуля charts:

  • Пончиковая диаграмма (DonutChart) – отлично подходит для представления распределений и соотношений. Можно задать значения, цвета и толщину кольца.

  • Столбчатая диаграмма (BarChart) – удобна для отображения категориальных значения или временных рядов. Поддерживает настройку отступов, подписей и цветов.

  • Scatter-график (ScatterPlot) – позволяет разместить на слайде облако точек, идеально подходящее, например, для визуализации распределений или результатов кластеризации.

Диаграммы, созданные с помощью pptx-shapes
Диаграммы, созданные с помощью pptx-shapes

Можно спросить: зачем вообще реализовывать собственные диаграммы, если в PowerPoint уже есть встроенные?

Дело в контроле. Иногда хочется, чтобы визуализация точно соответствовала стилю слайда: нужная толщина колец, точное позиционирование подписей, индивидуальные цвета для каждой категории. Стандартные диаграммы в PowerPoint во многом удобны, но порой слишком ограничены – особенно если вы хотите, чтобы график был не просто «вставкой», а частью иллюстрации.

Кроме того, для pptx-shapes это естественное продолжение идеи: раз уж есть возможность программно собирать фигуры, почему бы не использовать её для создания наглядных визуализаций, прямо в коде?

Где почитать подробнее?

Если вам захотелось попробовать pptx-shapes в деле, добро пожаловать в документацию на Read the Docs – там описаны доступные фигуры, параметры и примеры использования.

А если интересует, как всё устроено внутри, загляните в репозиторий на GitHub. Исходный код открыт и, надеюсь, читаем – вдруг вам захочется внести свой вклад или просто что-нибудь подправить под себя.

Есть ещё примеры?

Все примеры доступны в директории examples репозитория, а наиболее интересные дополнительно описаны в соответствующем разделе документации.

Вот несколько наиболее наглядных:

Галерея фигур

Пример, в котором на одном слайде собраны практически все доступные типы фигур – от простых линий и прямоугольников до текстовых полей, стрелок и секторов. Отличная отправная точка, чтобы быстро увидеть возможности библиотеки.

Код примера
from pptx_shapes import Presentation
from pptx_shapes.enums import Align, ArrowType, LineDash, VerticalAlign
from pptx_shapes.shapes import Arc, Arrow, Ellipse, Group, Line, Polygon, Rectangle, TextBox
from pptx_shapes.style import FillStyle, FontFormat, FontStyle, StrokeStyle


def main() -> None:
    with Presentation(presentation_path="empty.pptx") as presentation:
        presentation.add(shape=TextBox(
            x=23, y=4, width=12, height=2, text="Hello from pptx-shapes!", angle=45, style=FontStyle(size=32), formatting=FontFormat(bold=True)
        ))

        presentation.add(shape=TextBox(
            x=7.5, y=17.2, width=18.5, height=1.5,
            text="Python library for adding basic geometric shapes directly to PowerPoint (.pptx) slides by editing the XML structure.",
            style=FontStyle(size=16, align=Align.LEFT),
            auto_fit=True
        ))

        # ellipses
        presentation.add(shape=Ellipse(x=20, y=2, width=4, height=4, fill=FillStyle(color="#7699d4")))

        # arrows
        presentation.add(shape=Arrow(x1=10, y1=9, x2=14, y2=11, start_type=ArrowType.OVAL, end_type=ArrowType.ARROW, stroke=StrokeStyle(thickness=2)))

        # arcs
        presentation.add(shape=Arc(
            x=24, y=9, width=5, height=8, start_angle=90, end_angle=270, angle=45, stroke=StrokeStyle(color="#f00", thickness=2.5, dash=LineDash.DASH_DOTTED)
        ))
        presentation.add(shape=Arc(
            x=19.5, y=1.5, width=5, height=5, start_angle=5, end_angle=175, stroke=StrokeStyle(color="#7699d4", thickness=2, dash=LineDash.DOTTED)
        ))
        presentation.add(shape=Arc(
            x=19.5, y=1.5, width=5, height=5, start_angle=185, end_angle=355, stroke=StrokeStyle(color="#7699d4", thickness=2, dash=LineDash.DASHED)
        ))

        # rectangles
        presentation.add(shape=Rectangle(
            x=18, y=8, width=4, height=8.5, radius=0.25, fill=FillStyle(color="#dd7373"), stroke=StrokeStyle(color="#222", thickness=3), angle=30
        ))
        presentation.add(shape=Rectangle(
            x=27, y=14, width=3, height=3, radius=0, fill=FillStyle(color="#dd7373"), stroke=StrokeStyle(color="#222", thickness=1)
        ))

        # polygons
        presentation.add(shape=Polygon(
            points=[(11, 12), (13, 14), (11, 16), (9, 14), (11, 12)], fill=FillStyle(color="yellow"), stroke=StrokeStyle(color="magenta", thickness=2.5)
        ))
        presentation.add(shape=Polygon(
            points=[(15, 5), (16, 6), (15, 7), (12, 7), (11, 6), (12, 5)], angle=45, fill=FillStyle(color="#88ff88")
        ))

        # groups
        presentation.add(shape=Group(shapes=[
            Line(x1=1, y1=1, x2=13, y2=1, stroke=StrokeStyle(thickness=2, color="#7699d4")),
            Line(x1=1, y1=1, x2=1, y2=6, stroke=StrokeStyle(thickness=2, color="#dd7373")),
            Line(x1=13, y1=1, x2=1, y2=6, stroke=StrokeStyle(thickness=2, color="#89dd73")),
            TextBox(x=0.7, y=3.5, width=13, height=1, text="hypotenuse", angle=-22.6, style=FontStyle(size=18, color="#89dd73", vertical_align=VerticalAlign.TOP)),
            TextBox(x=-2, y=3, width=5, height=1, text="kathete", angle=90, style=FontStyle(size=18, color="#dd7373", vertical_align=VerticalAlign.TOP)),
            TextBox(x=1, y=0, width=12, height=1, text="kathete", style=FontStyle(size=18, color="#7699d4", vertical_align=VerticalAlign.BOTTOM))
        ]))

        presentation.add(shape=Group(shapes=[
            Ellipse(x=4.5, y=6.0, width=2.0, height=3.5, fill=FillStyle(color="#dd7373", opacity=0.5), stroke=StrokeStyle(color="black", thickness=2, opacity=0.75)),
            Ellipse(x=3.0, y=8.5, width=3.5, height=2.0, fill=FillStyle(color="#dd7373", opacity=0.25), stroke=StrokeStyle(color="black", opacity=0.25), angle=-45),
            Ellipse(x=5.0, y=8.5, width=3.5, height=2.0, fill=FillStyle(color="#dd7373", opacity=0.85), stroke=StrokeStyle(color="black", opacity=0.85), angle=45)
        ]))

        presentation.add(shape=Group(shapes=[
            TextBox(x=1, y=15, width=4.8, height=1, text="little histogram", style=FontStyle(size=20, color="#7699d4")),
            Rectangle(x=1, y=16, width=1.2, height=2.7, radius=0.2, fill=FillStyle(color="#7699d4"), stroke=StrokeStyle(color="#fff")),
            Rectangle(x=2.2, y=16.4, width=1.2, height=2.3, radius=0.2, fill=FillStyle(color="#7699d4"), stroke=StrokeStyle(color="#fff")),
            Rectangle(x=3.4, y=17, width=1.2, height=1.7, radius=0.2, fill=FillStyle(color="#7699d4"), stroke=StrokeStyle(color="#fff")),
            Rectangle(x=4.6, y=16.1, width=1.2, height=2.6, radius=0.2, fill=FillStyle(color="#7699d4"), stroke=StrokeStyle(color="#fff"))
        ]))

        presentation.save("basic.pptx")


if __name__ == "__main__":
    main()
Результат работы примера с основными фигурами
Результат работы примера с основными фигурами

Фрактал

Пример построения фрактала из линий с помощью простой рекурсивной функции.

Скрытый текст
import math
from dataclasses import dataclass
from typing import List

from pptx_shapes import Presentation
from pptx_shapes.shapes import Group, Line, Rectangle, Shape
from pptx_shapes.style import FillStyle, StrokeStyle


@dataclass
class Config:
    start_color: str
    end_color: str
    depth: int
    start_branch_num: int
    branch_num: int
    branch_angle: float
    length: float

    def get_stroke(self, depth: int) -> StrokeStyle:
        ratio = depth / self.depth

        r1, g1, b1 = int(self.start_color[1:3], 16), int(self.start_color[3:5], 16), int(self.start_color[5:7], 16)
        r2, g2, b2 = int(self.end_color[1:3], 16), int(self.end_color[3:5], 16), int(self.end_color[5:7], 16)

        r = math.floor(r1 * (1 - ratio) + r2 * ratio)
        g = math.floor(g1 * (1 - ratio) + g2 * ratio)
        b = math.floor(b1 * (1 - ratio) + b2 * ratio)

        return StrokeStyle(color=f"#{r:02X}{g:02X}{b:02X}", opacity=1 - math.pow(depth / (self.depth + 1), 2))


def draw_fractal_line(config: Config, start_x: float, start_y: float, depth: int, degree: float, length: float, shapes: List[Shape]) -> None:
    if depth > config.depth:
        return

    start_angle = -(config.branch_num - 1) * config.branch_angle / 2 + degree

    for _ in range(config.branch_num):
        angle = start_angle * math.pi / 180
        x = start_x + math.cos(angle) * length
        y = start_y + math.sin(angle) * length

        shapes.append(Line(x1=start_x, y1=start_y, x2=x, y2=y, stroke=config.get_stroke(depth=depth)))
        draw_fractal_line(config, x, y, depth + 1, start_angle, length * math.pow(config.length, depth), shapes)
        start_angle += config.branch_angle


def draw_fractal(config: Config, x0: float, y0: float, length: float) -> List[Shape]:
    shapes = []
    degree = 360 / config.start_branch_num

    for i in range(config.start_branch_num):
        angle = degree * i * math.pi / 180
        x = x0 + math.cos(angle) * length
        y = y0 + math.sin(angle) * length

        shapes.append(Line(x1=x0, y1=y0, x2=x, y2=y, stroke=config.get_stroke(0)))
        draw_fractal_line(config, x, y, 1, degree * i, length, shapes)

    return shapes


def main() -> None:
    config = Config(
        start_color="#ff0000",
        end_color="#ffff00",
        depth=5,
        start_branch_num=7,
        branch_num=4,
        branch_angle=75,  # 20..90
        length=0.93  # 0.6..1.3
    )

    width = 33.867
    height = 19.05

    with Presentation(presentation_path="empty.pptx") as presentation:
        shapes = draw_fractal(config=config, x0=width / 2, y0=height / 2, length=2)
        presentation.add(shape=Rectangle(x=0, y=0, width=width, height=height, fill=FillStyle(color="#000")))
        presentation.add(shape=Group(shapes))
        presentation.save("fractal.pptx")


if __name__ == "__main__":
    main()
Результат работы примера с построением фрактала (9555 линий)
Результат работы примера с построением фрактала (9555 линий)

Разбиение полигонов

Пример демонстрирует, как можно делить полигоны линиями и визуализировать результат – полезно, если нужно показать взаимодействие или пересечения геометрических объектов.

Код примера
from typing import List, Tuple

from pptx_shapes import Presentation
from pptx_shapes.shapes import Group, Polygon
from pptx_shapes.style import FillStyle, StrokeStyle


def split_polygon(polygon: List[Tuple[float, float]], line: Tuple[float, float, float]) -> Tuple[List[Tuple[float, float]], List[Tuple[float, float]]]:
    polygon1, polygon2 = [], []
    a, b, c = line

    for i, (x1, y1) in enumerate(polygon):
        x2, y2 = polygon[(i + 1) % len(polygon)]

        sign1 = a * x1 + b * y1 + c
        sign2 = a * x2 + b * y2 + c

        if sign1 <= 0:
            polygon1.append((x1, y1))

        if sign1 >= 0:
            polygon2.append((x1, y1))

        if sign1 * sign2 >= 0:
            continue

        t = sign1 / (sign1 - sign2)
        x = x1 + t * (x2 - x1)
        y = y1 + t * (y2 - y1)

        polygon1.append((x, y))
        polygon2.append((x, y))

    return polygon1, polygon2


def mix_colors(color1: str, color2: str) -> str:
    r1, g1, b1 = color1[1:3], color1[3:5], color1[5:]
    r2, g2, b2 = color2[1:3], color2[3:5], color2[5:]

    r = (int(r1, 16) + int(r2, 16)) // 2
    g = (int(g1, 16) + int(g2, 16)) // 2
    b = (int(b1, 16) + int(b2, 16)) // 2

    return f"#{r:02X}{g:02X}{b:02X}"


def split_polygons(polygons: List[dict], line: Tuple[float, float, float]) -> List[dict]:
    new_polygons = []

    for polygon in polygons:
        polygon1, polygon2 = split_polygon(polygon=polygon["points"], line=line)

        if len(polygon1) > 2:
            new_polygons.append({"points": polygon1, "color": mix_colors(polygon["color"], "#dd7373")})

        if len(polygon2) > 2:
            new_polygons.append({"points": polygon2, "color": mix_colors(polygon["color"], "#7699d4")})

    return new_polygons


def view_points(points: List[Tuple[float, float]], limits: dict, x0: float, y0: float, width: float, height: float) -> List[Tuple[float, float]]:
    mapped_points = []

    for x, y in points:
        x = x0 + (x - limits["x_min"]) / (limits["x_max"] - limits["x_min"]) * width
        y = y0 + (limits["y_max"] - y) / (limits["y_max"] - limits["y_min"]) * height
        mapped_points.append((x, y))

    return mapped_points


def main() -> None:
    lines = [
        (0.04, 0.3, -0.01),
        (-0.75, 0.1, -0.97),
        (-0.14, 0.9, 0.96),
        (1.14, 0.18, -1.05),
        (1.27, -0.07, 0.04),
        (-0.2, 0.24, -0.15),
        (0.35, 1.34, -0.96),
        (0.26, -0.9, -0.54)
    ]

    limits = {"x_min": -1.7, "y_min": -1.7, "x_max": 1.7, "y_max": 1.7}
    polygons = [
        {"points": [(-1.7, -1.7), (-1.7, 1.7), (1.7, 1.7), (1.7, -1.7)], "color": "#ffffff"}
    ]

    x0, y0 = 1, 1.5
    size = 7.5
    gap = 0.5
    columns = 4

    with Presentation(presentation_path="empty.pptx") as presentation:
        for i, line in enumerate(lines):
            polygons = split_polygons(polygons=polygons, line=line)
            x = x0 + (size + gap) * (i % columns)
            y = y0 + (size + gap) * (i // columns)

            shapes = []
            for polygon in polygons:
                points = view_points(points=polygon["points"], limits=limits, x0=x, y0=y, width=size, height=size)
                shapes.append(Polygon(points=points, fill=FillStyle(color=polygon["color"]), stroke=StrokeStyle(color="#222", thickness=0.5)))

            presentation.add(shape=Group(shapes=shapes))

        presentation.save("polygons.pptx")


if __name__ == "__main__":
    main()
Результат работы примера с разбиением полигонов линиями
Результат работы примера с разбиением полигонов линиями
Слайды, созданные с помощью pptx-shapes

Здесь представлены лишь иллюстрации, что были сделаны с помощью библиотеки, так как сами слайды целиком показать я пока не могу.

Вариационный автоэнкодер
Вариационный автоэнкодер
Полигональная спираль
Полигональная спираль
ScatterPlot во всей красе
ScatterPlot во всей красе
Низкополигональный котик с КДПВ тоже сделан с помощью библиотеки
Низкополигональный котик с КДПВ тоже сделан с помощью библиотеки

Спасибо за внимание!

Надеюсь, pptx-shapes окажется для вас полезной – будь то для визуализации данных, генерации иллюстраций или просто красивых слайдов. Если у вас появятся идеи, вопросы или мысли, буду рад, если заглянете в репозиторий. Всегда приятно узнать, что библиотека кому-то пригодилась!

Комментарии (3)


  1. maxzh83
    19.05.2025 07:08

    Когда у тебя в руке молоток, все становится похожим на гвозди. А когда python, везде хочется его применить. Как обучающая статья, вполне норм. А так еще есть макросы vb и трассировщики в svg.


    1. pnmv
      19.05.2025 07:08

      Vba, уже довольно давно, стало, чуть ли не ругательным словом, для многих из тех, кто никогда не пробовал им пользоваться или имел неудачный опыт применения. При этом, когда речь идёт о схожих вещах, допустим, в инструментах от google, отторжения ни у кого не возникает.


  1. IAMBIRD
    19.05.2025 07:08

    Очень любил векторный редактор PowerPoint, пока не открыл для себя ультраудобный (и увы заброшенный) Expression Design. Если не изменяет память, выгруженный оттуда SVG можно было каким-нибудь сервисом (или даже инкскейпом) сконвертировать в WMF/EMF и уже такой вектор кажись офис принимал.