Код в Word без картинок: подсветка синтаксиса через python-docx и Pygments

Около ста строк на Python – и листинги в .docx выглядят как в IDE, копируются, ищутся и нумеруются сами.
В прошлой статье я рассказывал про «Отчет Creator» – скрипт, который собирает отчеты по лабам со Stepik в Word. Код решений он вставлял картинками, и первый же комментарий под статьей был: «Код рисунками – брр».
Спорить было сложно. Но тогда я честно думал, что python-docx по-другому не умеет. С этой библиотекой я вообще познакомился случайно: в 2024 году спросил у ChatGPT, чем генерировать Word-файлы из Python, он назвал python-docx – так и пошло. Документацию я тогда читал по диагонали, ровно до момента «картинка вставилась – работает».
После комментариев я все-таки сел разбираться. Выяснилось, что код текстом с подсветкой сделать можно, просто придется пару раз спуститься в XML и наступить на несколько граблей. Все это я собрал в небольшой модуль, который можно забрать в любой свой генератор документов: отчеты, курсовые, документацию.
Вот как было:

А вот как стало:

Одногруппники и преподаватели, мягко говоря, удивились такому завозу: код в отчетах наконец можно выделить, скопировать и прочитать без лупы, а сами файлы заметно похудели.
Чем плохи картинки
Картинка | Текст | |
|---|---|---|
Скопировать код | нельзя | можно |
Найти через Ctrl+F | нельзя | можно |
Четкость при печати и масштабе | зависит от разрешения | всегда четко |
Поменять шрифт или размер в Word | только перегенерировать | через стиль, за секунду |
И еще скорость с размером. Для честного сравнения я взял 30 одинаковых листингов по 14 строк и собрал из них два документа: в первом код отрисован через Pillow (JetBrains Mono, 64 px, как в старой версии), во втором вставлен текстом.
Способ | Время генерации | Размер .docx |
|---|---|---|
Картинки (Pillow) | 4,1 с | 130 КБ |
Текст (python-docx + Pygments) | 0,6 с | 39 КБ |
Пустой документ сам по себе весит около 36 КБ, так что 30 текстовых листингов добавляют к нему всего пару килобайт.
Почему бы не LaTeX или pandoc? Их в комментариях к прошлой статье тоже советовали, и это отличные инструменты. Но кафедра принимает только .docx по своему шаблону, а попасть в чужой шаблон из pandoc сложнее, чем дописать сто строк.
Шаг 1. Стиль для кода
Подсказка из комментариев: в Word для кода заводят отдельный стиль, как и для заголовков. Тогда все листинги выглядят одинаково, а шрифт меняется в одном месте.
from docx.enum.style import WD_STYLE_TYPE
from docx.shared import Pt
CODE_STYLE = "Code"
def ensure_code_style(doc, font="Courier New", size=10):
if CODE_STYLE in [s.name for s in doc.styles]:
return
style = doc.styles.add_style(CODE_STYLE, WD_STYLE_TYPE.PARAGRAPH)
style.base_style = doc.styles["Normal"]
style.font.size = Pt(size)
style.font.no_proof = True # без красных волнистых подчеркиваний
_set_all_fonts(style.element.get_or_add_rPr(), font)
pf = style.paragraph_format
pf.first_line_indent = Pt(0) # в шаблонах по ГОСТу обычно есть красная строка
pf.left_indent = Pt(0)
pf.line_spacing = 1.0 # а еще полуторный интервал
pf.space_before = Pt(0)
pf.space_after = Pt(0)
Строчка с no_proof выглядит необязательной, пока не откроешь документ без нее. Word включает режим строгой учительницы русского языка: def – ошибка, popleft – ошибка, elif – вообще непонятно что. Через пару страниц листинг больше похож на сочинение двоечника, чем на код. no_proof – это галочка «Не проверять правописание», и стоит она сразу на весь стиль.
Со шрифтом подвох другого рода. Если просто написать style.font.name = "Courier New", python-docx выставит шрифт только для латиницы (атрибуты w:ascii и w:hAnsi). Для остальных диапазонов символов Word возьмет шрифт из базового стиля – и в одной строке могут встретиться два шрифта. Поэтому задаем все четыре атрибута:
from docx.oxml.ns import qn
def _set_all_fonts(rpr, name):
rfonts = rpr.get_or_add_rFonts()
for attr in ("w:ascii", "w:hAnsi", "w:cs", "w:eastAsia"):
rfonts.set(qn(attr), name)
Почему Courier New, а не JetBrains Mono, как было на картинках? Его не нужно устанавливать: он есть в Windows и macOS, и у преподавателя документ откроется так же, как у меня.
Шаг 2. Рамка и заливка
Чтобы листинг читался как отдельный блок, добавим стилю светлую заливку и тонкую рамку. Готового API для этого в python-docx нет, так что лезем в XML:
from docx.oxml import OxmlElement
def _add_box(ppr, fill="F6F8FA", border="D0D7DE"):
pbdr = OxmlElement("w:pBdr")
for side in ("top", "left", "bottom", "right"):
el = OxmlElement(f"w:{side}")
el.set(qn("w:val"), "single")
el.set(qn("w:sz"), "4") # в восьмых долях пункта: 4 = 0,5 pt
el.set(qn("w:space"), "4")
el.set(qn("w:color"), border)
pbdr.append(el)
ppr.insert_element_before(pbdr, "w:shd", *_PPR_TAIL)
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), fill)
ppr.insert_element_before(shd, *_PPR_TAIL)
Самое коварное здесь – порядок элементов. Внутри <w:pPr> дочерние теги должны идти строго в порядке, который задает схема OOXML. Если сделать просто ppr.append(shd), заливка может оказаться после <w:spacing>. LibreOffice такое молча проглотит, а Word может отказаться открывать файл с сообщением про «нечитаемое содержимое». Поэтому вставляем через insert_element_before и передаем список тегов, которые обязаны идти после нашего:
_PPR_TAIL = (
"w:tabs", "w:suppressAutoHyphens", "w:kinsoku", "w:wordWrap",
"w:overflowPunct", "w:topLinePunct", "w:autoSpaceDE", "w:autoSpaceDN",
"w:bidi", "w:adjustRightInd", "w:snapToGrid", "w:spacing", "w:ind",
"w:contextualSpacing", "w:mirrorIndents", "w:suppressOverlap", "w:jc",
"w:textDirection", "w:textAlignment", "w:textboxTightWrap",
"w:outlineLvl", "w:divId", "w:cnfStyle", "w:rPr", "w:sectPr", "w:pPrChange",
)
Список я подсмотрел в исходниках самой python-docx, в классе CT_PPr: библиотека хранит его ровно для этой цели.
Шаг 3. Подсветка: из токенов во фрагменты
Абзац в Word состоит из фрагментов – runs, и у каждого свое форматирование. Pygments режет код на токены и для каждого сообщает цвет, жирность и курсив. Остается превратить одно в другое:
from pygments import lex
from pygments.lexers import get_lexer_by_name
from pygments.styles import get_style_by_name
def _runs(code, language, style_name):
style = get_style_by_name(style_name)
chunks = []
lexer = get_lexer_by_name(language, ensurenl=False)
for token_type, value in lex(code, lexer):
s = style.style_for_token(token_type)
fmt = (s["color"], s["bold"], s["italic"])
if chunks and (chunks[-1][1] == fmt or value.isspace()):
chunks[-1][0] += value # склеиваем с предыдущим фрагментом
else:
chunks.append([value, fmt])
return chunks
Первая версия делала по фрагменту на каждый токен, и это расточительно: Pygments дробит код очень мелко, каждый пробел и каждая запятая – отдельный токен. Поэтому соседние токены с одинаковым стилем склеиваются, а пробелы (им цвет не важен) прилипают к предыдущему фрагменту. Для листинга с поиском в ширину получается 57 фрагментов вместо 147, для примера на C++ – 39 вместо 103. Меньше фрагментов – меньше XML в документе.
ensurenl=False – мелочь, которую я нашел только по пустой строке внизу каждой рамки. По умолчанию Pygments дописывает в конец кода перевод строки, эта опция его отключает.
Сама вставка:
from docx.shared import RGBColor
def add_code(doc, code, language="python", style_name="friendly"):
ensure_code_style(doc)
p = doc.add_paragraph(style=CODE_STYLE)
for text, (color, bold, italic) in _runs(code.strip("\n"), language, style_name):
run = p.add_run(text)
if color:
run.font.color.rgb = RGBColor.from_string(color)
run.bold = bold
run.italic = italic
return p
Весь листинг – один абзац. add_run сам превращает \n в разрыв строки <w:br/>, а \t – в табуляцию и ставит xml:space="preserve", так что отступы в Python не теряются. Если делать по абзацу на каждую строку кода, рамка нарисуется вокруг каждой строки отдельно, а между строками появятся интервалы из шаблона.
Шаг 4. Подпись с автонумерацией
Номер в подписи можно вписать текстом: «Листинг 3». Но стоит вставить новый листинг в середину – и нумерация поедет. В Word для этого есть поле SEQ, то самое, что вставляет пункт «Ссылки → Вставить название». Сделаем его руками:
def _add_field(paragraph, instr, cached):
def fld(kind):
r = paragraph.add_run()
el = OxmlElement("w:fldChar")
el.set(qn("w:fldCharType"), kind)
r._r.append(el)
fld("begin")
r = paragraph.add_run()
instr_el = OxmlElement("w:instrText")
instr_el.set(qn("xml:space"), "preserve")
instr_el.text = f" {instr} "
r._r.append(instr_el)
fld("separate")
paragraph.add_run(cached) # то, что видно до обновления полей
fld("end")
def add_listing(doc, code, title, number, language="python"):
caption = doc.add_paragraph("Листинг ")
_add_field(caption, "SEQ Листинг \\* ARABIC", str(number))
caption.add_run(f" – {title}")
caption.paragraph_format.keep_with_next = True
add_code(doc, code, language)
Поле устроено как бутерброд из трех fldChar: начало, разделитель, конец. Между началом и разделителем лежит инструкция, между разделителем и концом – закешированный результат. Его мы сразу заполняем правильным номером, поэтому документ выглядит нормально и без обновления полей. А если потом переставить листинги вручную, хватит Ctrl+A и F9 – Word пересчитает номера сам.
keep_with_next нужен, чтобы подпись не осталась сиротой внизу страницы, пока код уехал на следующую.
Как пользоваться
from pathlib import Path
from docx import Document
from docx_code import add_listing
doc = Document("template.docx") # ваш шаблон по ГОСТу
add_listing(doc, Path("bfs.py").read_text(encoding="utf-8"), "Поиск в ширину", 1)
add_listing(doc, Path("main.cpp").read_text(encoding="utf-8"), "Чтение массива", 2, language="cpp")
doc.save("report.docx")
encoding="utf-8" здесь не для красоты: на Windows open() без кодировки читает файл в cp1251, и русские комментарии в коде превращаются в кракозябры.
Что можно настроить:
Язык – любой из 500+ лексеров Pygments:
"cpp","java","sql","bash","go". Если язык заранее неизвестен, естьguess_lexer(code), но угадывает он не всегда.Цветовая схема.
"friendly"хорошо смотрится на светлом фоне. Для черно-белой печати есть"bw"– только жирный и курсив. Остальные схемы – на pygments.org/styles.Шрифт и размер – аргументы
ensure_code_style. Если стильCodeуже есть в шаблоне, функция его не трогает, так что оформление можно целиком настроить в самом Word.
Что пока не идеально
Длинные строки Word переносит по ширине страницы, и отступ у продолжения теряется. Проще всего держать код в пределах 80 символов или уменьшить шрифт до 9 pt.
Номера строк я в модуль не добавлял. Встроенная нумерация Word (lnNumType) считает строки на весь раздел, а не на отдельный листинг, так что самый надежный вариант – таблица из двух колонок: номера и код.
И главное правило: LibreOffice прощает ошибки в структуре документа, Word – нет. Если генерируете документы для сдачи, проверяйте результат именно в Word.
Где это работает
Модуль уже живет в «Отчет Creator»: отчет по разделу курса со Stepik теперь собирается с листингами текстом, а старый режим с картинками остался за флагом --images – вдруг кому-то так привычнее. Полный код модуля – в файле docx_code.py.
Спасибо @milssky за идею со стилем и @Andrey_Solomatin за Pygments – без комментариев к прошлой статье этого текста бы не было. Если знаете, как красиво сделать номера строк или перенос длинных строк с сохранением отступа, – пишите в комментариях.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.