@@ -0,0 +1,146 @@
|
||||
# md2gost (ТЗ МИРЭА)
|
||||
|
||||
Конвертер Markdown → DOCX по методическим указаниям РТУ МИРЭА (ГОСТ 7.32-2017) на базе [md2gost](https://github.com/benzlokzik/md2gost).
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
poetry install
|
||||
# или
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --type coursework --check
|
||||
```
|
||||
|
||||
FODT (LibreOffice, без Word): `python -m md2fodt report.md -o report.fodt` — см. [`md2fodt/`](../md2fodt/).
|
||||
|
||||
Типы: `coursework` | `practice` | `vkr` | **`PIS_custom`** | **`APID_coursework`**.
|
||||
|
||||
| Тип | Когда | Отличия |
|
||||
|-----|--------|---------|
|
||||
| `coursework` / `practice` / `vkr` | ТЗ МИРЭА / ГОСТ 7.32 | H1 слева с отступом; нумерация объектов **1.1, 2.1**; введение/заключение/список |
|
||||
| **`PIS_custom`** | Итоговый отчёт по практическим работам | H1 **по центру ПРОПИСНЫМИ**; H2 с **абзацным отступом**; нумерация **сквозная (1, 2, 3)**; H1 = «Практическая работа №N. …» |
|
||||
| **`APID_coursework`** | КР «Архитектура приложений и данных» | Стили как у `coursework`; источники **7–20**; проверка глав «Теоретические…» / «Прикладные…» и пунктов 2.1–2.4 |
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --type PIS_custom --check
|
||||
# Титул: «Отчёт по практическим работам …» — отдельный DOCX:
|
||||
python -m md2gost report.md -o report.docx --type PIS_custom --title title.docx
|
||||
# Курсовая АПИД (источники 7–20, проверка пунктов 2.1–2.4):
|
||||
python -m md2gost report.md -o report.docx --type APID_coursework --check --no-emdash-to-hyphen --title title.docx --assignment assignment.docx
|
||||
```
|
||||
|
||||
Пример: [`examples/pis_custom.md`](../examples/pis_custom.md).
|
||||
|
||||
### Нумерация заголовков (`--heading-numbering`)
|
||||
|
||||
| Режим | Когда | Поведение |
|
||||
|--------|--------|-----------|
|
||||
| **`manual`** (по умолчанию) | В md уже есть `# 1 …`, `## 1.1 …` | Цифры остаются из markdown; автонумерация Word **отключена** (нет двойных «1 1 …») |
|
||||
| **`auto`** | В md заголовки без цифр: `# Анализ…` | Нумерацию ставит Word; ведущие цифры в тексте md, если были, снимаются |
|
||||
|
||||
### Содержание (`--toc`)
|
||||
|
||||
| Режим | Поведение |
|
||||
|--------|-----------|
|
||||
| **`native`** (по умолчанию) | Встроенное поле Word `TOC`. При открытии Word предложит обновить поле (номера страниц и ссылки). |
|
||||
| **`manual`** | Содержание собирает md2gost сам (номера из layout-трекера), без поля Word. |
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --toc native
|
||||
python -m md2gost report.md -o report.docx --toc manual
|
||||
```
|
||||
|
||||
### Тире (`--emdash-to-hyphen` / `--no-emdash-to-hyphen`)
|
||||
|
||||
По умолчанию типографское «—» заменяется на «-» (в тексте и подписях).
|
||||
Оставить длинное тире по ГОСТ: `--no-emdash-to-hyphen`.
|
||||
|
||||
## Синтаксис (кратко)
|
||||
|
||||
| Элемент | Markdown |
|
||||
|--------|----------|
|
||||
| Спецраздел | `# *ВВЕДЕНИЕ` |
|
||||
| Содержание | `# *СОДЕРЖАНИЕ` + `[TOC]` |
|
||||
| Рисунок | `` + `@Рисунок:id` |
|
||||
| Таблица | `%id Подпись` перед таблицей + `@Таблица:id` |
|
||||
| Склеивание ячеек | `^` — rowspan (ячейка сверху), `>` — colspan (ячейка слева) |
|
||||
| Листинг | `%id Подпись` перед code fence |
|
||||
| Диаграмма UML/BPMN/C4 | `%id Подпись` + ````uml` / ````bpmn` / ````c4` → PNG (Рисунок); `+listing` — ещё и Листинг |
|
||||
| Формула | `%eq1` + `$$…$$` + `@Формула:eq1` (номер только при ссылке) |
|
||||
| Источник | `[1]` в тексте; `[1]: …` в списке |
|
||||
|
||||
### Таблицы со склеиванием
|
||||
|
||||
```markdown
|
||||
%req Требования к системе
|
||||
|
||||
| Категория | Описание |
|
||||
|-----------|----------|
|
||||
| Производительность | Требование 1 |
|
||||
| ^ | Требование 2 |
|
||||
| Масштабируемость | Требование 3 |
|
||||
| ^ | Требование 4 |
|
||||
```
|
||||
|
||||
Горизонтально: `| широкий текст | > | другая |` — первая ячейка на 2 столбца.
|
||||
|
||||
При разрыве таблицы на страницах merge **не переносится** через границу фрагмента.
|
||||
|
||||
### Продолжение таблицы (`--table-continuation`)
|
||||
|
||||
**Важно:** ни DOCX, ни ODT **не умеют** сами вставлять текст «Продолжение Таблицы N»
|
||||
только на второй и следующих страницах. В Word есть лишь повтор шапки (`tblHeader`).
|
||||
Разные шапки «первый раз / продолжение» есть в LaTeX (`longtable`), не в Office.
|
||||
|
||||
Наша оценка высоты строк ≠ вёрстка Word → если резать таблицу в скрипте, получается
|
||||
mid-page «Продолжение…» (как было на 2.4). Поэтому по умолчанию таблицу **не режем**.
|
||||
|
||||
| Режим | Поведение |
|
||||
|--------|-----------|
|
||||
| **`off`** / **`soft`** (по умолчанию) | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно |
|
||||
| **`legacy`** | Режем по нашей оценке высоты + «Продолжение…» с `page_break_before` (могут быть дыры) |
|
||||
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение…» (то же ограничение точности) |
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --table-continuation off
|
||||
python -m md2gost report.md -o report.docx --table-continuation caption
|
||||
```
|
||||
|
||||
### Диаграммы
|
||||
|
||||
```markdown
|
||||
%usecase1 Диаграмма прецедентов +listing
|
||||
|
||||
```uml
|
||||
@startuml
|
||||
actor Student
|
||||
Student --> (Login)
|
||||
@enduml
|
||||
```
|
||||
```
|
||||
|
||||
Рендер (по приоритету):
|
||||
|
||||
1. `PLANTUML_JAR` / `--plantuml-jar` + Java → `plantuml.jar`
|
||||
2. `KROKI_URL` / `--kroki-url` (по умолчанию `http://localhost:8000`)
|
||||
3. remote `https://kroki.io` при `--diagram-fallback remote` (по умолчанию; предупреждение в лог)
|
||||
|
||||
Кэш PNG: `{каталог_md}/.md2gost-cache/`.
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --plantuml-jar C:\tools\plantuml.jar
|
||||
python -m md2gost report.md -o report.docx --diagram-fallback local
|
||||
```
|
||||
|
||||
Подробности и ИИ-промпт: [`prompts/`](../prompts/).
|
||||
|
||||
PDF через LaTeX (XeLaTeX, шаблон МИРЭА): [`md2latex/README.md`](../md2latex/README.md).
|
||||
|
||||
## Проверки
|
||||
|
||||
`--check` печатает замечания по структуре, «рис.», ссылкам во введении, числу/возрасту источников, приложениям и т.д. `--strict` завершает процесс с кодом 1 при ошибках.
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
"""md2gost — Markdown → DOCX (MIREA TZ / GOST)."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def package_dir() -> str:
|
||||
"""Directory of the md2gost package (Template.docx, mml2omml, etc.)."""
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python
|
||||
from argparse import ArgumentParser, BooleanOptionalAction
|
||||
import os
|
||||
import os.path
|
||||
import sys
|
||||
from getpass import getuser
|
||||
|
||||
from docx import Document
|
||||
|
||||
from .converter import Converter
|
||||
from .profiles import (
|
||||
DOC_TYPES,
|
||||
HEADING_NUMBERING_MODES,
|
||||
DEFAULT_HEADING_NUMBERING,
|
||||
TOC_MODES,
|
||||
DEFAULT_TOC_MODE,
|
||||
TABLE_CONTINUATION_MODES,
|
||||
DEFAULT_TABLE_CONTINUATION,
|
||||
get_profile,
|
||||
)
|
||||
from .checker import check_markdown, format_report
|
||||
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser(
|
||||
prog="md2gost",
|
||||
description=(
|
||||
"Генерация DOCX-отчётов из Markdown по ТЗ МИРЭА / ГОСТ. "
|
||||
"Типы: coursework/practice/vkr, APID_coursework, PIS_custom. "
|
||||
"FODT: python -m md2fodt …"
|
||||
),
|
||||
)
|
||||
parser.add_argument("filename", help="Путь до исходного markdown файла")
|
||||
parser.add_argument("-o", "--output", help="Путь до сгенерированного .docx")
|
||||
parser.add_argument("-t", "--template", help="Путь до шаблона .docx")
|
||||
parser.add_argument(
|
||||
"--type", dest="doc_type", choices=DOC_TYPES, default="coursework",
|
||||
help="Тип: coursework | practice | vkr | PIS_custom | APID_coursework",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--heading-numbering",
|
||||
choices=HEADING_NUMBERING_MODES,
|
||||
default=DEFAULT_HEADING_NUMBERING,
|
||||
help=(
|
||||
"Нумерация заголовков разделов: "
|
||||
"manual — цифры из markdown (# 1 … / ## 1.1 …), автонумерация Word отключена; "
|
||||
"auto — нумерует Word, цифры в начале заголовка md снимаются. "
|
||||
f"По умолчанию: {DEFAULT_HEADING_NUMBERING}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--toc",
|
||||
choices=TOC_MODES,
|
||||
default=DEFAULT_TOC_MODE,
|
||||
help=(
|
||||
"Содержание: native — встроенное поле Word TOC (обновить при открытии); "
|
||||
"manual — собрать в md2gost с номерами страниц из layout. "
|
||||
f"По умолчанию: {DEFAULT_TOC_MODE}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--table-continuation",
|
||||
choices=TABLE_CONTINUATION_MODES,
|
||||
default=DEFAULT_TABLE_CONTINUATION,
|
||||
help=(
|
||||
"Таблицы длиннее страницы: "
|
||||
"off/soft — одна таблица, пагинация Word, без авто«Продолжение» (по умолчанию); "
|
||||
"legacy/caption — режем по оценке высоты и вставляем «Продолжение…» "
|
||||
"(оценка ≠ Word, возможны артефакты). "
|
||||
f"По умолчанию: {DEFAULT_TABLE_CONTINUATION}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--emdash-to-hyphen",
|
||||
action=BooleanOptionalAction,
|
||||
default=True,
|
||||
help=(
|
||||
"Автозамена типографского тире «—» на дефис «-» в тексте и подписях. "
|
||||
"Включено по умолчанию; отключить: --no-emdash-to-hyphen."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--title", help="DOCX титульного листа (вставляется перед телом)")
|
||||
parser.add_argument("--assignment", help="DOCX бланка задания")
|
||||
parser.add_argument("--check", help="Проверить markdown по ТЗ и вывести отчёт",
|
||||
action="store_true")
|
||||
parser.add_argument("--check-only", help="Только проверка, без генерации документа",
|
||||
action="store_true")
|
||||
parser.add_argument("--strict", help="Код выхода 1 при ошибках проверки",
|
||||
action="store_true")
|
||||
parser.add_argument("--syntax-highlighting", help="Подсветка синтаксиса в листингах",
|
||||
action=BooleanOptionalAction)
|
||||
parser.add_argument(
|
||||
"--plantuml-jar",
|
||||
help="Путь к plantuml.jar (иначе env PLANTUML_JAR)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kroki-url",
|
||||
help="URL локального Kroki (иначе env KROKI_URL, default http://localhost:8000)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diagram-fallback",
|
||||
choices=["local", "remote", "off"],
|
||||
default="remote",
|
||||
help="Если локальный рендер UML недоступен: remote (kroki.io), local (ошибка), off",
|
||||
)
|
||||
parser.add_argument("--debug", help="Добавляет отладочные данные в документ",
|
||||
action="store_true")
|
||||
|
||||
args = parser.parse_args()
|
||||
filename, output, template, debug = \
|
||||
args.filename, args.output, args.template, args.debug
|
||||
if args.syntax_highlighting:
|
||||
os.environ["SYNTAX_HIGHLIGHTING"] = "1"
|
||||
|
||||
from .diagram_renderer import configure_diagrams
|
||||
configure_diagrams(
|
||||
plantuml_jar=args.plantuml_jar,
|
||||
kroki_url=args.kroki_url,
|
||||
fallback=args.diagram_fallback,
|
||||
)
|
||||
|
||||
if not filename.endswith(".md"):
|
||||
print("Error: filename must have md format")
|
||||
exit(1)
|
||||
|
||||
os.environ["WORKING_DIR"] = os.path.dirname(os.path.abspath(filename)) or "."
|
||||
|
||||
with open(filename, encoding="utf-8") as f:
|
||||
md_text = f.read()
|
||||
|
||||
if args.check or args.check_only:
|
||||
issues = check_markdown(md_text, args.doc_type)
|
||||
print(format_report(issues))
|
||||
errors = [i for i in issues if i.severity == "error"]
|
||||
if args.strict and errors:
|
||||
sys.exit(1)
|
||||
if args.check_only:
|
||||
sys.exit(0 if not errors else (1 if args.strict else 0))
|
||||
|
||||
if not output:
|
||||
output = os.path.basename(filename).replace(".md", ".docx")
|
||||
elif not output.endswith(".docx"):
|
||||
print("Error: output file must have docx format")
|
||||
exit(1)
|
||||
|
||||
if not template:
|
||||
from . import package_dir
|
||||
template = os.path.join(package_dir(), "Template.docx")
|
||||
|
||||
converter = Converter(
|
||||
filename, output, template, debug,
|
||||
doc_type=args.doc_type,
|
||||
heading_numbering=args.heading_numbering,
|
||||
emdash_to_hyphen=args.emdash_to_hyphen,
|
||||
toc_mode=args.toc,
|
||||
table_continuation=args.table_continuation,
|
||||
)
|
||||
converter.convert()
|
||||
|
||||
document = converter.document
|
||||
|
||||
# Front matter is appended *into* a shell that already has coursework styles.
|
||||
# Never use title.docx as compose base: python-docx default template has
|
||||
# Calibri + accent-blue headings and would override ГОСТ стили.
|
||||
if args.title or args.assignment:
|
||||
try:
|
||||
from docxcompose.composer import Composer
|
||||
except ImportError:
|
||||
print("Error: docxcompose required for --title/--assignment")
|
||||
sys.exit(3)
|
||||
from .styles import apply_document_styles
|
||||
|
||||
shell = Document(template)
|
||||
apply_document_styles(shell, get_profile(args.doc_type).style_preset)
|
||||
body = shell.element.body
|
||||
for child in list(body):
|
||||
if not child.tag.endswith("}sectPr"):
|
||||
body.remove(child)
|
||||
|
||||
composer = Composer(shell)
|
||||
if args.title:
|
||||
composer.append(Document(args.title))
|
||||
shell.add_page_break()
|
||||
if args.assignment:
|
||||
composer.append(Document(args.assignment))
|
||||
shell.add_page_break()
|
||||
composer.append(document)
|
||||
document = composer.doc
|
||||
apply_document_styles(document, get_profile(args.doc_type).style_preset)
|
||||
|
||||
document.core_properties.author = getuser()
|
||||
document.core_properties.comments = \
|
||||
"Создано при помощи md2gost (ТЗ МИРЭА)"
|
||||
|
||||
document.save(output)
|
||||
print(f"Generated document: {os.path.abspath(output)}")
|
||||
|
||||
if debug:
|
||||
import platform
|
||||
if platform.system() == 'Darwin':
|
||||
import subprocess
|
||||
subprocess.call(('open', output))
|
||||
elif platform.system() == 'Windows':
|
||||
os.startfile(output)
|
||||
else:
|
||||
import subprocess
|
||||
subprocess.call(('xdg-open', output))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Convert [n]: source lines under bibliography heading into Bibliography paragraphs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .bibliography import BIBLIO_LINE_RE, BiblioEntry, extract_year
|
||||
from .bibliography_renderable import Bibliography
|
||||
from .renderable import Renderable
|
||||
from .renderable.heading import Heading
|
||||
from .renderable.paragraph import Paragraph
|
||||
|
||||
|
||||
BIBLIO_HEADING = re.compile(
|
||||
r"СПИСОК\s+ИСПОЛЬЗОВАНН?ЫХ\s+ИСТОЧНИКОВ",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
APPENDIX_HEADING = re.compile(r"^ПРИЛОЖЕН", re.IGNORECASE)
|
||||
|
||||
# Marko merges consecutive [n]: lines into one paragraph (soft breaks ignored),
|
||||
# so we must find every entry inside the paragraph text.
|
||||
BIBLIO_FIND_RE = re.compile(
|
||||
r"\[(\d+(?:\.\d+)?)\]:\s*(.*?)(?=\s*\[\d+(?:\.\d+)?\]:|\s*$)",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def entries_from_text(text: str) -> list[BiblioEntry]:
|
||||
"""Pull all [n]: … entries from a (possibly concatenated) paragraph."""
|
||||
entries: list[BiblioEntry] = []
|
||||
if not text or not text.strip():
|
||||
return entries
|
||||
for m in BIBLIO_FIND_RE.finditer(text.strip()):
|
||||
key = m.group(1)
|
||||
body = re.sub(r"\s+", " ", m.group(2)).strip()
|
||||
if not body:
|
||||
continue
|
||||
entries.append(BiblioEntry(key=key, text=body, year=extract_year(body)))
|
||||
return entries
|
||||
|
||||
|
||||
def extract_bibliography_from_markdown(md: str) -> list[BiblioEntry] | list[tuple[str, list[BiblioEntry]]]:
|
||||
"""
|
||||
Parse bibliography from raw markdown (reliable even if Marko merges lines).
|
||||
Returns either a flat list of entries, or a list of (section_title, entries) for VKR.
|
||||
"""
|
||||
m = re.search(
|
||||
r"^#\s*\*?\s*СПИСОК\s+ИСПОЛЬЗОВАНН?ЫХ\s+ИСТОЧНИКОВ\s*$",
|
||||
md,
|
||||
re.M | re.I,
|
||||
)
|
||||
if not m:
|
||||
return []
|
||||
start = m.end()
|
||||
rest = md[start:]
|
||||
next_h = re.search(r"^#\s+", rest, re.M)
|
||||
block = rest[: next_h.start()] if next_h else rest
|
||||
|
||||
sections: list[tuple[str, list[BiblioEntry]]] = []
|
||||
current_title: str | None = None
|
||||
current: list[BiblioEntry] = []
|
||||
flat: list[BiblioEntry] = []
|
||||
|
||||
for line in block.splitlines():
|
||||
hm = re.match(r"^#{2,6}\s+(\*?)(.+)$", line)
|
||||
if hm:
|
||||
title = hm.group(2).strip()
|
||||
if current_title is not None:
|
||||
sections.append((current_title, current))
|
||||
elif current:
|
||||
flat.extend(current)
|
||||
current_title = title
|
||||
current = []
|
||||
continue
|
||||
entries = entries_from_text(line)
|
||||
if entries:
|
||||
if current_title is not None:
|
||||
current.extend(entries)
|
||||
else:
|
||||
flat.extend(entries)
|
||||
continue
|
||||
# concatenated line with several [n]:
|
||||
if "[" in line and "]:" in line:
|
||||
more = entries_from_text(line)
|
||||
if current_title is not None:
|
||||
current.extend(more)
|
||||
else:
|
||||
flat.extend(more)
|
||||
|
||||
if current_title is not None:
|
||||
sections.append((current_title, current))
|
||||
elif current:
|
||||
flat.extend(current)
|
||||
|
||||
if sections:
|
||||
return sections
|
||||
return flat
|
||||
|
||||
|
||||
def fold_bibliography(renderables: list[Renderable], parent,
|
||||
raw_markdown: str | None = None) -> list[Renderable]:
|
||||
"""Replace bibliography source paragraphs with a Bibliography renderable."""
|
||||
# Prefer raw markdown extraction (handles Marko soft-break merge)
|
||||
raw_entries = None
|
||||
if raw_markdown:
|
||||
raw_entries = extract_bibliography_from_markdown(raw_markdown)
|
||||
|
||||
result: list[Renderable] = []
|
||||
i = 0
|
||||
while i < len(renderables):
|
||||
r = renderables[i]
|
||||
result.append(r)
|
||||
if isinstance(r, Heading) and BIBLIO_HEADING.search(r.text or ""):
|
||||
i += 1
|
||||
# Skip / drop following paragraphs that are biblio lines (already extracted)
|
||||
consumed = 0
|
||||
while i + consumed < len(renderables):
|
||||
item = renderables[i + consumed]
|
||||
if isinstance(item, Heading):
|
||||
title = (item.text or "").strip()
|
||||
if APPENDIX_HEADING.match(title):
|
||||
break
|
||||
if item.level == 1:
|
||||
break
|
||||
# subsection inside biblio — skip heading, content comes from raw
|
||||
consumed += 1
|
||||
continue
|
||||
if isinstance(item, Paragraph):
|
||||
text = (item._docx_paragraph.text or "").strip()
|
||||
if entries_from_text(text) or (not text):
|
||||
consumed += 1
|
||||
continue
|
||||
break
|
||||
break
|
||||
|
||||
if raw_entries:
|
||||
if raw_entries and isinstance(raw_entries[0], tuple):
|
||||
result.append(Bibliography(parent, [], sections=raw_entries)) # type: ignore
|
||||
else:
|
||||
result.append(Bibliography(parent, raw_entries)) # type: ignore
|
||||
else:
|
||||
# Fallback: parse from renderable paragraphs
|
||||
entries: list[BiblioEntry] = []
|
||||
for j in range(consumed):
|
||||
item = renderables[i + j]
|
||||
if isinstance(item, Paragraph):
|
||||
entries.extend(entries_from_text(item._docx_paragraph.text or ""))
|
||||
if entries:
|
||||
result.append(Bibliography(parent, entries))
|
||||
|
||||
i += consumed
|
||||
continue
|
||||
i += 1
|
||||
return result
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Bibliography parsing helpers (no docx dependency) and optional renderable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
BIBLIO_LINE_RE = re.compile(
|
||||
r"^\[(\d+(?:\.\d+)?)\]:\s*(.+)$"
|
||||
)
|
||||
CITE_RE = re.compile(
|
||||
r"\[(\d+(?:\.\d+)?(?:\s*,\s*\d+(?:\.\d+)?)*)"
|
||||
r"(?:\s*,\s*[cс]\.\s*[\d\-–]+)?"
|
||||
r"\]"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BiblioEntry:
|
||||
key: str
|
||||
text: str
|
||||
year: int | None = None
|
||||
|
||||
|
||||
def extract_year(text: str) -> int | None:
|
||||
years = re.findall(r"(?:^|[^\d])((?:19|20)\d{2})(?:[^\d]|$)", text)
|
||||
if not years:
|
||||
return None
|
||||
return max(int(y) for y in years)
|
||||
|
||||
|
||||
def parse_biblio_block(lines: list[str]) -> list[BiblioEntry]:
|
||||
entries = []
|
||||
for line in lines:
|
||||
m = BIBLIO_LINE_RE.match(line.strip())
|
||||
if m:
|
||||
key, text = m.group(1), m.group(2).strip()
|
||||
entries.append(BiblioEntry(key=key, text=text, year=extract_year(text)))
|
||||
return entries
|
||||
|
||||
|
||||
def find_citations(text: str) -> list[str]:
|
||||
seen: list[str] = []
|
||||
for m in CITE_RE.finditer(text):
|
||||
inner = m.group(1)
|
||||
for part in re.split(r"\s*,\s*", inner):
|
||||
part = part.strip()
|
||||
if part and part not in seen:
|
||||
seen.append(part)
|
||||
return seen
|
||||
|
||||
|
||||
def Bibliography(*args, **kwargs):
|
||||
"""Lazy wrapper to avoid importing docx/freetype at checker import time."""
|
||||
from .bibliography_renderable import Bibliography as _Bibliography
|
||||
return _Bibliography(*args, **kwargs)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Bibliography renderable (docx-dependent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import copy
|
||||
from typing import Generator
|
||||
|
||||
from docx.shared import Parented
|
||||
|
||||
from .bibliography import BiblioEntry
|
||||
from .layout_tracker import LayoutState
|
||||
from .renderable import Renderable
|
||||
from .renderable.paragraph import Paragraph
|
||||
from .rendered_info import RenderedInfo
|
||||
from .sub_renderable import SubRenderable
|
||||
|
||||
|
||||
class Bibliography(Renderable):
|
||||
def __init__(self, parent: Parented, entries: list[BiblioEntry],
|
||||
sections: list[tuple[str, list[BiblioEntry]]] | None = None):
|
||||
self._parent = parent
|
||||
self._entries = entries
|
||||
self._sections = sections
|
||||
self._paragraphs: list[Paragraph] = []
|
||||
|
||||
if sections:
|
||||
for title, sect_entries in sections:
|
||||
h = Paragraph(parent)
|
||||
try:
|
||||
h.style = "Bibliography Heading"
|
||||
except KeyError:
|
||||
pass
|
||||
h.first_line_indent = 0
|
||||
h.add_run(title.upper())
|
||||
self._paragraphs.append(h)
|
||||
for e in sect_entries:
|
||||
self._paragraphs.append(self._make_entry(parent, e))
|
||||
else:
|
||||
for e in entries:
|
||||
self._paragraphs.append(self._make_entry(parent, e))
|
||||
|
||||
@staticmethod
|
||||
def _make_entry(parent: Parented, e: BiblioEntry) -> Paragraph:
|
||||
p = Paragraph(parent)
|
||||
try:
|
||||
p.style = "Bibliography"
|
||||
except KeyError:
|
||||
pass
|
||||
# Avoid hyphen-splitting URLs: put whole entry in one run via direct text
|
||||
run = p._docx_paragraph.add_run(f"{e.key}. {e.text}")
|
||||
return p
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
|
||||
RenderedInfo | SubRenderable, None, None]:
|
||||
for paragraph in self._paragraphs:
|
||||
for x in paragraph.render(previous_rendered, copy(layout_state)):
|
||||
layout_state.add_height(x.height)
|
||||
previous_rendered = x
|
||||
yield x
|
||||
@@ -0,0 +1,431 @@
|
||||
"""MIREA TZ markdown checker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from ..bibliography import CITE_RE, BIBLIO_LINE_RE, parse_biblio_block, find_citations, extract_year
|
||||
from ..profiles import get_profile, current_year, DocProfile
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
id: str
|
||||
severity: str # error | warning
|
||||
message: str
|
||||
line: int | None = None
|
||||
|
||||
|
||||
SPECIAL_UNNUMBERED = {
|
||||
"СОДЕРЖАНИЕ", "ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ",
|
||||
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
|
||||
"СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ",
|
||||
"ПРИЛОЖЕНИЕ", "ПРИЛОЖЕНИЯ",
|
||||
}
|
||||
|
||||
HEADING_RE = re.compile(r"^(#{1,6})\s+(\*?)(.+)$", re.M)
|
||||
FOOTNOTE_RE = re.compile(r"\[\^[^\]]+\]")
|
||||
RIS_RE = re.compile(r"(?i)\bрис\.")
|
||||
TABL_RE = re.compile(r"(?i)\bтабл\.")
|
||||
CAPTION_HYPHEN_RE = re.compile(r"^%\w+\s+.+\s+-\s+", re.M)
|
||||
NOPP_RE = re.compile(r"№\s*п\s*/\s*п", re.I)
|
||||
IMAGE_RE = re.compile(r"!\[.*?\]\([^)]+\)")
|
||||
TABLE_CAPTION_RE = re.compile(r"^%(\w+)", re.M)
|
||||
APPENDIX_LETTER_RE = re.compile(r"(?i)приложение\s+([А-ЯA-ZЁ])")
|
||||
FORBIDDEN_APPENDIX = set("ЁЗЙОЧЬЫЪ")
|
||||
|
||||
|
||||
def _line_of(text: str, pos: int) -> int:
|
||||
return text.count("\n", 0, pos) + 1
|
||||
|
||||
|
||||
def _sections(text: str) -> list[tuple[str, str, int]]:
|
||||
"""Return list of (heading_upper, body, start_line)."""
|
||||
parts = []
|
||||
matches = list(HEADING_RE.finditer(text))
|
||||
for i, m in enumerate(matches):
|
||||
start = m.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
title = m.group(3).strip()
|
||||
parts.append((title.upper(), text[start:end], _line_of(text, m.start())))
|
||||
return parts
|
||||
|
||||
|
||||
PRACTICAL_H1_RE = re.compile(
|
||||
r"^#\s+(?!\*)(.+)$",
|
||||
re.M,
|
||||
)
|
||||
PRACTICAL_TITLE_RE = re.compile(
|
||||
r"(?i)практическая\s+работа\s*№?\s*\d+",
|
||||
)
|
||||
|
||||
|
||||
def check_structure(text: str, profile: DocProfile) -> list[Issue]:
|
||||
issues = []
|
||||
upper = text.upper()
|
||||
|
||||
if profile.require_practical_works:
|
||||
# PIS_custom: содержание + практические работы как H1
|
||||
if "СОДЕРЖАНИЕ" not in upper:
|
||||
issues.append(Issue(
|
||||
"structure.missing", "error",
|
||||
"Отсутствует раздел «СОДЕРЖАНИЕ»",
|
||||
))
|
||||
if "[TOC]" not in text and "[toc]" not in text:
|
||||
issues.append(Issue(
|
||||
"structure.toc", "error",
|
||||
"Нет маркера [TOC] для оглавления (обязательно для итогового отчёта)",
|
||||
))
|
||||
h1_titles = [m.group(1).strip() for m in PRACTICAL_H1_RE.finditer(text)]
|
||||
practicals = [t for t in h1_titles if PRACTICAL_TITLE_RE.search(t)]
|
||||
if not practicals:
|
||||
issues.append(Issue(
|
||||
"structure.practical", "error",
|
||||
"Нет заголовков вида «Практическая работа №N. …» "
|
||||
"(H1 без *, каждая работа — отдельный раздел)",
|
||||
))
|
||||
for t in practicals:
|
||||
if t.rstrip().endswith("."):
|
||||
issues.append(Issue(
|
||||
"heading.trailing_dot", "warning",
|
||||
f"Заголовок раздела без точки в конце: «{t.rstrip('.')}»",
|
||||
))
|
||||
return issues
|
||||
|
||||
required = ["СОДЕРЖАНИЕ", "ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"]
|
||||
if not profile.require_intro_conclusion:
|
||||
required = ["СОДЕРЖАНИЕ"]
|
||||
if profile.require_bibliography and "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ" not in required:
|
||||
required.append("СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ")
|
||||
# allow alternate spelling
|
||||
if "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ" not in upper and "СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ" in upper:
|
||||
required = [
|
||||
("СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ" if r.startswith("СПИСОК") else r)
|
||||
for r in required
|
||||
]
|
||||
for name in required:
|
||||
if name not in upper:
|
||||
issues.append(Issue("structure.missing", "error",
|
||||
f"Отсутствует обязательный раздел «{name}»"))
|
||||
if "[TOC]" not in text and "[toc]" not in text:
|
||||
issues.append(Issue("structure.toc", "warning",
|
||||
"Нет маркера [TOC] для автособираемого содержания"))
|
||||
# numbered chapter exists
|
||||
if not re.search(r"^#\s+[^*\n]", text, re.M):
|
||||
issues.append(Issue("structure.body", "error",
|
||||
"Нет нумерованного раздела основной части (# заголовок без *)"))
|
||||
if profile.require_graphic_appendix:
|
||||
if "ГРАФИЧЕСКИЙ МАТЕРИАЛ" not in upper:
|
||||
issues.append(Issue(
|
||||
"structure.graphic", "error",
|
||||
"Для ВКР обязательно приложение «Графический материал»",
|
||||
))
|
||||
if profile.require_apid_kr_structure:
|
||||
issues.extend(check_apid_kr_structure(text))
|
||||
return issues
|
||||
|
||||
|
||||
APID_REQUIRED_HEADING_PATTERNS = [
|
||||
(r"теоретическ\w+\s+аспект", "1. Теоретические аспекты разработки архитектуры приложений и данных: …"),
|
||||
(r"прикладн\w+\s+аспект", "2. Прикладные аспекты разработки архитектуры приложений и данных …"),
|
||||
(r"2\.1\b.{0,40}описани[ея]\s+проекта", "2.1 Описание проекта команды и разрабатываемого программного приложения"),
|
||||
(r"2\.2\b.{0,40}описани[ея]\s+роли", "2.2 Описание роли … в приложении …"),
|
||||
(r"2\.3\b.{0,80}архитектур", "2.3 Описание архитектуры программного приложения и данных для роли"),
|
||||
(r"2\.4\b.{0,40}вариант", "2.4 Варианты развития архитектуры программного приложения"),
|
||||
]
|
||||
|
||||
|
||||
def check_apid_kr_structure(text: str) -> list[Issue]:
|
||||
"""Обязательные разделы КР по методичке АПИД (Аншина, Лагунова)."""
|
||||
issues: list[Issue] = []
|
||||
headings = [m.group(3).strip() for m in HEADING_RE.finditer(text)]
|
||||
blob = "\n".join(headings)
|
||||
for pat, title in APID_REQUIRED_HEADING_PATTERNS:
|
||||
if not re.search(pat, blob, re.I):
|
||||
issues.append(Issue(
|
||||
"structure.apid", "error",
|
||||
f"Для КР АПИД отсутствует обязательный раздел: «{title}»",
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_pis_headings(text: str, profile: DocProfile) -> list[Issue]:
|
||||
"""PIS: подразделы с прописной, без точки; H1 — практические работы."""
|
||||
if not profile.require_practical_works:
|
||||
return []
|
||||
issues = []
|
||||
for m in HEADING_RE.finditer(text):
|
||||
level = len(m.group(1))
|
||||
title = m.group(3).strip()
|
||||
line = _line_of(text, m.start())
|
||||
if title.endswith("."):
|
||||
issues.append(Issue(
|
||||
"heading.trailing_dot", "warning",
|
||||
f"Заголовок без точки в конце: «{title.rstrip('.')}»",
|
||||
line,
|
||||
))
|
||||
if level >= 2 and title and title[0].islower():
|
||||
issues.append(Issue(
|
||||
"heading.case", "warning",
|
||||
f"Подраздел с прописной буквы: «{title[0].upper() + title[1:]}»",
|
||||
line,
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_special_headings(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
for m in HEADING_RE.finditer(text):
|
||||
starred = bool(m.group(2))
|
||||
title = m.group(3).strip()
|
||||
upper = title.upper()
|
||||
line = _line_of(text, m.start())
|
||||
for special in SPECIAL_UNNUMBERED:
|
||||
if upper == special or (special.startswith("ПРИЛОЖЕН") and upper.startswith("ПРИЛОЖЕН")):
|
||||
if not starred and m.group(1) == "#":
|
||||
issues.append(Issue(
|
||||
"heading.numbered_special", "error",
|
||||
f"Спецраздел «{title}» должен быть без номера: # *{title}",
|
||||
line,
|
||||
))
|
||||
if title != title.upper() and upper in SPECIAL_UNNUMBERED:
|
||||
issues.append(Issue(
|
||||
"heading.case", "warning",
|
||||
f"Спецраздел лучше писать ПРОПИСНЫМИ: «{upper}»",
|
||||
line,
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_forbidden_abbreviations(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
for m in RIS_RE.finditer(text):
|
||||
issues.append(Issue("ref.ris", "error",
|
||||
"Используйте «Рисунок», не «рис.»", _line_of(text, m.start())))
|
||||
for m in TABL_RE.finditer(text):
|
||||
issues.append(Issue("ref.tabl", "error",
|
||||
"Используйте «Таблица», не «табл.»", _line_of(text, m.start())))
|
||||
for m in FOOTNOTE_RE.finditer(text):
|
||||
issues.append(Issue("footnote", "error",
|
||||
"Сноски в конце страницы не допускаются", _line_of(text, m.start())))
|
||||
for m in NOPP_RE.finditer(text):
|
||||
issues.append(Issue("table.nopp", "error",
|
||||
"Графу «№ п/п» в таблицы не включают", _line_of(text, m.start())))
|
||||
return issues
|
||||
|
||||
|
||||
def check_citations_in_intro_conclusion(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
for title, body, line in _sections(text):
|
||||
if title in ("ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ"):
|
||||
for m in CITE_RE.finditer(body):
|
||||
issues.append(Issue(
|
||||
"cite.intro", "error",
|
||||
f"Во разделе «{title}» ссылки на источники не указываются",
|
||||
line + body[:m.start()].count("\n"),
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_bibliography(text: str, profile: DocProfile) -> list[Issue]:
|
||||
issues = []
|
||||
if profile.min_sources <= 0 and not profile.require_bibliography:
|
||||
# PIS_custom и др.: список источников не обязателен
|
||||
m_probe = re.search(
|
||||
r"^#\s*\*?\s*СПИСОК\s+ИСПОЛЬЗОВАНН?ЫХ\s+ИСТОЧНИКОВ\s*$",
|
||||
text, re.M | re.I,
|
||||
)
|
||||
if not m_probe:
|
||||
return issues
|
||||
# Extract biblio block
|
||||
m = re.search(
|
||||
r"^#\s*\*?\s*СПИСОК\s+ИСПОЛЬЗОВАНН?ЫХ\s+ИСТОЧНИКОВ\s*$",
|
||||
text, re.M | re.I,
|
||||
)
|
||||
if not m:
|
||||
return issues
|
||||
start = m.end()
|
||||
next_h = re.search(r"^#\s+", text[start:], re.M)
|
||||
block = text[start: start + next_h.start()] if next_h else text[start:]
|
||||
|
||||
entries = []
|
||||
for line in block.splitlines():
|
||||
bm = BIBLIO_LINE_RE.match(line.strip())
|
||||
if bm:
|
||||
entries.append((bm.group(1), bm.group(2), extract_year(bm.group(2))))
|
||||
|
||||
n = len(entries)
|
||||
if profile.sectional_biblio:
|
||||
# Count per ## section roughly
|
||||
if n < profile.min_sources:
|
||||
issues.append(Issue(
|
||||
"biblio.count", "warning",
|
||||
f"ВКР: в каждом разделе списка желательно ≥{profile.min_sources} источников "
|
||||
f"(сейчас всего {n})",
|
||||
))
|
||||
else:
|
||||
if n < profile.min_sources:
|
||||
issues.append(Issue(
|
||||
"biblio.count", "error",
|
||||
f"Источников должно быть от {profile.min_sources} до {profile.max_sources} "
|
||||
f"(сейчас {n})",
|
||||
))
|
||||
elif profile.max_sources and n > profile.max_sources:
|
||||
issues.append(Issue(
|
||||
"biblio.count", "error",
|
||||
f"Источников должно быть от {profile.min_sources} до {profile.max_sources} "
|
||||
f"(сейчас {n})",
|
||||
))
|
||||
|
||||
year_now = current_year()
|
||||
for key, txt, year in entries:
|
||||
if year and year < year_now - profile.max_source_age_years:
|
||||
issues.append(Issue(
|
||||
"biblio.age", "error",
|
||||
f"Источник [{key}] старше {profile.max_source_age_years} лет (год {year})",
|
||||
))
|
||||
|
||||
# Citation order vs first appearance
|
||||
# Exclude intro/conclusion from citation scan for order
|
||||
body_for_cites = text
|
||||
cites = find_citations(body_for_cites)
|
||||
# Filter cites that appear only in intro/conclusion — still listed
|
||||
keys = [e[0] for e in entries]
|
||||
if cites and keys:
|
||||
# First N unique cites should match order of keys for simple lists
|
||||
if not profile.sectional_biblio:
|
||||
expected = cites[:len(keys)]
|
||||
if keys != expected and set(keys) == set(expected):
|
||||
issues.append(Issue(
|
||||
"biblio.order", "warning",
|
||||
"Порядок источников в списке должен совпадать с порядком первого упоминания",
|
||||
))
|
||||
for c in cites:
|
||||
if c not in keys:
|
||||
issues.append(Issue(
|
||||
"biblio.missing", "error",
|
||||
f"Ссылка [{c}] есть в тексте, но нет в списке источников",
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_object_refs(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
# Captions / images
|
||||
labels = set(re.findall(r"^%(\w+)", text, re.M))
|
||||
# image titles with %id
|
||||
labels.update(re.findall(r'!\[[^\]]*\]\([^)]*%(\w+)', text))
|
||||
refs = set(re.findall(r"@[\wА-Яа-я]+:(\w+)", text))
|
||||
for lab in labels:
|
||||
# At least one @?:lab or word reference — soft check
|
||||
if lab not in refs and f"@{lab}" not in text:
|
||||
# only warn if label looks intentional
|
||||
pass
|
||||
for ref in refs:
|
||||
if ref not in labels:
|
||||
issues.append(Issue(
|
||||
"ref.dangling", "error",
|
||||
f"Ссылка на несуществующую метку «{ref}»",
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_appendices(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
for m in APPENDIX_LETTER_RE.finditer(text):
|
||||
letter = m.group(1).upper()
|
||||
if letter in FORBIDDEN_APPENDIX:
|
||||
issues.append(Issue(
|
||||
"appendix.letter", "error",
|
||||
f"Буква «{letter}» не используется для обозначения приложений",
|
||||
_line_of(text, m.start()),
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
_TABLE_BLOCK_RE = re.compile(
|
||||
r"(?:^[ \t]*\|.+\|[ \t]*\n)+"
|
||||
r"(?:^[ \t]*\|[-:| ]+\|[ \t]*\n)"
|
||||
r"(?:^[ \t]*\|.+\|[ \t]*\n?)*",
|
||||
re.M,
|
||||
)
|
||||
_MERGE_ONLY_RE = re.compile(r"^\s*(\^{1,2}|>{1,2})\s*$")
|
||||
|
||||
|
||||
def _split_table_row(line: str) -> list[str]:
|
||||
parts = re.split(r"\s*(?<!\\)\|\s*", line.strip())
|
||||
if parts and not parts[0]:
|
||||
parts.pop(0)
|
||||
if parts and not parts[-1]:
|
||||
parts.pop()
|
||||
return parts
|
||||
|
||||
|
||||
def check_table_merge(text: str) -> list[Issue]:
|
||||
"""Validate ^ / > merge markers in pipe tables."""
|
||||
issues: list[Issue] = []
|
||||
for tm in _TABLE_BLOCK_RE.finditer(text):
|
||||
block = tm.group(0)
|
||||
block_start = tm.start()
|
||||
lines = [ln for ln in block.splitlines() if ln.strip()]
|
||||
if len(lines) < 2:
|
||||
continue
|
||||
|
||||
header = _split_table_row(lines[0])
|
||||
data_rows = [_split_table_row(ln) for ln in lines[2:]]
|
||||
n_cols = len(header)
|
||||
header_line = _line_of(text, block_start)
|
||||
|
||||
for cell in header:
|
||||
if _MERGE_ONLY_RE.match(cell):
|
||||
issues.append(Issue(
|
||||
"table.merge_header", "error",
|
||||
"Маркер склеивания ^/> нельзя использовать в первой (заголовочной) строке таблицы",
|
||||
header_line,
|
||||
))
|
||||
break
|
||||
|
||||
for r_i, raw_cells in enumerate(data_rows):
|
||||
line_no = header_line + 2 + r_i
|
||||
cells = (raw_cells + [""] * n_cols)[:n_cols]
|
||||
for c_i, cell in enumerate(cells):
|
||||
m = _MERGE_ONLY_RE.match(cell)
|
||||
if not m:
|
||||
continue
|
||||
if m.group(1).startswith(">") and c_i == 0:
|
||||
issues.append(Issue(
|
||||
"table.merge_col", "error",
|
||||
"Маркер «>» нельзя ставить в первом столбце (нет ячейки слева)",
|
||||
line_no,
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_markdown(text: str, doc_type: str = "coursework") -> list[Issue]:
|
||||
profile = get_profile(doc_type)
|
||||
issues: list[Issue] = []
|
||||
issues.extend(check_structure(text, profile))
|
||||
issues.extend(check_special_headings(text))
|
||||
issues.extend(check_pis_headings(text, profile))
|
||||
issues.extend(check_forbidden_abbreviations(text))
|
||||
if profile.require_intro_conclusion:
|
||||
issues.extend(check_citations_in_intro_conclusion(text))
|
||||
issues.extend(check_bibliography(text, profile))
|
||||
issues.extend(check_object_refs(text))
|
||||
issues.extend(check_appendices(text))
|
||||
issues.extend(check_table_merge(text))
|
||||
return issues
|
||||
|
||||
|
||||
def format_report(issues: Iterable[Issue]) -> str:
|
||||
issues = list(issues)
|
||||
if not issues:
|
||||
return "Проверка ТЗ: замечаний нет."
|
||||
lines = [f"Проверка ТЗ: найдено замечаний — {len(issues)}"]
|
||||
for i in issues:
|
||||
loc = f" (стр. md:{i.line})" if i.line else ""
|
||||
lines.append(f" [{i.severity}] {i.id}{loc}: {i.message}")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,119 @@
|
||||
import docx
|
||||
from docx.document import Document
|
||||
|
||||
from .debugger import Debugger
|
||||
from .parser_ import Parser
|
||||
from .toc_processor import TocProcessor
|
||||
from .renderer import Renderer
|
||||
from .styles import apply_document_styles
|
||||
from .profiles import (
|
||||
preprocess_markdown,
|
||||
find_formula_refs,
|
||||
get_profile,
|
||||
DEFAULT_HEADING_NUMBERING,
|
||||
HEADING_NUMBERING_MODES,
|
||||
DEFAULT_TOC_MODE,
|
||||
TOC_MODES,
|
||||
DEFAULT_TABLE_CONTINUATION,
|
||||
TABLE_CONTINUATION_MODES,
|
||||
)
|
||||
from .label_pass import (
|
||||
assign_numbers,
|
||||
set_active_registry,
|
||||
resolve_reference,
|
||||
resolve_pending_in_renderables,
|
||||
)
|
||||
from .renderable.heading import Heading
|
||||
from .renderable.toc import ToC
|
||||
from .renderable.table import Table
|
||||
|
||||
|
||||
class Converter:
|
||||
"""Converts markdown file to docx file (MIREA TZ)."""
|
||||
|
||||
def __init__(self, input_path: str, output_path: str,
|
||||
template_path: str = None, debug: bool = False,
|
||||
doc_type: str = "coursework",
|
||||
heading_numbering: str = DEFAULT_HEADING_NUMBERING,
|
||||
emdash_to_hyphen: bool = False,
|
||||
toc_mode: str = DEFAULT_TOC_MODE,
|
||||
table_continuation: str = DEFAULT_TABLE_CONTINUATION):
|
||||
if heading_numbering not in HEADING_NUMBERING_MODES:
|
||||
raise ValueError(
|
||||
f"heading_numbering must be one of {HEADING_NUMBERING_MODES}, "
|
||||
f"got {heading_numbering!r}"
|
||||
)
|
||||
if toc_mode not in TOC_MODES:
|
||||
raise ValueError(
|
||||
f"toc_mode must be one of {TOC_MODES}, got {toc_mode!r}"
|
||||
)
|
||||
if table_continuation not in TABLE_CONTINUATION_MODES:
|
||||
raise ValueError(
|
||||
f"table_continuation must be one of {TABLE_CONTINUATION_MODES}, "
|
||||
f"got {table_continuation!r}"
|
||||
)
|
||||
self._output_path = output_path
|
||||
self._doc_type = doc_type
|
||||
self._heading_numbering = heading_numbering
|
||||
self._emdash_to_hyphen = emdash_to_hyphen
|
||||
self._toc_mode = toc_mode
|
||||
self._table_continuation = table_continuation
|
||||
self._profile = get_profile(doc_type)
|
||||
self._document: Document = docx.Document(template_path)
|
||||
self._document._body.clear_content()
|
||||
apply_document_styles(self._document, self._profile.style_preset)
|
||||
self._debugger = Debugger(self._document) if debug else None
|
||||
with open(input_path, encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
self._raw_markdown = raw
|
||||
self._preprocessed = preprocess_markdown(raw, emdash_to_hyphen=emdash_to_hyphen)
|
||||
self.parser = Parser(self._document, self._preprocessed)
|
||||
|
||||
def convert(self):
|
||||
renderables = list(self.parser.parse())
|
||||
from .biblio_processor import fold_bibliography
|
||||
renderables = fold_bibliography(
|
||||
renderables, self._document._body, raw_markdown=self._preprocessed
|
||||
)
|
||||
|
||||
# Resolve heading numbering mode + TOC mode + table continuation
|
||||
for r in renderables:
|
||||
if isinstance(r, Heading):
|
||||
r.apply_numbering_mode(self._heading_numbering)
|
||||
elif isinstance(r, ToC):
|
||||
r.set_heading_numbering(self._heading_numbering)
|
||||
r.set_toc_mode(self._toc_mode)
|
||||
elif isinstance(r, Table):
|
||||
r.set_continuation_mode(self._table_continuation)
|
||||
|
||||
formula_refs = find_formula_refs(self._raw_markdown)
|
||||
registry = assign_numbers(
|
||||
renderables, formula_refs,
|
||||
numbering_scope=self._profile.numbering_scope,
|
||||
)
|
||||
set_active_registry(registry)
|
||||
resolve_pending_in_renderables(renderables, resolve_reference)
|
||||
|
||||
renderer = Renderer(self._document, self._debugger,
|
||||
numbered_equations=formula_refs,
|
||||
skip_numbering=True,
|
||||
numbering_scope=self._profile.numbering_scope)
|
||||
renderer.process(renderables)
|
||||
TocProcessor().process(renderables)
|
||||
set_active_registry(None)
|
||||
|
||||
@property
|
||||
def document(self) -> Document:
|
||||
return self._document
|
||||
|
||||
@property
|
||||
def raw_markdown(self) -> str:
|
||||
return self._raw_markdown
|
||||
|
||||
@property
|
||||
def doc_type(self) -> str:
|
||||
return self._doc_type
|
||||
|
||||
@property
|
||||
def heading_numbering(self) -> str:
|
||||
return self._heading_numbering
|
||||
@@ -0,0 +1,230 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
from PIL.ImageDraw import ImageDraw
|
||||
from docx.document import Document
|
||||
from docx.shared import Parented, Length, Pt, Cm
|
||||
|
||||
from docx.oxml import parse_xml, register_element_cls, CT_P
|
||||
from docx.oxml.ns import nsdecls
|
||||
from docx.oxml.shape import CT_Picture
|
||||
from docx.oxml.xmlchemy import BaseOxmlElement, OneAndOnlyOne
|
||||
from docx.text.paragraph import Paragraph
|
||||
|
||||
from md2gost.util import create_element
|
||||
from .renderer import BOTTOM_MARGIN
|
||||
|
||||
EMUS_PER_PX = Pt(1)
|
||||
|
||||
|
||||
# refer to docx.oxml.shape.CT_Inline
|
||||
class CT_Anchor(BaseOxmlElement):
|
||||
"""
|
||||
``<w:anchor>`` element, container for a floating image.
|
||||
"""
|
||||
extent = OneAndOnlyOne('wp:extent')
|
||||
docPr = OneAndOnlyOne('wp:docPr')
|
||||
graphic = OneAndOnlyOne('a:graphic')
|
||||
|
||||
@classmethod
|
||||
def new(cls, cx, cy, shape_id, pic, pos_x, pos_y):
|
||||
"""
|
||||
Return a new ``<wp:anchor>`` element populated with the values passed
|
||||
as parameters.
|
||||
"""
|
||||
anchor = parse_xml(cls._anchor_xml(pos_x, pos_y))
|
||||
anchor.extent.cx = cx
|
||||
anchor.extent.cy = cy
|
||||
anchor.docPr.id = shape_id
|
||||
anchor.docPr.name = 'Picture %d' % shape_id
|
||||
anchor.graphic.graphicData.uri = (
|
||||
'http://schemas.openxmlformats.org/drawingml/2006/picture'
|
||||
)
|
||||
anchor.graphic.graphicData._insert_pic(pic)
|
||||
return anchor
|
||||
|
||||
@classmethod
|
||||
def new_pic_anchor(cls, shape_id, rId, filename, cx, cy, pos_x, pos_y):
|
||||
"""
|
||||
Return a new `wp:anchor` element containing the `pic:pic` element
|
||||
specified by the argument values.
|
||||
"""
|
||||
pic_id = 0 # Word doesn't seem to use this, but does not omit it
|
||||
pic = CT_Picture.new(pic_id, filename, rId, cx, cy)
|
||||
|
||||
try:
|
||||
pic.nvPicPr.xpath("pic:cNvPicPr")[0].append(
|
||||
create_element("a:picLocks", {
|
||||
"noChangeAspect": "1",
|
||||
"noMove": "1",
|
||||
"noResize": "1",
|
||||
"noRot": "1",
|
||||
})
|
||||
)
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
anchor = cls.new(cx, cy, shape_id, pic, pos_x, pos_y)
|
||||
anchor.graphic.graphicData._insert_pic(pic)
|
||||
return anchor
|
||||
|
||||
@classmethod
|
||||
def _anchor_xml(cls, pos_x, pos_y):
|
||||
return (
|
||||
'<wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="0" \n'
|
||||
' behindDoc="1" locked="0" layoutInCell="1" allowOverlap="1" \n'
|
||||
' %s>\n'
|
||||
' <wp:simplePos x="0" y="0"/>\n'
|
||||
' <wp:positionH relativeFrom="page">\n'
|
||||
' <wp:posOffset>%d</wp:posOffset>\n'
|
||||
' </wp:positionH>\n'
|
||||
' <wp:positionV relativeFrom="page">\n'
|
||||
' <wp:posOffset>%d</wp:posOffset>\n'
|
||||
' </wp:positionV>\n'
|
||||
' <wp:extent cx="914400" cy="914400"/>\n'
|
||||
' <wp:wrapNone/>\n'
|
||||
' <wp:docPr id="666" name="unnamed"/>\n'
|
||||
' <wp:cNvGraphicFramePr>\n'
|
||||
' <a:graphicFrameLocks noChangeAspect="1"/>\n'
|
||||
' </wp:cNvGraphicFramePr>\n'
|
||||
' <a:graphic>\n'
|
||||
' <a:graphicData uri="URI not set"/>\n'
|
||||
' </a:graphic>\n'
|
||||
'</wp:anchor>' % ( nsdecls('wp', 'a', 'pic', 'r'), int(pos_x), int(pos_y) )
|
||||
)
|
||||
|
||||
|
||||
# refer to docx.parts.story.BaseStoryPart.new_pic_inline
|
||||
def new_pic_anchor(part, image_descriptor, width, height, pos_x, pos_y):
|
||||
"""Return a newly-created `w:anchor` element.
|
||||
|
||||
The element contains the image specified by *image_descriptor* and is scaled
|
||||
based on the values of *width* and *height*.
|
||||
"""
|
||||
rId, image = part.get_or_add_image(image_descriptor)
|
||||
cx, cy = image.scaled_dimensions(width, height)
|
||||
shape_id, filename = part.next_id, image.filename
|
||||
return CT_Anchor.new_pic_anchor(shape_id, rId, filename, cx, cy, pos_x, pos_y)
|
||||
|
||||
|
||||
# refer to docx.text.run.add_picture
|
||||
def add_float_picture(p, image_path_or_stream, width=None, height=None, pos_x=0, pos_y=0):
|
||||
"""Add float picture at fixed position `pos_x` and `pos_y` to the top-left point of page.
|
||||
"""
|
||||
run = p.add_run()
|
||||
anchor = new_pic_anchor(run.part, image_path_or_stream, width, height, pos_x, pos_y)
|
||||
run._r.add_drawing(anchor)
|
||||
|
||||
|
||||
# refer to docx.oxml.shape.__init__.py
|
||||
register_element_cls('wp:anchor', CT_Anchor)
|
||||
|
||||
def to_px(length: Length) -> int:
|
||||
return round(length / EMUS_PER_PX)
|
||||
|
||||
|
||||
class _Page:
|
||||
def __init__(self, width: Length, height: Length,
|
||||
margin_left: Length, margin_top: Length,
|
||||
margin_right: Length, margin_bottom: Length, first_color: str = None):
|
||||
self._left_offset = margin_left
|
||||
self._right_offset = margin_right
|
||||
self._max_height = height - margin_bottom
|
||||
|
||||
self._width, self._height = width, height
|
||||
self._image = Image.new("RGBA", (to_px(width), to_px(height)))
|
||||
self._draw = ImageDraw(self._image)
|
||||
self._offset = Length(margin_top)
|
||||
|
||||
self._colors = [
|
||||
(255, 0, 0, 100), # red
|
||||
(0, 255, 0, 100), # green
|
||||
(0, 0, 255, 100), # blue
|
||||
]
|
||||
if first_color in self._colors:
|
||||
self._i = self._colors.index(first_color)
|
||||
else:
|
||||
self._i = 0
|
||||
|
||||
@classmethod
|
||||
def from_document(cls, document: Document, *args, **kwargs):
|
||||
return cls(
|
||||
document.sections[0].page_width,
|
||||
document.sections[0].page_height,
|
||||
document.sections[0].left_margin,
|
||||
document.sections[0].top_margin,
|
||||
document.sections[0].right_margin,
|
||||
BOTTOM_MARGIN, # todo: fix detection
|
||||
*args, **kwargs
|
||||
)
|
||||
|
||||
@property
|
||||
def last_color(self):
|
||||
return self._colors[(self._i-1) % len(self._colors)]
|
||||
|
||||
def add_height(self, height: Length) -> Length:
|
||||
"""Return remaining height, that didn't fit to the page"""
|
||||
fitting = min(height, self._max_height - self._offset)
|
||||
remaining = height - fitting
|
||||
|
||||
self._draw.rectangle(
|
||||
(
|
||||
(to_px(self._left_offset), to_px(self._offset)),
|
||||
(to_px(self._width-self._right_offset), to_px(self._offset + fitting))
|
||||
),
|
||||
self._get_color()
|
||||
)
|
||||
self._offset += height
|
||||
return remaining
|
||||
|
||||
@property
|
||||
def image(self) -> BytesIO:
|
||||
io = BytesIO()
|
||||
self._image.save(io, Image.registered_extensions()[".png"])
|
||||
return io
|
||||
|
||||
def _get_color(self):
|
||||
color = self._colors[self._i % len(self._colors)]
|
||||
self._i += 1
|
||||
return color
|
||||
|
||||
|
||||
class Debugger:
|
||||
def __init__(self, document: Document):
|
||||
self._document = document
|
||||
|
||||
self._pages: list[_Page] = [_Page.from_document(document)]
|
||||
self._paragraphs_by_page: defaultdict[int, Paragraph] = defaultdict(lambda: None)
|
||||
|
||||
def add(self, docx_element: Parented, height: Length):
|
||||
remaining_height = self._current_page.add_height(height)
|
||||
|
||||
while remaining_height:
|
||||
self._pages.append(_Page.from_document(self._document, self._current_page.last_color))
|
||||
remaining_height = self._current_page.add_height(remaining_height)
|
||||
|
||||
if not self._paragraphs_by_page[len(self._pages)-1] and isinstance(docx_element, Paragraph)\
|
||||
and docx_element.text and "\n" not in docx_element.text:
|
||||
self._paragraphs_by_page[len(self._pages)-1] = docx_element
|
||||
|
||||
def after_rendered(self):
|
||||
"""Must be called after rendering is finished"""
|
||||
if not self._document.paragraphs:
|
||||
logging.debug("No paragraphs found, can't add debug info")
|
||||
return
|
||||
|
||||
for i in range(len(self._pages)):
|
||||
if self._paragraphs_by_page[i] is None:
|
||||
logging.debug(f"Skipping page {i} as there are no paragraphs")
|
||||
continue
|
||||
add_float_picture(
|
||||
self._paragraphs_by_page[i],
|
||||
self._pages[i].image,
|
||||
# self._document.sections[0].page_width
|
||||
)
|
||||
|
||||
@property
|
||||
def _current_page(self):
|
||||
return self._pages[-1]
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Render UML/BPMN/C4 fenced blocks to PNG (PlantUML jar / Kroki local / remote)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import requests
|
||||
|
||||
from . import package_dir
|
||||
|
||||
DIAGRAM_LANGS = frozenset({"uml", "plantuml", "bpmn", "c4"})
|
||||
FallbackMode = Literal["local", "remote", "off"]
|
||||
|
||||
DEFAULT_KROKI_URL = "http://localhost:8000"
|
||||
REMOTE_KROKI_URL = "https://kroki.io"
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiagramConfig:
|
||||
plantuml_jar: str | None = None
|
||||
kroki_url: str | None = None
|
||||
fallback: FallbackMode = "remote"
|
||||
cache_dir: str | None = None
|
||||
|
||||
|
||||
_CONFIG = DiagramConfig()
|
||||
|
||||
|
||||
def configure_diagrams(
|
||||
plantuml_jar: str | None = None,
|
||||
kroki_url: str | None = None,
|
||||
fallback: FallbackMode = "remote",
|
||||
cache_dir: str | None = None,
|
||||
) -> None:
|
||||
global _CONFIG
|
||||
_CONFIG = DiagramConfig(
|
||||
plantuml_jar=plantuml_jar or os.environ.get("PLANTUML_JAR"),
|
||||
kroki_url=kroki_url or os.environ.get("KROKI_URL"),
|
||||
fallback=fallback,
|
||||
cache_dir=cache_dir,
|
||||
)
|
||||
|
||||
|
||||
def diagrams_dir() -> Path:
|
||||
return Path(package_dir()) / "diagrams"
|
||||
|
||||
|
||||
def prepare_source(lang: str, source: str) -> tuple[str, str]:
|
||||
"""Return (prepared_source, kroki_diagram_type)."""
|
||||
lang = (lang or "uml").lower().strip()
|
||||
text = source.strip()
|
||||
|
||||
if lang == "bpmn":
|
||||
if "@startbpmn" not in text.lower() and "<definitions" not in text.lower():
|
||||
# PlantUML BPMN dialect
|
||||
if not text.startswith("@start"):
|
||||
text = "@startbpmn\n" + text + "\n@endbpmn"
|
||||
return text, "bpmn"
|
||||
|
||||
if lang == "c4":
|
||||
if "!include" not in text and "!includeurl" not in text.lower():
|
||||
# Prefer PlantUML stdlib; also ship local stubs for jar -I path
|
||||
includes = (
|
||||
f"!include {diagrams_dir() / 'C4_Container.puml'}\n"
|
||||
if (diagrams_dir() / "C4_Container.puml").exists()
|
||||
else "!include <C4/C4_Container>\n"
|
||||
)
|
||||
body = text
|
||||
if body.lower().startswith("@startuml"):
|
||||
lines = body.splitlines()
|
||||
text = lines[0] + "\n" + includes + "\n".join(lines[1:])
|
||||
else:
|
||||
text = f"@startuml\n{includes}{body}\n@enduml"
|
||||
elif not text.lower().startswith("@start"):
|
||||
text = f"@startuml\n{text}\n@enduml"
|
||||
return text, "plantuml"
|
||||
|
||||
# uml / plantuml
|
||||
if not text.lower().startswith("@start"):
|
||||
text = f"@startuml\n{text}\n@enduml"
|
||||
return text, "plantuml"
|
||||
|
||||
|
||||
def _cache_path(source: str, cache_dir: str | None = None) -> Path:
|
||||
root = cache_dir or _CONFIG.cache_dir
|
||||
if not root:
|
||||
wd = os.environ.get("WORKING_DIR", ".")
|
||||
root = os.path.join(wd, ".md2gost-cache")
|
||||
Path(root).mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:24]
|
||||
return Path(root) / f"{digest}.png"
|
||||
|
||||
|
||||
def _java_available() -> bool:
|
||||
return shutil.which("java") is not None
|
||||
|
||||
|
||||
def _render_plantuml_jar(source: str, out_png: Path, jar: str) -> bool:
|
||||
if not jar or not os.path.isfile(jar) or not _java_available():
|
||||
return False
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / "diagram.puml"
|
||||
src.write_text(source, encoding="utf-8")
|
||||
cmd = [
|
||||
"java", "-jar", jar,
|
||||
"-tpng",
|
||||
"-charset", "UTF-8",
|
||||
f"-I{diagrams_dir()}",
|
||||
"-o", tmp,
|
||||
str(src),
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, text=True, timeout=120, check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
_log.warning("PlantUML jar failed: %s", e)
|
||||
return False
|
||||
produced = Path(tmp) / "diagram.png"
|
||||
if proc.returncode != 0 or not produced.is_file():
|
||||
_log.warning("PlantUML jar error: %s", proc.stderr or proc.stdout)
|
||||
return False
|
||||
shutil.copyfile(produced, out_png)
|
||||
return True
|
||||
|
||||
|
||||
def _render_kroki(source: str, diagram_type: str, base_url: str, out_png: Path) -> bool:
|
||||
url = base_url.rstrip("/") + f"/{diagram_type}/png"
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
data=source.encode("utf-8"),
|
||||
headers={"Content-Type": "text/plain"},
|
||||
timeout=60,
|
||||
)
|
||||
if resp.status_code != 200 or not resp.content.startswith(b"\x89PNG"):
|
||||
_log.warning("Kroki %s → HTTP %s", url, resp.status_code)
|
||||
return False
|
||||
out_png.write_bytes(resp.content)
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
_log.warning("Kroki request failed (%s): %s", url, e)
|
||||
return False
|
||||
|
||||
|
||||
def render_diagram(
|
||||
lang: str,
|
||||
source: str,
|
||||
*,
|
||||
plantuml_jar: str | None = None,
|
||||
kroki_url: str | None = None,
|
||||
fallback: FallbackMode | None = None,
|
||||
cache_dir: str | None = None,
|
||||
) -> str:
|
||||
"""Render diagram to PNG path. Raises RuntimeError if all backends fail."""
|
||||
prepared, kroki_type = prepare_source(lang, source)
|
||||
out = _cache_path(prepared, cache_dir)
|
||||
if out.is_file() and out.stat().st_size > 0:
|
||||
return str(out)
|
||||
|
||||
jar = plantuml_jar if plantuml_jar is not None else _CONFIG.plantuml_jar
|
||||
jar = jar or os.environ.get("PLANTUML_JAR")
|
||||
local_kroki = kroki_url if kroki_url is not None else _CONFIG.kroki_url
|
||||
local_kroki = local_kroki or os.environ.get("KROKI_URL") or DEFAULT_KROKI_URL
|
||||
mode: FallbackMode = fallback if fallback is not None else _CONFIG.fallback
|
||||
|
||||
# 1) PlantUML jar
|
||||
if _render_plantuml_jar(prepared, out, jar or ""):
|
||||
return str(out)
|
||||
|
||||
# 2) Local Kroki
|
||||
if _render_kroki(prepared, kroki_type, local_kroki, out):
|
||||
return str(out)
|
||||
|
||||
if mode == "off":
|
||||
raise RuntimeError(
|
||||
"Не удалось отрендерить диаграмму локально "
|
||||
"(задайте --plantuml-jar или KROKI_URL; remote fallback отключён)"
|
||||
)
|
||||
if mode == "local":
|
||||
raise RuntimeError(
|
||||
"Локальный рендер диаграммы недоступен "
|
||||
"(Java+plantuml.jar или локальный Kroki)"
|
||||
)
|
||||
|
||||
# 3) Remote fallback
|
||||
_log.warning(
|
||||
"Диаграмма: локальный рендер недоступен, использую remote %s",
|
||||
REMOTE_KROKI_URL,
|
||||
)
|
||||
if _render_kroki(prepared, kroki_type, REMOTE_KROKI_URL, out):
|
||||
return str(out)
|
||||
|
||||
raise RuntimeError("Не удалось отрендерить диаграмму (PlantUML/Kroki)")
|
||||
@@ -0,0 +1,67 @@
|
||||
' Theme: md2gost local C4 stub — works with PlantUML when stdlib C4 is unavailable.
|
||||
' Prefer !include <C4/C4_Container> when using full PlantUML distribution.
|
||||
|
||||
!ifndef C4_INCLUDED
|
||||
!define C4_INCLUDED
|
||||
|
||||
!unquoted procedure Person($alias, $label, $descr="")
|
||||
actor "$label" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure System($alias, $label, $descr="")
|
||||
rectangle "$label" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure System_Ext($alias, $label, $descr="")
|
||||
rectangle "$label" as $alias #LightGray
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Container($alias, $label, $techn="", $descr="")
|
||||
rectangle "$label\n[$techn]" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure ContainerDb($alias, $label, $techn="", $descr="")
|
||||
database "$label\n[$techn]" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Component($alias, $label, $techn="", $descr="")
|
||||
component [$label] as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel($from, $to, $label="", $techn="")
|
||||
$from --> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_R($from, $to, $label="", $techn="")
|
||||
$from -right-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_L($from, $to, $label="", $techn="")
|
||||
$from -left-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_D($from, $to, $label="", $techn="")
|
||||
$from -down-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_U($from, $to, $label="", $techn="")
|
||||
$from -up-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Boundary($alias, $label, $type="")
|
||||
rectangle "$label" as $alias {
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Boundary_End()
|
||||
}
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure System_Boundary($alias, $label)
|
||||
Boundary($alias, $label, "system")
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Container_Boundary($alias, $label)
|
||||
Boundary($alias, $label, "container")
|
||||
!endprocedure
|
||||
|
||||
!endif
|
||||
@@ -0,0 +1,67 @@
|
||||
' Theme: md2gost local C4 stub — works with PlantUML when stdlib C4 is unavailable.
|
||||
' Prefer !include <C4/C4_Container> when using full PlantUML distribution.
|
||||
|
||||
!ifndef C4_INCLUDED
|
||||
!define C4_INCLUDED
|
||||
|
||||
!unquoted procedure Person($alias, $label, $descr="")
|
||||
actor "$label" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure System($alias, $label, $descr="")
|
||||
rectangle "$label" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure System_Ext($alias, $label, $descr="")
|
||||
rectangle "$label" as $alias #LightGray
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Container($alias, $label, $techn="", $descr="")
|
||||
rectangle "$label\n[$techn]" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure ContainerDb($alias, $label, $techn="", $descr="")
|
||||
database "$label\n[$techn]" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Component($alias, $label, $techn="", $descr="")
|
||||
component [$label] as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel($from, $to, $label="", $techn="")
|
||||
$from --> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_R($from, $to, $label="", $techn="")
|
||||
$from -right-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_L($from, $to, $label="", $techn="")
|
||||
$from -left-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_D($from, $to, $label="", $techn="")
|
||||
$from -down-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_U($from, $to, $label="", $techn="")
|
||||
$from -up-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Boundary($alias, $label, $type="")
|
||||
rectangle "$label" as $alias {
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Boundary_End()
|
||||
}
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure System_Boundary($alias, $label)
|
||||
Boundary($alias, $label, "system")
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Container_Boundary($alias, $label)
|
||||
Boundary($alias, $label, "container")
|
||||
!endprocedure
|
||||
|
||||
!endif
|
||||
@@ -0,0 +1,67 @@
|
||||
' Theme: md2gost local C4 stub — works with PlantUML when stdlib C4 is unavailable.
|
||||
' Prefer !include <C4/C4_Container> when using full PlantUML distribution.
|
||||
|
||||
!ifndef C4_INCLUDED
|
||||
!define C4_INCLUDED
|
||||
|
||||
!unquoted procedure Person($alias, $label, $descr="")
|
||||
actor "$label" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure System($alias, $label, $descr="")
|
||||
rectangle "$label" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure System_Ext($alias, $label, $descr="")
|
||||
rectangle "$label" as $alias #LightGray
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Container($alias, $label, $techn="", $descr="")
|
||||
rectangle "$label\n[$techn]" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure ContainerDb($alias, $label, $techn="", $descr="")
|
||||
database "$label\n[$techn]" as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Component($alias, $label, $techn="", $descr="")
|
||||
component [$label] as $alias
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel($from, $to, $label="", $techn="")
|
||||
$from --> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_R($from, $to, $label="", $techn="")
|
||||
$from -right-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_L($from, $to, $label="", $techn="")
|
||||
$from -left-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_D($from, $to, $label="", $techn="")
|
||||
$from -down-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Rel_U($from, $to, $label="", $techn="")
|
||||
$from -up-> $to : $label
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Boundary($alias, $label, $type="")
|
||||
rectangle "$label" as $alias {
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Boundary_End()
|
||||
}
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure System_Boundary($alias, $label)
|
||||
Boundary($alias, $label, "system")
|
||||
!endprocedure
|
||||
|
||||
!unquoted procedure Container_Boundary($alias, $label)
|
||||
Boundary($alias, $label, "container")
|
||||
!endprocedure
|
||||
|
||||
!endif
|
||||
@@ -0,0 +1,119 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from docx.oxml import CT_Tbl
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Length, Parented
|
||||
from docx.table import Table, _Row, _Cell
|
||||
|
||||
from md2gost.util import create_element
|
||||
|
||||
|
||||
def _twips(value) -> int:
|
||||
"""Length or EMU-int → twips.
|
||||
|
||||
python-docx ``Length + Length`` returns a bare ``int`` in EMUs, not twips.
|
||||
"""
|
||||
if hasattr(value, "twips"):
|
||||
return int(value.twips)
|
||||
# bare int from Length arithmetic is EMU (1 twip = 635 EMU)
|
||||
return int(value) // 635
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_table",
|
||||
"create_table_row",
|
||||
"create_table_cell",
|
||||
"apply_cell_merge",
|
||||
]
|
||||
|
||||
|
||||
def _set_table_width(table: Table, width: Length) -> None:
|
||||
"""Force fixed table width (CT_Tbl.new_tbl leaves tblW auto/0 → Word shrinks table)."""
|
||||
tbl = table._tbl
|
||||
tblPr = tbl.tblPr
|
||||
if tblPr is None:
|
||||
tblPr = create_element("w:tblPr")
|
||||
tbl.insert(0, tblPr)
|
||||
for el in tblPr.findall(qn("w:tblW")):
|
||||
tblPr.remove(el)
|
||||
tblPr.append(create_element("w:tblW", {
|
||||
"w:type": "dxa",
|
||||
"w:w": str(_twips(width)),
|
||||
}))
|
||||
for el in tblPr.findall(qn("w:tblLayout")):
|
||||
tblPr.remove(el)
|
||||
tblPr.append(create_element("w:tblLayout", {"w:type": "fixed"}))
|
||||
for el in tblPr.findall(qn("w:tblInd")):
|
||||
tblPr.remove(el)
|
||||
tblPr.append(create_element("w:tblInd", {
|
||||
"w:type": "dxa",
|
||||
"w:w": "0",
|
||||
}))
|
||||
|
||||
|
||||
def _sync_grid_cols(table: Table, cols: int, width: Length) -> None:
|
||||
tbl = table._tbl
|
||||
grid = tbl.tblGrid
|
||||
if grid is None:
|
||||
grid = create_element("w:tblGrid")
|
||||
tbl.insert(list(tbl).index(tbl.tblPr) + 1 if tbl.tblPr is not None else 0, grid)
|
||||
for el in list(grid.findall(qn("w:gridCol"))):
|
||||
grid.remove(el)
|
||||
col_w = max(_twips(width) // max(cols, 1), 1)
|
||||
for _ in range(cols):
|
||||
grid.append(create_element("w:gridCol", {"w:w": str(col_w)}))
|
||||
|
||||
|
||||
def create_table(parent: Parented, rows: int, cols, width: Length, style="Table Grid"):
|
||||
table = Table(CT_Tbl.new_tbl(rows, cols, width), parent)
|
||||
table.style = style
|
||||
_set_table_width(table, width)
|
||||
_sync_grid_cols(table, cols, width)
|
||||
|
||||
# google docs fix
|
||||
borders = parent.part.styles[style]._element.xpath("w:tblPr/w:tblBorders")
|
||||
if borders:
|
||||
tblPr = table._tbl.tblPr
|
||||
for el in tblPr.findall(qn("w:tblBorders")):
|
||||
tblPr.remove(el)
|
||||
tblPr.append(deepcopy(borders[0]))
|
||||
for i in range(rows):
|
||||
for j in range(cols):
|
||||
cell = table.cell(i, j)
|
||||
cell._element.remove(cell.paragraphs[0]._element)
|
||||
table.cell(i, j)._element.tcPr.append(create_element("w:shd", {
|
||||
"w:fill": "auto", "w:val": "clear"
|
||||
}))
|
||||
|
||||
return table
|
||||
|
||||
|
||||
def create_table_row(parent: Table, *, header: bool = False):
|
||||
children = [create_element("w:cantSplit")]
|
||||
if header:
|
||||
children.append(create_element("w:tblHeader"))
|
||||
row = _Row(create_element("w:tr"), parent)
|
||||
row._tr.insert(0, create_element("w:trPr", children))
|
||||
return row
|
||||
|
||||
|
||||
def create_table_cell(parent: _Row, width: Length):
|
||||
cell = _Cell(create_element("w:tc"), parent)
|
||||
cell.width = width
|
||||
return cell
|
||||
|
||||
|
||||
def apply_cell_merge(cell: _Cell, v_merge: str | None = None, grid_span: int | None = None) -> None:
|
||||
"""Apply Word OXML vertical merge and/or horizontal gridSpan on a cell.
|
||||
|
||||
v_merge: ``restart`` | ``continue`` | None
|
||||
grid_span: column span (>= 2) or None
|
||||
"""
|
||||
tc = cell._tc
|
||||
tcPr = tc.get_or_add_tcPr()
|
||||
if v_merge == "restart":
|
||||
tcPr.append(create_element("w:vMerge", {"w:val": "restart"}))
|
||||
elif v_merge == "continue":
|
||||
tcPr.append(create_element("w:vMerge"))
|
||||
if grid_span and grid_span > 1:
|
||||
tcPr.append(create_element("w:gridSpan", {"w:val": str(grid_span)}))
|
||||
@@ -0,0 +1,36 @@
|
||||
from marko import Markdown
|
||||
from marko.ext.gfm import GFM
|
||||
from marko.helpers import MarkoExtension
|
||||
|
||||
from marko import inline, block
|
||||
from marko.ext.gfm import elements
|
||||
|
||||
from marko.inline import *
|
||||
from marko.inline import InlineElement
|
||||
from marko.block import *
|
||||
from marko.block import BlockElement
|
||||
from marko.ext.gfm.elements import *
|
||||
|
||||
from .caption import Caption
|
||||
from .equation import Equation
|
||||
from .heading import Heading
|
||||
from .reference import Reference
|
||||
from .table import Table
|
||||
from .toc import TOC
|
||||
from .inline_formula import InlineEquation
|
||||
from .image import Image
|
||||
|
||||
Extension = MarkoExtension(
|
||||
elements=[
|
||||
Equation,
|
||||
Reference,
|
||||
Caption,
|
||||
Table,
|
||||
TOC,
|
||||
Heading,
|
||||
InlineEquation,
|
||||
Image
|
||||
]
|
||||
)
|
||||
|
||||
markdown = Markdown(extensions=[GFM, Extension])
|
||||
@@ -0,0 +1,33 @@
|
||||
from marko.block import BlockElement
|
||||
from marko.source import Source
|
||||
from re import Match, compile as re_compile
|
||||
|
||||
|
||||
_LISTING_FLAG_RE = re_compile(r"(?i)(?:^|\s)\+?listing\b")
|
||||
|
||||
|
||||
class Caption(BlockElement):
|
||||
"""Represents caption element
|
||||
|
||||
Syntax: %label Caption text [+listing]
|
||||
"""
|
||||
|
||||
pattern = r"\%(\w+)( (.+))?"
|
||||
|
||||
def __init__(self, match: Match[str]):
|
||||
self.unique_name = match.group(1)
|
||||
raw = (match.group(3) or "").strip()
|
||||
self.with_listing = bool(_LISTING_FLAG_RE.search(raw))
|
||||
if self.with_listing:
|
||||
raw = _LISTING_FLAG_RE.sub(" ", raw).strip()
|
||||
self.text = raw or None
|
||||
|
||||
@classmethod
|
||||
def match(cls, source: Source) -> Match[str] | None:
|
||||
return source.expect_re(cls.pattern)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, source: Source) -> Match[str] | None:
|
||||
m = source.match
|
||||
source.consume()
|
||||
return m
|
||||
@@ -0,0 +1,26 @@
|
||||
import re
|
||||
|
||||
from marko.block import BlockElement
|
||||
from marko.source import Source
|
||||
from re import Match
|
||||
|
||||
|
||||
class Equation(BlockElement):
|
||||
"""Represents formula element
|
||||
|
||||
Syntax: $$ 2 + 2 = 4 $$"""
|
||||
|
||||
pattern = re.compile(r"\$\$([\S\s]*?)\$\$", re.M)
|
||||
|
||||
def __init__(self, match: Match[str]):
|
||||
self.latex_equation = match.group(1).strip()
|
||||
|
||||
@classmethod
|
||||
def match(cls, source: Source) -> Match[str] | None:
|
||||
return source.expect_re(cls.pattern)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, source: Source) -> Match[str] | None:
|
||||
m = source.match
|
||||
source.consume()
|
||||
return m
|
||||
@@ -0,0 +1,36 @@
|
||||
import re
|
||||
from re import Match
|
||||
|
||||
from marko.block import BlockElement
|
||||
from marko.source import Source
|
||||
|
||||
|
||||
class Heading(BlockElement):
|
||||
"""Heading element: (### Hello\n)
|
||||
|
||||
Asterisk before text means that headings is unnumbered"""
|
||||
|
||||
priority = 6
|
||||
pattern = re.compile(
|
||||
r" {0,3}(#{1,6})((?=\s)[^\n]*?|[^\n\S]*)(?:(?<=\s)(?<!\\)#+)?[^\n\S]*$\n?",
|
||||
flags=re.M,
|
||||
)
|
||||
override = True
|
||||
|
||||
def __init__(self, match: Match[str]) -> None:
|
||||
self.level = len(match.group(1))
|
||||
inline_body = match.group(2).strip()
|
||||
self.numbered = not (inline_body[0] == "*")
|
||||
if not self.numbered:
|
||||
inline_body = inline_body[1:]
|
||||
self.inline_body = inline_body
|
||||
|
||||
@classmethod
|
||||
def match(cls, source: Source) -> Match[str] | None:
|
||||
return source.expect_re(cls.pattern)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, source: Source) -> Match[str] | None:
|
||||
m = source.match
|
||||
source.consume()
|
||||
return m
|
||||
@@ -0,0 +1,15 @@
|
||||
import re
|
||||
|
||||
from marko.inline import Image as Image_
|
||||
|
||||
|
||||
class Image(Image_):
|
||||
override = True
|
||||
def __init__(self, match):
|
||||
super().__init__(match)
|
||||
|
||||
self.unique_name = None
|
||||
|
||||
if self.title and (m := re.match(r"\%(\w+)( (.+))?", self.title)):
|
||||
self.unique_name = m.group(1)
|
||||
self.title = (m.group(3) or "").strip() or None
|
||||
@@ -0,0 +1,14 @@
|
||||
from marko.inline import InlineElement
|
||||
from re import Match
|
||||
|
||||
|
||||
class InlineEquation(InlineElement):
|
||||
"""Represents inline formula element
|
||||
|
||||
Syntax: \\( y = x \\)"""
|
||||
|
||||
pattern = r"\$(.*?)\$"
|
||||
priority = 6
|
||||
|
||||
def __init__(self, match: Match[str]):
|
||||
self.latex_equation = match.group(1)
|
||||
@@ -0,0 +1,18 @@
|
||||
from marko.inline import InlineElement
|
||||
from re import Match
|
||||
|
||||
|
||||
class Reference(InlineElement):
|
||||
"""Represents Reference element
|
||||
|
||||
Syntax: @Type:label
|
||||
|
||||
Label is only word characters so trailing punctuation in
|
||||
«@Рисунок:id,» or «(@Рисунок:id)» is not swallowed.
|
||||
"""
|
||||
|
||||
pattern = r"@(\w+):(\w+)"
|
||||
|
||||
def __init__(self, match: Match[str]):
|
||||
self.type = match.group(1)
|
||||
self.name = match.group(2)
|
||||
@@ -0,0 +1,157 @@
|
||||
import re
|
||||
|
||||
from marko import block
|
||||
from marko.ext.gfm.elements import TableCell as GfmTableCell
|
||||
|
||||
|
||||
MERGE_V_MARKERS = {"^", "^^"}
|
||||
MERGE_H_MARKERS = {">", ">>"}
|
||||
|
||||
|
||||
class TableCell(GfmTableCell):
|
||||
"""GFM table cell with rowspan/colspan continue markers (^ / >)."""
|
||||
|
||||
def __init__(self, text: str, position: int | None = None) -> None:
|
||||
stripped = text.strip()
|
||||
self.merge_v = "none"
|
||||
self.merge_h = "none"
|
||||
if stripped in MERGE_V_MARKERS:
|
||||
self.merge_v = "continue"
|
||||
text = " "
|
||||
elif stripped in MERGE_H_MARKERS:
|
||||
self.merge_h = "continue"
|
||||
text = " "
|
||||
super().__init__(text, position)
|
||||
|
||||
|
||||
class TableRow(block.BlockElement):
|
||||
"""A table row element."""
|
||||
|
||||
splitter = re.compile(r"\s*(?<!\\)\|\s*")
|
||||
delimiter = re.compile(r":?-+:?")
|
||||
virtual = True
|
||||
_cells = None
|
||||
_is_delimiter = False
|
||||
|
||||
def __init__(self, cells):
|
||||
self.children = cells
|
||||
|
||||
@classmethod
|
||||
def match(cls, source):
|
||||
line = source.next_line()
|
||||
if not line or not re.match(r" {,3}\S", line):
|
||||
return False
|
||||
parts = cls.splitter.split(line.strip())
|
||||
if parts and not parts[0]:
|
||||
parts.pop(0)
|
||||
if parts and not parts[-1]:
|
||||
parts.pop()
|
||||
if len(parts) < 1:
|
||||
return False
|
||||
cls._cells = parts
|
||||
cls._is_delimiter = all(cls.delimiter.match(cell) for cell in parts)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def parse(cls, source):
|
||||
source.consume()
|
||||
parent = source.state
|
||||
cells = cls._cells[:]
|
||||
if len(cells) < parent._num_of_cols:
|
||||
cells.extend("" for _ in range(parent._num_of_cols - len(cells)))
|
||||
elif len(cells) > parent._num_of_cols:
|
||||
cells = cells[: parent._num_of_cols]
|
||||
cells = [TableCell(cell) for cell in cells]
|
||||
if parent.children:
|
||||
for head, cell in zip(parent.children[0].children, cells):
|
||||
cell.align = head.align
|
||||
return cells
|
||||
|
||||
|
||||
class Table(block.BlockElement):
|
||||
"""A table element."""
|
||||
|
||||
_num_of_cols = None
|
||||
_prefix = ""
|
||||
override = True
|
||||
|
||||
@classmethod
|
||||
def match(cls, source):
|
||||
source.anchor()
|
||||
if TableRow.match(source) and not TableRow._is_delimiter:
|
||||
if not TableRow.splitter.search(source.next_line()):
|
||||
return False
|
||||
source.pos = source.match.end()
|
||||
num_of_cols = len(TableRow._cells)
|
||||
if (
|
||||
TableRow.match(source)
|
||||
and TableRow._is_delimiter
|
||||
and num_of_cols == len(TableRow._cells)
|
||||
):
|
||||
cls._num_of_cols = num_of_cols
|
||||
lens = [len(x) for x in TableRow._cells]
|
||||
proportions = [x/sum(lens) for x in lens]
|
||||
TableRow.proportions = proportions
|
||||
source.reset()
|
||||
return True
|
||||
source.reset()
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def parse(cls, source):
|
||||
rv = cls()
|
||||
rv._num_of_cols = cls._num_of_cols
|
||||
rv.children = []
|
||||
with source.under_state(rv):
|
||||
TableRow.match(source)
|
||||
header = TableRow(TableRow.parse(source))
|
||||
rv.children.append(header)
|
||||
TableRow.match(source)
|
||||
delimiters = TableRow._cells
|
||||
source.consume()
|
||||
for d, th in zip(delimiters, header.children):
|
||||
stripped_d = d.strip()
|
||||
th.header = True
|
||||
if stripped_d[0] == ":" and stripped_d[-1] == ":":
|
||||
th.align = "center"
|
||||
elif stripped_d[0] == ":":
|
||||
th.align = "left"
|
||||
elif stripped_d[-1] == ":":
|
||||
th.align = "right"
|
||||
while not source.exhausted:
|
||||
for e in source.parser._build_block_element_list():
|
||||
if issubclass(e, (Table, block.Paragraph)):
|
||||
continue
|
||||
if e.match(source):
|
||||
break
|
||||
else:
|
||||
if TableRow.match(source):
|
||||
rv.children.append(TableRow(TableRow.parse(source)))
|
||||
continue
|
||||
break
|
||||
_resolve_merge_restarts(rv)
|
||||
return rv
|
||||
|
||||
|
||||
def _resolve_merge_restarts(table: Table) -> None:
|
||||
"""Mark restart cells that start a vertical/horizontal merge group."""
|
||||
rows = table.children
|
||||
if not rows:
|
||||
return
|
||||
n_cols = table._num_of_cols
|
||||
n_rows = len(rows)
|
||||
|
||||
for col in range(n_cols):
|
||||
for row in range(n_rows):
|
||||
cell = rows[row].children[col]
|
||||
if cell.merge_v != "continue":
|
||||
# restart if any continue below until next non-continue
|
||||
if row + 1 < n_rows and rows[row + 1].children[col].merge_v == "continue":
|
||||
cell.merge_v = "restart"
|
||||
|
||||
for row in range(n_rows):
|
||||
for col in range(n_cols):
|
||||
cell = rows[row].children[col]
|
||||
if cell.merge_h != "continue":
|
||||
if col + 1 < n_cols and rows[row].children[col + 1].merge_h == "continue":
|
||||
cell.merge_h = "restart"
|
||||
@@ -0,0 +1,24 @@
|
||||
from marko.block import BlockElement
|
||||
from marko.source import Source
|
||||
from re import Match
|
||||
|
||||
|
||||
class TOC(BlockElement):
|
||||
"""Represents TOC field
|
||||
|
||||
Syntax: [TOC]"""
|
||||
|
||||
pattern = r"\[(TOC)\]+"
|
||||
|
||||
def __init__(self, match: Match[str]):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def match(cls, source: Source) -> Match[str] | None:
|
||||
return source.expect_re(cls.pattern)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, source: Source) -> Match[str] | None:
|
||||
m = source.match
|
||||
source.consume()
|
||||
return m
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Pre-assign section-scoped numbers and resolve @Type:label references."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .numberer import Numberer, APPENDIX_LETTERS
|
||||
from .renderable import Renderable
|
||||
from .renderable.heading import Heading
|
||||
from .renderable.requires_numbering import RequiresNumbering
|
||||
from .renderable.equation import Equation
|
||||
from .renderable.paragraph import Paragraph
|
||||
from .renderable.caption import CaptionInfo
|
||||
|
||||
|
||||
APPENDIX_RE = re.compile(r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-Z])\b", re.IGNORECASE)
|
||||
|
||||
|
||||
class LabelRegistry:
|
||||
def __init__(self):
|
||||
self._map: dict[tuple[str, str], str] = {}
|
||||
|
||||
def put(self, category: str, name: str, number: str):
|
||||
self._map[(category.lower(), name)] = number
|
||||
# also store without category for loose lookup
|
||||
self._map[("*", name)] = f"{category} {number}"
|
||||
|
||||
def get(self, category: str, name: str) -> str | None:
|
||||
return self._map.get((category.lower(), name))
|
||||
|
||||
def get_display(self, category: str, name: str) -> str | None:
|
||||
n = self.get(category, name)
|
||||
if n:
|
||||
return f"{category} {n}"
|
||||
# try Russian category aliases
|
||||
aliases = {
|
||||
"рисунок": "Рисунок",
|
||||
"figure": "Рисунок",
|
||||
"таблица": "Таблица",
|
||||
"table": "Таблица",
|
||||
"листинг": "Листинг",
|
||||
"listing": "Листинг",
|
||||
"формула": "Формула",
|
||||
"formula": "Формула",
|
||||
"equation": "Формула",
|
||||
}
|
||||
cat = aliases.get(category.lower(), category)
|
||||
n = self.get(cat, name)
|
||||
if n:
|
||||
return f"{cat} {n}"
|
||||
return None
|
||||
|
||||
|
||||
def assign_numbers(renderables: list[Renderable],
|
||||
numbered_equations: set[str] | None = None,
|
||||
numbering_scope: str = "section") -> LabelRegistry:
|
||||
"""Walk renderables once, set_number on objects, fill LabelRegistry."""
|
||||
numbered_equations = numbered_equations or set()
|
||||
numberer = Numberer(mode=numbering_scope)
|
||||
registry = LabelRegistry()
|
||||
section_count = 0
|
||||
appendix_index = 0
|
||||
|
||||
for r in renderables:
|
||||
if isinstance(r, Heading):
|
||||
text = (r.text or "").strip()
|
||||
upper = text.upper()
|
||||
if r.level == 1 and r.is_numbered:
|
||||
section_count += 1
|
||||
numberer.enter_section(section_count)
|
||||
m = APPENDIX_RE.match(upper)
|
||||
if m:
|
||||
numberer.enter_appendix(m.group(1).upper())
|
||||
elif upper.startswith("ПРИЛОЖЕНИЕ"):
|
||||
parts = upper.split()
|
||||
if len(parts) >= 2 and parts[1] in APPENDIX_LETTERS:
|
||||
numberer.enter_appendix(parts[1])
|
||||
elif appendix_index < len(APPENDIX_LETTERS):
|
||||
numberer.enter_appendix(APPENDIX_LETTERS[appendix_index])
|
||||
appendix_index += 1
|
||||
|
||||
if isinstance(r, Equation):
|
||||
label = r.unique_name
|
||||
if r.needs_numbering or (label and label in numbered_equations):
|
||||
r.enable_numbering()
|
||||
num = numberer.next_number("Формула", label)
|
||||
r.set_number(num)
|
||||
if label:
|
||||
registry.put("Формула", label, num)
|
||||
continue
|
||||
|
||||
if isinstance(r, RequiresNumbering):
|
||||
label = getattr(r, "unique_name", None)
|
||||
num = numberer.next_number(r.numbering_category, label)
|
||||
r.set_number(num)
|
||||
if label:
|
||||
registry.put(r.numbering_category, label, num)
|
||||
# Diagram +listing → also number Листинг with same label
|
||||
from .renderable.diagram import DiagramFigure
|
||||
if isinstance(r, DiagramFigure) and r.listing is not None:
|
||||
lnum = numberer.next_number("Листинг", label)
|
||||
r.set_listing_number(lnum)
|
||||
if label:
|
||||
registry.put("Листинг", label, lnum)
|
||||
# Images nested in paragraphs
|
||||
if isinstance(r, Paragraph):
|
||||
pass
|
||||
|
||||
# Images attached to paragraphs
|
||||
if isinstance(r, Paragraph):
|
||||
for img in getattr(r, "_images", []):
|
||||
label = getattr(img, "unique_name", None)
|
||||
num = numberer.next_number(img.numbering_category, label)
|
||||
img.set_number(num)
|
||||
if label:
|
||||
registry.put(img.numbering_category, label, num)
|
||||
|
||||
return registry
|
||||
|
||||
|
||||
def resolve_pending_in_renderables(renderables: list[Renderable], resolve_fn) -> None:
|
||||
"""Resolve @Type:label placeholders in paragraphs, lists, headings and table cells."""
|
||||
from .renderable.list import List as RList
|
||||
from .renderable.table import Table
|
||||
from .renderable.heading import Heading
|
||||
|
||||
def resolve_para(p):
|
||||
if hasattr(p, "resolve_pending_refs"):
|
||||
p.resolve_pending_refs(resolve_fn)
|
||||
|
||||
for r in renderables:
|
||||
if isinstance(r, Paragraph):
|
||||
resolve_para(r)
|
||||
elif isinstance(r, Heading):
|
||||
resolve_para(r)
|
||||
elif isinstance(r, RList):
|
||||
for p in getattr(r, "_paragraphs", []):
|
||||
resolve_para(p)
|
||||
elif isinstance(r, Table):
|
||||
for row in getattr(r, "_rows", []):
|
||||
for cell in row:
|
||||
for p in cell:
|
||||
resolve_para(p)
|
||||
|
||||
|
||||
# Patch Paragraph to resolve references at run-creation time via module-level registry
|
||||
_ACTIVE_REGISTRY: LabelRegistry | None = None
|
||||
|
||||
|
||||
def set_active_registry(registry: LabelRegistry | None):
|
||||
global _ACTIVE_REGISTRY
|
||||
_ACTIVE_REGISTRY = registry
|
||||
|
||||
|
||||
def resolve_reference(type_: str, name: str) -> str:
|
||||
if _ACTIVE_REGISTRY is None:
|
||||
return f"{type_}?"
|
||||
display = _ACTIVE_REGISTRY.get_display(type_, name)
|
||||
return display or f"{type_}?"
|
||||
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
from copy import deepcopy
|
||||
|
||||
from lxml import etree
|
||||
|
||||
import latex2mathml.converter
|
||||
from lxml.etree import _Element
|
||||
|
||||
from . import package_dir
|
||||
|
||||
|
||||
def latex_to_omml(latex_equation: str) -> _Element:
|
||||
try:
|
||||
mathml = latex2mathml.converter.convert(latex_equation)
|
||||
tree = etree.fromstring(mathml)
|
||||
xslt = etree.parse(
|
||||
os.path.join(package_dir(), "mml2omml")
|
||||
)
|
||||
transform = etree.XSLT(xslt)
|
||||
new_dom = transform(tree)
|
||||
word_math = new_dom.getroot()
|
||||
except Exception:
|
||||
raise ValueError(f"Can't parse the formula:\n{latex_equation}")
|
||||
|
||||
return word_math
|
||||
|
||||
|
||||
def inline_omml(omml: _Element):
|
||||
omml = deepcopy(omml)
|
||||
|
||||
def new_r_with_t(text):
|
||||
r = etree.Element("{http://schemas.openxmlformats.org/officeDocument/2006/math}r", nsmap=nsmap)
|
||||
t = etree.Element("{http://schemas.openxmlformats.org/officeDocument/2006/math}t", nsmap=nsmap)
|
||||
t.text = text
|
||||
r.append(t)
|
||||
return r
|
||||
|
||||
nsmap = omml.nsmap
|
||||
|
||||
for fraction in omml.xpath("//m:f", namespaces=nsmap):
|
||||
num = fraction.xpath('./m:num', namespaces=nsmap)[0]
|
||||
den = fraction.xpath('./m:den', namespaces=nsmap)[0]
|
||||
|
||||
new_elements = []
|
||||
|
||||
if len(num) == 1:
|
||||
new_elements += num
|
||||
else:
|
||||
new_elements.append(new_r_with_t("("))
|
||||
new_elements += num
|
||||
new_elements.append(new_r_with_t(")"))
|
||||
|
||||
new_elements.append(new_r_with_t("/"))
|
||||
|
||||
if len(den) == 1:
|
||||
new_elements += den
|
||||
else:
|
||||
new_elements.append(new_r_with_t("("))
|
||||
new_elements += den
|
||||
new_elements.append(new_r_with_t(")"))
|
||||
|
||||
for i in range(len(new_elements)):
|
||||
fraction.getparent().insert(fraction.getparent().index(fraction) + i, new_elements[i])
|
||||
fraction.getparent().remove(fraction)
|
||||
|
||||
return omml
|
||||
@@ -0,0 +1,53 @@
|
||||
from copy import copy
|
||||
|
||||
from docx.shared import Length
|
||||
|
||||
|
||||
class LayoutState:
|
||||
def __init__(self, max_height: Length, max_width: Length):
|
||||
self.max_height: Length = max_height
|
||||
self.max_width: Length = max_width
|
||||
self._current_height: Length = Length(0)
|
||||
|
||||
def new_page(self):
|
||||
self._current_height += self.remaining_page_height
|
||||
|
||||
@property
|
||||
def current_page_height(self):
|
||||
return self._current_height % self.max_height
|
||||
|
||||
@property
|
||||
def remaining_page_height(self) -> Length:
|
||||
return self.max_height - self.current_page_height
|
||||
|
||||
@property
|
||||
def page(self):
|
||||
return self._current_height // self.max_height + 1
|
||||
|
||||
def add_height(self, height: Length):
|
||||
self._current_height += height
|
||||
|
||||
|
||||
class LayoutTracker:
|
||||
def __init__(self, max_height: Length, max_width: Length):
|
||||
self._state = LayoutState(max_height, max_width)
|
||||
self._is_new_page = False
|
||||
|
||||
@property
|
||||
def current_state(self):
|
||||
return copy(self._state)
|
||||
|
||||
@property
|
||||
def is_new_page(self):
|
||||
return self._is_new_page
|
||||
|
||||
def add_height(self, height: Length):
|
||||
page = self._state.page
|
||||
self._state.add_height(height)
|
||||
self._is_new_page = self._state.page > page
|
||||
|
||||
def can_fit_to_page(self, height: Length):
|
||||
return height <= self._state.remaining_page_height
|
||||
|
||||
def new_page(self):
|
||||
self._state.new_page()
|
||||
+3822
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
"""Section-scoped and continuous object numbering."""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
APPENDIX_LETTERS = "АБВГДЕЖИКЛМНПРСТУФХЦШЩЭЮЯ" # without Ё З Й О Ч Ь Ы Ъ
|
||||
|
||||
|
||||
class Numberer:
|
||||
"""Assigns object numbers.
|
||||
|
||||
* ``section`` (MIREA): ``1.1``, ``2.3`` or ``Б.1`` — counters reset per section/appendix.
|
||||
* ``continuous`` (PIS_custom): ``1``, ``2``, ``3`` — сквозная нумерация по всему документу.
|
||||
"""
|
||||
|
||||
def __init__(self, mode: str = "section"):
|
||||
if mode not in ("section", "continuous"):
|
||||
raise ValueError(f"Unknown numbering mode: {mode!r}")
|
||||
self._mode = mode
|
||||
self._section: int = 0
|
||||
self._appendix: str | None = None
|
||||
self._counters: dict[str, int] = defaultdict(int)
|
||||
self._labels: dict[tuple[str, str], str] = {} # (category, unique_name) -> number string
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
return self._mode
|
||||
|
||||
@property
|
||||
def section(self) -> int:
|
||||
return self._section
|
||||
|
||||
@property
|
||||
def appendix(self) -> str | None:
|
||||
return self._appendix
|
||||
|
||||
def enter_section(self, section_number: int) -> None:
|
||||
"""Start a numbered chapter (Heading 1 numbered)."""
|
||||
self._section = section_number
|
||||
self._appendix = None
|
||||
if self._mode != "continuous":
|
||||
self._counters.clear()
|
||||
|
||||
def enter_appendix(self, letter: str) -> None:
|
||||
"""Start an appendix (letter А, Б, …)."""
|
||||
self._appendix = letter
|
||||
if self._mode != "continuous":
|
||||
self._counters.clear()
|
||||
|
||||
def next_number(self, category: str, unique_name: str | None = None) -> str:
|
||||
"""Allocate next number for category; optionally bind a label."""
|
||||
self._counters[category] += 1
|
||||
n = self._counters[category]
|
||||
if self._mode == "continuous":
|
||||
number = str(n)
|
||||
elif self._appendix:
|
||||
number = f"{self._appendix}.{n}"
|
||||
elif self._section:
|
||||
number = f"{self._section}.{n}"
|
||||
else:
|
||||
number = str(n)
|
||||
if unique_name:
|
||||
self._labels[(category, unique_name)] = number
|
||||
return number
|
||||
|
||||
def resolve(self, category: str, unique_name: str) -> str | None:
|
||||
return self._labels.get((category, unique_name))
|
||||
|
||||
def register_label(self, category: str, unique_name: str, number: str) -> None:
|
||||
self._labels[(category, unique_name)] = number
|
||||
@@ -0,0 +1,36 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from docx import Document
|
||||
from marko.block import BlankLine
|
||||
|
||||
from .extended_markdown import markdown, Caption
|
||||
from .renderable.caption import CaptionInfo
|
||||
from .renderable.renderable import Renderable
|
||||
from .renderable_factory import RenderableFactory
|
||||
|
||||
|
||||
class Parser:
|
||||
"""Parses given markdown string and returns Renderable elements"""
|
||||
|
||||
def __init__(self, document: Document, text: str):
|
||||
self._document = document
|
||||
self._parsed = markdown.parse(text)
|
||||
self._caption_info: CaptionInfo | None = None
|
||||
|
||||
def parse(self) -> Generator[Renderable, None, None]:
|
||||
factory = RenderableFactory(self._document._body)
|
||||
|
||||
for marko_element in self._parsed.children:
|
||||
if isinstance(marko_element, BlankLine):
|
||||
continue
|
||||
|
||||
if isinstance(marko_element, Caption):
|
||||
self._caption_info = CaptionInfo(
|
||||
marko_element.unique_name,
|
||||
marko_element.text,
|
||||
getattr(marko_element, "with_listing", False),
|
||||
)
|
||||
continue
|
||||
|
||||
yield factory.create(marko_element, self._caption_info)
|
||||
self._caption_info = None
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Document type profiles and preprocess helpers for MIREA TZ."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
DOC_TYPES = ("coursework", "practice", "vkr", "PIS_custom", "APID_coursework")
|
||||
|
||||
# auto — Word multilevel list numbers headings; strip digits from md text
|
||||
# manual — digits stay in md (# 1 … / ## 1.1 …); Word auto-numbering disabled
|
||||
HEADING_NUMBERING_MODES = ("auto", "manual")
|
||||
DEFAULT_HEADING_NUMBERING = "manual"
|
||||
|
||||
# manual — content built by md2gost with page numbers from layout tracker
|
||||
# native — Word TOC field (обновить поле при открытии в Word)
|
||||
TOC_MODES = ("manual", "native")
|
||||
DEFAULT_TOC_MODE = "native"
|
||||
|
||||
# off — одна таблица Word, пагинацию делает Word (по умолчанию; без автоподписи)
|
||||
# legacy — режем по оценке высоты + «Продолжение…» с page_break_before
|
||||
# caption — режем по оценке + явный PageBreak + «Продолжение…»
|
||||
# soft — синоним off (автоподпись mid-page без точной вёрстки Word невозможна в DOCX)
|
||||
TABLE_CONTINUATION_MODES = ("off", "legacy", "caption", "soft")
|
||||
DEFAULT_TABLE_CONTINUATION = "off"
|
||||
|
||||
# section — Рисунок 1.1 / 2.1 (ТЗ МИРЭА); continuous — Рисунок 1, 2, 3 (ПИС)
|
||||
NUMBERING_SCOPES = ("section", "continuous")
|
||||
STYLE_PRESETS = ("mirea", "pis_custom")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocProfile:
|
||||
name: str
|
||||
min_sources: int
|
||||
max_sources: int | None # None = no upper bound
|
||||
sectional_biblio: bool
|
||||
require_graphic_appendix: bool
|
||||
max_source_age_years: int = 5
|
||||
# mirea — ГОСТ/методичка МИРЭА; pis_custom — итоговый отчёт по практическим работам
|
||||
style_preset: str = "mirea"
|
||||
numbering_scope: str = "section"
|
||||
# Structure expectations for checker
|
||||
require_intro_conclusion: bool = True
|
||||
require_bibliography: bool = True
|
||||
require_practical_works: bool = False
|
||||
# КР по «Архитектуре приложений и данных» (методичка Аншиной/Лагуновой)
|
||||
require_apid_kr_structure: bool = False
|
||||
|
||||
|
||||
PROFILES = {
|
||||
"coursework": DocProfile("coursework", 5, 7, False, False),
|
||||
"practice": DocProfile("practice", 5, 7, False, False),
|
||||
"vkr": DocProfile("vkr", 10, None, True, True),
|
||||
# Курсовая АПИД: оформление ГОСТ/Кириллина, источники 7–20 по методичке дисциплины
|
||||
"APID_coursework": DocProfile(
|
||||
"APID_coursework",
|
||||
min_sources=7,
|
||||
max_sources=20,
|
||||
sectional_biblio=False,
|
||||
require_graphic_appendix=False,
|
||||
max_source_age_years=5,
|
||||
style_preset="mirea",
|
||||
numbering_scope="section",
|
||||
require_intro_conclusion=True,
|
||||
require_bibliography=True,
|
||||
require_practical_works=False,
|
||||
require_apid_kr_structure=True,
|
||||
),
|
||||
# Итоговый отчёт по практическим работам (чек-лист ПИС)
|
||||
"PIS_custom": DocProfile(
|
||||
"PIS_custom",
|
||||
min_sources=0,
|
||||
max_sources=None,
|
||||
sectional_biblio=False,
|
||||
require_graphic_appendix=False,
|
||||
max_source_age_years=10,
|
||||
style_preset="pis_custom",
|
||||
numbering_scope="continuous",
|
||||
require_intro_conclusion=False,
|
||||
require_bibliography=False,
|
||||
require_practical_works=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_profile(doc_type: str) -> DocProfile:
|
||||
if doc_type not in PROFILES:
|
||||
raise ValueError(f"Unknown document type: {doc_type}. Expected one of {DOC_TYPES}")
|
||||
return PROFILES[doc_type]
|
||||
|
||||
|
||||
# --- text autofixes ---
|
||||
|
||||
def fix_russian_quotes(text: str) -> str:
|
||||
"""Replace "..." with «...» for Russian text segments (heuristic)."""
|
||||
def repl(m):
|
||||
inner = m.group(1)
|
||||
if inner.lstrip().startswith("%"):
|
||||
return m.group(0)
|
||||
return f"«{inner}»"
|
||||
return re.sub(r'"([^"\n]+)"', repl, text)
|
||||
|
||||
|
||||
def fix_dashes(text: str) -> str:
|
||||
"""Replace spaced hyphen used as dash with em dash; keep range hyphens."""
|
||||
# "слово - слово" → "слово — слово"
|
||||
text = re.sub(r"(\S) - (\S)", r"\1 — \2", text)
|
||||
return text
|
||||
|
||||
|
||||
def replace_emdash_with_hyphen(text: str) -> str:
|
||||
"""Replace typographic em dash U+2014 with ASCII hyphen-minus."""
|
||||
return text.replace("—", "-")
|
||||
|
||||
|
||||
# Runtime switch for — vs - (set from CLI / Converter)
|
||||
_EMDASH_TO_HYPHEN = False
|
||||
|
||||
|
||||
def set_emdash_to_hyphen(enabled: bool) -> None:
|
||||
global _EMDASH_TO_HYPHEN
|
||||
_EMDASH_TO_HYPHEN = bool(enabled)
|
||||
|
||||
|
||||
def emdash_to_hyphen_enabled() -> bool:
|
||||
return _EMDASH_TO_HYPHEN
|
||||
|
||||
|
||||
def dash_separator() -> str:
|
||||
"""Caption / prose dash: ' - ' or ' — ' depending on CLI flag."""
|
||||
return " - " if _EMDASH_TO_HYPHEN else " — "
|
||||
|
||||
|
||||
def preprocess_markdown(text: str, emdash_to_hyphen: bool | None = None) -> str:
|
||||
if emdash_to_hyphen is not None:
|
||||
set_emdash_to_hyphen(emdash_to_hyphen)
|
||||
text = fix_russian_quotes(text)
|
||||
if _EMDASH_TO_HYPHEN:
|
||||
# Keep ASCII hyphens; flatten any em dashes from the source
|
||||
text = replace_emdash_with_hyphen(text)
|
||||
else:
|
||||
text = fix_dashes(text)
|
||||
text = separate_biblio_lines(text)
|
||||
return text
|
||||
|
||||
|
||||
def separate_biblio_lines(text: str) -> str:
|
||||
"""Ensure each [n]: bibliography line is its own paragraph (Marko soft-breaks otherwise)."""
|
||||
lines = text.splitlines(keepends=True)
|
||||
out: list[str] = []
|
||||
in_biblio = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if re.match(r"^#\s*\*?\s*СПИСОК\s+ИСПОЛЬЗОВАНН?ЫХ\s+ИСТОЧНИКОВ\s*$", stripped, re.I):
|
||||
in_biblio = True
|
||||
out.append(line)
|
||||
continue
|
||||
if in_biblio and re.match(r"^#\s+", stripped):
|
||||
in_biblio = False
|
||||
if in_biblio and re.match(r"^\[\d+(?:\.\d+)?\]:", stripped):
|
||||
# blank line before entry if previous wasn't blank
|
||||
if out and out[-1].strip():
|
||||
out.append("\n")
|
||||
out.append(line if line.endswith("\n") else line + "\n")
|
||||
out.append("\n")
|
||||
continue
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def find_formula_refs(text: str) -> set[str]:
|
||||
"""Labels of formulas that are cited, e.g. @Формула:euler or (1.1) after %eq."""
|
||||
refs = set()
|
||||
for m in re.finditer(r"@Формула:(\w+)", text, re.IGNORECASE):
|
||||
refs.add(m.group(1))
|
||||
for m in re.finditer(r"@Formula:(\w+)", text, re.IGNORECASE):
|
||||
refs.add(m.group(1))
|
||||
return refs
|
||||
|
||||
|
||||
def current_year() -> int:
|
||||
return datetime.now().year
|
||||
@@ -0,0 +1,9 @@
|
||||
from .renderable import Renderable
|
||||
from .paragraph import Paragraph
|
||||
from .listing import Listing
|
||||
|
||||
__all__ = [
|
||||
"Renderable",
|
||||
"Paragraph",
|
||||
"Listing"
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
from copy import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import Generator
|
||||
|
||||
from docx.shared import Parented
|
||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
|
||||
from md2gost.layout_tracker import LayoutState
|
||||
from md2gost.renderable import Renderable
|
||||
from md2gost.rendered_info import RenderedInfo
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
from ..util import create_element
|
||||
|
||||
|
||||
# Map category → Word style name
|
||||
CAPTION_STYLES = {
|
||||
"Рисунок": "Caption Figure",
|
||||
"Таблица": "Caption Table",
|
||||
"Листинг": "Caption Listing",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptionInfo:
|
||||
unique_name: str | None
|
||||
text: str | None
|
||||
with_listing: bool = False
|
||||
|
||||
|
||||
class Caption(Renderable):
|
||||
def __init__(self, parent: Parented, category: str, caption_info: CaptionInfo | None,
|
||||
number: str | None = None, before: bool = True):
|
||||
self._parent = parent
|
||||
self._before = before
|
||||
self._category = category
|
||||
self._docx_paragraph = DocxParagraph(create_element("w:p"), parent)
|
||||
|
||||
style_name = CAPTION_STYLES.get(category, "Caption")
|
||||
try:
|
||||
self._docx_paragraph.style = style_name
|
||||
except KeyError:
|
||||
self._docx_paragraph.style = "Caption"
|
||||
|
||||
# Format: «Рисунок 1.1 — Название» (or « - » if --emdash-to-hyphen)
|
||||
from ..profiles import dash_separator
|
||||
self._docx_paragraph.add_run(f"{category} ")
|
||||
self._numbering_run = self._docx_paragraph.add_run(str(number) if number else "?")
|
||||
if caption_info and caption_info.text:
|
||||
text = caption_info.text.strip().rstrip(".")
|
||||
if text:
|
||||
self._docx_paragraph.add_run(f"{dash_separator()}{text}")
|
||||
|
||||
def center(self):
|
||||
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
|
||||
"RenderedInfo | Renderable", None, None]:
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
previous_rendered.docx_element
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
|
||||
layout_state.max_width
|
||||
).calculate_height()
|
||||
|
||||
if self._before and ((height_data.lines + 2 - 1) * height_data.line_spacing + 1) * height_data.line_height \
|
||||
> layout_state.remaining_page_height:
|
||||
self._docx_paragraph.paragraph_format.page_break_before = True
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
None,
|
||||
layout_state.max_width
|
||||
).calculate_height()
|
||||
|
||||
yield RenderedInfo(self._docx_paragraph, height_data.full + (layout_state.remaining_page_height
|
||||
if self._docx_paragraph.paragraph_format.page_break_before else 0))
|
||||
@@ -0,0 +1,68 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Generator
|
||||
|
||||
from docx.shared import Parented
|
||||
|
||||
from .caption import CaptionInfo
|
||||
from .image import Image
|
||||
from .listing import Listing
|
||||
from .renderable import Renderable
|
||||
from .requires_numbering import RequiresNumbering
|
||||
from ..diagram_renderer import render_diagram
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..rendered_info import RenderedInfo
|
||||
|
||||
|
||||
class DiagramFigure(Renderable, RequiresNumbering):
|
||||
"""UML/BPMN/C4 fence → PNG figure (+ optional source listing)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Parented,
|
||||
lang: str,
|
||||
source: str,
|
||||
caption_info: CaptionInfo | None = None,
|
||||
with_listing: bool = False,
|
||||
):
|
||||
super().__init__("Рисунок")
|
||||
self._parent = parent
|
||||
self._lang = lang
|
||||
self._source = source
|
||||
self._caption_info = caption_info
|
||||
self._with_listing = with_listing or (
|
||||
caption_info.with_listing if caption_info else False
|
||||
)
|
||||
self._number = None
|
||||
self._listing_number = None
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
self.listing: Listing | None = None
|
||||
if self._with_listing:
|
||||
listing_caption = CaptionInfo(
|
||||
self.unique_name, # same id → @Листинг:id
|
||||
caption_info.text if caption_info else None,
|
||||
)
|
||||
self.listing = Listing(parent, lang, listing_caption)
|
||||
self.listing.set_text(source)
|
||||
|
||||
def set_number(self, number: str):
|
||||
self._number = number
|
||||
|
||||
def set_listing_number(self, number: str):
|
||||
self._listing_number = number
|
||||
if self.listing:
|
||||
self.listing.set_number(number)
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) \
|
||||
-> Generator[RenderedInfo, None, None]:
|
||||
png_path = render_diagram(self._lang, self._source)
|
||||
image = Image(self._parent, png_path, self._caption_info)
|
||||
if self._number is not None:
|
||||
image.set_number(self._number)
|
||||
yield from image.render(previous_rendered, layout_state)
|
||||
|
||||
if self.listing is not None:
|
||||
if self._listing_number is not None:
|
||||
self.listing.set_number(self._listing_number)
|
||||
yield from self.listing.render(None, layout_state)
|
||||
@@ -0,0 +1,91 @@
|
||||
from typing import Generator
|
||||
|
||||
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
|
||||
from docx.oxml import CT_Tbl
|
||||
from docx.shared import Pt, Twips
|
||||
from docx.table import Table
|
||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||
|
||||
from .requires_numbering import RequiresNumbering
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..renderable import Renderable
|
||||
from ..rendered_info import RenderedInfo
|
||||
from ..util import create_element
|
||||
from ..latex_math import latex_to_omml
|
||||
|
||||
|
||||
_HEIGHT = Pt(50)
|
||||
|
||||
|
||||
class Equation(Renderable, RequiresNumbering):
|
||||
def __init__(self, parent, latex_formula: str, unique_name: str | None = None,
|
||||
numbered: bool = False):
|
||||
super().__init__("Формула")
|
||||
self.unique_name = unique_name
|
||||
self._numbered = numbered
|
||||
word_math = latex_to_omml(latex_formula)
|
||||
|
||||
sect = parent.part.document.sections[0]
|
||||
|
||||
left_margin = Twips(int(
|
||||
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
right_margin = Twips(int(
|
||||
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:right")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
|
||||
table_width = sect.page_width - sect.right_margin - sect.left_margin + left_margin + right_margin
|
||||
|
||||
self._parent = parent
|
||||
self._table = table = Table(CT_Tbl.new_tbl(1, 2, table_width), parent)
|
||||
|
||||
left_cell = table.cell(0, 0)
|
||||
right_cell = table.cell(0, 1)
|
||||
right_cell.width = Pt(40)
|
||||
left_cell.width = table_width - right_cell.width
|
||||
|
||||
table.rows[0].height = _HEIGHT
|
||||
|
||||
left_paragraph = left_cell.paragraphs[0]
|
||||
left_paragraph.style = "Formula Content"
|
||||
left_paragraph._p.append(word_math)
|
||||
left_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
||||
|
||||
self._right_paragraph = right_cell.paragraphs[0]
|
||||
self._right_paragraph.style = "Formula Numbering"
|
||||
self._number_text = "?"
|
||||
right_cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
|
||||
|
||||
self._spacer_before = create_element("w:p")
|
||||
self._spacer_after = create_element("w:p")
|
||||
|
||||
def enable_numbering(self):
|
||||
self._numbered = True
|
||||
|
||||
@property
|
||||
def needs_numbering(self) -> bool:
|
||||
return self._numbered
|
||||
|
||||
def set_number(self, number: str):
|
||||
self._number_text = str(number)
|
||||
self._numbered = True
|
||||
# Rebuild right cell content
|
||||
p = self._right_paragraph._p
|
||||
for child in list(p):
|
||||
if child.tag.endswith("}r"):
|
||||
p.remove(child)
|
||||
p.append(create_element("w:r", "("))
|
||||
p.append(create_element("w:r", self._number_text))
|
||||
p.append(create_element("w:r", ")"))
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
|
||||
"RenderedInfo | Renderable", None, None]:
|
||||
spacer_h = Pt(21) # ~ one empty line at 14pt * 1.5
|
||||
height = _HEIGHT + spacer_h * 2
|
||||
|
||||
if height > layout_state.remaining_page_height:
|
||||
height += layout_state.remaining_page_height
|
||||
|
||||
yield RenderedInfo(DocxParagraph(self._spacer_before, self._parent), spacer_h)
|
||||
yield RenderedInfo(self._table, _HEIGHT)
|
||||
yield RenderedInfo(DocxParagraph(self._spacer_after, self._parent), spacer_h)
|
||||
@@ -0,0 +1,43 @@
|
||||
from sys import platform, exit
|
||||
import subprocess
|
||||
import logging
|
||||
from functools import cache
|
||||
|
||||
|
||||
def __find_font_linux(name: str, bold: bool, italic: bool):
|
||||
result = subprocess.run(
|
||||
"fc-list", shell=True, check=True, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
fonts = \
|
||||
[line.split(":") for line in result.stdout.strip().split("\n")]
|
||||
fonts = [font for font in fonts if len(font) == 3]
|
||||
else:
|
||||
logging.log(logging.ERROR, "fc-list not found")
|
||||
exit(1)
|
||||
|
||||
for path, names, styles in fonts:
|
||||
if (name in names
|
||||
and ("Bold" in styles) == bool(bold)
|
||||
and ("Italic" in styles) == bool(italic)):
|
||||
return path
|
||||
raise ValueError(f"Font {name} not found")
|
||||
|
||||
|
||||
@cache
|
||||
def find_font(name: str, bold: bool, italic: bool):
|
||||
if not name:
|
||||
raise ValueError("Invalid font")
|
||||
if platform == "linux":
|
||||
return __find_font_linux(name, bold, italic)
|
||||
else:
|
||||
from matplotlib.font_manager import findfont, FontProperties
|
||||
return findfont(FontProperties(
|
||||
family=name,
|
||||
weight="bold" if bold else "normal",
|
||||
style="italic" if italic else "normal"), fallback_to_default=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(find_font("Courier New", False, False))
|
||||
@@ -0,0 +1,162 @@
|
||||
from copy import copy
|
||||
from typing import Generator
|
||||
import re
|
||||
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||
from docx.shared import Parented, Length
|
||||
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
from ..layout_tracker import LayoutState
|
||||
from .paragraph import Paragraph
|
||||
from ..rendered_info import RenderedInfo
|
||||
from ..sub_renderable import SubRenderable
|
||||
from ..util import create_element
|
||||
|
||||
|
||||
# Leading section numbers written in markdown: "1 ", "1.1 ", "1.2.3. "
|
||||
_LEADING_NUMBER_RE = re.compile(r"^\d+(?:\.\d+)*\.?\s+")
|
||||
|
||||
|
||||
class Heading(Paragraph):
|
||||
def __init__(self, parent: Parented, level: int, numbered: bool,
|
||||
numbering_mode: str = "manual"):
|
||||
super().__init__(parent)
|
||||
|
||||
self._numbered = numbered
|
||||
self._numbering_mode = numbering_mode # auto | manual
|
||||
self._parent = parent
|
||||
self._level = level
|
||||
|
||||
if not 1 <= level <= 9:
|
||||
raise ValueError("Heading level must be in range from 1 to 9")
|
||||
|
||||
self.style = f"Heading {level}"
|
||||
|
||||
if not numbered:
|
||||
self._remove_numbering()
|
||||
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
elif numbering_mode == "manual":
|
||||
# Digits already in markdown text — kill Word list numbering to avoid "1 1 …"
|
||||
self._remove_numbering()
|
||||
|
||||
self._rendered_page = 0
|
||||
self._title_stripped = False
|
||||
|
||||
@property
|
||||
def is_numbered(self) -> bool:
|
||||
return self._numbered
|
||||
|
||||
@property
|
||||
def numbering_mode(self) -> str:
|
||||
return self._numbering_mode
|
||||
|
||||
@numbering_mode.setter
|
||||
def numbering_mode(self, value: str):
|
||||
self._numbering_mode = value
|
||||
if self._numbered and value == "manual":
|
||||
self._remove_numbering()
|
||||
|
||||
@property
|
||||
def rendered_page(self) -> int:
|
||||
return self._rendered_page
|
||||
|
||||
@property
|
||||
def level(self) -> int:
|
||||
return self._level
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self._docx_paragraph.text
|
||||
|
||||
def apply_numbering_mode(self, mode: str):
|
||||
"""Apply auto/manual after runs are filled."""
|
||||
self._numbering_mode = mode
|
||||
if not self._numbered:
|
||||
return
|
||||
if mode == "manual":
|
||||
self._remove_numbering()
|
||||
elif mode == "auto":
|
||||
self._strip_leading_number_from_runs()
|
||||
|
||||
def _strip_leading_number_from_runs(self):
|
||||
"""Remove '1 ', '1.1 ' etc. from text so Word list provides the number."""
|
||||
if self._title_stripped or not self._numbered:
|
||||
return
|
||||
full = self._docx_paragraph.text or ""
|
||||
m = _LEADING_NUMBER_RE.match(full)
|
||||
if not m:
|
||||
return
|
||||
# Rewrite runs: put remaining text into first run, clear the rest
|
||||
remaining = full[m.end():]
|
||||
runs = self._docx_paragraph.runs
|
||||
if not runs:
|
||||
return
|
||||
runs[0].text = remaining
|
||||
for r in runs[1:]:
|
||||
r.text = ""
|
||||
self._title_stripped = True
|
||||
|
||||
def _remove_numbering(self):
|
||||
pPr = self._docx_paragraph._p.get_or_add_pPr()
|
||||
# Remove existing numPr if any, then set numId=0
|
||||
for child in list(pPr):
|
||||
if child.tag.endswith("}numPr"):
|
||||
pPr.remove(child)
|
||||
pPr.append(
|
||||
create_element("w:numPr", [
|
||||
create_element("w:ilvl", {
|
||||
"w:val": "0"
|
||||
}),
|
||||
create_element("w:numId", {
|
||||
"w:val": "0"
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
def exclude_from_toc(self):
|
||||
"""Keep Heading N look, but outline level = Body Text so Word TOC skips it."""
|
||||
pPr = self._docx_paragraph._p.get_or_add_pPr()
|
||||
for child in list(pPr):
|
||||
if child.tag.endswith("}outlineLvl"):
|
||||
pPr.remove(child)
|
||||
# 9 = body text (not outline levels 1–3 used by TOC \o "1-3")
|
||||
pPr.append(create_element("w:outlineLvl", {"w:val": "9"}))
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
remaining_height = layout_state.remaining_page_height
|
||||
|
||||
if self._level == 1 and layout_state.page != 1 and\
|
||||
not (isinstance(previous_rendered.docx_element, DocxParagraph)
|
||||
and previous_rendered.docx_element.text == "\n"):
|
||||
self.page_break_before = True
|
||||
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
previous_rendered.docx_element
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
|
||||
layout_state.max_width).calculate_height()
|
||||
|
||||
if layout_state.current_page_height == 0 and layout_state.page != 1:
|
||||
height_data.before = 0
|
||||
|
||||
# if a heading + 3 lines don't fit to the page, they go to the next page
|
||||
if ((height_data.lines + 3 - 1) * height_data.line_spacing + 1) * height_data.line_height\
|
||||
> layout_state.remaining_page_height:
|
||||
self._docx_paragraph.paragraph_format.space_before = 0 # libreoffice fix
|
||||
height = height_data.full - height_data.before
|
||||
|
||||
# force this behaviour as there could be a table or an image instead of text
|
||||
self.page_break_before = True
|
||||
|
||||
else:
|
||||
height = height_data.full
|
||||
|
||||
if self.page_break_before:
|
||||
height += remaining_height
|
||||
|
||||
layout_state.add_height(height)
|
||||
self._rendered_page = layout_state.page
|
||||
|
||||
yield RenderedInfo(self._docx_paragraph, Length(height))
|
||||
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
from copy import copy
|
||||
from io import BytesIO
|
||||
from typing import Generator
|
||||
from os import environ
|
||||
import os.path
|
||||
|
||||
import requests
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from docx.shared import Parented, Length
|
||||
from docx.text.paragraph import Paragraph
|
||||
|
||||
from .caption import Caption, CaptionInfo
|
||||
from .renderable import Renderable
|
||||
from .requires_numbering import RequiresNumbering
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..rendered_info import RenderedInfo
|
||||
from ..sub_renderable import SubRenderable
|
||||
from ..util import create_element
|
||||
|
||||
|
||||
class Image(Renderable, RequiresNumbering):
|
||||
def __init__(self, parent: Parented, path: str, caption_info: CaptionInfo | None = None):
|
||||
super().__init__("Рисунок")
|
||||
self._parent = parent
|
||||
self._caption_info = caption_info
|
||||
self._docx_paragraph = Paragraph(create_element("w:p"), parent)
|
||||
self._docx_paragraph.paragraph_format.space_before = 0
|
||||
self._docx_paragraph.paragraph_format.space_after = 0
|
||||
self._docx_paragraph.paragraph_format.first_line_indent = 0
|
||||
self._docx_paragraph.paragraph_format.line_spacing = 1
|
||||
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
self._invalid = False
|
||||
|
||||
run = self._docx_paragraph.add_run()
|
||||
|
||||
if path.startswith("http"):
|
||||
bytesio = BytesIO()
|
||||
bytesio.write(requests.get(path).content)
|
||||
self._image = run.add_picture(bytesio)
|
||||
else:
|
||||
try:
|
||||
path = os.path.expanduser(path)
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.join(environ.get("WORKING_DIR", "."), path)
|
||||
self._image = run.add_picture(path)
|
||||
except FileNotFoundError:
|
||||
logging.warning(f"Invalid image path: {path}, skipping...")
|
||||
self._invalid = True
|
||||
|
||||
self._number = None
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
def set_number(self, number: str):
|
||||
self._number = number
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
if self._invalid:
|
||||
yield from []
|
||||
return
|
||||
|
||||
# limit width
|
||||
if self._image.width > layout_state.max_width:
|
||||
height_by_width = self._image.height / self._image.width
|
||||
self._image.width = layout_state.max_width
|
||||
self._image.height = Length(self._image.width * height_by_width)
|
||||
|
||||
# limit height
|
||||
if self._image.height > layout_state.max_height:
|
||||
width_by_height = self._image.width / self._image.height
|
||||
self._image.height = layout_state.max_height
|
||||
self._image.width = Length(self._image.height * width_by_height)
|
||||
|
||||
height = self._image.height
|
||||
|
||||
if layout_state.remaining_page_height < height:
|
||||
height += layout_state.remaining_page_height
|
||||
|
||||
yield (rendered_image := RenderedInfo(self._docx_paragraph, Length(height)))
|
||||
|
||||
layout_state.add_height(rendered_image.height)
|
||||
|
||||
caption = Caption(self._parent, "Рисунок", self._caption_info, self._number, False)
|
||||
caption.center()
|
||||
|
||||
yield from caption.render(rendered_image, copy(layout_state))
|
||||
@@ -0,0 +1,92 @@
|
||||
from copy import copy
|
||||
from typing import Generator
|
||||
|
||||
from docx.shared import Pt, Cm, Twips
|
||||
|
||||
from . import Paragraph
|
||||
from .renderable import Renderable
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..rendered_info import RenderedInfo
|
||||
|
||||
|
||||
# ТЗ табл. 2.2: маркер 1,25 см; текст / табуляция 2,25 см
|
||||
MARKER_POS = Cm(1.25)
|
||||
TEXT_POS = Cm(2.25)
|
||||
BULLET = "–" # en dash, единый маркер на весь документ
|
||||
|
||||
|
||||
class List(Renderable):
|
||||
def __init__(self, parent, ordered: bool):
|
||||
self._parent = parent
|
||||
self._ordered = ordered
|
||||
self._paragraphs: list[Paragraph] = []
|
||||
self._last_paragraph_space_after = 0
|
||||
self._numbering = [0 for _ in range(10)]
|
||||
self._item_count = 0
|
||||
|
||||
def add_item(self, level: int) -> Paragraph:
|
||||
self._numbering[level - 1] += 1
|
||||
for i in range(level, len(self._numbering)):
|
||||
self._numbering[i] = 0
|
||||
|
||||
self._item_count += 1
|
||||
paragraph = Paragraph(self._parent)
|
||||
|
||||
if self._ordered:
|
||||
marker = f"{self._numbering[level - 1]}."
|
||||
else:
|
||||
marker = BULLET
|
||||
|
||||
paragraph.add_run(marker + "\t")
|
||||
|
||||
# hanging indent: left = TEXT_POS (+ nesting), first_line = MARKER_POS - TEXT_POS
|
||||
nest = TEXT_POS * (level - 1)
|
||||
paragraph._docx_paragraph.paragraph_format.tab_stops.add_tab_stop(TEXT_POS + nest)
|
||||
paragraph._docx_paragraph.paragraph_format.left_indent = TEXT_POS + nest
|
||||
paragraph._docx_paragraph.paragraph_format.first_line_indent = MARKER_POS - TEXT_POS
|
||||
|
||||
self._last_paragraph_space_after = paragraph._docx_paragraph.paragraph_format.space_after
|
||||
paragraph._docx_paragraph.paragraph_format.space_before = 0
|
||||
paragraph._docx_paragraph.paragraph_format.space_after = 0
|
||||
|
||||
self._paragraphs.append(paragraph)
|
||||
return paragraph
|
||||
|
||||
def finalize_punctuation(self) -> None:
|
||||
"""Apply TZ list punctuation: bullet → lowercase + ';' (last '.'); numbered → Capital + '.'."""
|
||||
for i, paragraph in enumerate(self._paragraphs):
|
||||
runs = paragraph._docx_paragraph.runs
|
||||
if len(runs) < 2:
|
||||
continue
|
||||
# Text after marker run
|
||||
text_runs = runs[1:]
|
||||
full = "".join(r.text or "" for r in text_runs).strip()
|
||||
if not full:
|
||||
continue
|
||||
is_last = i == len(self._paragraphs) - 1
|
||||
if self._ordered:
|
||||
fixed = full[0].upper() + full[1:] if full else full
|
||||
if not fixed.endswith("."):
|
||||
fixed = fixed.rstrip(";,. ") + "."
|
||||
else:
|
||||
fixed = full[0].lower() + full[1:] if full else full
|
||||
if is_last:
|
||||
fixed = fixed.rstrip(";,. ") + "."
|
||||
else:
|
||||
fixed = fixed.rstrip(";,. ") + ";"
|
||||
# Put all text into first content run, clear others
|
||||
text_runs[0].text = fixed
|
||||
for r in text_runs[1:]:
|
||||
r.text = ""
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) -> Generator[
|
||||
RenderedInfo | Renderable, None, None]:
|
||||
self.finalize_punctuation()
|
||||
if self._paragraphs:
|
||||
self._paragraphs[-1]._docx_paragraph.paragraph_format.space_after = self._last_paragraph_space_after
|
||||
|
||||
for paragraph in self._paragraphs:
|
||||
for x in paragraph.render(previous_rendered, copy(layout_state)):
|
||||
layout_state.add_height(x.height)
|
||||
previous_rendered = x
|
||||
yield x
|
||||
@@ -0,0 +1,156 @@
|
||||
from copy import copy
|
||||
import os
|
||||
from typing import Generator, Callable
|
||||
|
||||
from docx.oxml import CT_Tbl
|
||||
from docx.shared import Length, Pt, RGBColor, Twips
|
||||
from docx.table import Table
|
||||
|
||||
from pygments import highlight
|
||||
from pygments.formatter import Formatter
|
||||
from pygments.lexers import get_lexer_by_name
|
||||
|
||||
from .caption import Caption, CaptionInfo
|
||||
from .paragraph import Paragraph
|
||||
from .renderable import Renderable
|
||||
from .requires_numbering import RequiresNumbering
|
||||
from ..docx_elements import create_table, _twips
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..rendered_info import RenderedInfo
|
||||
from ..sub_renderable import SubRenderable
|
||||
|
||||
|
||||
class DocxParagraphPygmentsFormatter(Formatter):
|
||||
def __init__(self, paragraphs: list[Paragraph], creator: Callable[[], Paragraph], **options):
|
||||
Formatter.__init__(self, style="sas", **options)
|
||||
self._creator = creator
|
||||
self._paragraphs = paragraphs
|
||||
self._styles = {}
|
||||
|
||||
for token, style in self.style:
|
||||
self._styles[token] = style
|
||||
|
||||
def _add_run_to_last_paragraph(self, text, style):
|
||||
self._paragraphs[-1].add_run(text, style["bold"] or None, style["italic"] in style or None,
|
||||
RGBColor.from_string(style['color']) if style['color'] else None)
|
||||
|
||||
def format(self, tokensource, outfile):
|
||||
self._paragraphs.append(self._creator())
|
||||
for ttype, value in tokensource:
|
||||
style = self._styles[ttype]
|
||||
lines = iter(value.split("\n"))
|
||||
self._add_run_to_last_paragraph(next(lines), style)
|
||||
for line in lines:
|
||||
self._paragraphs.append(self._creator())
|
||||
self._add_run_to_last_paragraph(line, style)
|
||||
self._paragraphs.pop(-1) # remove last empty line
|
||||
|
||||
|
||||
LISTING_OFFSET = Pt(31) - Twips(108 * 2) # todo: fix
|
||||
|
||||
|
||||
class Listing(Renderable, RequiresNumbering):
|
||||
def __init__(self, parent, language: str, caption_info: CaptionInfo):
|
||||
super().__init__("Листинг")
|
||||
self._caption_info = caption_info
|
||||
self._language = language
|
||||
self._parent = parent
|
||||
self.paragraphs: list[Paragraph] = []
|
||||
self._number = None
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
def _create_table(self, parent, width: Length):
|
||||
# todo: style inheritance
|
||||
left_margin = Twips(int(
|
||||
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
right_margin = Twips(int(
|
||||
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:right")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
|
||||
return create_table(
|
||||
parent, 1, 1,
|
||||
Twips(_twips(width) + _twips(left_margin) + _twips(right_margin)),
|
||||
)
|
||||
|
||||
def set_text(self, text: str):
|
||||
def create_paragraph() -> Paragraph:
|
||||
paragraph = Paragraph(self._parent)
|
||||
paragraph.style = "Code"
|
||||
return paragraph
|
||||
|
||||
text = text.removesuffix("\n")
|
||||
|
||||
if self._language and "SYNTAX_HIGHLIGHTING" in os.environ and os.environ["SYNTAX_HIGHLIGHTING"] == "1":
|
||||
formatter = DocxParagraphPygmentsFormatter(self.paragraphs, lambda: create_paragraph())
|
||||
highlight(text, get_lexer_by_name(self._language), formatter)
|
||||
else:
|
||||
for line in text.removesuffix("\n").split("\n"):
|
||||
paragraph = create_paragraph()
|
||||
paragraph.add_run(line)
|
||||
self.paragraphs.append(paragraph)
|
||||
|
||||
def set_number(self, number: str):
|
||||
self._number = number
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
caption_rendered_infos = list(
|
||||
Caption(self._parent, "Листинг", self._caption_info, self._number, True)
|
||||
.render(previous_rendered, copy(layout_state))
|
||||
)
|
||||
layout_state.add_height(sum([info.height for info in caption_rendered_infos]))
|
||||
yield from caption_rendered_infos
|
||||
|
||||
table = self._create_table(self._parent, layout_state.max_width)
|
||||
previous = None
|
||||
|
||||
table_height = Pt(1) # table borders, 4 eights of point for each border
|
||||
|
||||
# if first line doesn't fit move listing to the next page
|
||||
paragraph_layout_state = copy(layout_state)
|
||||
paragraph_layout_state.max_width -= LISTING_OFFSET
|
||||
paragraph_rendered_info = next(self.paragraphs[0].render(previous, paragraph_layout_state))
|
||||
if paragraph_rendered_info.height + table_height > layout_state.remaining_page_height:
|
||||
table_height += layout_state.remaining_page_height
|
||||
layout_state.add_height(layout_state.remaining_page_height)
|
||||
|
||||
for paragraph in self.paragraphs:
|
||||
paragraph_layout_state = copy(layout_state)
|
||||
paragraph_layout_state.max_width -= LISTING_OFFSET
|
||||
paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state))
|
||||
|
||||
if paragraph_rendered_info.height > layout_state.remaining_page_height: # todo add before after
|
||||
table_rendered_info = RenderedInfo(table, table_height)
|
||||
yield table_rendered_info
|
||||
|
||||
table_height = Pt(1) # table borders, 4 eights of point for each border
|
||||
|
||||
continuation_paragraph = Paragraph(self._parent)
|
||||
continuation_paragraph.add_run(f"Продолжение Листинга {self._number}")
|
||||
continuation_paragraph.style = "Caption Listing"
|
||||
continuation_paragraph.first_line_indent = 0
|
||||
continuation_paragraph.page_break_before = True
|
||||
|
||||
continuation_rendered_info = next(
|
||||
continuation_paragraph.render(None, copy(layout_state)))
|
||||
|
||||
layout_state.add_height(continuation_rendered_info.height)
|
||||
yield continuation_rendered_info
|
||||
|
||||
table = self._create_table(self._parent, layout_state.max_width)
|
||||
|
||||
previous = None
|
||||
|
||||
paragraph_layout_state = copy(layout_state)
|
||||
paragraph_layout_state.max_width -= LISTING_OFFSET
|
||||
paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state))
|
||||
|
||||
table._cells[0]._element.append(paragraph_rendered_info.docx_element._element)
|
||||
layout_state.add_height(paragraph_rendered_info.height)
|
||||
table_height += paragraph_rendered_info.height
|
||||
|
||||
previous = paragraph_rendered_info
|
||||
|
||||
yield RenderedInfo(table, table_height)
|
||||
@@ -0,0 +1,30 @@
|
||||
from typing import Generator
|
||||
|
||||
from docx.shared import Parented, Pt
|
||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..rendered_info import RenderedInfo
|
||||
from ..sub_renderable import SubRenderable
|
||||
from ..util import create_element
|
||||
from .renderable import Renderable
|
||||
|
||||
|
||||
class PageBreak(Renderable):
|
||||
def __init__(self, parent: Parented):
|
||||
self._docx_paragraph = DocxParagraph(create_element("w:p", [
|
||||
create_element("w:r", [
|
||||
create_element("w:br", {"w:type": "page"})
|
||||
])
|
||||
]), parent)
|
||||
self._docx_paragraph.runs[0].font.size = Pt(1)
|
||||
self._docx_paragraph.paragraph_format.space_before = 0
|
||||
self._docx_paragraph.paragraph_format.space_after = 0
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
yield RenderedInfo(
|
||||
self._docx_paragraph,
|
||||
max(layout_state.remaining_page_height, ParagraphSizer(self._docx_paragraph, None, layout_state.max_width).calculate_height().line_height)
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
from copy import copy
|
||||
from typing import Generator, Any
|
||||
|
||||
from docx.shared import Length, Parented, RGBColor
|
||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||
from docx.text.paragraph import Run as DocxRun
|
||||
from docx.enum.text import WD_LINE_SPACING
|
||||
from docx.opc.constants import RELATIONSHIP_TYPE
|
||||
|
||||
from . import Renderable
|
||||
from .caption import CaptionInfo
|
||||
from .image import Image
|
||||
from .paragraph_sizer import ParagraphSizer
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..sub_renderable import SubRenderable
|
||||
from ..util import create_element
|
||||
from ..rendered_info import RenderedInfo
|
||||
from ..latex_math import latex_to_omml, inline_omml
|
||||
|
||||
|
||||
class Link:
|
||||
def __init__(self, url, docx_paragraph: DocxParagraph):
|
||||
self._docx_paragraph = docx_paragraph
|
||||
r_id = docx_paragraph.part.relate_to(url, RELATIONSHIP_TYPE.HYPERLINK, is_external=True)
|
||||
|
||||
self._hyperlink = create_element("w:hyperlink", {
|
||||
"r:id": r_id
|
||||
})
|
||||
|
||||
def add_run(self, text: str, is_bold: bool = None, is_italic: bool = None, color: RGBColor = None,
|
||||
strike_through: bool = None):
|
||||
|
||||
parts = text.split("-")
|
||||
for i, part in enumerate(parts):
|
||||
docx_run = DocxRun(create_element("w:r"), self._docx_paragraph)
|
||||
self._hyperlink.append(docx_run._element)
|
||||
docx_run.text = text
|
||||
docx_run.style = "Hyperlink"
|
||||
docx_run.bold = is_bold
|
||||
docx_run.italic = is_italic
|
||||
docx_run.font.color.rgb = color
|
||||
docx_run.font.strike = strike_through
|
||||
if i != len(parts) - 1:
|
||||
self._hyperlink.append(create_element("w:r", [create_element("w:noBreakHyphen")]))
|
||||
|
||||
@property
|
||||
def element(self):
|
||||
return self._hyperlink
|
||||
|
||||
|
||||
class Paragraph(Renderable):
|
||||
def __init__(self, parent: Parented):
|
||||
self._parent = parent
|
||||
self._docx_paragraph = DocxParagraph(create_element("w:p"), parent)
|
||||
self._docx_paragraph.style = "Normal"
|
||||
self._images: list[Image] = []
|
||||
self._pending_refs: list[tuple[object, str, str]] = [] # (run, type, name)
|
||||
|
||||
def add_run(self, text: str, is_bold: bool = None, is_italic: bool = None, color: RGBColor = None,
|
||||
strike_through: bool = None):
|
||||
# replace all hyphens with non-breaking hyphens
|
||||
parts = text.split("-")
|
||||
for i, part in enumerate(parts):
|
||||
docx_run = self._docx_paragraph.add_run(part)
|
||||
docx_run.bold = is_bold
|
||||
docx_run.italic = is_italic
|
||||
docx_run.font.color.rgb = color
|
||||
docx_run.font.strike = strike_through
|
||||
if i != len(parts)-1:
|
||||
self._docx_paragraph.add_run()._element.\
|
||||
append(create_element("w:noBreakHyphen"))
|
||||
|
||||
def add_reference(self, type_: str, name: str):
|
||||
"""Placeholder run resolved later via resolve_pending_refs()."""
|
||||
run = self._docx_paragraph.add_run(f"{type_}?")
|
||||
self._pending_refs.append((run, type_, name))
|
||||
|
||||
def resolve_pending_refs(self, resolve_fn):
|
||||
for run, type_, name in self._pending_refs:
|
||||
run.text = resolve_fn(type_, name)
|
||||
self._pending_refs.clear()
|
||||
|
||||
def add_image(self, path: str, caption_info: CaptionInfo):
|
||||
self._images.append(Image(self._parent, path, caption_info))
|
||||
|
||||
def add_link(self, url: str):
|
||||
link = Link(url, self._docx_paragraph)
|
||||
self._docx_paragraph._p.append(link.element)
|
||||
return link
|
||||
|
||||
def add_inline_equation(self, formula: str):
|
||||
# omml = inline_omml(latex_to_omml(formula))
|
||||
# for r in omml.xpath("//m:r", namespaces=omml.nsmap):
|
||||
# r.append(create_element("w:rPr", [
|
||||
# create_element("w:sz", {"w:val": "24"}),
|
||||
# create_element("w:szCs", {"w:val": "24"}),
|
||||
# ]))
|
||||
# self._docx_paragraph._element.append(omml)
|
||||
self.add_run(formula, is_italic=True)
|
||||
|
||||
@property
|
||||
def page_break_before(self) -> bool:
|
||||
return self._docx_paragraph.paragraph_format.page_break_before
|
||||
|
||||
@page_break_before.setter
|
||||
def page_break_before(self, value: bool):
|
||||
self._docx_paragraph.paragraph_format.page_break_before = value
|
||||
|
||||
@property
|
||||
def style(self):
|
||||
return self._docx_paragraph.style
|
||||
|
||||
@style.setter
|
||||
def style(self, value: str):
|
||||
self._docx_paragraph.style = value
|
||||
|
||||
@property
|
||||
def first_line_indent(self):
|
||||
return self._docx_paragraph.paragraph_format.first_line_indent
|
||||
|
||||
@first_line_indent.setter
|
||||
def first_line_indent(self, value: Length):
|
||||
self._docx_paragraph.paragraph_format.first_line_indent = value
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
remaining_space = layout_state.remaining_page_height
|
||||
|
||||
if self.page_break_before:
|
||||
layout_state.add_height(layout_state.remaining_page_height)
|
||||
if self._docx_paragraph.text or not self._images:
|
||||
height_data = ParagraphSizer(
|
||||
self._docx_paragraph,
|
||||
previous_rendered.docx_element
|
||||
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,
|
||||
layout_state.max_width).calculate_height()
|
||||
|
||||
if layout_state.current_page_height == 0 and layout_state.page > 1:
|
||||
height_data.before = 0
|
||||
|
||||
fitting_lines = 0
|
||||
for lines in range(1, height_data.lines+1):
|
||||
if height_data.before + ((lines - 1) * height_data.line_spacing + 1) * height_data.line_height \
|
||||
> layout_state.remaining_page_height:
|
||||
break
|
||||
fitting_lines += 1
|
||||
|
||||
if fitting_lines == height_data.lines:
|
||||
# the whole paragraph fits page
|
||||
height = min(height_data.full, layout_state.remaining_page_height)
|
||||
elif fitting_lines <= 1 or (height_data.lines-fitting_lines == 1 and height_data.lines == 3):
|
||||
# if only no or only one line fits the page, paragraph goes to the next page
|
||||
height = layout_state.remaining_page_height + height_data.full
|
||||
elif height_data.lines-fitting_lines == 1:
|
||||
# if all lines except last fit the page, the last two lines go to the new page
|
||||
height = layout_state.remaining_page_height + \
|
||||
height_data.before + height_data.line_height * height_data.line_spacing * 2 \
|
||||
+ height_data.after
|
||||
else:
|
||||
height = layout_state.remaining_page_height + \
|
||||
height_data.before + height_data.line_height * height_data.line_spacing * \
|
||||
(height_data.lines-fitting_lines) + height_data.after
|
||||
|
||||
if self.page_break_before:
|
||||
height += remaining_space
|
||||
|
||||
yield (previous_rendered := RenderedInfo(self._docx_paragraph, Length(height)))
|
||||
layout_state.add_height(height)
|
||||
|
||||
images = iter(self._images)
|
||||
|
||||
for image in images:
|
||||
rendered_image = list(image.render(previous_rendered, copy(layout_state)))
|
||||
rendered_image_height = sum([x.height for x in rendered_image])
|
||||
if rendered_image:
|
||||
previous_rendered = rendered_image[-1]
|
||||
if rendered_image_height <= layout_state.remaining_page_height:
|
||||
yield SubRenderable(image, False)
|
||||
layout_state.add_height(rendered_image_height)
|
||||
else:
|
||||
yield SubRenderable(image, True)
|
||||
break
|
||||
|
||||
yield from images
|
||||
@@ -0,0 +1,267 @@
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from math import ceil
|
||||
|
||||
from docx.enum.text import WD_LINE_SPACING
|
||||
from docx.oxml import CT_R
|
||||
from docx.text.run import Run
|
||||
from freetype import Face
|
||||
|
||||
from docx.text.paragraph import Paragraph
|
||||
from docx.text.font import Font as DocxFont
|
||||
from docx.shared import Length, Pt, Inches
|
||||
from docx.text.parfmt import ParagraphFormat
|
||||
from docx.styles.style import _ParagraphStyle
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from .find_font import find_font
|
||||
|
||||
|
||||
def _merge_objects(*objects):
|
||||
from inspect import ismethod
|
||||
"""
|
||||
Returns the new object containing attributes from objects, where the latest
|
||||
one has the highest priority.
|
||||
"""
|
||||
|
||||
class MergedObject:
|
||||
pass
|
||||
|
||||
merged_object = MergedObject()
|
||||
|
||||
def safe_attrs(obj):
|
||||
names = set(dir(obj))
|
||||
for name in names:
|
||||
if name.startswith("_"):
|
||||
continue
|
||||
try:
|
||||
value = getattr(obj, name)
|
||||
except (AttributeError, ValueError, TypeError):
|
||||
continue
|
||||
if ismethod(value) or callable(value):
|
||||
continue
|
||||
yield name, value
|
||||
|
||||
for name, value in safe_attrs(objects[0]):
|
||||
merged_object.__setattr__(name, value)
|
||||
|
||||
for object_ in objects[1:]:
|
||||
for name, value in safe_attrs(object_):
|
||||
if value is not None:
|
||||
merged_object.__setattr__(name, value)
|
||||
|
||||
return merged_object
|
||||
|
||||
|
||||
class Font:
|
||||
def __init__(self, name: str, bold: bool, italic: bool, size_pt: int):
|
||||
path = find_font(name, bold, italic)
|
||||
self._freetypefont = ImageFont.truetype(path, size_pt)
|
||||
self._draw = ImageDraw.Draw(Image.new("RGB", (1000, 1000)))
|
||||
|
||||
self._face = Face(path)
|
||||
self._face.set_char_size(int(size_pt * 64))
|
||||
|
||||
def get_text_width(self, text: str) -> Length:
|
||||
if not self.is_mono:
|
||||
bbox = self._draw.textbbox((0, 0), text, self._freetypefont)
|
||||
return Pt(bbox[2] - bbox[0])
|
||||
else:
|
||||
return Pt(len(text) * self._face.glyph.advance.x / 64)
|
||||
|
||||
def get_line_height(self) -> Length:
|
||||
# TODO: make it work for all fonts
|
||||
if "Times" in str(self._face.family_name) and self._freetypefont.size == 14:
|
||||
return Pt(16.05)
|
||||
if "Courier" in str(self._face.family_name) and self._freetypefont.size == 12:
|
||||
return Pt(13.61)
|
||||
else:
|
||||
return Pt(self._face.size.height / 64)
|
||||
@cached_property
|
||||
def is_mono(self):
|
||||
self._face.load_char("i")
|
||||
i_width = self._face.glyph.advance.x
|
||||
self._face.load_char("m")
|
||||
return i_width == self._face.glyph.advance.x
|
||||
# return self._face.glyph.bitmap.width
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParagraphSizerResult:
|
||||
before: Length
|
||||
lines: int
|
||||
line_height: Length
|
||||
line_spacing: float
|
||||
after: Length
|
||||
|
||||
@property
|
||||
def base(self) -> Length:
|
||||
return self.before + ((self.lines - 1) * self.line_spacing + 1) * self.line_height
|
||||
|
||||
@property
|
||||
def full(self) -> Length:
|
||||
return Length(self.before + self.line_height * self.line_spacing * self.lines + self.after)
|
||||
|
||||
|
||||
class ParagraphSizer:
|
||||
def __init__(self, paragraph: Paragraph, previous_paragraph: Paragraph | None, max_width: Length):
|
||||
self.previous_paragraph = previous_paragraph
|
||||
self.max_width = max_width
|
||||
self.paragraph = paragraph
|
||||
|
||||
self.same_style_as_previous = (paragraph.style == previous_paragraph.style) if previous_paragraph else False
|
||||
|
||||
@cached_property
|
||||
def _default_style(self):
|
||||
|
||||
default_style_element = type("DefaultStyle", (), {})
|
||||
default_style_element.rPr = \
|
||||
self.paragraph.part.document.styles.element.xpath(
|
||||
'w:docDefaults/w:rPrDefault/w:rPr')[0]
|
||||
default_style_element.pPr = \
|
||||
self.paragraph.part.document.styles.element.xpath(
|
||||
'w:docDefaults/w:pPrDefault/w:pPr')[0]
|
||||
default_style = _ParagraphStyle(default_style_element)
|
||||
return default_style
|
||||
|
||||
@cached_property
|
||||
def _styles(self) -> list[_ParagraphStyle]:
|
||||
styles = [self.paragraph.style]
|
||||
while styles[-1].base_style:
|
||||
styles.append(styles[-1].base_style)
|
||||
styles.append(self._default_style)
|
||||
return styles
|
||||
|
||||
@cached_property
|
||||
def _is_contextual_spacing(self) -> bool:
|
||||
contextual_spacing = False
|
||||
pPrs = [self.paragraph.paragraph_format._element.pPr] + \
|
||||
[style._element.pPr for style in self._styles]
|
||||
for pPr in pPrs:
|
||||
if pPr.xpath("./w:contextualSpacing"):
|
||||
contextual_spacing = True
|
||||
break
|
||||
return contextual_spacing
|
||||
|
||||
def count_lines(self, runs: list[Run], max_width: Length, docx_font: DocxFont, first_line_indent: Length,
|
||||
is_mono: bool = False):
|
||||
lines = 1
|
||||
line_width = first_line_indent
|
||||
|
||||
space_width = Font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt).get_text_width(" ")
|
||||
if not is_mono:
|
||||
space_width *= 0.81
|
||||
|
||||
word_part = ""
|
||||
word_parts_widths = [0]
|
||||
spaces = 0
|
||||
for i, run in enumerate(runs):
|
||||
if word_part:
|
||||
word_part = ""
|
||||
word_parts_widths.append(0)
|
||||
|
||||
run_docx_font = _merge_objects(
|
||||
docx_font,
|
||||
run.font
|
||||
)
|
||||
font = Font(run_docx_font.name, run_docx_font.bold, run_docx_font.italic, run_docx_font.size.pt)
|
||||
|
||||
run_text = run.text
|
||||
if run_text == "" and run._element.xpath("w:noBreakHyphen"):
|
||||
run_text = "-"
|
||||
if i == len(runs) - 1:
|
||||
run_text += " " # add space to the end of the last run, so it adds the last word
|
||||
|
||||
for c in run_text:
|
||||
if c == " ":
|
||||
if any(word_parts_widths):
|
||||
width = spaces*space_width + sum(word_parts_widths)
|
||||
if width <= max_width - line_width:
|
||||
line_width += width
|
||||
elif width > max_width - first_line_indent:
|
||||
if lines == 1 and line_width == first_line_indent and not spaces:
|
||||
lines += ceil((width - (max_width - first_line_indent)) / max_width)
|
||||
line_width = (width - (max_width - first_line_indent)) % max_width
|
||||
else:
|
||||
lines += ceil(width / max_width)
|
||||
line_width = width % max_width
|
||||
else:
|
||||
lines += 1
|
||||
line_width = sum(word_parts_widths)
|
||||
|
||||
word_part = ""
|
||||
word_parts_widths = [0]
|
||||
spaces = 1
|
||||
else:
|
||||
spaces += 1
|
||||
else:
|
||||
word_part += c
|
||||
word_parts_widths[-1] = font.get_text_width(word_part)
|
||||
|
||||
return int(lines)
|
||||
|
||||
def calculate_height(self) -> ParagraphSizerResult:
|
||||
max_width = self.max_width
|
||||
|
||||
|
||||
docx_font: DocxFont = _merge_objects(
|
||||
*[style.font for style in self._styles[::-1] if style.font],
|
||||
self.paragraph.style.font)
|
||||
|
||||
paragraph_format: ParagraphFormat = _merge_objects(
|
||||
*[style.paragraph_format for style in self._styles[::-1]
|
||||
if style.paragraph_format],
|
||||
self.paragraph.paragraph_format
|
||||
)
|
||||
|
||||
max_width -= (paragraph_format.left_indent or 0) + \
|
||||
(paragraph_format.right_indent or 0)
|
||||
|
||||
font = Font(docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt)
|
||||
|
||||
# here self.paragraph.runs is not used because
|
||||
# it does not always return all runs (e.g. if they are inside hyperlink)
|
||||
runs = []
|
||||
for element in self.paragraph._element.getiterator():
|
||||
if isinstance(element, CT_R):
|
||||
runs.append(Run(element, self.paragraph))
|
||||
|
||||
lines = self.count_lines(runs, max_width, docx_font, paragraph_format.first_line_indent or 0,
|
||||
font.is_mono)
|
||||
|
||||
previous_paragraph_format: ParagraphFormat = None
|
||||
if self.previous_paragraph:
|
||||
previous_paragraph_styles = [self.previous_paragraph.style]
|
||||
while previous_paragraph_styles[-1].base_style:
|
||||
previous_paragraph_styles.append(
|
||||
previous_paragraph_styles[-1].base_style
|
||||
)
|
||||
previous_paragraph_styles.append(self._default_style)
|
||||
previous_paragraph_format = _merge_objects(
|
||||
*[style.paragraph_format for style in previous_paragraph_styles[::-1]
|
||||
if style.paragraph_format],
|
||||
self.previous_paragraph.paragraph_format
|
||||
)
|
||||
|
||||
if self._is_contextual_spacing and self.same_style_as_previous:
|
||||
before = (previous_paragraph_format.space_after or 0)
|
||||
else:
|
||||
before = (paragraph_format.space_before or 0)
|
||||
if previous_paragraph_format:
|
||||
before = max(0, before - (previous_paragraph_format.space_after or 0))
|
||||
|
||||
after = (paragraph_format.space_after or 0)
|
||||
|
||||
line_height = font.get_line_height()
|
||||
line_spacing = paragraph_format.line_spacing
|
||||
if paragraph_format.line_spacing_rule == WD_LINE_SPACING.EXACTLY:
|
||||
line_spacing /= line_height
|
||||
# raise NotImplementedError("Line spacing rule AT_LEAST is not supported")
|
||||
elif paragraph_format.line_spacing_rule == WD_LINE_SPACING.AT_LEAST:
|
||||
raise NotImplementedError("Line spacing rule AT_LEAST is not supported")
|
||||
|
||||
return ParagraphSizerResult(before, lines, line_height, line_spacing, after)
|
||||
@@ -0,0 +1,18 @@
|
||||
from typing import TYPE_CHECKING
|
||||
from collections.abc import Generator
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..rendered_info import RenderedInfo
|
||||
if TYPE_CHECKING:
|
||||
from ..sub_renderable import SubRenderable
|
||||
|
||||
|
||||
class Renderable(ABC):
|
||||
@abstractmethod
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator["RenderedInfo | SubRenderable", None, None]:
|
||||
"""Renders the object to one or multiple Parented objects or Renderables to be rendered on the next page"""
|
||||
|
||||
def added_to_document(self):
|
||||
pass
|
||||
@@ -0,0 +1,11 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class RequiresNumbering(ABC):
|
||||
def __init__(self, category: str):
|
||||
self.numbering_category = category
|
||||
self.unique_name: str | None = None
|
||||
|
||||
@abstractmethod
|
||||
def set_number(self, number: str):
|
||||
pass
|
||||
@@ -0,0 +1,218 @@
|
||||
from copy import copy
|
||||
from typing import Generator
|
||||
|
||||
from docx.shared import Length, Parented, Pt, Twips
|
||||
|
||||
from . import Paragraph
|
||||
from .caption import Caption, CaptionInfo
|
||||
from .page_break import PageBreak
|
||||
from .renderable import Renderable
|
||||
from .requires_numbering import RequiresNumbering
|
||||
from ..docx_elements import *
|
||||
from ..docx_elements import _twips
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..profiles import DEFAULT_TABLE_CONTINUATION, TABLE_CONTINUATION_MODES
|
||||
from ..rendered_info import RenderedInfo
|
||||
|
||||
CELL_OFFSET = Pt(9) - Twips(108 * 2)
|
||||
# Slack only for modes that fragment by our height estimate (legacy/caption).
|
||||
ROW_HEIGHT_SLACK = Pt(4)
|
||||
|
||||
# Modes that do NOT cut the table into fragments — Word owns page breaks.
|
||||
_WORD_PAGED_MODES = frozenset({"off", "soft"})
|
||||
|
||||
|
||||
class Table(Renderable, RequiresNumbering):
|
||||
def __init__(self, parent: Parented, rows: int, cols: int, caption_info: CaptionInfo):
|
||||
super().__init__("Таблица")
|
||||
self._parent = parent
|
||||
self._caption_info = caption_info
|
||||
self._cols = cols
|
||||
self._continuation_mode = DEFAULT_TABLE_CONTINUATION
|
||||
|
||||
left_margin = Twips(int(parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib["{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
right_margin = Twips(int(parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:right")[0].attrib["{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
|
||||
self._cell_margin_lr = left_margin + right_margin
|
||||
self._number = "?"
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
self._rows: list[list[list[Paragraph]]] = [[[] for i in range(cols)] for j in range(rows)]
|
||||
self._merge: list[list[tuple[str, str]]] = [
|
||||
[("none", "none") for _ in range(cols)] for _ in range(rows)
|
||||
]
|
||||
|
||||
def set_continuation_mode(self, mode: str) -> None:
|
||||
if mode not in TABLE_CONTINUATION_MODES:
|
||||
raise ValueError(
|
||||
f"table continuation must be one of {TABLE_CONTINUATION_MODES}, got {mode!r}"
|
||||
)
|
||||
self._continuation_mode = mode
|
||||
|
||||
def _table_width(self, layout_state: LayoutState) -> Length:
|
||||
return Twips(_twips(layout_state.max_width) + _twips(self._cell_margin_lr))
|
||||
|
||||
def add_paragraph_to_cell(self, row: int, col: int) -> Paragraph:
|
||||
paragraph = Paragraph(self._parent)
|
||||
try:
|
||||
paragraph.style = "Table Text"
|
||||
except KeyError:
|
||||
pass
|
||||
paragraph.first_line_indent = 0
|
||||
paragraph._docx_paragraph.paragraph_format.space_before = 0
|
||||
paragraph._docx_paragraph.paragraph_format.space_after = 0
|
||||
paragraph._docx_paragraph.paragraph_format.line_spacing = 1
|
||||
self._rows[row][col].append(paragraph)
|
||||
return paragraph
|
||||
|
||||
def set_cell_merge(self, row: int, col: int, merge_v: str = "none", merge_h: str = "none") -> None:
|
||||
self._merge[row][col] = (merge_v or "none", merge_h or "none")
|
||||
|
||||
def set_number(self, number: str):
|
||||
self._number = number
|
||||
|
||||
def _col_width(self, layout_state: LayoutState) -> Length:
|
||||
return self._table_width(layout_state) / self._cols
|
||||
|
||||
def _grid_span(self, merge_row: list[tuple[str, str]], col: int) -> int:
|
||||
_, merge_h = merge_row[col]
|
||||
if merge_h == "continue":
|
||||
return 0
|
||||
span = 1
|
||||
while col + span < self._cols and merge_row[col + span][1] == "continue":
|
||||
span += 1
|
||||
return span
|
||||
|
||||
def _build_docx_row(self, docx_table, row_idx: int, apply_merge: bool, layout_state: LayoutState):
|
||||
row = self._rows[row_idx]
|
||||
merge_row = self._merge[row_idx]
|
||||
docx_row = create_table_row(docx_table, header=(row_idx == 0))
|
||||
row_height = 0
|
||||
col_w = self._col_width(layout_state)
|
||||
|
||||
col = 0
|
||||
while col < self._cols:
|
||||
merge_v, merge_h = merge_row[col]
|
||||
if apply_merge and merge_h == "continue":
|
||||
col += 1
|
||||
continue
|
||||
|
||||
span = self._grid_span(merge_row, col) if apply_merge else 1
|
||||
if span < 1:
|
||||
span = 1
|
||||
|
||||
docx_cell = create_table_cell(docx_row, col_w * span)
|
||||
v_merge = merge_v if apply_merge and merge_v in ("restart", "continue") else None
|
||||
grid_span = span if apply_merge and span > 1 else None
|
||||
if v_merge or grid_span:
|
||||
apply_cell_merge(docx_cell, v_merge=v_merge, grid_span=grid_span)
|
||||
|
||||
cell_height = 0
|
||||
if not (apply_merge and merge_v == "continue"):
|
||||
for paragraph in row[col]:
|
||||
cell_layout_state = LayoutState(
|
||||
layout_state.max_height, layout_state.max_width
|
||||
)
|
||||
cell_layout_state.max_width = col_w * span - CELL_OFFSET
|
||||
for paragraph_rendered_info in paragraph.render(None, cell_layout_state):
|
||||
docx_cell._element.append(paragraph_rendered_info.docx_element._element)
|
||||
cell_height += paragraph_rendered_info.height
|
||||
else:
|
||||
from ..util import create_element
|
||||
docx_cell._element.append(create_element("w:p"))
|
||||
|
||||
row_height = max(cell_height, row_height)
|
||||
docx_row._element.append(docx_cell._element)
|
||||
col += span
|
||||
|
||||
row_height += Pt(0.5) + (
|
||||
ROW_HEIGHT_SLACK if self._continuation_mode not in _WORD_PAGED_MODES else Pt(0)
|
||||
)
|
||||
return docx_row, row_height
|
||||
|
||||
def _should_split_fragment(
|
||||
self, row_height: Length, layout_state: LayoutState, rows_in_fragment: int
|
||||
) -> bool:
|
||||
# off/soft: never fragment — height estimate ≠ Word; mid-page «Продолжение» bug
|
||||
if self._continuation_mode in _WORD_PAGED_MODES:
|
||||
return False
|
||||
if rows_in_fragment == 0:
|
||||
return False
|
||||
return row_height > layout_state.remaining_page_height
|
||||
|
||||
def _make_continuation_paragraph(self) -> Paragraph:
|
||||
continuation_paragraph = Paragraph(self._parent)
|
||||
continuation_paragraph.add_run(f"Продолжение Таблицы {self._number}")
|
||||
continuation_paragraph.style = "Caption Table"
|
||||
continuation_paragraph.first_line_indent = 0
|
||||
return continuation_paragraph
|
||||
|
||||
def _emit_page_break_and_optional_caption(
|
||||
self, layout_state: LayoutState
|
||||
) -> Generator[RenderedInfo, None, None]:
|
||||
"""For legacy/caption only: break page and insert «Продолжение Таблицы N»."""
|
||||
mode = self._continuation_mode
|
||||
|
||||
if mode == "legacy":
|
||||
continuation_paragraph = self._make_continuation_paragraph()
|
||||
continuation_paragraph.page_break_before = True
|
||||
info = next(continuation_paragraph.render(None, copy(layout_state)))
|
||||
layout_state.add_height(info.height)
|
||||
yield info
|
||||
return
|
||||
|
||||
# caption
|
||||
page_break_info = next(PageBreak(self._parent).render(None, layout_state))
|
||||
layout_state.add_height(page_break_info.height)
|
||||
yield page_break_info
|
||||
|
||||
continuation_paragraph = self._make_continuation_paragraph()
|
||||
continuation_paragraph._docx_paragraph.paragraph_format.keep_with_next = True
|
||||
info = next(continuation_paragraph.render(None, copy(layout_state)))
|
||||
layout_state.add_height(info.height)
|
||||
yield info
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) \
|
||||
-> Generator[RenderedInfo, None, None]:
|
||||
caption_rendered_infos = list(
|
||||
Caption(self._parent, "Таблица", self._caption_info, self._number, True)
|
||||
.render(previous_rendered, copy(layout_state))
|
||||
)
|
||||
layout_state.add_height(sum(info.height for info in caption_rendered_infos))
|
||||
yield from caption_rendered_infos
|
||||
|
||||
docx_table = create_table(
|
||||
self._parent, 0, self._cols, self._table_width(layout_state)
|
||||
)
|
||||
|
||||
table_height = Pt(0.5)
|
||||
apply_merge = True
|
||||
rows_in_fragment = 0
|
||||
|
||||
for row_idx in range(len(self._rows)):
|
||||
docx_row, row_height = self._build_docx_row(
|
||||
docx_table, row_idx, apply_merge, layout_state
|
||||
)
|
||||
|
||||
if self._should_split_fragment(row_height, layout_state, rows_in_fragment):
|
||||
yield RenderedInfo(docx_table, table_height)
|
||||
yield from self._emit_page_break_and_optional_caption(layout_state)
|
||||
|
||||
docx_table = create_table(
|
||||
self._parent, 0, self._cols, self._table_width(layout_state)
|
||||
)
|
||||
apply_merge = False
|
||||
rows_in_fragment = 0
|
||||
table_height = Pt(0.5)
|
||||
|
||||
docx_row, row_height = self._build_docx_row(
|
||||
docx_table, row_idx, apply_merge, layout_state
|
||||
)
|
||||
|
||||
docx_table._element.append(docx_row._element)
|
||||
layout_state.add_height(row_height)
|
||||
table_height += row_height
|
||||
rows_in_fragment += 1
|
||||
|
||||
yield RenderedInfo(docx_table, table_height)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Table of contents: manual (layout-based) or native Word TOC field."""
|
||||
|
||||
from copy import copy
|
||||
from typing import Generator
|
||||
|
||||
from docx.enum.text import WD_TAB_LEADER, WD_TAB_ALIGNMENT, WD_PARAGRAPH_ALIGNMENT
|
||||
from docx.shared import Parented
|
||||
|
||||
from . import Paragraph
|
||||
from .page_break import PageBreak
|
||||
from .renderable import Renderable
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..rendered_info import RenderedInfo
|
||||
from ..sub_renderable import SubRenderable
|
||||
from ..util import create_element
|
||||
|
||||
|
||||
SPECIAL_TITLES = {
|
||||
"СОДЕРЖАНИЕ", "ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ",
|
||||
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
|
||||
"СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ",
|
||||
"ПРИЛОЖЕНИЕ", "ПРИЛОЖЕНИЯ",
|
||||
}
|
||||
|
||||
# Word field: outline levels 1–3, hyperlinks, hide tab/page in web view
|
||||
_NATIVE_TOC_INSTR = r'TOC \o "1-3" \h \z \u'
|
||||
|
||||
|
||||
class ToC(Renderable):
|
||||
def __init__(self, parent: Parented, heading_numbering: str = "manual",
|
||||
toc_mode: str = "native"):
|
||||
self._parent = parent
|
||||
self._heading_numbering = heading_numbering # auto | manual
|
||||
self._toc_mode = toc_mode # manual | native
|
||||
self._paragraph = Paragraph(parent)
|
||||
self._paragraph._docx_paragraph.paragraph_format.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
|
||||
self._paragraph.first_line_indent = 0
|
||||
self._items: list[tuple[int, str, int, bool]] = []
|
||||
self._native_ready = False
|
||||
|
||||
def set_heading_numbering(self, mode: str):
|
||||
self._heading_numbering = mode
|
||||
|
||||
def set_toc_mode(self, mode: str):
|
||||
self._toc_mode = mode
|
||||
|
||||
@property
|
||||
def toc_mode(self) -> str:
|
||||
return self._toc_mode
|
||||
|
||||
def add_item(self, level: int, title: str, page: int, numbered: bool):
|
||||
self._items.append((level, title, page, numbered))
|
||||
|
||||
def fill(self):
|
||||
if self._toc_mode == "native":
|
||||
self._fill_native()
|
||||
else:
|
||||
self._fill_manual()
|
||||
|
||||
def _fill_native(self):
|
||||
"""Insert a Word TOC field. Pages appear after update in Word."""
|
||||
if self._native_ready:
|
||||
return
|
||||
p = self._paragraph._docx_paragraph
|
||||
# Clear any leftover runs
|
||||
for child in list(p._p):
|
||||
if child.tag.endswith("}r"):
|
||||
p._p.remove(child)
|
||||
|
||||
def add_fld_char(fld_char_type: str, dirty: bool = False):
|
||||
attrs = {"w:fldCharType": fld_char_type}
|
||||
if dirty:
|
||||
attrs["w:dirty"] = "true"
|
||||
run = create_element("w:r", [
|
||||
create_element("w:fldChar", attrs)
|
||||
])
|
||||
p._p.append(run)
|
||||
|
||||
def add_instr(text: str):
|
||||
instr = create_element("w:instrText", {"xml:space": "preserve"}, f" {text} ")
|
||||
run = create_element("w:r", [instr])
|
||||
p._p.append(run)
|
||||
|
||||
def add_text(text: str):
|
||||
run = create_element("w:r", [
|
||||
create_element("w:t", {"xml:space": "preserve"}, text)
|
||||
])
|
||||
p._p.append(run)
|
||||
|
||||
add_fld_char("begin", dirty=True)
|
||||
add_instr(_NATIVE_TOC_INSTR)
|
||||
add_fld_char("separate")
|
||||
add_text(
|
||||
"Обновите содержание в Word: ПКМ по полю → Обновить поле → целиком."
|
||||
)
|
||||
add_fld_char("end")
|
||||
self._native_ready = True
|
||||
|
||||
def _fill_manual(self):
|
||||
p = self._paragraph._docx_paragraph
|
||||
usable = (p.part.document.sections[0].page_width
|
||||
- p.part.document.sections[0].left_margin
|
||||
- p.part.document.sections[0].right_margin)
|
||||
p.paragraph_format.tab_stops.add_tab_stop(
|
||||
usable, alignment=WD_TAB_ALIGNMENT.RIGHT, leader=WD_TAB_LEADER.DOTS)
|
||||
p.paragraph_format.tab_stops.add_tab_stop(
|
||||
0, alignment=WD_TAB_ALIGNMENT.LEFT, leader=WD_TAB_LEADER.SPACES)
|
||||
|
||||
numbering = [0 for _ in range(10)]
|
||||
for level, title, page, numbered in self._items:
|
||||
numbering[level - 1] += 1
|
||||
for i in range(level, len(numbering)):
|
||||
numbering[i] = 0
|
||||
|
||||
if title.strip().upper() == "СОДЕРЖАНИЕ":
|
||||
continue
|
||||
|
||||
display = title.strip()
|
||||
if display.upper() in SPECIAL_TITLES or display.upper().startswith("ПРИЛОЖЕНИЕ"):
|
||||
display = display.upper()
|
||||
|
||||
p.add_run(" " * (level - 1))
|
||||
if numbered and self._heading_numbering == "auto":
|
||||
num = ".".join(str(x) for x in numbering[:level])
|
||||
p.add_run(f"{num} ")
|
||||
run = p.add_run(display)
|
||||
run.bold = False
|
||||
p.add_run(f"\t{page}")
|
||||
p.add_run("\n")
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
# Native TOC: insert field shell early so it lands in the body before page break.
|
||||
# Final instr is ensured again in fill() after render.
|
||||
if self._toc_mode == "native" and not self._native_ready:
|
||||
self._fill_native()
|
||||
|
||||
for rendered_info in self._paragraph.render(previous_rendered, copy(layout_state)):
|
||||
yield RenderedInfo(rendered_info.docx_element, 0)
|
||||
yield from PageBreak(self._parent).render(None, copy(layout_state))
|
||||
@@ -0,0 +1,163 @@
|
||||
import logging
|
||||
from functools import singledispatchmethod
|
||||
|
||||
from docx.shared import Parented, RGBColor
|
||||
|
||||
from .renderable import *
|
||||
from .renderable import Renderable
|
||||
from . import extended_markdown
|
||||
from .renderable.caption import CaptionInfo
|
||||
from .renderable.paragraph import Link
|
||||
from .renderable.table import Table
|
||||
from .renderable.equation import Equation
|
||||
from .renderable.heading import Heading
|
||||
from .renderable.list import List
|
||||
from .renderable.toc import ToC
|
||||
from .renderable.diagram import DiagramFigure
|
||||
from .label_pass import resolve_reference
|
||||
from .diagram_renderer import DIAGRAM_LANGS
|
||||
|
||||
|
||||
class RenderableFactory:
|
||||
def __init__(self, parent: Parented):
|
||||
self._parent = parent
|
||||
|
||||
@singledispatchmethod
|
||||
def create(self, marko_element: extended_markdown.BlockElement,
|
||||
caption_info: CaptionInfo) -> Renderable:
|
||||
paragraph = Paragraph(self._parent)
|
||||
paragraph.add_run(f"{marko_element.get_type()} is not supported", color=RGBColor.from_string('ff0000'))
|
||||
logging.warning(f"{marko_element.get_type()} is not supported")
|
||||
return paragraph
|
||||
|
||||
@staticmethod
|
||||
def _create_runs(paragraph_or_link: Paragraph | Link, children, classes: list[type] = None):
|
||||
if not classes:
|
||||
classes = []
|
||||
for child in children:
|
||||
if isinstance(child, (extended_markdown.RawText, extended_markdown.Literal)):
|
||||
paragraph_or_link.add_run(child.children,
|
||||
is_bold=extended_markdown.StrongEmphasis in classes or None,
|
||||
is_italic=extended_markdown.Emphasis in classes or None,
|
||||
strike_through=extended_markdown.Strikethrough in classes or None)
|
||||
elif isinstance(child, extended_markdown.CodeSpan):
|
||||
paragraph_or_link.add_run(child.children, is_italic=True)
|
||||
elif isinstance(child, extended_markdown.Image):
|
||||
caption = CaptionInfo(child.unique_name, child.title)
|
||||
paragraph_or_link.add_image(child.dest, caption)
|
||||
elif isinstance(child, extended_markdown.LineBreak):
|
||||
pass
|
||||
elif isinstance(child, extended_markdown.InlineEquation):
|
||||
paragraph_or_link.add_inline_equation(child.latex_equation)
|
||||
elif isinstance(child, extended_markdown.Reference):
|
||||
if hasattr(paragraph_or_link, "add_reference"):
|
||||
paragraph_or_link.add_reference(child.type, child.name)
|
||||
else:
|
||||
paragraph_or_link.add_run(resolve_reference(child.type, child.name))
|
||||
elif isinstance(child, (extended_markdown.Link, extended_markdown.Url)):
|
||||
RenderableFactory._create_runs(paragraph_or_link.add_link(child.dest),
|
||||
child.children, classes)
|
||||
elif isinstance(child, (extended_markdown.Emphasis, extended_markdown.StrongEmphasis,
|
||||
extended_markdown.Strikethrough)):
|
||||
RenderableFactory._create_runs(paragraph_or_link,
|
||||
child.children, classes + [type(child)])
|
||||
else:
|
||||
paragraph_or_link.add_run(f" {child.get_type()} is not supported ",
|
||||
color=RGBColor.from_string("FF0000"))
|
||||
logging.warning(f"{child.get_type()} is not supported")
|
||||
|
||||
@create.register
|
||||
def _(self, marko_paragraph: extended_markdown.Paragraph, caption_info: CaptionInfo):
|
||||
paragraph = Paragraph(self._parent)
|
||||
RenderableFactory._create_runs(paragraph, marko_paragraph.children)
|
||||
return paragraph
|
||||
|
||||
@create.register
|
||||
def _(self, marko_heading: extended_markdown.Heading, caption_info: CaptionInfo):
|
||||
heading = Heading(self._parent, marko_heading.level, marko_heading.numbered)
|
||||
RenderableFactory._create_runs(heading, marko_heading.children)
|
||||
return heading
|
||||
|
||||
@create.register
|
||||
def _(self, marko_code_block: extended_markdown.FencedCode, caption_info: CaptionInfo):
|
||||
lang = (marko_code_block.lang or "").strip().lower()
|
||||
source = marko_code_block.children[0].children
|
||||
if lang in DIAGRAM_LANGS:
|
||||
return DiagramFigure(
|
||||
self._parent,
|
||||
lang,
|
||||
source,
|
||||
caption_info,
|
||||
with_listing=bool(caption_info and caption_info.with_listing),
|
||||
)
|
||||
listing = Listing(self._parent, marko_code_block.lang, caption_info)
|
||||
listing.set_text(source)
|
||||
return listing
|
||||
|
||||
@create.register
|
||||
def _(self, marko_code_block: extended_markdown.CodeBlock, caption_info: CaptionInfo):
|
||||
lang = (getattr(marko_code_block, "lang", "") or "").strip().lower()
|
||||
source = marko_code_block.children[0].children
|
||||
if lang in DIAGRAM_LANGS:
|
||||
return DiagramFigure(
|
||||
self._parent,
|
||||
lang,
|
||||
source,
|
||||
caption_info,
|
||||
with_listing=bool(caption_info and caption_info.with_listing),
|
||||
)
|
||||
listing = Listing(self._parent, getattr(marko_code_block, "lang", "") or "", caption_info)
|
||||
listing.set_text(source)
|
||||
return listing
|
||||
|
||||
@create.register
|
||||
def _(self, marko_equation: extended_markdown.Equation, caption_info: CaptionInfo):
|
||||
unique = caption_info.unique_name if caption_info else None
|
||||
formula = Equation(self._parent, marko_equation.latex_equation,
|
||||
unique_name=unique, numbered=False)
|
||||
return formula
|
||||
|
||||
@create.register
|
||||
def _(self, marko_list: extended_markdown.List, caption_info: CaptionInfo):
|
||||
list_ = List(self._parent, marko_list.ordered)
|
||||
|
||||
def create_items_from_marko(marko_list_, level=1):
|
||||
for list_item in marko_list_.children:
|
||||
for child in list_item.children:
|
||||
if isinstance(child, extended_markdown.List):
|
||||
create_items_from_marko(child, level + 1)
|
||||
elif isinstance(child, extended_markdown.Paragraph):
|
||||
RenderableFactory._create_runs(
|
||||
list_.add_item(level),
|
||||
child.children
|
||||
)
|
||||
|
||||
create_items_from_marko(marko_list)
|
||||
|
||||
return list_
|
||||
|
||||
@create.register
|
||||
def _(self, marko_table: extended_markdown.Table, caption_info: CaptionInfo):
|
||||
table = Table(self._parent, len(marko_table.children), len(marko_table.children[0].children),
|
||||
caption_info)
|
||||
|
||||
for i, row in enumerate(marko_table.children):
|
||||
for j, cell in enumerate(row.children):
|
||||
merge_v = getattr(cell, "merge_v", "none") or "none"
|
||||
merge_h = getattr(cell, "merge_h", "none") or "none"
|
||||
table.set_cell_merge(i, j, merge_v, merge_h)
|
||||
if merge_v == "continue" or merge_h == "continue":
|
||||
# Placeholder paragraph so cell is still addressable; content unused on render
|
||||
table.add_paragraph_to_cell(i, j)
|
||||
continue
|
||||
RenderableFactory._create_runs(
|
||||
table.add_paragraph_to_cell(i, j),
|
||||
cell.children
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
@create.register
|
||||
def _(self, marko_toc: extended_markdown.TOC, caption_info: CaptionInfo):
|
||||
toc = ToC(self._parent)
|
||||
return toc
|
||||
@@ -0,0 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
from docx.shared import Parented, Length
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderedInfo:
|
||||
docx_element: Parented
|
||||
height: Length
|
||||
@@ -0,0 +1,232 @@
|
||||
from typing import TYPE_CHECKING
|
||||
from itertools import chain
|
||||
import re
|
||||
|
||||
from docx.document import Document
|
||||
from docx.shared import Length, Cm, Parented, Pt, Mm
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
|
||||
from .numberer import Numberer, APPENDIX_LETTERS
|
||||
from .renderable import Renderable
|
||||
from .renderable.requires_numbering import RequiresNumbering
|
||||
from .renderable.heading import Heading
|
||||
from .renderable.equation import Equation
|
||||
from .rendered_info import RenderedInfo
|
||||
from .sub_renderable import SubRenderable
|
||||
from .util import create_element
|
||||
from .layout_tracker import LayoutTracker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .debugger import Debugger
|
||||
|
||||
BOTTOM_MARGIN = Mm(20)
|
||||
|
||||
APPENDIX_RE = re.compile(
|
||||
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-Z])\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SPECIAL_CENTER = {
|
||||
"СОДЕРЖАНИЕ",
|
||||
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
|
||||
"СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ",
|
||||
}
|
||||
|
||||
|
||||
class Renderer:
|
||||
"""Renders Renderable elements to docx file"""
|
||||
|
||||
def __init__(self, document: Document, debugger: "Debugger | None" = None,
|
||||
numbered_equations: set[str] | None = None,
|
||||
skip_numbering: bool = False,
|
||||
numbering_scope: str = "section"):
|
||||
self._document: Document = document
|
||||
self._numberer = Numberer(mode=numbering_scope)
|
||||
self._debugger = debugger
|
||||
self._numbered_equations = numbered_equations or set()
|
||||
self._skip_numbering = skip_numbering
|
||||
self._section_count = 0
|
||||
self._appendix_index = 0
|
||||
self._after_toc = False
|
||||
self._body_section_started = False
|
||||
|
||||
max_height = (document.sections[0].page_height
|
||||
- document.sections[0].top_margin
|
||||
- BOTTOM_MARGIN)
|
||||
max_width = (self._document.sections[0].page_width
|
||||
- self._document.sections[0].left_margin
|
||||
- self._document.sections[0].right_margin)
|
||||
self._layout_tracker = LayoutTracker(max_height, max_width)
|
||||
|
||||
# Front-matter section: no page numbers (титул / задание / содержание)
|
||||
self._clear_footer(self._document.sections[0])
|
||||
|
||||
self.previous_rendered = None
|
||||
self._to_new_page: list[Renderable] = []
|
||||
|
||||
@staticmethod
|
||||
def _clear_footer(section):
|
||||
footer = section.footer
|
||||
footer.is_linked_to_previous = False
|
||||
for p in footer.paragraphs:
|
||||
p.clear()
|
||||
if not footer.paragraphs:
|
||||
footer.add_paragraph()
|
||||
|
||||
def _ensure_body_section_with_page_numbers(self):
|
||||
"""After TOC: start a new section with centered PAGE (TNR 12)."""
|
||||
if self._body_section_started:
|
||||
return
|
||||
self._body_section_started = True
|
||||
# Continuous/new page section break is inserted via last paragraph sectPr;
|
||||
# python-docx: add_section creates new section.
|
||||
from docx.enum.section import WD_ORIENT, WD_SECTION
|
||||
new_section = self._document.add_section(WD_SECTION.NEW_PAGE)
|
||||
new_section.page_width = self._document.sections[0].page_width
|
||||
new_section.page_height = self._document.sections[0].page_height
|
||||
new_section.left_margin = Mm(30)
|
||||
new_section.right_margin = Mm(10)
|
||||
new_section.top_margin = Mm(20)
|
||||
new_section.bottom_margin = Mm(20)
|
||||
|
||||
paragraph = new_section.footer.paragraphs[0]
|
||||
paragraph.paragraph_format.first_line_indent = 0
|
||||
paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
run = paragraph.add_run()
|
||||
run.font.name = "Times New Roman"
|
||||
run.font.size = Pt(12)
|
||||
# PAGE field
|
||||
paragraph._p.append(create_element("w:fldSimple", {
|
||||
"w:instr": "PAGE \\* MERGEFORMAT"
|
||||
}))
|
||||
|
||||
def process(self, renderables: list[Renderable]):
|
||||
for i in range(len(renderables)):
|
||||
self.render(renderables[i])
|
||||
|
||||
self._flush_to_new_screen()
|
||||
# If document had no TOC, still add page numbers to the only section
|
||||
if not self._body_section_started:
|
||||
section = self._document.sections[0]
|
||||
paragraph = section.footer.paragraphs[0]
|
||||
paragraph.paragraph_format.first_line_indent = 0
|
||||
paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
paragraph._p.append(create_element("w:fldSimple", {
|
||||
"w:instr": "PAGE \\* MERGEFORMAT"
|
||||
}))
|
||||
if self._debugger:
|
||||
self._debugger.after_rendered()
|
||||
|
||||
def _handle_heading(self, heading: Heading):
|
||||
text = (heading.text or "").strip()
|
||||
upper = text.upper()
|
||||
|
||||
if heading.level == 1 and heading.is_numbered:
|
||||
self._section_count += 1
|
||||
self._numberer.enter_section(self._section_count)
|
||||
|
||||
# Appendix: «Приложение А» / «ПРИЛОЖЕНИЕ А»
|
||||
m = APPENDIX_RE.match(upper.replace("Ё", "Е"))
|
||||
if m or (heading.level <= 3 and upper.startswith("ПРИЛОЖЕНИЕ")):
|
||||
letter = None
|
||||
if m:
|
||||
letter = m.group(1).upper()
|
||||
else:
|
||||
parts = upper.split()
|
||||
if len(parts) >= 2 and parts[1] in APPENDIX_LETTERS:
|
||||
letter = parts[1]
|
||||
elif self._appendix_index < len(APPENDIX_LETTERS):
|
||||
letter = APPENDIX_LETTERS[self._appendix_index]
|
||||
self._appendix_index += 1
|
||||
if letter:
|
||||
self._numberer.enter_appendix(letter)
|
||||
|
||||
# Center special unnumbered headings that must be centered (already centered if unnumbered)
|
||||
if upper in SPECIAL_CENTER or upper.startswith("ПРИЛОЖЕНИЕ"):
|
||||
heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
|
||||
# СОДЕРЖАНИЕ / СПИСОК — center even if somehow numbered
|
||||
if upper in ("СОДЕРЖАНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"):
|
||||
heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
|
||||
# Native Word TOC picks up Heading 1–3; «СОДЕРЖАНИЕ» must not list itself
|
||||
if upper == "СОДЕРЖАНИЕ":
|
||||
heading.exclude_from_toc()
|
||||
|
||||
def render(self, renderable: Renderable):
|
||||
if isinstance(renderable, Heading):
|
||||
self._handle_heading(renderable)
|
||||
|
||||
# After ToC page-break renderable we open body section — detected via ToC's PageBreak
|
||||
from .renderable.toc import ToC
|
||||
from .renderable.page_break import PageBreak
|
||||
if isinstance(renderable, PageBreak) and self._after_toc:
|
||||
self._ensure_body_section_with_page_numbers()
|
||||
|
||||
if isinstance(renderable, ToC):
|
||||
self._after_toc = True
|
||||
|
||||
if not self._skip_numbering and isinstance(renderable, RequiresNumbering):
|
||||
if isinstance(renderable, Equation):
|
||||
label = renderable.unique_name
|
||||
if renderable.needs_numbering or (label and label in self._numbered_equations):
|
||||
renderable.enable_numbering()
|
||||
number = self._numberer.next_number(
|
||||
renderable.numbering_category, label)
|
||||
renderable.set_number(number)
|
||||
else:
|
||||
number = self._numberer.next_number(
|
||||
renderable.numbering_category,
|
||||
getattr(renderable, "unique_name", None),
|
||||
)
|
||||
renderable.set_number(number)
|
||||
|
||||
infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state)
|
||||
|
||||
try:
|
||||
first = next(infos)
|
||||
if isinstance(first, RenderedInfo) and first.height \
|
||||
>= self._layout_tracker.current_state.remaining_page_height:
|
||||
self._flush_to_new_screen()
|
||||
infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state)
|
||||
else:
|
||||
infos = chain([first], infos)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
for info in infos:
|
||||
if isinstance(info, SubRenderable):
|
||||
if info.add_to_new_page:
|
||||
self._to_new_page.append(info.renderable)
|
||||
else:
|
||||
self.render(info.renderable)
|
||||
else:
|
||||
self._add(info.docx_element, info.height)
|
||||
self.previous_rendered = info
|
||||
|
||||
def _flush_to_new_screen(self):
|
||||
while self._to_new_page:
|
||||
renderable = self._to_new_page.pop(0)
|
||||
if not self._skip_numbering and isinstance(renderable, RequiresNumbering):
|
||||
number = self._numberer.next_number(
|
||||
renderable.numbering_category,
|
||||
getattr(renderable, "unique_name", None),
|
||||
)
|
||||
renderable.set_number(number)
|
||||
for info_ in renderable.render(self.previous_rendered, self._layout_tracker.current_state):
|
||||
if isinstance(info_, SubRenderable):
|
||||
continue
|
||||
self._add(info_.docx_element, info_.height)
|
||||
self.previous_rendered = info_
|
||||
|
||||
def _add(self, element: Parented, height: Length):
|
||||
self._document._body._element.append(
|
||||
element._element
|
||||
)
|
||||
self._layout_tracker.add_height(height)
|
||||
|
||||
if self._debugger:
|
||||
self._debugger.add(element, height)
|
||||
|
||||
@property
|
||||
def numberer(self) -> Numberer:
|
||||
return self._numberer
|
||||
@@ -0,0 +1,304 @@
|
||||
"""Apply MIREA TZ (GOST 7.32 / методичка 2022) paragraph styles to a Document."""
|
||||
|
||||
from docx.document import Document
|
||||
from docx.enum.style import WD_STYLE_TYPE
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING, WD_TAB_ALIGNMENT, WD_TAB_LEADER
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Cm, Mm, Pt
|
||||
from docx.styles.style import _ParagraphStyle as ParagraphStyle
|
||||
|
||||
|
||||
def _set_run_font(style: ParagraphStyle, name: str, size_pt: float, bold: bool = False, italic: bool = False):
|
||||
"""Lock typeface/size/color so Word theme (Calibri + accent blue) cannot leak."""
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.shared import RGBColor
|
||||
|
||||
font = style.font
|
||||
font.name = name
|
||||
font.size = Pt(size_pt)
|
||||
font.bold = bold
|
||||
font.italic = italic
|
||||
font.color.rgb = RGBColor(0, 0, 0)
|
||||
|
||||
rPr = style.element.get_or_add_rPr()
|
||||
for child in list(rPr):
|
||||
tag = child.tag
|
||||
if tag.endswith("}rFonts") or tag.endswith("}color") or tag.endswith("}sz") or tag.endswith("}szCs"):
|
||||
rPr.remove(child)
|
||||
|
||||
rFonts = OxmlElement("w:rFonts")
|
||||
rFonts.set(qn("w:ascii"), name)
|
||||
rFonts.set(qn("w:hAnsi"), name)
|
||||
rFonts.set(qn("w:cs"), name)
|
||||
rFonts.set(qn("w:eastAsia"), name)
|
||||
rPr.insert(0, rFonts)
|
||||
|
||||
half_points = str(int(size_pt * 2))
|
||||
sz = OxmlElement("w:sz")
|
||||
sz.set(qn("w:val"), half_points)
|
||||
rPr.append(sz)
|
||||
sz_cs = OxmlElement("w:szCs")
|
||||
sz_cs.set(qn("w:val"), half_points)
|
||||
rPr.append(sz_cs)
|
||||
|
||||
color = OxmlElement("w:color")
|
||||
color.set(qn("w:val"), "000000")
|
||||
rPr.append(color)
|
||||
|
||||
|
||||
def _ensure_style(document: Document, name: str, base: str = "Normal") -> ParagraphStyle:
|
||||
try:
|
||||
return document.styles[name]
|
||||
except KeyError:
|
||||
style = document.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH)
|
||||
style.base_style = document.styles[base]
|
||||
return style
|
||||
|
||||
|
||||
def _clear_tab_stops(style: ParagraphStyle) -> None:
|
||||
pPr = style.element.get_or_add_pPr()
|
||||
tabs = pPr.find(qn("w:tabs"))
|
||||
if tabs is not None:
|
||||
pPr.remove(tabs)
|
||||
|
||||
|
||||
def _fix_toc_tab_stops(document: Document) -> None:
|
||||
"""Align TOC page-number tabs with the text area (Template.docx used 175 mm for L=25 mm)."""
|
||||
section = document.sections[0]
|
||||
right_tab = section.page_width - section.left_margin - section.right_margin
|
||||
left_tabs = {
|
||||
"toc 1": Mm(5),
|
||||
"toc 2": Mm(12.5),
|
||||
"toc 3": Mm(20),
|
||||
}
|
||||
|
||||
for style in document.styles:
|
||||
if style.type != WD_STYLE_TYPE.PARAGRAPH:
|
||||
continue
|
||||
name = style.name.lower()
|
||||
if not name.startswith("toc ") or name == "toc heading":
|
||||
continue
|
||||
_clear_tab_stops(style)
|
||||
pf = style.paragraph_format
|
||||
left = left_tabs.get(name)
|
||||
if left is not None:
|
||||
pf.tab_stops.add_tab_stop(left, WD_TAB_ALIGNMENT.LEFT)
|
||||
pf.tab_stops.add_tab_stop(
|
||||
right_tab, WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS
|
||||
)
|
||||
|
||||
|
||||
def apply_mirea_styles(document: Document) -> None:
|
||||
"""Mutate section margins and key paragraph styles to match the MIREA method guide."""
|
||||
_apply_common_page_and_body(document)
|
||||
|
||||
# --- Headings (табл. 2.1): слева с отступом 1,25 см ---
|
||||
heading_specs = [
|
||||
(1, 18, Mm(0), Mm(10), True),
|
||||
(2, 16, Mm(15), Mm(10), False),
|
||||
(3, 14, Mm(15), Mm(10), False),
|
||||
]
|
||||
for level, size, before, after, page_break in heading_specs:
|
||||
style: ParagraphStyle = document.styles[f"Heading {level}"]
|
||||
_set_run_font(style, "Times New Roman", size, bold=True)
|
||||
if level == 1:
|
||||
style.font.all_caps = True
|
||||
else:
|
||||
style.font.all_caps = False
|
||||
hpf = style.paragraph_format
|
||||
hpf.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
hpf.first_line_indent = Cm(0)
|
||||
hpf.left_indent = Cm(1.25)
|
||||
hpf.right_indent = Cm(0)
|
||||
hpf.space_before = before
|
||||
hpf.space_after = after
|
||||
hpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
||||
hpf.page_break_before = page_break
|
||||
hpf.keep_with_next = True
|
||||
hpf.widow_control = True
|
||||
|
||||
_apply_common_captions_and_misc(document)
|
||||
|
||||
|
||||
def apply_pis_custom_styles(document: Document) -> None:
|
||||
"""Styles for PIS_custom: итоговый отчёт по практическим работам.
|
||||
|
||||
* H1 (разделы / практические работы): по центру, ПРОПИСНЫЕ, без точки.
|
||||
* H2 (подразделы): с абзацного отступа 1,25 см, с прописной буквы.
|
||||
* Поля / шрифт / интервал / красная строка — как в чек-листе ПИС (= ГОСТ поля).
|
||||
"""
|
||||
_apply_common_page_and_body(document)
|
||||
|
||||
# H1 — раздел: центр, caps, с новой страницы
|
||||
h1: ParagraphStyle = document.styles["Heading 1"]
|
||||
_set_run_font(h1, "Times New Roman", 14, bold=True)
|
||||
h1.font.all_caps = True
|
||||
h1pf = h1.paragraph_format
|
||||
h1pf.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
h1pf.first_line_indent = Cm(0)
|
||||
h1pf.left_indent = Cm(0)
|
||||
h1pf.right_indent = Cm(0)
|
||||
h1pf.space_before = Mm(0)
|
||||
h1pf.space_after = Mm(10)
|
||||
h1pf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
||||
h1pf.page_break_before = True
|
||||
h1pf.keep_with_next = True
|
||||
h1pf.widow_control = True
|
||||
|
||||
# H2 / H3 — подразделы: абзацный отступ, не caps
|
||||
for level, size, before in ((2, 14, Mm(15)), (3, 14, Mm(10))):
|
||||
style = document.styles[f"Heading {level}"]
|
||||
_set_run_font(style, "Times New Roman", size, bold=True)
|
||||
style.font.all_caps = False
|
||||
hpf = style.paragraph_format
|
||||
hpf.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||
hpf.first_line_indent = Cm(1.25)
|
||||
hpf.left_indent = Cm(0)
|
||||
hpf.right_indent = Cm(0)
|
||||
hpf.space_before = before
|
||||
hpf.space_after = Mm(10)
|
||||
hpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
||||
hpf.page_break_before = False
|
||||
hpf.keep_with_next = True
|
||||
hpf.widow_control = True
|
||||
|
||||
_apply_common_captions_and_misc(document)
|
||||
|
||||
|
||||
def apply_document_styles(document: Document, style_preset: str = "mirea") -> None:
|
||||
if style_preset == "pis_custom":
|
||||
apply_pis_custom_styles(document)
|
||||
else:
|
||||
apply_mirea_styles(document)
|
||||
|
||||
|
||||
def _apply_common_page_and_body(document: Document) -> None:
|
||||
for section in document.sections:
|
||||
section.page_width = Mm(210)
|
||||
section.page_height = Mm(297)
|
||||
section.left_margin = Mm(30)
|
||||
section.right_margin = Mm(10)
|
||||
section.top_margin = Mm(20)
|
||||
section.bottom_margin = Mm(20)
|
||||
|
||||
_fix_toc_tab_stops(document)
|
||||
|
||||
normal: ParagraphStyle = document.styles["Normal"]
|
||||
_set_run_font(normal, "Times New Roman", 14)
|
||||
pf = normal.paragraph_format
|
||||
pf.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||
pf.first_line_indent = Cm(1.25)
|
||||
pf.left_indent = Cm(0)
|
||||
pf.right_indent = Cm(0)
|
||||
pf.space_before = Pt(0)
|
||||
pf.space_after = Pt(0)
|
||||
pf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
||||
pf.widow_control = True
|
||||
|
||||
|
||||
def _apply_common_captions_and_misc(document: Document) -> None:
|
||||
# --- Caption Figure: 12pt bold, center, under figure ---
|
||||
caption_fig = _ensure_style(document, "Caption Figure", "Caption")
|
||||
_set_run_font(caption_fig, "Times New Roman", 12, bold=True)
|
||||
cpf = caption_fig.paragraph_format
|
||||
cpf.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
cpf.first_line_indent = Cm(0)
|
||||
cpf.left_indent = Cm(0)
|
||||
cpf.space_before = Mm(0)
|
||||
cpf.space_after = Mm(6)
|
||||
cpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
||||
cpf.widow_control = True
|
||||
|
||||
# --- Caption Table: 12pt italic, left, above table ---
|
||||
caption_tbl = _ensure_style(document, "Caption Table", "Caption")
|
||||
_set_run_font(caption_tbl, "Times New Roman", 12, italic=True)
|
||||
tpf = caption_tbl.paragraph_format
|
||||
tpf.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
tpf.first_line_indent = Cm(0)
|
||||
tpf.left_indent = Cm(0)
|
||||
tpf.space_before = Mm(6)
|
||||
tpf.space_after = Mm(0)
|
||||
tpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
||||
tpf.widow_control = True
|
||||
|
||||
# --- Caption Listing (как таблицы) ---
|
||||
caption_lst = _ensure_style(document, "Caption Listing", "Caption")
|
||||
_set_run_font(caption_lst, "Times New Roman", 12, italic=True)
|
||||
lpf = caption_lst.paragraph_format
|
||||
lpf.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
lpf.first_line_indent = Cm(0)
|
||||
lpf.left_indent = Cm(0)
|
||||
lpf.space_before = Mm(6)
|
||||
lpf.space_after = Mm(0)
|
||||
lpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
||||
lpf.keep_with_next = True
|
||||
lpf.widow_control = True
|
||||
|
||||
caption = document.styles["Caption"]
|
||||
_set_run_font(caption, "Times New Roman", 12, bold=True)
|
||||
capf = caption.paragraph_format
|
||||
capf.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
capf.first_line_indent = Cm(0)
|
||||
capf.space_before = Mm(0)
|
||||
capf.space_after = Mm(6)
|
||||
capf.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
||||
|
||||
code = _ensure_style(document, "Code", "Normal")
|
||||
_set_run_font(code, "Courier New", 10)
|
||||
cdpf = code.paragraph_format
|
||||
cdpf.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
cdpf.first_line_indent = Cm(0)
|
||||
cdpf.left_indent = Cm(0)
|
||||
cdpf.space_before = Mm(0)
|
||||
cdpf.space_after = Mm(0)
|
||||
cdpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
||||
|
||||
table_text = _ensure_style(document, "Table Text", "Normal")
|
||||
_set_run_font(table_text, "Times New Roman", 12)
|
||||
ttf = table_text.paragraph_format
|
||||
ttf.first_line_indent = Cm(0)
|
||||
ttf.space_before = Mm(0)
|
||||
ttf.space_after = Mm(0)
|
||||
ttf.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
||||
|
||||
biblio = _ensure_style(document, "Bibliography", "Normal")
|
||||
_set_run_font(biblio, "Times New Roman", 14)
|
||||
bpf = biblio.paragraph_format
|
||||
bpf.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||
bpf.first_line_indent = Cm(1.25)
|
||||
bpf.space_before = Pt(0)
|
||||
bpf.space_after = Pt(0)
|
||||
bpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
||||
|
||||
biblio_h = _ensure_style(document, "Bibliography Heading", "Normal")
|
||||
_set_run_font(biblio_h, "Times New Roman", 14)
|
||||
biblio_h.font.all_caps = True
|
||||
bhpf = biblio_h.paragraph_format
|
||||
bhpf.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
bhpf.first_line_indent = Cm(0)
|
||||
bhpf.left_indent = Cm(0)
|
||||
bhpf.space_before = Mm(6)
|
||||
bhpf.space_after = Mm(6)
|
||||
bhpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
|
||||
bhpf.keep_with_next = True
|
||||
|
||||
try:
|
||||
footer_style = document.styles["Footer"]
|
||||
_set_run_font(footer_style, "Times New Roman", 12)
|
||||
footer_style.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
footer_style.paragraph_format.first_line_indent = Cm(0)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
for hyper_name in ("Hyperlink", "FollowedHyperlink"):
|
||||
try:
|
||||
hyper = document.styles[hyper_name]
|
||||
except KeyError:
|
||||
continue
|
||||
_set_run_font(hyper, "Times New Roman", 14)
|
||||
hyper.font.underline = False
|
||||
|
||||
after = _ensure_style(document, "Space After Table", "Normal")
|
||||
apf = after.paragraph_format
|
||||
apf.space_before = Mm(6)
|
||||
apf.first_line_indent = Cm(1.25)
|
||||
@@ -0,0 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from md2gost.renderable import Renderable
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubRenderable:
|
||||
renderable: Renderable
|
||||
add_to_new_page: bool
|
||||
@@ -0,0 +1,51 @@
|
||||
from md2gost.renderable import Renderable
|
||||
from md2gost.renderable.heading import Heading
|
||||
from md2gost.renderable.toc import ToC
|
||||
from md2gost.util import create_element
|
||||
|
||||
|
||||
def _enable_update_fields_on_open(document) -> None:
|
||||
"""Ask Word to refresh fields (TOC) when the document is opened."""
|
||||
settings = document.settings.element
|
||||
# Remove existing updateFields if any
|
||||
for child in list(settings):
|
||||
if child.tag.endswith("}updateFields"):
|
||||
settings.remove(child)
|
||||
settings.append(create_element("w:updateFields", {"w:val": "true"}))
|
||||
|
||||
|
||||
class TocProcessor:
|
||||
def process(self, renderables: list[Renderable]):
|
||||
renderables_iter = iter(renderables)
|
||||
|
||||
toc = None
|
||||
for renderable in renderables_iter:
|
||||
if isinstance(renderable, ToC):
|
||||
toc = renderable
|
||||
break
|
||||
|
||||
if not toc:
|
||||
return
|
||||
|
||||
if toc.toc_mode == "native":
|
||||
# Field already in body from render; ensure dirty + updateFields
|
||||
toc.fill()
|
||||
try:
|
||||
doc = toc._paragraph._docx_paragraph.part.document
|
||||
_enable_update_fields_on_open(doc)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
for renderable in renderables_iter:
|
||||
if isinstance(renderable, Heading):
|
||||
title = (renderable.text or "").strip()
|
||||
if title.upper() == "СОДЕРЖАНИЕ":
|
||||
continue
|
||||
toc.add_item(
|
||||
renderable.level,
|
||||
renderable.text,
|
||||
renderable.rendered_page,
|
||||
renderable.is_numbered,
|
||||
)
|
||||
toc.fill()
|
||||
@@ -0,0 +1,34 @@
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from lxml.etree import _Element
|
||||
|
||||
|
||||
def create_element(name: str, *args: dict[str, str] | list[_Element] | str)\
|
||||
-> _Element:
|
||||
"""Creates an OxmlElement
|
||||
|
||||
Variable arguments:
|
||||
* dict -- element's attributes
|
||||
* list -- element's children
|
||||
* string -- element's text
|
||||
"""
|
||||
attrs = {}
|
||||
children = []
|
||||
text = None
|
||||
|
||||
for arg in args:
|
||||
if isinstance(arg, dict):
|
||||
attrs.update(arg)
|
||||
elif isinstance(arg, list):
|
||||
children.extend(arg)
|
||||
elif isinstance(arg, str):
|
||||
text = arg
|
||||
|
||||
element = OxmlElement(name, {
|
||||
(qn(name) if ":" in name else name): value for name, value in attrs.items()
|
||||
})
|
||||
for child in children:
|
||||
element.append(child)
|
||||
if text:
|
||||
element.text = text
|
||||
return element
|
||||
Reference in New Issue
Block a user