BigUpdate
Python application / build (push) Has been cancelled

This commit is contained in:
Igor20264
2026-09-03 10:44:08 +03:00
parent d2da20fdb2
commit 516abe7b83
177 changed files with 40178 additions and 2 deletions
+146
View File
@@ -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`; источники **720**; проверка глав «Теоретические…» / «Прикладные…» и пунктов 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]` |
| Рисунок | `![…](file.png "%id Подпись")` + `@Рисунок: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.
+8
View File
@@ -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__))
+211
View 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()
+154
View File
@@ -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
+57
View File
@@ -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)
+59
View File
@@ -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
+431
View File
@@ -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)
+119
View File
@@ -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
+230
View File
@@ -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]
+204
View File
@@ -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)")
+67
View File
@@ -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
+67
View File
@@ -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
+67
View File
@@ -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
+119
View File
@@ -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)}))
+36
View File
@@ -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])
+33
View File
@@ -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
+26
View File
@@ -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
+36
View File
@@ -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
+15
View File
@@ -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)
+18
View File
@@ -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)
+157
View File
@@ -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"
+24
View File
@@ -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
+159
View File
@@ -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_}?"
+66
View File
@@ -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
+53
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -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
+36
View File
@@ -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
+185
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
from .renderable import Renderable
from .paragraph import Paragraph
from .listing import Listing
__all__ = [
"Renderable",
"Paragraph",
"Listing"
]
+76
View File
@@ -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))
+68
View File
@@ -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)
+91
View File
@@ -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)
+43
View File
@@ -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))
+162
View File
@@ -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 13 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))
+88
View File
@@ -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))
+92
View File
@@ -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
+156
View File
@@ -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)
+30
View File
@@ -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)
)
+184
View File
@@ -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
+267
View File
@@ -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)
+18
View File
@@ -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
+11
View File
@@ -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
+218
View File
@@ -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)
+140
View File
@@ -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 13, 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))
+163
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
from dataclasses import dataclass
from docx.shared import Parented, Length
@dataclass(frozen=True)
class RenderedInfo:
docx_element: Parented
height: Length
+232
View File
@@ -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 13; «СОДЕРЖАНИЕ» 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
+304
View File
@@ -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)
+9
View File
@@ -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
+51
View File
@@ -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()
+34
View File
@@ -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