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

Около ста строк на Python – и листинги в .docx выглядят как в IDE, копируются, ищутся и нумеруются сами.

В прошлой статье я рассказывал про «Отчет Creator» – скрипт, который собирает отчеты по лабам со Stepik в Word. Код решений он вставлял картинками, и первый же комментарий под статьей был: «Код рисунками – брр».

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

После комментариев я все-таки сел разбираться. Выяснилось, что код текстом с подсветкой сделать можно, просто придется пару раз спуститься в XML и наступить на несколько граблей. Все это я собрал в небольшой модуль, который можно забрать в любой свой генератор документов: отчеты, курсовые, документацию.

Вот как было:

Страница старого отчета, код решения вставлен картинкой
Страница старого отчета, код решения вставлен картинкой

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

Два листинга, вставленных текстом: Python и C++
Два листинга, вставленных текстом: Python и C++

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

Чем плохи картинки

Картинка

Текст

Скопировать код

нельзя

можно

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

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