Сообщить об ошибке.

Базовые best practices с модулем python-docx

Содержание:

Ниже - практики и паттерны, которые обычно дают предсказуемый результат в python-docx (ориентируюсь на официальную документацию python-docx 1.2.0).

Базовые best practices с модулем python-docx

  1. Template-first: стартуйте не с Document(), а с заранее подготовленного .docx-шаблона (стили, колонтитулы, таблицы, нумерация, корпоративные шрифты). У python-docx сильная сторона - "править существующее", а не "верстать с нуля".
  2. Стилевая архитектура вместо ручного форматирования: задавайте оформление через document.styles[...], а в коде применяйте только имена стилей. Важный нюанс: если стиль не определён в самом документе, Word просто проигнорирует применение (без ошибки).
  3. Не делайте paragraph.text = ..., если важно сохранить форматирование внутри параграфа: присваивание текста параграфу заменяет контент одним Run и удаляет run-level форматирование (bold/italic/и т.п.).
  4. Держите плейсхолдеры "в одном run" (в шаблоне): Word легко дробит текст на несколько runs из-за правок/полей/стилей; это критично для поиска/замены.
  5. Tri-state свойства: многие параметры (bold, italic, alignment и т.д.) имеют True/False/None, где None = "унаследовать". Это нормальное состояние, а не "пропало значение".

"Builder" вокруг шаблона (структура проекта)

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

from docx import Document


@dataclass(frozen=True)
class ReportContext:
    title: str
    customer_name: str
    items: list[tuple[str, int, float]] # (наименование, qty, price)


class DocxReportBuilder:
    """
    Паттерн: шаблон + чистые функции построения.
    - бизнес-данные (ReportContext) отдельно
    - "рисование" документа отдельно
    """

    def __init__(self, template_path: Path) -> None:
        self._doc = Document(str(template_path)) # открываем .docx-шаблон

    def build(self, ctx: ReportContext) -> Document:
        self._fill_title(ctx.title)
        self._fill_customer(ctx.customer_name)
        self._render_items_table(ctx.items)
        return self._doc

    def save(self, out_path: Path) -> None:
        self._doc.save(str(out_path))

    # --- ниже "кирпичики" построения ---

    def _fill_title(self, title: str) -> None:
        # Лучше применять стиль заголовка (из шаблона), а не форматировать руками.
        p = self._doc.add_paragraph(style="Title") # стиль должен существовать в шаблоне
        p.add_run(title)

    def _fill_customer(self, customer_name: str) -> None:
        p = self._doc.add_paragraph(style="Body Text")
        p.add_run("Customer: ").bold = True
        p.add_run(customer_name)

    def _render_items_table(self, items: list[tuple[str, int, float]]) -> None:
        # Табличный стиль также лучше зашить в шаблон и применить по имени.
        table = self._doc.add_table(rows=1, cols=4, style="Table Grid")
        hdr = table.rows[0].cells
        hdr[0].text = "Item"
        hdr[1].text = "Qty"
        hdr[2].text = "Price"
        hdr[3].text = "Sum"

        for name, qty, price in items:
            row = table.add_row().cells
            row[0].text = name
            row[1].text = str(qty)
            row[2].text = f"{price:.2f}"
            row[3].text = f"{qty * price:.2f}"

Примечания по стилям:

  • доступ/применение: document.styles[...], paragraph.style = 'Heading 1', table.style = ...
  • имена встроенных стилей в файле хранятся на английском ("Heading 1", "Normal" и т.д.), даже если Word локализован

Управление секциями, ориентацией, полями страницы

from docx.enum.section import WD_ORIENT, WD_SECTION
from docx.shared import Inches

doc = Document("template.docx")

# Добавляем новую секцию (например, для ландшафтных страниц с широкой таблицей)
section = doc.add_section(WD_SECTION.NEW_PAGE)

# Ориентация: при смене на LANDSCAPE обычно меняют местами width/height
new_width, new_height = section.page_height, section.page_width
section.orientation = WD_ORIENT.LANDSCAPE
section.page_width = new_width
section.page_height = new_height

# Поля страницы
section.left_margin = Inches(1.0)
section.right_margin = Inches(1.0)
section.top_margin = Inches(0.75)
section.bottom_margin = Inches(0.75)

Документация по секциям, ориентации и margin’ам - в Working with Sections.

Колонтитулы "как часть секции"

from docx import Document

doc = Document("template.docx")
section = doc.sections[0]

header = section.header # отдельный story-контейнер
p = header.paragraphs[0] # в новом header уже есть пустой параграф
p.text = "Report header (left)\t Center\t Right"
p.style = doc.styles["Header"] # таб-стопы часто живут в стиле Header шаблона

# Удалить header можно через "link to previous"
# header.is_linked_to_previous = True

Это именно "story" контейнер, редактируется как документ, и поведение is_linked_to_previous важно для много-секционных документов.

Надёжная подстановка плейсхолдеров (без потери форматирования)

Что НЕ делать

Если в параграфе есть форматирование по словам (несколько runs), не используйте paragraph.text = ... для замены: вы потеряете run-level форматирование.

Базовый вариант (работает, если плейсхолдер целиком внутри одного Run)

from docx.text.paragraph import Paragraph

def replace_in_paragraph_runs(paragraph: Paragraph, mapping: dict[str, str]) -> None:
    """
    Заменяет токены вида {{TOKEN}} в пределах одного run.
    Плюсы: сохраняет форматирование run’ов.
    Минусы: не поймает токен, если Word разрезал его на несколько runs.
    """
    for run in paragraph.runs:
        for key, value in mapping.items():
            token = f"{{{{{key}}}}}"
            if token in run.text:
                run.text = run.text.replace(token, value)

Применение ко всему документу (включая таблицы)

from docx.document import Document as Doc

def iter_all_paragraphs(doc: Doc):
    # Основное тело
    for p in doc.paragraphs:
        yield p
    # Таблицы (ячейки содержат свои paragraphs)
    for tbl in doc.tables:
        for row in tbl.rows:
            for cell in row.cells:
                for p in cell.paragraphs:
                    yield p

def replace_tokens(doc: Doc, mapping: dict[str, str]) -> None:
    for p in iter_all_paragraphs(doc):
        replace_in_paragraph_runs(p, mapping)

Практика для шаблона: делайте плейсхолдеры отдельными "словами" без смешанного форматирования, чтобы Word не дробил их на несколько runs.

Если нужен более "шаблонизаторный" подход для существующих документов, часто используют docxtpl (Jinja2-теги в .docx), потому что он специально создан для сценариев "модифицировать шаблон", где чистого python-docx бывает недостаточно.

Комментарии (review comments) - официальный API

from docx import Document

doc = Document()
p = doc.add_paragraph("Hello, world!")

# Комментарий привязывается к диапазону runs
comment = doc.add_comment(
    runs=p.runs,
    text="I have this to say about that",
    author="Your Name",
    initials="YN",
)

doc.save("with-comments.docx")

В доках есть важные детали про "диапазон должен начинаться/заканчиваться на границе run", иногда для точной привязки нужно делить runs.

Частые ограничения, которые лучше учитывать заранее

  • Inline-картинки: официально поддерживаются только inline pictures (не плавающие/floating) - это влияет на "обтекание текстом" и водяные знаки.
  • Field codes (PAGE/NUMPAGES/TOC и т.п.): добавление/вставка field codes как полноценной фичи исторически запрошена отдельным issue; на практике надёжнее держать поля уже вставленными в шаблон и минимально трогать соответствующие параграфы/раны.
  • Стили: применение "несуществующего" стиля не падает ошибкой, но Word его проигнорирует - решается тем, что стиль заранее определён в шаблоне.