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

—

от автора

Код в 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_TYPEfrom docx.shared import PtCODE_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 qndef _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 OxmlElementdef _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 lexfrom pygments.lexers import get_lexer_by_namefrom pygments.styles import get_style_by_namedef _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 RGBColordef 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 Pathfrom docx import Documentfrom docx_code import add_listingdoc = 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 – без комментариев к прошлой статье этого текста бы не было. Если знаете, как красиво сделать номера строк или перенос длинных строк с сохранением отступа, – пишите в комментариях.

ссылка на оригинал статьи https://habr.com/ru/articles/1090756/