Claude Skill

docx-from-sample

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

LLM Mart · 0 points · 13 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download Desko77-claude-code-skills-1c-skills_docx-from-sample-eb281b4.zip · 32 KB
Part of desko77/claude-code-skills-1c — 48 skills

Install

skills CLI npx skills add https://github.com/Desko77/claude-code-skills-1c/tree/main/skills/docx-from-sample
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install desko77-claude-code-skills-1c@llmmart
Git git clone https://github.com/Desko77/claude-code-skills-1c.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole desko77/claude-code-skills-1c collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

/docx-from-sample - новый документ в оформлении образца

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

Когда применять

  • "сделай такой же документ, но по другой теме", "в формате как в примере"
  • заказчик прислал образец отчета, ТЗ, ПМИ, регламента - нужен свой документ в том же виде
  • документов несколько и они должны выглядеть одинаково
  • документ уже сделан, но оформление не совпадает с эталоном - пересобрать

Не для этого скила: правка существующего документа без смены оформления (штатные средства работы с DOCX), markdown в DOCX без образца (скил md-to-docx), таблицы и презентации (xlsx, pptx).

Зависимости

python -m pip install python-docx pymupdf

pymupdf нужен для картинок страниц, Word - для сборки оглавления и экспорта в PDF (на Windows через COM). Без Word документ соберется, но оглавление останется незаполненным.

Образец не зашит в скил

Скил глобальный и не привязан ни к одному шаблону. Образец задается профилем: ключ sample в JSON либо флаг --sample при запуске. Для каждого нового шаблона делается свой профиль, старые не трогаются.

Ситуация Что делать
Первый документ по этому шаблону inspect_sample.py на образце, довести профиль, собрать
Еще один документ по тому же шаблону взять готовый профиль, поменять только материал
Другой заказчик, другой бланк новый профиль на новый образец
Профиль есть, образец переехал --sample с новым путем либо поправить sample в профиле

Путь к образцу может быть относительным: он ищется рядом с профилем, потом в текущей папке. Так профиль и образец переносятся между машинами вместе.

Где хранить профили - profiles/README.md. Коротко: профиль конкретного проекта живет рядом с документами проекта или в ~/.claude/plans/<проект>/, а профиль шаблона, нужного из разных проектов, кладется в profiles/ внутри скила.

Порядок работы

1. Снять оформление с образца

python scripts/inspect_sample.py "образец.docx" --json profile.json

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

Читать отчет ОБЯЗАТЕЛЬНО: черновой профиль угадывает уровни заголовков по кеглю, и это часто неверно. Сверить с отчетом: какой стиль стоит на разделах верхнего уровня, какой на подразделах, чем размечены подзаголовки внутри разделов.

2. Довести профиль

Профиль - это карта "роль в документе -> оформление образца". Формат и все ключи: references/profile-format.md. Минимум, что правится руками:

  • styles.h1/h2/h3 - уровни заголовков (из отчета, а не из догадки скрипта);
  • tables.default_style_id и grid_style_id - идентификаторы стилей таблиц;
  • headings_map - какие тексты являются заголовками какого уровня в НОВОМ документе;
  • footer.template - текст подвала, {title} подставляется.

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

3. Подготовить содержание

Два пути.

Markdown - когда документ линейный (заголовки, абзацы, таблицы). Материал пишется в .md, таблицы обычным markdown, секции переключаются маркерами <!-- landscape --> и <!-- portrait -->. Сборка:

python scripts/md_to_docx_sample.py "материал.md" --profile profile.json --out "новый.docx" --title "Название" --author "Имя Ф."

Свой сценарий на python - когда документов несколько и они собираются из данных (словарь, таблица, выгрузка). Тогда данные и сборка разделяются: модуль с данными плюс вызовы SampleDoc. Так шесть однотипных документов правятся в одном месте и не разъезжаются. Пример: references/build-example.py.

4. Проверить

python scripts/verify_result.py "новый.docx" --md "материал.md" --update-fields --png 1,2,9

Скрипт сверяет число таблиц и строк с исходником (ловит потерю строк и склейку таблиц), показывает секции и полосу набора, ищет таблицы шире полосы, считает шапки с заливкой и повтором, обновляет поле оглавления через Word и рендерит указанные страницы в PNG.

Картинки страниц надо посмотреть глазами. Ни один структурный тест не покажет разъехавшуюся верстку: заголовок внизу страницы отдельно от своей таблицы, текст, рвущийся по буквам в узкой колонке, пропавший колонтитул. Открыть PNG инструментом Read и сравнить с таким же рендером образца.

Рендер образца для сравнения:

python scripts/verify_result.py "образец.docx" --png 1,2,9 --outdir preview_sample

Что переносится из образца

Элемент Как
Стили абзацев и знаков, темы, шрифты наследуются вместе с файлом образца
Автонумерация заголовков (1., 1.1., 1.1.1.) из стиля образца; снимается точечно там, где номер не нужен
Стили таблиц по идентификатору стиля из образца
Ширины столбцов из профиля явно либо по содержимому: колонка-счетчик узкая, остальные делят полосу
Заливка шапки, границы, повтор шапки при переносе из профиля, значения снимаются с образца
Колонтитулы собираются заново по шаблону из профиля
Книжные и альбомные секции, поля из профиля

Колонтитулы и оглавление сознательно НЕ копируются из образца: в них почти всегда остается название чужого документа. Подвал собирается по шаблону с подстановкой названия.

Обязательные проверки перед сдачей

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

Грабли

Полный разбор с симптомами: references/ooxml-pitfalls.md. Коротко, самое дорогое:

  1. Две таблицы подряд склеиваются в одну. Между таблицами обязателен пустой абзац, иначе Word объединит их, и число таблиц молча уменьшится.
  2. Подвал пропадает на первой странице каждой секции. Свойство "первая страница отдельно" наследуется новой секцией от предыдущей, его надо сбрасывать.
  3. Ориентация. В python-docx смена ориентации не меняет ширину и высоту страницы, их надо менять самому. В javascript-библиотеке docx наоборот: она меняет их сама, и ручная перестановка дает двойной переворот.
  4. Заливка шапки не появляется сама даже когда стиль таблицы ее рисует: в образцах она обычно задана явно на ячейках. Ставить явно.
  5. Узкая колонка-счетчик рвет свой заголовок по буквам. Ширина такой колонки должна считаться по длине заголовка: "№ п/п" уже, чем "Номер версии".
  6. Строки-разделители таблицы ("От Заказчика" на всю ширину) в markdown выглядят как повтор текста по всем колонкам - в DOCX их надо объединять в одну ячейку.

Ограничения

  • Скил не переносит картинки, диаграммы и фигуры из образца - только текстовое оформление.
  • Профиль под жанр документа делается один раз и потом переиспользуется; на новый жанр нужен новый профиль.
  • Автоопределение уровней заголовков в черновом профиле - подсказка, а не результат.
  • Сборка оглавления и экспорт в PDF требуют установленного Word.
Files (claude-code-skills-1c)
  • profiles
    • README.md 2.3 KB
      # Каталог профилей
      
      Профиль привязан к КОНКРЕТНОМУ образцу и жанру документа. Скил универсален, образец
      задается профилем, поэтому под каждый шаблон делается свой профиль и переиспользуется
      дальше.
      
      ## Где хранить
      
      | Что за профиль | Где держать |
      |---|---|
      | Шаблон конкретного проекта или заказчика | рядом с документами проекта либо в `~/.claude/plans/<проект>/` |
      | Шаблон, который нужен из разных проектов (личный бланк, типовой отчет) | здесь, в `profiles/` |
      | Разовый документ | рядом с материалом, потом можно удалить |
      
      Проектные профили здесь держать не надо: в них попадают пути и внутренние названия
      разделов конкретного заказчика.
      
      ## Как назвать
      
      По жанру документа, а не по заказчику: `testing-scenario.json`, `tech-spec.json`,
      `monthly-report.json`. Внутри профиля ключ `sample` указывает на файл образца.
      
      ## Путь к образцу
      
      `sample` может быть абсолютным или относительным. Относительный ищется сначала рядом
      с самим профилем, потом в текущей папке - так профиль и образец переносятся вместе.
      Разово путь переопределяется флагом:
      
      ```
      python scripts/md_to_docx_sample.py doc.md --profile p.json --sample "другой образец.docx"
      ```
      
      ## Новый образец - новый профиль
      
      ```
      python scripts/inspect_sample.py "новый образец.docx" --json profiles/new-genre.json
      ```
      
      Дальше профиль правится по отчету инспектора: уровни заголовков, стили таблиц,
      `headings_map` под структуру нового документа. Существующие профили не трогаются.
      
  • references
    • build-example.py 4.4 KB
      # -*- coding: utf-8 -*-
      """Пример: несколько однотипных документов из данных, в оформлении образца.
      
      Схема, которая себя оправдала на серии документов: данные отдельно, сборка отдельно.
      Тогда правка формулировки делается в одном месте и все документы остаются одинаковыми.
      
      Запуск:  python build-example.py
      """
      import os
      import sys
      
      sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "scripts"))
      from docx_builder import SampleDoc
      
      PROFILE = "profile.json"
      OUT_DIR = "."
      
      # ---------------------------------------------------------------- данные
      
      COMMON_INTRO = [
          "Документ описывает порядок проверки и состав контролируемых данных.",
          "Документ предназначен для специалистов, выполняющих проверку.",
      ]
      
      DOCS = [
          {
              "file": "Отчет по направлению А.docx",
              "title": "Проверка по направлению А",
              "rows": [["Показатель 1", "Значение 1", "Примечание"],
                       ["Показатель 2", "Значение 2", ""]],
              "steps": [("Открытие формы", "Открыть форму обработки.", "Форма открыта."),
                        ("Заполнение", "Заполнить период и организацию.", "Данные введены.")],
          },
          {
              "file": "Отчет по направлению Б.docx",
              "title": "Проверка по направлению Б",
              "rows": [["Показатель 3", "Значение 3", ""]],
              "steps": [("Открытие формы", "Открыть форму обработки.", "Форма открыта.")],
          },
      ]
      
      # ---------------------------------------------------------------- сборка
      
      
      def build(spec):
          d = SampleDoc(PROFILE)
          d.footer_for_all("Отчет '%s'" % spec["title"])
      
          # титул: линейка, блок подписей, название
          d.rule_table(cols=3)
          d.plain_table([["СОГЛАСОВАНО", "УТВЕРЖДАЮ"],
                         ["Должность, организация", "Должность, организация"],
                         ["______________ Фамилия И. О.", "______________ Фамилия И. О."]],
                        bold_first_row=True)
          d.empty(3)
          d.title_block([("Отчет", 22, True), (spec["title"], 22, False)])
      
          # служебные разделы без номеров
          d.section("portrait")
          d.heading("Версии документа", 1, numbered=False)
          d.table(["Номер версии", "Содержание изменения", "Ответственный", "Дата"],
                  [["1", "Первая версия документа", "", ""]],
                  widths=[2.7, 7.0, 3.0, 3.8], style=None, head_size=9,
                  body_style_key="table_body_alt", borders=8, repeat=False)
          d.heading("Оглавление", 1, numbered=False)
          d.toc()
      
          # нумерованные разделы
          d.heading("Введение", 1)
          for line in COMMON_INTRO:
              d.body(line)
      
          d.heading("Контролируемые данные", 1)
          d.table(["№ п/п", "Показатель", "Значение", "Примечания"],
                  [[str(i + 1)] + list(r) for i, r in enumerate(spec["rows"])])
      
          # альбомная секция под широкую таблицу
          d.section("landscape")
          d.heading("Порядок проверки", 1)
          d.callout("ПОСЛЕДОВАТЕЛЬНОСТЬ ДЕЙСТВИЙ")
          d.table(["№ шага", "Название шага", "Описание действия", "Ожидаемый результат"],
                  [[str(i + 1)] + list(s) for i, s in enumerate(spec["steps"])])
      
          out = os.path.join(OUT_DIR, spec["file"])
          d.save(out, author="Имя Ф.", title=spec["title"])
          for w in d.warnings:
              print("  ВНИМАНИЕ: %s" % w)
          return out
      
      
      if __name__ == "__main__":
          for spec in DOCS:
              print("OK  %s" % build(spec))
          print("Дальше: verify_result.py по каждому файлу, с --update-fields и --png.")
      
    • ooxml-pitfalls.md 8.4 KB
      # Грабли сборки DOCX по образцу
      
      Симптомы и причины, пойманные на реальных документах. Все проверены на python-docx
      и Word на Windows.
      
      ## 1. Две таблицы подряд склеиваются в одну
      
      **Симптом.** В исходнике таблиц 57, в готовом документе 56, при этом строк ровно столько,
      сколько должно быть. Первая таблица получает лишние колонки.
      
      **Причина.** Если между таблицами нет абзаца, Word считает их одной таблицей и объединяет.
      Пустая строка в markdown таблицы не разделяет: при разборе она отбрасывается.
      
      **Лечение.** После каждой таблицы добавлять пустой абзац. В `SampleDoc.table` это делается
      само, но при ручной сборке через `doc.add_table` про это забывают.
      
      **Проверка.** Сверять число таблиц с исходником, а не только число строк.
      
      ## 2. Подвал пропадает на первой странице каждой секции
      
      **Симптом.** Колонтитул есть на всех страницах, кроме первой страницы раздела. Особенно
      заметно на альбомных секциях: первая страница без подвала, вторая с подвалом.
      
      **Причина.** Свойство "первая страница отдельно" (`titlePg`) при создании новой секции
      наследуется от предыдущей. Если оно включено на титульной секции, включится и дальше.
      
      **Лечение.** У каждой секции после титульной ставить `different_first_page_header_footer
      = False`.
      
      ## 3. Двойной переворот ориентации
      
      **Симптом.** Секция помечена альбомной, но страница осталась 21 на 29.7 см, таблицы
      шириной 25 см вылезают за поля.
      
      **Причина.** Разные библиотеки ведут себя противоположно:
      - **python-docx** при смене `orientation` размеры страницы НЕ меняет, их надо
        переставлять самому;
      - **javascript-библиотека `docx`** переставляет их сама, поэтому передавать надо книжные
        размеры, а ручная перестановка дает переворот дважды и возврат к книжной.
      
      **Лечение.** Знать, какая библиотека используется, и не переносить рецепт между ними.
      
      ## 4. Заливка шапки не появляется
      
      **Симптом.** Табличный стиль в образце рисует серую шапку, в новом документе шапка белая,
      хотя стиль тот же и условное форматирование (`tblLook`, `cnfStyle`) совпадает.
      
      **Причина.** В образцах заливка обычно задана явно на ячейках шапки, а не взята из стиля.
      
      **Лечение.** Ставить заливку явно на ячейки шапки и сбрасывать в `auto` на ячейках тела,
      цвет снимать с образца.
      
      ## 5. Колонка-счетчик рвет заголовок по буквам
      
      **Симптом.** В шапке видно "Ном ер верс ии" или "№ шаг а".
      
      **Причина.** Колонка-счетчик получает фиксированную узкую ширину, рассчитанную на "№ п/п",
      а заголовок оказывается длиннее.
      
      **Лечение.** Ширину колонки-счетчика считать от длины ее заголовка. Порог: заголовок
      длиннее пяти знаков - колонка шире; отдельные заголовки ("Номер версии") задавать точечно.
      
      ## 6. Строки-разделители таблицы дублируются по колонкам
      
      **Симптом.** Строка "От Заказчика" повторяется в каждой ячейке строки и в узкой первой
      колонке рвется по буквам.
      
      **Причина.** В markdown нет объединения ячеек, поэтому такие строки пишут повтором текста
      по всем колонкам.
      
      **Лечение.** При сборке объединять такие строки в одну ячейку на всю ширину. Список
      текстов-маркеров держать в профиле.
      
      ## 7. Оглавление пустое
      
      **Симптом.** В документе есть раздел "Оглавление", но под ним пусто или текст-заглушка.
      
      **Причина.** Поле оглавления вычисляется Word при обновлении полей. Библиотека вставляет
      только само поле.
      
      **Лечение.** После сборки открыть документ через Word и обновить поля (`Fields.Update` плюс
      `TablesOfContents.Item(1).Update`), затем сохранить. Пока это не сделано, вместо оглавления
      виден placeholder - он должен быть осмысленным текстом, а не пустотой.
      
      ## 8. Word закрывается вместе с документами пользователя
      
      **Симптом.** После автоматизации у пользователя пропало открытое окно Word с несохраненной
      работой.
      
      **Причина.** `$word.Quit()` закрывает приложение целиком, а Word работает одним процессом
      на все документы.
      
      **Лечение.** Перед запуском проверять `Get-Process winword`. Если Word уже запущен, свои
      документы закрывать, а `Quit` не вызывать.
      
      ## 9. Стиль таблицы не находится по имени
      
      **Симптом.** Обращение к стилю по имени падает или молча не применяется.
      
      **Причина.** Имена табличных стилей содержат символы, которые не переносятся между
      редакциями и кодировками (длинное тире, неразрывные пробелы).
      
      **Лечение.** Обращаться по идентификатору стиля: перебрать `doc.styles` и сравнить
      `style_id`. Идентификатор берется из отчета инспектора.
      
      ## 10. Ширины столбцов игнорируются
      
      **Симптом.** Ширины заданы, но Word пересчитывает их по содержимому, пропорции плывут.
      
      **Причина.** Без фиксированной раскладки таблицы Word считает ширины сам.
      
      **Лечение.** Ставить фиксированную раскладку (`tblLayout type="fixed"`) и задавать ширину
      и на уровне столбца, и на уровне каждой ячейки: Word смотрит на ячейки.
      
      ## 11. Отчет по строкам совпал, а документ битый
      
      **Симптом.** Число таблиц и строк сошлось, но верстка развалилась.
      
      **Причина.** Структурные проверки не видят переносов страниц, отрыва заголовка от таблицы,
      вылезания за поля.
      
      **Лечение.** Рендерить страницы в картинки (Word в PDF, дальше pymupdf в PNG) и смотреть
      глазами. Для сравнения рендерить те же страницы образца.
      
    • profile-format.md 6.7 KB
      # Формат профиля сборки
      
      Профиль - это JSON, связывающий роли содержания с оформлением образца. Черновик делает
      `inspect_sample.py --json`, дальше он правится руками по отчету инспектора.
      
      ```json
      {
        "sample": "путь к образцу.docx",
      
        "page": {
          "width_cm": 21.0,
          "height_cm": 29.7,
          "margins_cm": [2.5, 2.0, 2.0, 2.0]
        },
      
        "styles": {
          "body": "Основной",
          "h1": "Заголовок 1",
          "h2": "Заголовок 2",
          "h3": "Заголовок 3",
          "callout": "Выделение по тексту",
          "bullet": "Маркированный список",
          "table_head": "Таблица шапка",
          "table_body": "Таблица текст",
          "table_body_alt": "Таблица текст 2"
        },
      
        "tables": {
          "default_style_id": "-1511",
          "grid_style_id": "affff0",
          "head_fill": "D9D9D9",
          "head_fill_alt": "BFBFBF",
          "border_sz": 4,
          "repeat_header": true,
          "numbering_dot": true,
          "counter_headers": ["№"],
          "counter_widths": {"default": 1.1, "long_header": 1.4, "Номер версии": 2.7},
          "full_width_rows": ["От Заказчика", "От Исполнителя"]
        },
      
        "table_rules": {
          "default": {"style": "default"},
          "реквизиты": {"match_header": "Реквизит", "style": "grid", "widths": [7.6, 17.6]},
          "глоссарий": {"match_header": "№ п/п | Термин, сокращение | Расшифровка",
                        "style": null, "head_fill": "BFBFBF",
                        "body_style_key": "table_body_alt", "border_sz": 6}
        },
      
        "footer": {
          "line": true,
          "line_len": 77,
          "size_pt": 8,
          "template": "Сценарий тестирования "{title}""
        },
      
        "title_page": {
          "separate_section": true,
          "no_footer": true,
          "big_pt": 22,
          "small_pt": 16,
          "big_max_len": 90
        },
      
        "toc": {"placeholder": "Для сборки оглавления выделить документ целиком и нажать F9."},
      
        "headings_map": {
          "h1": ["Введение", "Функциональные требования"],
          "h1_unnumbered": ["Версии документа", "Лист согласования", "Оглавление"],
          "h2": ["Глоссарий", "Ссылки"],
          "h3": [],
          "callout": ["Исходные данные", "Контролируемые реквизиты"],
          "toc_after": ["Оглавление"],
          "h2_after_h1": ["Сценарии тестирования"],
          "h3_if_next_starts_with": ["Контрольный пример описывает"],
          "regex": {"^Группа \\d+\\. ": "card"}
        }
      }
      ```
      
      ## Разделы
      
      **page** - размер страницы и поля в сантиметрах. Полоса набора считается сама и служит
      базой для ширин таблиц.
      
      **styles** - имена стилей ИЗ ОБРАЗЦА под роли содержания. Если стиля нет, сборщик
      предупредит и поставит обычный текст, документ не сломается. Ключи `h1`, `h2`, `h3`
      должны соответствовать уровням в образце: инспектор угадывает их по кеглю и часто
      ошибается, проверять по отчету.
      
      **tables** - оформление таблиц по умолчанию:
      - `default_style_id`, `grid_style_id` - идентификаторы табличных стилей образца
        (именно идентификаторы: имена стилей содержат символы, которые ломаются при переносе);
      - `head_fill`, `head_fill_alt` - заливка шапки, шестнадцатеричный цвет без решетки;
      - `border_sz` - толщина границ в восьмых долях пункта (4 = 0.5 пт);
      - `numbering_dot` - ставить ли точку после номера в колонке-счетчике;
      - `counter_headers` - по какому началу заголовка колонка считается счетчиком;
      - `counter_widths` - ширина такой колонки: общая, для длинного заголовка и точечные
        переопределения по имени заголовка;
      - `full_width_rows` - тексты строк-разделителей, которые надо объединять на всю ширину.
      
      **table_rules** - отклонения для конкретных таблиц. Правило выбирается по `match_header`:
      либо полная шапка через " | ", либо заголовок первой колонки. Внутри правила можно задать
      `style` (`default`, `grid`, `null`), `widths`, `head_fill`, `body_style_key`, `border_sz`,
      `head_size`, `repeat_header`.
      
      **footer** - подвал собирается заново, из образца не копируется. В `template` подставляется
      `{title}`.
      
      **title_page** - титул: отдельная секция без колонтитула, кегли крупной и мелкой строки.
      
      **headings_map** - как распознать роль строки в НОВОМ документе:
      - `h1`, `h2`, `h3`, `callout` - точные совпадения текста;
      - `h1_unnumbered` - заголовки верхнего уровня без номера (обычно служебные разделы
        до введения);
      - `toc_after` - после какого заголовка вставить поле оглавления;
      - `h2_after_h1` - строка сразу после указанного заголовка первого уровня становится
        подзаголовком (типовой случай: название сценария под разделом);
      - `h3_if_next_starts_with` - строка становится заголовком третьего уровня, если следующий
        абзац начинается с указанного текста (надежный способ поймать названия примеров);
      - `regex` - словарь "регулярное выражение -> роль" для однотипных заголовков.
      
      Порядок распознавания: точные списки, потом регулярные выражения, потом правило
      `h2_after_h1`, потом `h3_if_next_starts_with`, потом маркированный список, иначе
      обычный текст.
      
  • scripts
    • docx_builder.py 18.9 KB
      # -*- coding: utf-8 -*-
      """Сборка DOCX по образцу: образец служит шаблоном стилей, содержимое подставляется новое.
      
      Образец открывается как документ, его тело очищается, стили / нумерация / темы / параметры
      страницы остаются родными. Дальше документ наполняется через методы SampleDoc.
      
      Использование:
      
          from docx_builder import SampleDoc
          d = SampleDoc("profile.json")
          d.title_block([("Отчет", 22, True), ("Проект N", 16, False)])
          d.section("portrait")
          d.heading("Введение", 1)
          d.body("Текст абзаца.")
          d.table(["№ п/п", "Показатель", "Значение"], rows)
          d.save("out.docx", author="Имя Ф.", title="Отчет")
      """
      import copy
      import json
      import os
      
      from docx import Document
      from docx.enum.section import WD_ORIENT, WD_SECTION
      from docx.enum.text import WD_ALIGN_PARAGRAPH
      from docx.oxml import OxmlElement
      from docx.oxml.ns import qn
      from docx.shared import Cm, Pt
      
      EMU_PER_CM = 360000
      
      
      class SampleDoc:
          """Документ, собираемый по образцу."""
      
          def __init__(self, profile, sample=None):
              profile_dir = None
              if isinstance(profile, str):
                  profile_dir = os.path.dirname(os.path.abspath(profile))
                  with open(profile, encoding="utf-8") as f:
                      profile = json.load(f)
              self.p = profile
              self.sample = self._resolve_sample(sample or profile.get("sample"), profile_dir)
              self.doc = Document(self.sample)
              self.warnings = []
              page = self.p.get("page", {})
              self.pw = Cm(page.get("width_cm", 21.0))
              self.ph = Cm(page.get("height_cm", 29.7))
              m = page.get("margins_cm", [2.5, 2.0, 2.0, 2.0])
              self.m_left, self.m_right, self.m_top, self.m_bottom = [Cm(x) for x in m]
              self.band_portrait = page.get("width_cm", 21.0) - m[0] - m[1]
              self.band_landscape = page.get("height_cm", 29.7) - m[0] - m[1]
              self.band = self.band_portrait
              self.footer_text = None
              self._clear_body()
      
          # ------------------------------------------------------------- внутреннее
      
          @staticmethod
          def _resolve_sample(path, profile_dir):
              """Образец задается профилем, но путь может быть и относительным.
      
              Профиль переезжает между машинами и папками вместе с образцом, поэтому
              относительный путь ищется рядом с самим профилем.
              """
              if not path:
                  raise SystemExit("в профиле не задан образец (ключ sample) и не передан явно")
              if os.path.isabs(path):
                  if os.path.exists(path):
                      return path
                  raise SystemExit("образец не найден: %s" % path)
              for base in ([profile_dir] if profile_dir else []) + [os.getcwd()]:
                  cand = os.path.join(base, path)
                  if os.path.exists(cand):
                      return cand
              raise SystemExit("образец не найден ни рядом с профилем, ни в текущей папке: %s" % path)
      
          def _clear_body(self):
              body = self.doc.element.body
              sect = copy.deepcopy(body.find(qn("w:sectPr")))
              for el in list(body):
                  body.remove(el)
              body.append(sect)
              sec = self.doc.sections[0]
              self._apply_page(sec, landscape=False)
              if self.p.get("title_page", {}).get("no_footer", True):
                  sec.different_first_page_header_footer = True
      
          def _apply_page(self, sec, landscape):
              if landscape:
                  sec.orientation = WD_ORIENT.LANDSCAPE
                  sec.page_width, sec.page_height = self.ph, self.pw
              else:
                  sec.orientation = WD_ORIENT.PORTRAIT
                  sec.page_width, sec.page_height = self.pw, self.ph
              sec.left_margin, sec.right_margin = self.m_left, self.m_right
              sec.top_margin, sec.bottom_margin = self.m_top, self.m_bottom
      
          def _style(self, key, required=False):
              """Имя стиля из профиля; если стиля нет в образце - предупреждение и None."""
              name = self.p.get("styles", {}).get(key)
              if not name:
                  if required:
                      self.warnings.append("в профиле нет стиля %r" % key)
                  return None
              try:
                  self.doc.styles[name]
              except KeyError:
                  self.warnings.append("стиля %r нет в образце (ключ %s)" % (name, key))
                  return None
              return name
      
          def _style_by_id(self, style_id):
              if not style_id:
                  return None
              for s in self.doc.styles:
                  if s.style_id == style_id:
                      return s
              self.warnings.append("стиля таблицы с id %r нет в образце" % style_id)
              return None
      
          # ------------------------------------------------------------- абзацы
      
          def para(self, text="", style_key=None, style_name=None, align=None, size=None,
                   bold=None, keep_next=False, space_after=None):
              name = style_name if style_name else (self._style(style_key) if style_key else None)
              p = self.doc.add_paragraph(style=name)
              if keep_next:
                  p.paragraph_format.keep_with_next = True
              if text:
                  r = p.add_run(text)
                  if size:
                      r.font.size = Pt(size)
                  if bold is not None:
                      r.bold = bold
              if align == "center":
                  p.alignment = WD_ALIGN_PARAGRAPH.CENTER
              elif align == "right":
                  p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
              if space_after is not None:
                  p.paragraph_format.space_after = Pt(space_after)
              return p
      
          def body(self, text):
              return self.para(text, style_key="body")
      
          def callout(self, text, keep_next=True):
              return self.para(text, style_key="callout", keep_next=keep_next)
      
          def bullet(self, text):
              return self.para(text, style_key="bullet")
      
          def heading(self, text, level=1, numbered=True, keep_next=False):
              p = self.para(text, style_key="h%d" % level, keep_next=keep_next)
              if not numbered:
                  self.unnumber(p)
              return p
      
          @staticmethod
          def unnumber(p):
              """Снять нумерацию с абзаца, стиль которого нумерованный (numId=0)."""
              pPr = p._p.get_or_add_pPr()
              numPr = OxmlElement("w:numPr")
              ilvl = OxmlElement("w:ilvl")
              ilvl.set(qn("w:val"), "0")
              numId = OxmlElement("w:numId")
              numId.set(qn("w:val"), "0")
              numPr.append(ilvl)
              numPr.append(numId)
              pPr.append(numPr)
              return p
      
          def title_block(self, lines):
              """Титульные строки: список (текст, кегль, жирный)."""
              for text, size, bold in lines:
                  self.para(text, align="center", size=size, bold=bold)
      
          def empty(self, count=1):
              for _ in range(count):
                  self.para()
      
          def toc(self, levels="1-3", placeholder=None):
              """Поле оглавления. Word собирает его по F9; до этого виден placeholder."""
              placeholder = placeholder or self.p.get("toc", {}).get(
                  "placeholder", "Для сборки оглавления выделить документ целиком и нажать F9.")
              p = self.doc.add_paragraph()
              r = p.add_run()
              beg = OxmlElement("w:fldChar")
              beg.set(qn("w:fldCharType"), "begin")
              r._r.append(beg)
              r2 = p.add_run()
              instr = OxmlElement("w:instrText")
              instr.set(qn("xml:space"), "preserve")
              instr.text = ' TOC \\o "%s" \\h \\z \\u ' % levels
              r2._r.append(instr)
              r3 = p.add_run()
              sep = OxmlElement("w:fldChar")
              sep.set(qn("w:fldCharType"), "separate")
              r3._r.append(sep)
              p.add_run(placeholder)
              r5 = p.add_run()
              end = OxmlElement("w:fldChar")
              end.set(qn("w:fldCharType"), "end")
              r5._r.append(end)
              return p
      
          # ------------------------------------------------------------- секции
      
          def section(self, orientation="portrait", footer=True, footer_text=None):
              """Новая секция с новой страницы. Ориентация меняет полосу набора."""
              sec = self.doc.add_section(WD_SECTION.NEW_PAGE)
              # Свойство наследуется от предыдущей секции: без сброса подвал пропадет
              # на первой странице КАЖДОЙ секции.
              sec.different_first_page_header_footer = False
              landscape = orientation == "landscape"
              self._apply_page(sec, landscape)
              self.band = self.band_landscape if landscape else self.band_portrait
              if footer:
                  self.set_footer(sec, footer_text or self.footer_text)
              return sec
      
          def set_footer(self, sec, text):
              if not text:
                  return
              cfg = self.p.get("footer", {})
              size = cfg.get("size_pt", 8)
              sec.footer.is_linked_to_previous = False
              f = sec.footer
              for p in list(f.paragraphs)[1:]:
                  p._p.getparent().remove(p._p)
              first = f.paragraphs[0]
              first.text = ""
              first.alignment = WD_ALIGN_PARAGRAPH.CENTER
              first.paragraph_format.space_after = Pt(0)
              if cfg.get("line", True):
                  first.add_run("_" * cfg.get("line_len", 77)).font.size = Pt(size)
                  second = f.add_paragraph()
              else:
                  second = first
              second.alignment = WD_ALIGN_PARAGRAPH.CENTER
              second.paragraph_format.space_after = Pt(0)
              second.add_run(text).font.size = Pt(size)
      
          def footer_for_all(self, text):
              """Текст подвала для секций, создаваемых дальше."""
              self.footer_text = text
      
          # ------------------------------------------------------------- таблицы
      
          def counter_width(self, header):
              cfg = self.p.get("tables", {}).get("counter_widths", {})
              if header in cfg:
                  return cfg[header]
              if len(header) > 5:
                  return cfg.get("long_header", 1.4)
              return cfg.get("default", 1.1)
      
          def is_counter(self, header, values):
              marks = self.p.get("tables", {}).get("counter_headers", ["№"])
              if any(header.startswith(m) for m in marks):
                  return True
              if header in self.p.get("tables", {}).get("counter_widths", {}):
                  return True
              if len(header) > 14:
                  return False
              seen = 0
              for v in values:
                  v = str(v).strip()
                  if not v:
                      continue
                  if not v.rstrip(".").isdigit() or len(v) > 4:
                      return False
                  seen += 1
              return seen > 0
      
          def auto_widths(self, header, rows, band=None, min_w=1.6):
              """Ширины по содержимому: колонка-счетчик узкая, остальные делят остаток.
      
              Вес колонки - корень из длины самой длинной ячейки: без корня одна
              многострочная ячейка забирает всю таблицу.
              """
              band = band or self.band
              n = len(header)
              cols = [(header[i], [str(r[i]) if i < len(r) else "" for r in rows]) for i in range(n)]
              widths = [None] * n
              free = band
              flex = []
              for i, (h, vals) in enumerate(cols):
                  if self.is_counter(h, vals):
                      widths[i] = self.counter_width(h)
                      free -= widths[i]
                  else:
                      flex.append(i)
              weights = []
              for i in flex:
                  h, vals = cols[i]
                  longest = max([len(h)] + [len(v) for v in vals]) if vals else len(h)
                  weights.append(min(longest, 400) ** 0.5)
              total = sum(weights) or 1
              for k, i in enumerate(flex):
                  widths[i] = max(min_w, free * weights[k] / total)
              over = sum(widths) - band
              if over > 0.01 and flex:
                  widest = max(flex, key=lambda i: widths[i])
                  widths[widest] -= over
              return widths
      
          def table(self, header, rows, widths=None, style="default", head_fill=None,
                    body_style_key="table_body", head_style_key="table_head", borders=None,
                    repeat=None, head_size=None, merge_rows=None, numbering_dot=None):
              """Таблица в оформлении образца.
      
              style: "default" | "grid" | None (без табличного стиля)
              merge_rows: список (индекс строки данных, текст) - строка-разделитель на всю ширину
              """
              tbl_cfg = self.p.get("tables", {})
              rows = [list(r) for r in rows]
              if numbering_dot is None:
                  numbering_dot = tbl_cfg.get("numbering_dot", True)
              if numbering_dot and header and str(header[0]).startswith("№"):
                  for r in rows:
                      if r and str(r[0]).isdigit():
                          r[0] = "%s." % r[0]
              t = self.doc.add_table(rows=0, cols=len(header))
              style_id = None
              if style == "default":
                  style_id = tbl_cfg.get("default_style_id")
              elif style == "grid":
                  style_id = tbl_cfg.get("grid_style_id")
              st = self._style_by_id(style_id) if style_id else None
              if st is not None:
                  t.style = st
              if widths is None:
                  widths = self.auto_widths(header, rows)
              fill = head_fill if head_fill is not None else tbl_cfg.get("head_fill", "D9D9D9")
              head_name = self._style(head_style_key)
              body_name = self._style(body_style_key)
      
              tr = t.add_row()
              for i, h in enumerate(header):
                  cell = tr.cells[i]
                  if fill:
                      self.shade(cell, fill)
                  p = cell.paragraphs[0]
                  if head_name:
                      p.style = self.doc.styles[head_name]
                  r = p.add_run(str(h))
                  if head_size:
                      r.font.size = Pt(head_size)
                      r.bold = True
              for row in rows:
                  tr = t.add_row()
                  for i, val in enumerate(row):
                      if i >= len(header):
                          break
                      cell = tr.cells[i]
                      self.shade(cell, "auto")
                      p = cell.paragraphs[0]
                      if body_name:
                          p.style = self.doc.styles[body_name]
                      if val:
                          p.add_run(str(val))
              self.fixed_layout(t)
              self.set_widths(t, widths)
              sz = borders if borders is not None else tbl_cfg.get("border_sz", 4)
              if sz:
                  self.set_borders(t, sz)
              if repeat if repeat is not None else tbl_cfg.get("repeat_header", True):
                  self.repeat_header(t)
              for idx, label in (merge_rows or []):
                  self.merge_full_row(t, idx + 1, label, body_name)
              # Две таблицы подряд Word склеивает в одну - разделяем пустым абзацем.
              self.para()
              return t
      
          def merge_full_row(self, table, row_index, label, style_name=None):
              row = table.rows[row_index]
              cell = row.cells[0].merge(row.cells[-1])
              cell.text = ""
              p = cell.paragraphs[0]
              if style_name:
                  p.style = self.doc.styles[style_name]
              p.add_run(label).bold = True
              return cell
      
          @staticmethod
          def shade(cell, fill):
              tcPr = cell._tc.get_or_add_tcPr()
              for old in tcPr.findall(qn("w:shd")):
                  tcPr.remove(old)
              el = OxmlElement("w:shd")
              el.set(qn("w:val"), "clear")
              el.set(qn("w:color"), "auto")
              el.set(qn("w:fill"), fill)
              tcPr.append(el)
      
          @staticmethod
          def set_borders(table, sz=4):
              tblPr = table._tbl.tblPr
              for old in tblPr.findall(qn("w:tblBorders")):
                  tblPr.remove(old)
              b = OxmlElement("w:tblBorders")
              for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
                  e = OxmlElement("w:" + edge)
                  e.set(qn("w:val"), "single")
                  e.set(qn("w:sz"), str(sz))
                  e.set(qn("w:space"), "0")
                  e.set(qn("w:color"), "auto")
                  b.append(e)
              tblPr.append(b)
      
          @staticmethod
          def fixed_layout(table):
              tblPr = table._tbl.tblPr
              for old in tblPr.findall(qn("w:tblLayout")):
                  tblPr.remove(old)
              el = OxmlElement("w:tblLayout")
              el.set(qn("w:type"), "fixed")
              tblPr.append(el)
      
          @staticmethod
          def repeat_header(table):
              trPr = table.rows[0]._tr.get_or_add_trPr()
              trPr.append(OxmlElement("w:tblHeader"))
      
          @staticmethod
          def set_widths(table, widths_cm):
              table.autofit = False
              for i, w in enumerate(widths_cm):
                  if i < len(table.columns):
                      table.columns[i].width = Cm(w)
              for row in table.rows:
                  for i, cell in enumerate(row.cells):
                      if i < len(widths_cm):
                          cell.width = Cm(widths_cm[i])
      
          def rule_table(self, cols=3, border_sz=24):
              """Декоративная таблица-линейка (часто стоит на титуле образцов)."""
              t = self.doc.add_table(rows=1, cols=cols)
              self.set_widths(t, [self.band / float(cols)] * cols)
              self.fixed_layout(t)
              b = OxmlElement("w:tblBorders")
              e = OxmlElement("w:bottom")
              e.set(qn("w:val"), "single")
              e.set(qn("w:sz"), str(border_sz))
              e.set(qn("w:space"), "0")
              e.set(qn("w:color"), "auto")
              b.append(e)
              t._tbl.tblPr.append(b)
              self.para()
              return t
      
          def plain_table(self, rows, widths=None, bold_first_row=False):
              """Таблица без стиля и без границ (шапка документа, блок подписей)."""
              if not rows:
                  return None
              t = self.doc.add_table(rows=0, cols=len(rows[0]))
              for ri, row in enumerate(rows):
                  tr = t.add_row()
                  for i, val in enumerate(row):
                      p = tr.cells[i].paragraphs[0]
                      r = p.add_run(str(val))
                      if bold_first_row and ri == 0:
                          r.bold = True
              self.set_widths(t, widths or [self.band / float(len(rows[0]))] * len(rows[0]))
              self.para()
              return t
      
          # ------------------------------------------------------------- сохранение
      
          def save(self, path, author=None, title=None):
              if author:
                  self.doc.core_properties.author = author
                  self.doc.core_properties.last_modified_by = author
              if title:
                  self.doc.core_properties.title = title
              self.doc.save(path)
              return path
      
    • inspect_sample.py 17.9 KB
      # -*- coding: utf-8 -*-
      """Снять оформление с DOCX-образца: отчет для человека и профиль для сборки.
      
          python inspect_sample.py sample.docx                  - отчет в консоль
          python inspect_sample.py sample.docx --json p.json     - плюс профиль сборки
          python inspect_sample.py sample.docx --paragraphs 80   - показать больше абзацев
      
      Отчет отвечает на вопросы: какие стили реально используются и на каком тексте,
      как устроены секции и колонтитулы, чем отличаются таблицы (стиль, ширины, заливка
      шапки, границы, повтор шапки, стили абзацев в ячейках).
      """
      import argparse
      import json
      import os
      import re
      import sys
      import zipfile
      
      from docx import Document
      from docx.enum.section import WD_ORIENT
      
      W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
      
      
      def cm(v):
          return round(v.cm, 2) if v is not None else None
      
      
      def para_num(p):
          numPr = p._p.find(".//" + W + "numPr")
          if numPr is None:
              return None
          numId = numPr.find(W + "numId")
          return numId.get(W + "val") if numId is not None else "?"
      
      
      def used_styles(doc):
          """Стиль -> сколько раз, где (текст или таблица) и примеры текста.
      
          Абзацы внутри таблиц в doc.paragraphs не попадают, а именно там живут стили
          шапки и тела таблиц - их надо собирать отдельно.
          """
          out = {}
      
          def add(p, where, head=False):
              if not p.text.strip():
                  return
              name = p.style.name if p.style is not None else "(нет стиля)"
              rec = out.setdefault(name, {"count": 0, "samples": [], "unnumbered": 0,
                                          "where": set(), "table_head": 0})
              rec["count"] += 1
              rec["where"].add(where)
              if head:
                  rec["table_head"] += 1
              if para_num(p) == "0":
                  rec["unnumbered"] += 1
              if len(rec["samples"]) < 4:
                  rec["samples"].append(p.text.strip()[:58])
      
          for p in doc.paragraphs:
              add(p, "текст")
          for t in doc.tables:
              for ri, row in enumerate(t.rows):
                  for c in row.cells:
                      for p in c.paragraphs:
                          add(p, "таблица", head=(ri == 0))
          return out
      
      
      def style_params(path):
          """Параметры пользовательских стилей прямо из styles.xml."""
          with zipfile.ZipFile(path) as z:
              xml = z.read("word/styles.xml").decode("utf-8")
          out = {}
          for m in re.finditer(r'<w:style [^>]*w:styleId="([^"]+)"[^>]*>(.*?)</w:style>', xml, re.S):
              sid, body = m.group(1), m.group(2)
              nm = re.search(r'<w:name w:val="([^"]+)"', body)
              if not nm:
                  continue
              info = {"id": sid}
              f = re.search(r'<w:rFonts[^>]*w:ascii="([^"]+)"', body)
              if f:
                  info["font"] = f.group(1)
              s = re.search(r'<w:sz w:val="(\d+)"', body)
              if s:
                  info["size_pt"] = int(s.group(1)) / 2.0
              if "<w:b/>" in body:
                  info["bold"] = True
              sp = re.search(r'<w:spacing([^>]*)/>', body)
              if sp:
                  info["spacing"] = sp.group(1).strip()
              ind = re.search(r'<w:ind([^>]*)/>', body)
              if ind:
                  info["indent"] = ind.group(1).strip()
              jc = re.search(r'<w:jc w:val="(\w+)"', body)
              if jc:
                  info["align"] = jc.group(1)
              num = re.search(r'<w:numId w:val="(\d+)"', body)
              if num:
                  info["numId"] = num.group(1)
              lvl = re.search(r'<w:outlineLvl w:val="(\d+)"', body)
              if lvl:
                  info["outline"] = int(lvl.group(1))
              based = re.search(r'<w:basedOn w:val="([^"]+)"', body)
              if based:
                  info["based_on_id"] = based.group(1)
              out[nm.group(1)] = info
          # Уровень структуры наследуется от базового стиля: без этого заголовок
          # третьего уровня выглядит как обычный текст.
          by_id = {v["id"]: k for k, v in out.items()}
          for name, info in out.items():
              seen = 0
              cur = info
              while "outline" not in cur and cur.get("based_on_id") and seen < 6:
                  parent = out.get(by_id.get(cur["based_on_id"], ""), None)
                  if not parent:
                      break
                  if "outline" in parent:
                      info["outline_inherited"] = parent["outline"]
                      break
                  cur = parent
                  seen += 1
          return out
      
      
      def table_info(doc):
          rows = []
          for i, t in enumerate(doc.tables, 1):
              pr = t._tbl.find(W + "tblPr")
              st = pr.find(W + "tblStyle") if pr is not None else None
              layout = pr.find(W + "tblLayout") if pr is not None else None
              borders = pr.find(W + "tblBorders") if pr is not None else None
              head_cell = t.rows[0].cells[0]
              shd = re.search(r'<w:shd[^>]*w:fill="([0-9A-Fa-f]{6}|auto)"', head_cell._tc.xml)
              widths = [cm(c.width) for c in t.rows[0].cells]
              cell_styles = []
              for ri in range(min(2, len(t.rows))):
                  names = []
                  for c in t.rows[ri].cells[:3]:
                      for p in c.paragraphs[:1]:
                          names.append(p.style.name if p.style is not None else "-")
                  cell_styles.append("/".join(names))
              border_sz = None
              if borders is not None:
                  top = borders.find(W + "top")
                  border_sz = top.get(W + "sz") if top is not None else None
              full_width = []
              for r in t.rows[1:]:
                  texts = [c.text.strip() for c in r.cells]
                  if texts and len(set(texts)) == 1 and texts[0]:
                      full_width.append(texts[0])
              rows.append({
                  "index": i,
                  "full_width_rows": full_width,
                  "style_id": st.get(W + "val") if st is not None else None,
                  "style_name": (t.style.name if t.style is not None else None),
                  "cols": len(t.columns),
                  "rows": len(t.rows),
                  "layout": layout.get(W + "type") if layout is not None else None,
                  "head_fill": shd.group(1) if shd else None,
                  "border_sz": border_sz,
                  "repeat_header": t.rows[0]._tr.find(".//" + W + "tblHeader") is not None,
                  "widths_cm": widths,
                  "head_text": " | ".join(c.text.strip()[:18] for c in t.rows[0].cells[:4]),
                  "cell_styles": cell_styles,
              })
          return rows
      
      
      def hf_texts(path):
          """Текст колонтитулов прямо из файла.
      
          Через python-docx он часто не виден: в шаблонах колонтитул обернут в sdt,
          и section.footer.paragraphs оказывается пустым.
          """
          out = {}
          with zipfile.ZipFile(path) as z:
              for name in z.namelist():
                  if not re.match(r"word/(header|footer)\d*\.xml", name):
                      continue
                  xml = z.read(name).decode("utf-8")
                  parts = re.findall(r"<w:t[^>]*>([^<]*)</w:t>", xml)
                  txt = " ".join(p for p in parts if p.strip())
                  if txt.strip():
                      out[os.path.basename(name)] = txt.strip()
          return out
      
      
      def sections_info(doc):
          out = []
          for i, s in enumerate(doc.sections, 1):
              foot = " / ".join(p.text.strip() for p in s.footer.paragraphs if p.text.strip())
              head = " / ".join(p.text.strip() for p in s.header.paragraphs if p.text.strip())
              out.append({
                  "index": i,
                  "orientation": "landscape" if s.orientation == WD_ORIENT.LANDSCAPE else "portrait",
                  "page_cm": [cm(s.page_width), cm(s.page_height)],
                  "margins_cm": [cm(s.left_margin), cm(s.right_margin), cm(s.top_margin), cm(s.bottom_margin)],
                  "first_page_differs": s.different_first_page_header_footer,
                  "footer": foot,
                  "header": head,
              })
          return out
      
      
      def guess_profile(path, styles, tables, sections, params, hf=None):
          """Черновой профиль сборки. Требует ручной доводки под конкретный жанр."""
          def outline_of(name):
              info = params.get(name, {})
              if "outline" in info:
                  return info["outline"]
              return info.get("outline_inherited")
      
          def head_style(level):
              cands = [n for n in styles if outline_of(n) == level]
              if not cands:
                  return None
              return max(cands, key=lambda n: styles[n]["count"])
      
          # Стиль по умолчанию берем только если своих стилей у образца нет: в таблицах
          # он лидирует по частоте за счет титульных блоков и забивает настоящие стили.
          DEFAULTS = {"normal", "обычный", "default paragraph font"}
      
          def rank(cands):
              own = [c for c in cands if c[1].lower() not in DEFAULTS]
              pool = own or cands
              pool.sort(reverse=True)
              return pool[0][1] if pool else None
      
          def table_style(head):
              """Стиль абзацев в шапке таблиц либо в их теле."""
              cands = []
              for name, rec in styles.items():
                  if "таблица" not in rec["where"]:
                      continue
                  in_head = rec["table_head"]
                  if head and in_head:
                      cands.append((in_head, name))
                  elif not head and rec["count"] > in_head:
                      cands.append((rec["count"] - in_head, name))
              return rank(cands)
      
          def body_style():
              cands = [(rec["count"], n) for n, rec in styles.items()
                       if "текст" in rec["where"] and outline_of(n) is None
                       and not params.get(n, {}).get("numId")]
              return rank(cands)
      
          def bullet_style():
              for name, info in params.items():
                  if info.get("numId") and outline_of(name) is None and "список" in name.lower():
                      return name
              return None
      
          # второй по частоте стиль тела таблиц: часто отличается кеглем
          table_bodies = [n for n, rec in styles.items()
                          if "таблица" in rec["where"] and rec["count"] > rec["table_head"]
                          and n.lower() not in DEFAULTS]
          table_bodies.sort(key=lambda n: -styles[n]["count"])
      
          body = body_style()
          heads = [head_style(0), head_style(1), head_style(2)]
          prof = {
              "sample": path,
              "page": {
                  "width_cm": sections[0]["page_cm"][0] if sections else 21.0,
                  "height_cm": sections[0]["page_cm"][1] if sections else 29.7,
                  "margins_cm": sections[0]["margins_cm"] if sections else [2.5, 2.0, 2.0, 2.0],
              },
              "styles": {
                  "body": body,
                  "h1": heads[0],
                  "h2": heads[1],
                  "h3": heads[2],
                  "callout": next((n for n, rec in styles.items()
                                   if "текст" in rec["where"] and outline_of(n) is None
                                   and params.get(n, {}).get("bold") and n != body), None),
                  "bullet": bullet_style(),
                  "table_head": table_style(head=True),
                  "table_body": table_bodies[0] if table_bodies else None,
                  "table_body_alt": table_bodies[1] if len(table_bodies) > 1 else None,
              },
              "tables": {
                  "default_style_id": None,
                  "grid_style_id": None,
                  "head_fill": None,
                  "head_fill_alt": None,
                  "border_sz": 4,
                  "repeat_header": True,
                  "numbering_dot": True,
                  "counter_headers": ["№"],
                  "counter_widths": {"default": 1.1, "long_header": 1.4},
              },
              "footer": {"line": True, "size_pt": 8, "line_len": 77},
              "title_page": {"separate_section": True, "no_footer": True},
              "toc": {"placeholder": "Для сборки оглавления выделить документ целиком и нажать F9."},
              "headings_map": {"h1": [], "h1_unnumbered": [], "h2": [], "h3": [], "callout": []},
          }
          fills = [t["head_fill"] for t in tables if t["head_fill"] and t["head_fill"] != "auto"]
          if fills:
              prof["tables"]["head_fill"] = max(set(fills), key=fills.count)
              others = [f for f in fills if f != prof["tables"]["head_fill"]]
              if others:
                  prof["tables"]["head_fill_alt"] = max(set(others), key=others.count)
          ids = [t["style_id"] for t in tables if t["style_id"]]
          if ids:
              prof["tables"]["default_style_id"] = max(set(ids), key=ids.count)
              rest = [i for i in ids if i != prof["tables"]["default_style_id"]]
              if rest:
                  prof["tables"]["grid_style_id"] = max(set(rest), key=rest.count)
          szs = [int(t["border_sz"]) for t in tables if t["border_sz"]]
          if szs:
              prof["tables"]["border_sz"] = max(set(szs), key=szs.count)
          if any(t["full_width_rows"] for t in tables):
              marks = sorted({m for t in tables for m in t["full_width_rows"]})
              prof["tables"]["full_width_rows"] = marks
          foot = next((s["footer"] for s in sections if s["footer"]), "")
          if not foot and hf:
              foot = next((v for k, v in sorted(hf.items()) if k.startswith("footer")), "")
          if foot:
              # В подвале образца стоит название чужого документа: заменяем его подстановкой.
              prof["footer"]["template"] = re.sub(r"\s{2,}", " ", re.sub(chr(0xab) + r'[^' + chr(0xbb) + r']*' + chr(0xbb), chr(0xab) + '{title}' + chr(0xbb), foot)).strip("_ ")
          return prof
      
      
      def main():
          # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError
          # везде, где консоль не в UTF-8: сборочный агент, чужая локаль.
          sys.stdout.reconfigure(encoding='utf-8')
          sys.stderr.reconfigure(encoding='utf-8')
          ap = argparse.ArgumentParser()
          ap.add_argument("sample")
          ap.add_argument("--json", help="куда записать черновой профиль")
          ap.add_argument("--paragraphs", type=int, default=40, help="сколько абзацев показать")
          a = ap.parse_args()
      
          doc = Document(a.sample)
          params = style_params(a.sample)
          styles = used_styles(doc)
          tables = table_info(doc)
          sections = sections_info(doc)
          hf = hf_texts(a.sample)
      
          print("=== СЕКЦИИ ===")
          for s in sections:
              print("  %d) %s %sx%s см, поля L%s R%s T%s B%s, первая страница отдельно: %s"
                    % (s["index"], s["orientation"], s["page_cm"][0], s["page_cm"][1],
                       s["margins_cm"][0], s["margins_cm"][1], s["margins_cm"][2], s["margins_cm"][3],
                       s["first_page_differs"]))
              if s["header"]:
                  print("       верхний колонтитул: %s" % s["header"][:70])
              if s["footer"]:
                  print("       нижний колонтитул: %s" % s["footer"][:70])
      
          print()
          print("=== СТИЛИ АБЗАЦЕВ В ДЕЛЕ ===")
          for name, rec in sorted(styles.items(), key=lambda kv: -kv[1]["count"]):
              info = params.get(name, {})
              bits = ["в " + "+".join(sorted(rec["where"]))]
              for k in ("font", "size_pt", "bold", "align", "numId", "outline", "outline_inherited"):
                  if k in info:
                      bits.append("%s=%s" % (k, info[k]))
              if rec["table_head"]:
                  bits.append("шапка таблиц x%d" % rec["table_head"])
              if rec["unnumbered"]:
                  bits.append("без номера x%d" % rec["unnumbered"])
              print("  %-30s x%-3d %s" % (name[:30], rec["count"], ", ".join(bits)))
              for s in rec["samples"]:
                  print("        %s" % s)
      
          print()
          print("=== ТАБЛИЦЫ ===")
          for t in tables:
              print("  %2d) %dx%d стиль=%s заливка шапки=%s границы sz=%s повтор шапки=%s"
                    % (t["index"], t["rows"], t["cols"], t["style_id"], t["head_fill"],
                       t["border_sz"], t["repeat_header"]))
              print("       ширины: %s" % t["widths_cm"])
              print("       шапка: %s" % t["head_text"])
              print("       стили ячеек: %s" % " ; ".join(t["cell_styles"]))
      
          print()
          print("=== ПОРЯДОК БЛОКОВ (первые %d) ===" % a.paragraphs)
          body = doc.element.body
          shown = 0
          ti = 0
          for child in body.iterchildren():
              tag = child.tag.split("}")[-1]
              if tag == "tbl":
                  ti += 1
                  if ti <= len(tables):
                      print("  [таблица %d] %dx%d %s" % (ti, tables[ti - 1]["rows"],
                                                         tables[ti - 1]["cols"],
                                                         tables[ti - 1]["head_text"][:46]))
                  continue
              if tag != "p" or shown >= a.paragraphs:
                  continue
              for p in doc.paragraphs:
                  if p._p is child:
                      if p.text.strip():
                          shown += 1
                          print("  %-26s %s" % ((p.style.name if p.style else "-")[:26],
                                                p.text.strip()[:74]))
                      break
      
          if a.json:
              prof = guess_profile(a.sample, styles, tables, sections, params, hf)
              with open(a.json, "w", encoding="utf-8") as f:
                  json.dump(prof, f, ensure_ascii=False, indent=2)
              print()
              print("Черновой профиль записан: %s" % a.json)
              print("Проверить руками по отчету выше: styles.h1/h2/h3, что считать основным стилем "
                    "таблиц (table_body против table_body_alt), tables.default_style_id и "
                    "grid_style_id, headings_map, footer.template.")
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • md_to_docx_sample.py 8.8 KB
      # -*- coding: utf-8 -*-
      """Markdown -> DOCX в оформлении образца.
      
          python md_to_docx_sample.py doc.md --profile profile.json --out doc.docx --title "Название"
      
      Разметка markdown:
          | ... |                  таблица (первая строка - шапка)
          <!-- landscape -->       дальше альбомная секция
          <!-- portrait -->        дальше книжная секция
          **Текст**                титульная строка (до первого заголовка документа)
          - Пункт                  маркированный список
      
      Уровень заголовка определяется профилем (headings_map): точным совпадением текста,
      регулярным выражением или по следующему абзацу. Все, что не опознано, - обычный текст.
      Профиль правится под жанр документа, скрипт универсален.
      """
      import argparse
      import io
      import json
      import os
      import re
      import sys
      
      sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
      from docx_builder import SampleDoc
      
      SEP = re.compile(r"^\|[\s\-:|]*-[\s\-:|]*\|$")
      ORIENT = re.compile(r"^<!--\s*(landscape|portrait)\s*-->$")
      BOLD_LINE = re.compile(r"^\*\*(.+)\*\*$")
      
      
      def parse_md(text):
          """Разбор на блоки: ("table", rows) | ("orient", value) | ("text", line)."""
          lines = text.split("\n")
          blocks = []
          i = 0
          while i < len(lines):
              ln = lines[i]
              if ln.startswith("|"):
                  rows = []
                  while i < len(lines) and lines[i].startswith("|"):
                      if not SEP.match(lines[i].strip()):
                          rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
                      i += 1
                  blocks.append(("table", rows))
                  continue
              m = ORIENT.match(ln.strip())
              if m:
                  blocks.append(("orient", m.group(1)))
                  i += 1
                  continue
              if ln.strip():
                  blocks.append(("text", ln.strip()))
              i += 1
          return blocks
      
      
      class Converter:
          def __init__(self, profile, title, sample=None):
              self.d = SampleDoc(profile, sample=sample)
              self.p = self.d.p
              self.hm = self.p.get("headings_map", {})
              self.title = title
              tmpl = self.p.get("footer", {}).get("template", "{title}")
              self.d.footer_for_all(tmpl.format(title=title) if title else None)
              self.in_title_zone = True
              self.prev_h1 = None
      
          # --- распознавание роли строки
      
          def role(self, text, next_text):
              if text in self.hm.get("h1_unnumbered", []):
                  return "h1_unnumbered"
              if text in self.hm.get("h1", []):
                  return "h1"
              if text in self.hm.get("h2", []):
                  return "h2"
              if text in self.hm.get("h3", []):
                  return "h3"
              if text in self.hm.get("callout", []):
                  return "callout"
              for rx, role in (self.hm.get("regex") or {}).items():
                  if re.match(rx, text):
                      return role
              after = self.hm.get("h2_after_h1", [])
              if self.prev_h1 in after:
                  return "h2_once"
              for prefix in self.hm.get("h3_if_next_starts_with", []):
                  if next_text.startswith(prefix):
                      return "h3"
              if text.startswith("- "):
                  return "bullet"
              return "body"
      
          def table_kind(self, header):
              """Как оформить таблицу: ключ профиля table_rules по шапке."""
              rules = self.p.get("table_rules", {})
              key = " | ".join(header)
              for name, cfg in rules.items():
                  if name == "default":
                      continue
                  match = cfg.get("match_header")
                  if match and (key == match or header[0] == match):
                      return cfg
              return rules.get("default", {})
      
          def emit_table(self, rows):
              if not rows:
                  return
              header, body = rows[0], rows[1:]
              if self.in_title_zone:
                  if len(header) >= 3 and not any("".join(r).strip() for r in rows):
                      self.d.rule_table(cols=len(header))
                  else:
                      self.d.plain_table(rows, bold_first_row=True)
                  return
              cfg = self.table_kind(header)
              merge = []
              clean = []
              markers = self.p.get("tables", {}).get("full_width_rows", [])
              for r in body:
                  if r and len(set(r)) == 1 and r[0] in markers:
                      merge.append((len(clean), r[0]))
                      clean.append([""] * len(header))
                  else:
                      clean.append(r)
              self.d.table(
                  header, clean,
                  style=cfg.get("style", "default"),
                  head_fill=cfg.get("head_fill"),
                  body_style_key=cfg.get("body_style_key", "table_body"),
                  borders=cfg.get("border_sz"),
                  repeat=cfg.get("repeat_header"),
                  head_size=cfg.get("head_size"),
                  widths=cfg.get("widths"),
                  merge_rows=merge,
              )
      
          def run(self, blocks, out, author):
              def next_text(pos):
                  for k in range(pos + 1, len(blocks)):
                      if blocks[k][0] == "text":
                          return blocks[k][1]
                  return ""
      
              for pos, (kind, val) in enumerate(blocks):
                  if kind == "orient":
                      self.d.section(val)
                      continue
                  if kind == "table":
                      self.emit_table(val)
                      continue
      
                  text = val
                  bold = BOLD_LINE.match(text)
                  if bold and self.in_title_zone:
                      self.d.para(bold.group(1), align="center",
                                  size=self.p.get("title_page", {}).get("big_pt", 22), bold=True)
                      continue
      
                  head = text[2:].strip() if text.startswith("- ") else None
                  if head and head in self.hm.get("h1_unnumbered", []):
                      if self.in_title_zone:
                          self.in_title_zone = False
                          self.d.section("portrait")
                      self.d.heading(head, 1, numbered=False)
                      if head in self.hm.get("toc_after", []):
                          self.d.toc()
                      continue
      
                  if self.in_title_zone:
                      cfg = self.p.get("title_page", {})
                      size = cfg.get("big_pt", 22) if len(text) < cfg.get("big_max_len", 90) \
                          else cfg.get("small_pt", 16)
                      self.d.para(text, align="center", size=size)
                      continue
      
                  role = self.role(text, next_text(pos))
                  if role == "h1_unnumbered":
                      self.d.heading(text, 1, numbered=False)
                      if text in self.hm.get("toc_after", []):
                          self.d.toc()
                  elif role == "h1":
                      self.d.heading(text, 1)
                      self.prev_h1 = text
                  elif role == "h2":
                      self.d.heading(text, 2)
                  elif role == "h2_once":
                      self.d.heading(text, 2)
                      self.prev_h1 = None
                  elif role == "h3":
                      self.d.heading(text, 3)
                  elif role == "card":
                      self.d.heading(text, 2, keep_next=True)
                  elif role == "callout":
                      self.d.callout(text)
                  elif role == "bullet":
                      self.d.bullet(text[2:].strip())
                  else:
                      self.d.body(text)
      
              self.d.save(out, author=author, title=self.title)
              return self.d
      
      
      def main():
          # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError
          # везде, где консоль не в UTF-8: сборочный агент, чужая локаль.
          sys.stdout.reconfigure(encoding='utf-8')
          sys.stderr.reconfigure(encoding='utf-8')
          ap = argparse.ArgumentParser()
          ap.add_argument("md")
          ap.add_argument("--profile", required=True)
          ap.add_argument("--out")
          ap.add_argument("--title", default="")
          ap.add_argument("--sample", help="образец, если надо переопределить путь из профиля")
          ap.add_argument("--author")
          a = ap.parse_args()
      
          out = a.out or (a.md[:-3] + ".docx")
          with io.open(a.md, encoding="utf-8") as f:
              blocks = parse_md(f.read())
          conv = Converter(a.profile, a.title, sample=a.sample)
          d = conv.run(blocks, out, a.author)
          print("Готово: %s" % out)
          print("  таблиц: %d, секций: %d" % (len(d.doc.tables), len(d.doc.sections)))
          for w in d.warnings:
              print("  ВНИМАНИЕ: %s" % w)
          print("  Дальше: verify_result.py (сверка, обновление оглавления, картинки страниц).")
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • verify_result.py 9.2 KB
      # -*- coding: utf-8 -*-
      """Проверка собранного DOCX: содержимое, оформление, поля Word, картинки страниц.
      
          python verify_result.py out.docx --md out.md            - сверка с исходником
          python verify_result.py out.docx --sample sample.docx   - сравнить оформление с образцом
          python verify_result.py out.docx --update-fields        - собрать оглавление (нужен Word)
          python verify_result.py out.docx --png 1,2,9            - картинки страниц для глаз
      
      Сверка с markdown ловит потерю строк и склейку таблиц: число таблиц и строк в DOCX
      должно совпадать с числом блоков и строк в исходнике.
      Картинки страниц - единственный способ увидеть реальную верстку: разрывы, вылезание
      таблиц за поля, пропавшие колонтитулы.
      """
      import argparse
      import glob
      import io
      import os
      import re
      import subprocess
      import sys
      
      from docx import Document
      from docx.enum.section import WD_ORIENT
      
      SEP = re.compile(r"^\|[\s\-:|]*-[\s\-:|]*\|$")
      W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
      # Символы заданы кодами, чтобы сам файл детектора их не содержал.
      BAD = {"\u2014": "тире длинное", "\u2013": "тире короткое",
             "\u0451": "е с точками", "\u0401": "Е с точками",
             "\u2026": "многоточие", "\u2192": "стрелка"}
      
      PS_UPDATE = r'''
      $OutputEncoding = [Console]::OutputEncoding = [Text.Encoding]::UTF8
      $ErrorActionPreference = "Stop"
      $was = Get-Process winword -ErrorAction SilentlyContinue
      $word = New-Object -ComObject Word.Application
      $word.Visible = $false
      $word.DisplayAlerts = 0
      $doc = $word.Documents.Open("{docx}", $false, $false)
      $doc.Fields.Update() | Out-Null
      if ($doc.TablesOfContents.Count -gt 0) {{ $doc.TablesOfContents.Item(1).Update() | Out-Null }}
      $doc.Repaginate()
      "страниц: $($doc.ComputeStatistics(2))"
      $doc.Save()
      {export}
      $doc.Close($false)
      if (-not $was) {{ $word.Quit() }}
      '''
      
      
      def md_stats(md_path):
          tables = rows = 0
          in_tab = False
          with io.open(md_path, encoding="utf-8") as f:
              for ln in f.read().split("\n"):
                  if ln.startswith("|"):
                      if not in_tab:
                          tables += 1
                          in_tab = True
                      if not SEP.match(ln.strip()):
                          rows += 1
                  else:
                      in_tab = False
          return tables, rows
      
      
      def docx_report(path):
          d = Document(path)
          tables = [(len(t.columns), len(t.rows)) for t in d.tables]
          rows = sum(r for _, r in tables)
          styles = {}
          for p in d.paragraphs:
              if p.text.strip():
                  nm = p.style.name if p.style is not None else "(нет стиля)"
                  styles[nm] = styles.get(nm, 0) + 1
          secs = []
          for s in d.sections:
              band = (s.page_width.cm - s.left_margin.cm - s.right_margin.cm)
              foot = any(p.text.strip() for p in s.footer.paragraphs)
              secs.append({"orient": "альб" if s.orientation == WD_ORIENT.LANDSCAPE else "кн",
                           "band": round(band, 1), "footer": foot,
                           "first_differs": s.different_first_page_header_footer})
          over = []
          for i, t in enumerate(d.tables, 1):
              wsum = sum((c.width.cm if c.width else 0) for c in t.rows[0].cells)
              if wsum > max(s["band"] for s in secs) + 0.1:
                  over.append((i, round(wsum, 1)))
          text = "\n".join([p.text for p in d.paragraphs] +
                           [c.text for t in d.tables for r in t.rows for c in r.cells])
          bad = ["%s x%d" % (lbl, text.count(ch)) for ch, lbl in BAD.items() if text.count(ch)]
          shaded = sum(1 for t in d.tables
                       if re.search(r'w:fill="(?!auto)[0-9A-Fa-f]{6}"', t.rows[0].cells[0]._tc.xml))
          repeat = sum(1 for t in d.tables if t.rows[0]._tr.find(".//" + W + "tblHeader") is not None)
          return {"doc": d, "tables": tables, "rows": rows, "styles": styles, "sections": secs,
                  "over": over, "bad": bad, "shaded": shaded, "repeat": repeat}
      
      
      def run_word(docx, export_pdf=None):
          export = ""
          if export_pdf:
              export = '$doc.ExportAsFixedFormat("%s", 17)' % export_pdf.replace("\\", "\\\\")
          script = PS_UPDATE.format(docx=docx.replace("\\", "\\\\"), export=export)
          exe = "pwsh"
          try:
              out = subprocess.run([exe, "-NoProfile", "-Command", script],
                                   capture_output=True, text=True, timeout=600,
                                   encoding="utf-8", errors="replace")
          except FileNotFoundError:
              out = subprocess.run(["powershell", "-NoProfile", "-Command", script],
                                   capture_output=True, text=True, timeout=600,
                                   encoding="utf-8", errors="replace")
          if out.returncode != 0:
              print("  Word недоступен или вернул ошибку:")
              print("  " + (out.stderr or "").strip()[:400])
              return None
          return (out.stdout or "").strip()
      
      
      def render_png(pdf, pages, outdir):
          try:
              import pymupdf
          except ImportError:
              print("  Нет pymupdf: pip install pymupdf - без него картинок страниц не будет.")
              return []
          os.makedirs(outdir, exist_ok=True)
          d = pymupdf.open(pdf)
          made = []
          for n in pages:
              if 1 <= n <= d.page_count:
                  f = os.path.join(outdir, "page_%02d.png" % n)
                  d[n - 1].get_pixmap(dpi=110).save(f)
                  made.append(f)
          return made
      
      
      def main():
          # Вывод содержит кириллицу. Без явного переключения печать падает с UnicodeEncodeError
          # везде, где консоль не в UTF-8: сборочный агент, чужая локаль.
          sys.stdout.reconfigure(encoding='utf-8')
          sys.stderr.reconfigure(encoding='utf-8')
          ap = argparse.ArgumentParser()
          ap.add_argument("docx")
          ap.add_argument("--md", help="исходный markdown для сверки количества таблиц и строк")
          ap.add_argument("--sample", help="образец, с оформлением которого сравнить")
          ap.add_argument("--update-fields", action="store_true", help="собрать оглавление через Word")
          ap.add_argument("--png", help="номера страниц через запятую: 1,2,9")
          ap.add_argument("--outdir", default="preview", help="куда класть картинки страниц")
          a = ap.parse_args()
      
          rep = docx_report(a.docx)
          print("=== %s ===" % os.path.basename(a.docx))
          print("  таблиц %d, строк в таблицах %d" % (len(rep["tables"]), rep["rows"]))
          parts = []
          for i, s in enumerate(rep["sections"]):
              note = ""
              if not s["footer"]:
                  note = " титульная, без подвала" if (i == 0 and s["first_differs"]) else " БЕЗ ПОДВАЛА"
              parts.append("%s полоса %s см%s" % (s["orient"], s["band"], note))
          print("  секции: %s" % ", ".join(parts))
          print("  шапок с заливкой %d из %d, с повтором при переносе %d"
                % (rep["shaded"], len(rep["tables"]), rep["repeat"]))
          print("  стили: %s" % ", ".join("%s=%d" % kv for kv in sorted(rep["styles"].items())))
          if rep["over"]:
              print("  ТАБЛИЦЫ ШИРЕ ПОЛОСЫ: %s" % rep["over"])
          if rep["bad"]:
              print("  ЗАПРЕЩЕННЫЕ СИМВОЛЫ: %s" % ", ".join(rep["bad"]))
      
          if a.md:
              t_md, r_md = md_stats(a.md)
              ok = (t_md == len(rep["tables"]) and r_md == rep["rows"])
              print("  сверка с markdown: таблиц %d/%d, строк %d/%d - %s"
                    % (t_md, len(rep["tables"]), r_md, rep["rows"], "СОВПАДАЕТ" if ok else "РАСХОЖДЕНИЕ"))
              if not ok:
                  print("    Обычная причина: две таблицы подряд без абзаца между ними "
                        "склеиваются Word в одну.")
      
          if a.sample:
              s = docx_report(a.sample)
              print("  образец: таблиц %d, стили: %s"
                    % (len(s["tables"]), ", ".join(sorted(s["styles"]))[:120]))
              only_here = set(rep["styles"]) - set(s["styles"])
              if only_here:
                  print("    стили, которых нет в образце: %s" % ", ".join(sorted(only_here)))
      
          pdf = None
          if a.update_fields or a.png:
              pdf = os.path.splitext(a.docx)[0] + ".preview.pdf" if a.png else None
              out = run_word(os.path.abspath(a.docx), os.path.abspath(pdf) if pdf else None)
              if out:
                  print("  Word: %s" % out)
      
          if a.png and pdf and os.path.exists(pdf):
              pages = [int(x) for x in a.png.split(",") if x.strip().isdigit()]
              made = render_png(pdf, pages, a.outdir)
              for f in made:
                  print("  картинка: %s" % f)
              print("  Посмотреть картинки глазами обязательно: верстка проверяется только так.")
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • SKILL.md 13.3 KB
    ---
    name: docx-from-sample
    description: "Создать новый DOCX в оформлении готового образца: взять чужой документ как шаблон стилей и наполнить своими данными, сохранив титул, заголовки с нумерацией, стили таблиц, ширины столбцов, заливку шапок, колонтитулы, книжные и альбомные секции. Используй когда просят сделать документ по образцу, в том же формате, как в примере, по шаблону заказчика, сохранить оформление существующего документа"
    argument-hint: "<образец.docx> [материал.md] [--profile profile.json]"
    allowed-tools:
      - Bash
      - PowerShell
      - Read
      - Write
      - Edit
      - Glob
    ---
    
    # /docx-from-sample - новый документ в оформлении образца
    
    Берет готовый DOCX как источник оформления и собирает новый документ с тем же видом,
    но с другим содержанием. Оформление не воспроизводится вручную, а наследуется: образец
    открывается как документ, его тело очищается, а стили, нумерация, темы и параметры
    страницы остаются родными.
    
    ## Когда применять
    
    - "сделай такой же документ, но по другой теме", "в формате как в примере"
    - заказчик прислал образец отчета, ТЗ, ПМИ, регламента - нужен свой документ в том же виде
    - документов несколько и они должны выглядеть одинаково
    - документ уже сделан, но оформление не совпадает с эталоном - пересобрать
    
    Не для этого скила: правка существующего документа без смены оформления (штатные средства работы с DOCX),
    markdown в DOCX без образца (скил `md-to-docx`), таблицы и презентации (`xlsx`, `pptx`).
    
    ## Зависимости
    
    ```
    python -m pip install python-docx pymupdf
    ```
    
    `pymupdf` нужен для картинок страниц, Word - для сборки оглавления и экспорта в PDF
    (на Windows через COM). Без Word документ соберется, но оглавление останется незаполненным.
    
    ## Образец не зашит в скил
    
    Скил глобальный и не привязан ни к одному шаблону. Образец задается профилем: ключ
    `sample` в JSON либо флаг `--sample` при запуске. Для каждого нового шаблона делается
    свой профиль, старые не трогаются.
    
    | Ситуация | Что делать |
    |---|---|
    | Первый документ по этому шаблону | `inspect_sample.py` на образце, довести профиль, собрать |
    | Еще один документ по тому же шаблону | взять готовый профиль, поменять только материал |
    | Другой заказчик, другой бланк | новый профиль на новый образец |
    | Профиль есть, образец переехал | `--sample` с новым путем либо поправить `sample` в профиле |
    
    Путь к образцу может быть относительным: он ищется рядом с профилем, потом в текущей
    папке. Так профиль и образец переносятся между машинами вместе.
    
    Где хранить профили - `profiles/README.md`. Коротко: профиль конкретного проекта живет
    рядом с документами проекта или в `~/.claude/plans/<проект>/`, а профиль шаблона, нужного
    из разных проектов, кладется в `profiles/` внутри скила.
    
    ## Порядок работы
    
    ### 1. Снять оформление с образца
    
    ```
    python scripts/inspect_sample.py "образец.docx" --json profile.json
    ```
    
    Печатает секции с ориентацией и колонтитулами, реально используемые стили абзацев
    с примерами текста, разбор каждой таблицы (стиль, ширины, заливка шапки, границы,
    повтор шапки, стили абзацев в ячейках) и порядок блоков документа. Заодно пишет
    черновой профиль сборки.
    
    Читать отчет ОБЯЗАТЕЛЬНО: черновой профиль угадывает уровни заголовков по кеглю, и это
    часто неверно. Сверить с отчетом: какой стиль стоит на разделах верхнего уровня, какой
    на подразделах, чем размечены подзаголовки внутри разделов.
    
    ### 2. Довести профиль
    
    Профиль - это карта "роль в документе -> оформление образца". Формат и все ключи:
    `references/profile-format.md`. Минимум, что правится руками:
    
    - `styles.h1/h2/h3` - уровни заголовков (из отчета, а не из догадки скрипта);
    - `tables.default_style_id` и `grid_style_id` - идентификаторы стилей таблиц;
    - `headings_map` - какие тексты являются заголовками какого уровня в НОВОМ документе;
    - `footer.template` - текст подвала, `{title}` подставляется.
    
    Идентификаторы стилей таблиц берутся из отчета как есть. Обращение по идентификатору,
    а не по имени: имена стилей содержат символы, которые ломаются при копировании.
    
    ### 3. Подготовить содержание
    
    Два пути.
    
    **Markdown** - когда документ линейный (заголовки, абзацы, таблицы). Материал пишется
    в .md, таблицы обычным markdown, секции переключаются маркерами `<!-- landscape -->`
    и `<!-- portrait -->`. Сборка:
    
    ```
    python scripts/md_to_docx_sample.py "материал.md" --profile profile.json --out "новый.docx" --title "Название" --author "Имя Ф."
    ```
    
    **Свой сценарий на python** - когда документов несколько и они собираются из данных
    (словарь, таблица, выгрузка). Тогда данные и сборка разделяются: модуль с данными плюс
    вызовы `SampleDoc`. Так шесть однотипных документов правятся в одном месте и не
    разъезжаются. Пример: `references/build-example.py`.
    
    ### 4. Проверить
    
    ```
    python scripts/verify_result.py "новый.docx" --md "материал.md" --update-fields --png 1,2,9
    ```
    
    Скрипт сверяет число таблиц и строк с исходником (ловит потерю строк и склейку таблиц),
    показывает секции и полосу набора, ищет таблицы шире полосы, считает шапки с заливкой
    и повтором, обновляет поле оглавления через Word и рендерит указанные страницы в PNG.
    
    **Картинки страниц надо посмотреть глазами.** Ни один структурный тест не покажет
    разъехавшуюся верстку: заголовок внизу страницы отдельно от своей таблицы, текст,
    рвущийся по буквам в узкой колонке, пропавший колонтитул. Открыть PNG инструментом Read
    и сравнить с таким же рендером образца.
    
    Рендер образца для сравнения:
    
    ```
    python scripts/verify_result.py "образец.docx" --png 1,2,9 --outdir preview_sample
    ```
    
    ## Что переносится из образца
    
    | Элемент | Как |
    |---|---|
    | Стили абзацев и знаков, темы, шрифты | наследуются вместе с файлом образца |
    | Автонумерация заголовков (1., 1.1., 1.1.1.) | из стиля образца; снимается точечно там, где номер не нужен |
    | Стили таблиц | по идентификатору стиля из образца |
    | Ширины столбцов | из профиля явно либо по содержимому: колонка-счетчик узкая, остальные делят полосу |
    | Заливка шапки, границы, повтор шапки при переносе | из профиля, значения снимаются с образца |
    | Колонтитулы | собираются заново по шаблону из профиля |
    | Книжные и альбомные секции, поля | из профиля |
    
    Колонтитулы и оглавление сознательно НЕ копируются из образца: в них почти всегда
    остается название чужого документа. Подвал собирается по шаблону с подстановкой названия.
    
    ## Обязательные проверки перед сдачей
    
    - число таблиц и строк совпадает с исходником;
    - ни одна таблица не шире полосы набора;
    - в каждой секции есть подвал (кроме титульной, если так в образце);
    - стили в документе те же, что в образце (скрипт покажет чужие);
    - поле оглавления собрано, а не оставлено заглушкой;
    - страницы просмотрены глазами хотя бы выборочно: титул, первая страница с таблицей,
      страница с переносом таблицы через страницу.
    
    ## Грабли
    
    Полный разбор с симптомами: `references/ooxml-pitfalls.md`. Коротко, самое дорогое:
    
    1. **Две таблицы подряд склеиваются в одну.** Между таблицами обязателен пустой абзац,
       иначе Word объединит их, и число таблиц молча уменьшится.
    2. **Подвал пропадает на первой странице каждой секции.** Свойство "первая страница
       отдельно" наследуется новой секцией от предыдущей, его надо сбрасывать.
    3. **Ориентация.** В python-docx смена ориентации не меняет ширину и высоту страницы,
       их надо менять самому. В javascript-библиотеке `docx` наоборот: она меняет их сама,
       и ручная перестановка дает двойной переворот.
    4. **Заливка шапки не появляется сама** даже когда стиль таблицы ее рисует: в образцах
       она обычно задана явно на ячейках. Ставить явно.
    5. **Узкая колонка-счетчик рвет свой заголовок по буквам.** Ширина такой колонки должна
       считаться по длине заголовка: "№ п/п" уже, чем "Номер версии".
    6. **Строки-разделители таблицы** ("От Заказчика" на всю ширину) в markdown выглядят как
       повтор текста по всем колонкам - в DOCX их надо объединять в одну ячейку.
    
    ## Ограничения
    
    - Скил не переносит картинки, диаграммы и фигуры из образца - только текстовое оформление.
    - Профиль под жанр документа делается один раз и потом переиспользуется; на новый жанр
      нужен новый профиль.
    - Автоопределение уровней заголовков в черновом профиле - подсказка, а не результат.
    - Сборка оглавления и экспорт в PDF требуют установленного Word.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related