Ниже - практики и паттерны, которые обычно дают предсказуемый результат в python-docx (ориентируюсь на официальную документацию python-docx 1.2.0).
Document(), а с заранее подготовленного .docx-шаблона (стили, колонтитулы, таблицы, нумерация, корпоративные шрифты). У python-docx сильная сторона - "править существующее", а не "верстать с нуля".document.styles[...], а в коде применяйте только имена стилей. Важный нюанс: если стиль не определён в самом документе, Word просто проигнорирует применение (без ошибки).paragraph.text = ..., если важно сохранить форматирование внутри параграфа: присваивание текста параграфу заменяет контент одним Run и удаляет run-level форматирование (bold/italic/и т.п.).bold, italic, alignment и т.д.) имеют True/False/None, где None = "унаследовать". Это нормальное состояние, а не "пропало значение".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 = ...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 форматирование.
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 бывает недостаточно.
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.