PunchNLNG unveils AI tool to cut MRI scan timeThe Jerusalem PostWearable counter‑drone tech enters frontline service across US, Ukraine and IDF unitsBollywood HungamaMahakavya Shri Ramayan Katha producer Prakash Mahobiya alleges ‘negative marketing’; hints at big-budget Ramayana saying, “We never got a chance to reach our audience”ESPNTransfer rumors, news: Man United, Arsenal, Chelsea battle for Freiburg strikerInquirerSara Duterte: Mom prefers Baste for national politicsCNN Türk"Fon" ödemesi hangi formülle olacak?UOLTelevangelista americano Jim Bakker, envolvido em escândalos de fraude e sexo, morre aos 86 anos20 MinutenXena (24): «Sie trauen mir als Frau den Chefposten nicht zu»ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioHet Laatste NieuwsVoormalig hoofd van Duitse inlichtingendienst aangehouden op verdenking van spionage en landverraadWirtualna PolskaPrezes UOKiK: Liczymy na refleksję po stronie Google'aNHK 社会デヴィ夫人 元マネージャーなど暴行の罪で罰金20万円
The Daily Newsstand · Free, Always
Tuesday, October 6, 2026

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

Translate

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.