Код в 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 – без комментариев к прошлой статье этого текста бы не было. Если знаете, как красиво сделать номера строк или перенос длинных строк с сохранением отступа, – пишите в комментариях.

