docx-from-sample
Создать новый DOCX в оформлении готового образца: взять чужой документ как шаблон стилей и наполнить своими данными, сохранив титул, заголовки с нумерацией, стили таблиц, ширины столбцов, заливку шапок, колонтитулы, книжные и альбомные секции. Используй когда просят сделать докум
Install
npx skills add https://github.com/Desko77/claude-code-skills-1c/tree/main/skills/docx-from-sample
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install desko77-claude-code-skills-1c@llmmart
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. Коротко, самое дорогое:
- Две таблицы подряд склеиваются в одну. Между таблицами обязателен пустой абзац, иначе Word объединит их, и число таблиц молча уменьшится.
- Подвал пропадает на первой странице каждой секции. Свойство "первая страница отдельно" наследуется новой секцией от предыдущей, его надо сбрасывать.
- Ориентация. В python-docx смена ориентации не меняет ширину и высоту страницы,
их надо менять самому. В javascript-библиотеке
docxнаоборот: она меняет их сама, и ручная перестановка дает двойной переворот. - Заливка шапки не появляется сама даже когда стиль таблицы ее рисует: в образцах она обычно задана явно на ячейках. Ставить явно.
- Узкая колонка-счетчик рвет свой заголовок по буквам. Ширина такой колонки должна считаться по длине заголовка: "№ п/п" уже, чем "Номер версии".
- Строки-разделители таблицы ("От Заказчика" на всю ширину) в 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.
Reviews (0)
No reviews yet.
No comments yet.