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
+32
View File
@@ -0,0 +1,32 @@
import docx
from docx.document import Document
from docx.enum.style import WD_STYLE_TYPE
from docx.oxml import OxmlElement
from docx.shared import Cm, Pt, Mm
from docx.styles.style import _ParagraphStyle
_EMUS_PER_PX = Pt(1) * 72/96
def _create_test_document():
document: Document = docx.Document()
document.styles["Normal"].paragraph_format.space_before = 0
document.styles["Normal"].paragraph_format.space_after = Cm(0.35)
document.styles["Normal"].paragraph_format.line_spacing = 1.5
document.styles["Normal"].paragraph_format.first_line_indent = Cm(1.25)
document.styles["Normal"].font.name = "Times New Roman"
document.styles["Normal"].font.size = Pt(14)
document.styles.add_style("Code", WD_STYLE_TYPE.PARAGRAPH)
document.styles["Code"].paragraph_format.space_before = 0
document.styles["Code"].paragraph_format.space_after = 0
document.styles["Code"].paragraph_format.line_spacing = 1
document.styles["Code"].paragraph_format.first_line_indent = 0
document.styles["Code"].font.name = "Courier New"
document.styles["Code"].font.size = Pt(12)
max_height = Mm(297) - Cm(2) - Cm(2)
max_width = Mm(210) - Cm(2.5) - Cm(1)
return document, max_height, max_width
+16
View File
@@ -0,0 +1,16 @@
import unittest
from md2gost.extended_markdown import markdown, Equation
class TestFormula(unittest.TestCase):
def test_single_line(self):
res = markdown.parse("$$ test $$").children[0]
self.assertIsInstance(res, Equation)
self.assertEqual("test", res.latex_equation)
def test_multi_line(self):
res = markdown.parse("""$$
test
$$""").children[0]
self.assertIsInstance(res, Equation)
self.assertEqual("test", res.latex_equation)
+104
View File
@@ -0,0 +1,104 @@
"""Tests for diagram renderer and factory routing."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from md2gost.diagram_renderer import (
DIAGRAM_LANGS,
prepare_source,
render_diagram,
configure_diagrams,
)
from md2gost.extended_markdown import markdown
from md2gost.extended_markdown.caption import Caption as CapEl
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.diagram import DiagramFigure
from md2gost.renderable.listing import Listing
from md2gost.renderable_factory import RenderableFactory
def test_prepare_uml_wraps_startuml():
src, dtype = prepare_source("uml", "A -> B")
assert "@startuml" in src
assert dtype == "plantuml"
def test_prepare_c4_adds_include():
src, dtype = prepare_source("c4", "Person(user, \"User\")")
assert "!include" in src
assert dtype == "plantuml"
def test_prepare_bpmn():
src, dtype = prepare_source("bpmn", "start -> end")
assert dtype == "bpmn"
assert "@startbpmn" in src.lower() or "start" in src
def test_caption_plus_listing():
doc = markdown.parse("%fig Demo +listing\n\n```uml\nA->B\n```\n")
caps = [c for c in doc.children if isinstance(c, CapEl)]
assert len(caps) == 1
assert caps[0].with_listing is True
assert caps[0].text == "Demo"
assert caps[0].unique_name == "fig"
def test_factory_routes_uml_to_diagram():
from docx import Document
from md2gost import package_dir
import os
md = "%d1 Use case +listing\n\n```uml\n@startuml\nA -> B\n@enduml\n```\n"
parsed = markdown.parse(md)
doc = Document(os.path.join(package_dir(), "Template.docx"))
factory = RenderableFactory(doc._body)
caption = None
renderables = []
for el in parsed.children:
from marko.block import BlankLine
if isinstance(el, BlankLine):
continue
if isinstance(el, CapEl):
caption = CaptionInfo(el.unique_name, el.text, el.with_listing)
continue
renderables.append(factory.create(el, caption))
caption = None
assert len(renderables) == 1
assert isinstance(renderables[0], DiagramFigure)
assert renderables[0].listing is not None
assert isinstance(renderables[0].listing, Listing)
def test_render_diagram_uses_cache(tmp_path):
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
def fake_kroki(source, diagram_type, base_url, out_png):
out_png.write_bytes(png)
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path))
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
path1 = render_diagram("uml", "A -> B", cache_dir=str(tmp_path))
path2 = render_diagram("uml", "A -> B", cache_dir=str(tmp_path))
assert path1 == path2
assert open(path1, "rb").read().startswith(b"\x89PNG")
def test_render_diagram_off_raises(tmp_path):
configure_diagrams(fallback="off", cache_dir=str(tmp_path))
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", return_value=False):
with pytest.raises(RuntimeError):
render_diagram("uml", "A -> B", fallback="off", cache_dir=str(tmp_path))
def test_diagram_langs():
assert "uml" in DIAGRAM_LANGS
assert "c4" in DIAGRAM_LANGS
assert "bpmn" in DIAGRAM_LANGS
+50
View File
@@ -0,0 +1,50 @@
import unittest
from docx.shared import Mm
from md2gost.layout_tracker import LayoutState, LayoutTracker
class TestLayoutState(unittest.TestCase):
def setUp(self):
self._state = LayoutState(Mm(297), Mm(210))
self._state._current_height += Mm(400)
def test_max_height(self):
self.assertEqual(Mm(297), self._state.max_height)
def test_max_width(self):
self.assertEqual(Mm(210), self._state.max_width)
def test_current_page_height(self):
self.assertEqual(Mm(103), self._state.current_page_height)
def test_remaining_page_height(self):
self.assertEqual(Mm(194), self._state.remaining_page_height)
def test_page(self):
self.assertEqual(2, self._state.page)
class TestLayoutTracker(unittest.TestCase):
def test_add_height(self):
layout_tracker = LayoutTracker(Mm(297), Mm(210))
layout_tracker.add_height(Mm(500))
self.assertEqual(Mm(203), layout_tracker._state.current_page_height)
def test_can_fit_to_page(self):
layout_tracker = LayoutTracker(Mm(297), Mm(210))
layout_tracker.add_height(Mm(397))
self.assertTrue(layout_tracker.can_fit_to_page(Mm(100)))
self.assertTrue(layout_tracker.can_fit_to_page(Mm(197)))
self.assertFalse(layout_tracker.can_fit_to_page(Mm(198)))
self.assertFalse(layout_tracker.can_fit_to_page(Mm(500)))
def test_new_page(self):
layout_tracker = LayoutTracker(Mm(297), Mm(210))
layout_tracker.add_height(Mm(497))
layout_tracker.new_page()
self.assertEqual(3, layout_tracker.current_state.page)
self.assertEqual(0, layout_tracker.current_state.current_page_height)
+70
View File
@@ -0,0 +1,70 @@
"""Tests for md2fodt (MD → Flat ODF Text)."""
from __future__ import annotations
import xml.etree.ElementTree as ET
from pathlib import Path
from md2fodt.converter import convert_md_to_fodt
from md2fodt.emitter import emit_fodt
from md2fodt.escape import escape_text, xml_id
NS = {
"office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0",
"text": "urn:oasis:names:tc:opendocument:xmlns:text:1.0",
"table": "urn:oasis:names:tc:opendocument:xmlns:table:1.0",
}
def test_escape_and_id():
assert "&" in escape_text("a&b")
assert xml_id("foo bar", "fig") == "fig-foo-bar"
def test_emit_fodt_has_core_elements():
md = """# 1 Введение
Абзац с **жирным** и *курсивом*.
См. @Таблица:t1.
%t1 Пример
| A | B |
|---|---|
| 1 | 2 |
## 1.1 Подраздел
- пункт один
- пункт два
```python
print("hi")
```
"""
xml = emit_fodt(md, work_dir=Path("."))
assert "office:document" in xml
root = ET.fromstring(xml)
assert root.tag == "{urn:oasis:names:tc:opendocument:xmlns:office:1.0}document"
hs = root.findall(".//text:h", NS)
assert len(hs) >= 1
tables = root.findall(".//table:table", NS)
assert len(tables) == 1
headers = root.findall(".//table:table-header-rows", NS)
assert len(headers) == 1
assert "table:table-header-rows" in xml
assert "tab-t1" in xml
assert "Таблица" in xml or "&#" in xml # caption present (encoding-safe: bookmark + table)
def test_convert_writes_file(tmp_path: Path):
md = tmp_path / "sample.md"
md.write_text("# Заголовок\n\nТекст.\n", encoding="utf-8")
out = convert_md_to_fodt(md, tmp_path / "out.fodt")
assert out.is_file()
assert out.suffix == ".fodt"
text = out.read_text(encoding="utf-8")
assert "office:document" in text
ET.fromstring(text)
+49
View File
@@ -0,0 +1,49 @@
"""Smoke tests for MD → LaTeX emitter."""
from pathlib import Path
from md2latex.emitter import emit_document
from md2latex.converter import convert_md_to_latex_project
SAMPLE = """\
# *СОДЕРЖАНИЕ
[TOC]
# *ВВЕДЕНИЕ
См. @Таблица:cmp.
%cmp Сравнение
| A | B |
|---|---|
| 1 | 2 |
| 3 | 4 |
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
[1]: Иванов И. И. Книга. — М., 2024.
"""
def test_emit_longtable_continuation_headers(tmp_path):
tex = emit_document(SAMPLE, work_dir=tmp_path, images_dir=tmp_path / "Images")
assert r"\begin{longtable}" in tex
assert r"\endfirsthead" in tex
assert r"\endhead" in tex
assert "Продолжение таблицы" in tex
assert r"\label{tab:cmp}" in tex
assert r"\begin{thebibliography}" in tex
def test_convert_project(tmp_path):
md = tmp_path / "t.md"
md.write_text(SAMPLE, encoding="utf-8")
out = tmp_path / "proj"
main = convert_md_to_latex_project(md, out)
assert main.is_file()
assert (out / "content.tex").is_file()
assert (out / "Settings" / "packages.tex").is_file()
body = (out / "content.tex").read_text(encoding="utf-8")
assert "longtable" in body
+380
View File
@@ -0,0 +1,380 @@
"""Unit tests for MIREA TZ checker and numbering helpers."""
from md2gost.checker import check_markdown
from md2gost.numberer import Numberer
from md2gost.profiles import preprocess_markdown, find_formula_refs
from md2gost.bibliography import find_citations, extract_year
SAMPLE_OK = """
# *СОДЕРЖАНИЕ
[TOC]
# *ВВЕДЕНИЕ
Текст введения без ссылок на источники.
# Анализ предметной области
Согласно исследованиям [1] применяется подход, показанный на @Рисунок:arch.
![схема](img.png "%arch Архитектура системы")
%tbl1 Сравнение подходов
| Подход | Оценка |
|--------|--------|
| A | 1 |
| B | 2 |
Далее см. Таблицу — @Таблица:tbl1.
# *ЗАКЛЮЧЕНИЕ
Выводы по работе.
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
[1]: Иванов И. И. Информационные системы. — М.: Наука, 2023. — 120 с.
[2]: Петров П. П. Базы данных. — СПб.: Питер, 2022. — 200 с.
[3]: ГОСТ 7.32-2017. Отчет о НИР. — М., 2022.
[4]: Сидоров С. С. Python. — М.: ДМК, 2024. — 300 с.
[5]: Козлов К. К. Анализ данных. — М.: Бином, 2022. — 150 с.
# *ПРИЛОЖЕНИЯ
## Приложение А Листинг модуля
Код.
"""
def test_numberer_section_scoped():
n = Numberer()
n.enter_section(1)
assert n.next_number("Рисунок", "a") == "1.1"
assert n.next_number("Рисунок", "b") == "1.2"
n.enter_section(2)
assert n.next_number("Рисунок") == "2.1"
n.enter_appendix("Б")
assert n.next_number("Рисунок", "x") == "Б.1"
def test_numberer_continuous_pis():
n = Numberer(mode="continuous")
n.enter_section(1)
assert n.next_number("Рисунок", "a") == "1"
assert n.next_number("Таблица", "t") == "1"
n.enter_section(2)
assert n.next_number("Рисунок", "b") == "2"
n.enter_appendix("А")
assert n.next_number("Рисунок", "c") == "3"
SAMPLE_PIS = """
# *СОДЕРЖАНИЕ
[TOC]
# Практическая работа №1. Формирование требований к системе
Цель работы — сформировать требования.
## Описание предметной области
Текст с рисунком @Рисунок:arch.
![схема](img.png "%arch Архитектура")
# Практическая работа №2. Моделирование процессов
## Построение модели
%tbl1 Сравнение подходов
| A | B |
|---|---|
| 1 | 2 |
См. @Таблица:tbl1.
"""
def test_checker_ok_pis_custom():
issues = check_markdown(SAMPLE_PIS, "PIS_custom")
errors = [i for i in issues if i.severity == "error"]
assert not errors, errors
def test_checker_pis_requires_practical():
text = SAMPLE_PIS.replace("Практическая работа №1.", "Раздел 1.")
text = text.replace("Практическая работа №2.", "Раздел 2.")
issues = check_markdown(text, "PIS_custom")
assert any(i.id == "structure.practical" for i in issues)
def test_pis_styles_h1_centered():
import docx
from md2gost.styles import apply_pis_custom_styles
from docx.enum.text import WD_ALIGN_PARAGRAPH
doc = docx.Document(r"md2gost/Template.docx")
apply_pis_custom_styles(doc)
h1 = doc.styles["Heading 1"]
assert h1.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
assert h1.font.all_caps is True
h2 = doc.styles["Heading 2"]
assert h2.paragraph_format.first_line_indent is not None
assert abs(h2.paragraph_format.first_line_indent.cm - 1.25) < 0.01
assert h2.paragraph_format.left_indent is None or h2.paragraph_format.left_indent.cm == 0
def test_soderzhanie_excluded_from_toc_outline():
import docx
from md2gost.renderable.heading import Heading
from md2gost.styles import apply_mirea_styles
doc = docx.Document(r"md2gost/Template.docx")
doc._body.clear_content()
apply_mirea_styles(doc)
h = Heading(doc._body, 1, False)
h.add_run("СОДЕРЖАНИЕ")
h.exclude_from_toc()
levels = h._docx_paragraph._p.pPr.xpath("./w:outlineLvl")
assert levels and levels[0].get(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
) == "9"
def test_heading_font_is_tnr_black():
import docx
from docx.oxml.ns import qn
from md2gost.styles import apply_mirea_styles
doc = docx.Document(r"md2gost/Template.docx")
apply_mirea_styles(doc)
rPr = doc.styles["Heading 1"].element.find(qn("w:rPr"))
rFonts = rPr.find(qn("w:rFonts"))
assert rFonts.get(qn("w:ascii")) == "Times New Roman"
assert rFonts.get(qn("w:hAnsi")) == "Times New Roman"
assert rFonts.get(qn("w:eastAsia")) == "Times New Roman"
color = rPr.find(qn("w:color"))
assert color is not None
assert color.get(qn("w:val")) == "000000"
assert color.get(qn("w:themeColor")) is None
def test_checker_ok_coursework():
issues = check_markdown(SAMPLE_OK, "coursework")
errors = [i for i in issues if i.severity == "error"]
assert not errors, errors
def test_checker_ris_forbidden():
text = SAMPLE_OK.replace("@Рисунок:arch", "рис. 1.1")
issues = check_markdown(text, "coursework")
assert any(i.id == "ref.ris" for i in issues)
def test_checker_cite_in_intro():
text = SAMPLE_OK.replace("Текст введения без ссылок на источники.", "См. [1].")
issues = check_markdown(text, "coursework")
assert any(i.id == "cite.intro" for i in issues)
def test_checker_vkr_graphic():
issues = check_markdown(SAMPLE_OK, "vkr")
assert any(i.id == "structure.graphic" for i in issues)
def test_preprocess_quotes():
assert "«тест»" in preprocess_markdown('Он сказал "тест" вслух.')
def test_formula_refs():
assert find_formula_refs("см. @Формула:eq1 и далее") == {"eq1"}
def test_citations_order():
assert find_citations("a [2] b [1] c [2, 3]") == ["2", "1", "3"]
def test_extract_year():
assert extract_year("М.: Наука, 2023. — 120 с.") == 2023
def test_biblio_entries_from_concatenated_paragraph():
from md2gost.biblio_processor import entries_from_text, extract_bibliography_from_markdown
entries = entries_from_text(
"[1]: Первый источник. — М., 2024.[2]: Второй. URL: https://a.com/x-y/[3]: Третий."
)
assert [e.key for e in entries] == ["1", "2", "3"]
assert "https://a.com/x-y/" in entries[1].text
md = """# *ВВЕДЕНИЕ
Текст.
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
[1]: Ааа. — М., 2024.
[2]: Ббб. — СПб., 2023.
# *ПРИЛОЖЕНИЯ
"""
flat = extract_bibliography_from_markdown(md)
assert len(flat) == 2
assert flat[0].key == "1"
def test_emdash_to_hyphen_preprocess():
from md2gost.profiles import preprocess_markdown, set_emdash_to_hyphen, dash_separator
out = preprocess_markdown("Цель — тест. Слово - слово.", emdash_to_hyphen=True)
assert "" not in out
assert "Цель - тест" in out
assert dash_separator() == " - "
out2 = preprocess_markdown("Цель — тест.", emdash_to_hyphen=False)
assert "" in out2
assert dash_separator() == ""
set_emdash_to_hyphen(False)
def test_native_toc_inserts_word_field():
import docx
from md2gost.renderable.toc import ToC
doc = docx.Document(r"md2gost/Template.docx")
doc._body.clear_content()
toc = ToC(doc._body, toc_mode="native")
toc.fill()
xml = toc._paragraph._docx_paragraph._p.xml
assert "TOC" in xml
assert "fldChar" in xml
def test_reference_ignores_trailing_punctuation():
from md2gost.extended_markdown import markdown
def refs(text):
doc = markdown.parse(text)
out = []
def walk(node):
if node.__class__.__name__ == "Reference":
out.append((node.type, node.name))
kids = getattr(node, "children", None)
if isinstance(kids, list):
for c in kids:
walk(c)
walk(doc)
return out
assert ("Рисунок", "submitted") in refs("@Рисунок:submitted, @Рисунок:lmsafter")
assert ("Рисунок", "deploy") in refs("см. (@Рисунок:deploy). Далее")
def test_heading_manual_kills_word_numbering():
import docx
from md2gost.renderable.heading import Heading
doc = docx.Document(r'md2gost/Template.docx')
doc._body.clear_content()
h = Heading(doc._body, 1, True, numbering_mode='manual')
h.add_run('1 Разработка')
h.apply_numbering_mode('manual')
assert h.text.startswith('1 ')
num_pr = h._docx_paragraph._p.pPr.xpath('.//w:numId')
assert num_pr and num_pr[0].get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val') == '0'
def test_heading_auto_strips_leading_number():
import docx
from md2gost.renderable.heading import Heading
doc = docx.Document(r'md2gost/Template.docx')
doc._body.clear_content()
h = Heading(doc._body, 2, True, numbering_mode='auto')
h.add_run('1.1 Описание')
h.apply_numbering_mode('auto')
assert h.text == 'Описание'
def test_toc_tab_stops_match_text_area():
import docx
from md2gost.styles import apply_mirea_styles
doc = docx.Document(r'md2gost/Template.docx')
apply_mirea_styles(doc)
section = doc.sections[0]
usable = section.page_width.mm - section.left_margin.mm - section.right_margin.mm
for name in ('toc 1', 'toc 2'):
tabs = list(doc.styles[name].paragraph_format.tab_stops)
right_tabs = [t for t in tabs if 'RIGHT' in str(t.alignment)]
assert len(right_tabs) == 1, (name, tabs)
assert abs(right_tabs[0].position.mm - usable) < 0.05, (name, right_tabs[0].position.mm, usable)
edge = section.left_margin.mm + right_tabs[0].position.mm
assert abs(edge - (section.page_width.mm - section.right_margin.mm)) < 0.05
SAMPLE_APID = """
# *СОДЕРЖАНИЕ
[TOC]
# *ВВЕДЕНИЕ
Текст введения без ссылок.
# 1 Теоретические аспекты разработки архитектуры приложений и данных: описание микросервисной архитектуры
Согласно [1] архитектура задаёт структуру системы. Далее [2].
# 2 Прикладные аспекты разработки архитектуры приложений и данных web-сервиса «SMEZHNO»
## 2.1 Описание проекта команды и разрабатываемого программного приложения «SMEZHNO»
Текст проекта [3].
## 2.2 Описание роли «Неавторизованный пользователь» web-сервиса «SMEZHNO»
Роль.
## 2.3 Описание архитектуры программного приложения и данных web-сервиса «SMEZHNO» для роли «Неавторизованный пользователь»
Архитектура [4].
## 2.4 Варианты развития архитектуры программного приложения «SMEZHNO»
Альтернативы [5].
# *ЗАКЛЮЧЕНИЕ
Выводы.
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
[1]: ISO/IEC/IEEE 42010:2022. Architecture description. — Geneva: ISO, 2022.
[2]: The Open Group. ArchiMate 3.2 Specification. — 2023.
[3]: Аншина М. Л. Материалы лекционного курса. — М.: РТУ МИРЭА, 2024.
[4]: Ньюмен С. Создание микросервисов. — СПб.: Питер, 2023. — 624 с.
[5]: The Open Group. The TOGAF Standard, 10th Edition. — 2022.
[6]: PostgreSQL 16 Documentation. URL: https://www.postgresql.org/docs/16/ (дата обращения: 01.05.2025).
[7]: Kubernetes Documentation. URL: https://kubernetes.io/docs/home/ (дата обращения: 01.05.2025).
"""
def test_checker_apid_ok():
issues = check_markdown(SAMPLE_APID, "APID_coursework")
errors = [i for i in issues if i.severity == "error"]
assert not errors, errors
def test_checker_apid_requires_sections():
text = SAMPLE_APID.replace("## 2.4 Варианты развития", "## 2.4 Прочее")
issues = check_markdown(text, "APID_coursework")
assert any(i.id == "structure.apid" for i in issues)
def test_apid_biblio_min_seven():
text = SAMPLE_APID
# drop last three sources -> 4 left
text = text.split("[5]:")[0] + "\n"
issues = check_markdown(text, "APID_coursework")
assert any(i.id == "biblio.count" for i in issues)
+21
View File
@@ -0,0 +1,21 @@
import unittest
from md2gost.layout_tracker import LayoutTracker
from md2gost.renderable.paragraph import Paragraph
from . import _create_test_document, _EMUS_PER_PX
class TestParagraph(unittest.TestCase):
def setUp(self) -> None:
self._document, self._max_height, self._max_width = _create_test_document()
def test_render(self):
paragraph = Paragraph(self._document._body)
layout_tracker = LayoutTracker(self._max_height, self._max_width)
paragraph.add_run("hello world")
info = list(paragraph.render(None, layout_tracker.current_state))[0]
self.assertAlmostEqual(45.5, info.height / _EMUS_PER_PX, delta=1/3)
+290
View File
@@ -0,0 +1,290 @@
import unittest
import docx
from docx import Document
from md2gost.renderable.paragraph_sizer import Font, ParagraphSizer
from md2gost.renderable.listing import LISTING_OFFSET
from docx.shared import Pt, Mm, Cm
from . import _create_test_document, _EMUS_PER_PX
delta = 10/29
class TestFont(unittest.case.TestCase):
def test_get_text_width(self):
font = Font("Times New Roman", False, False, 14)
self.assertAlmostEqual(37.5, font.get_text_width("hello") / _EMUS_PER_PX, delta=delta)
def test_get_text_width_short(self):
font = Font("Times New Roman", False, False, 14)
self.assertAlmostEqual(15, font.get_text_width("in") / _EMUS_PER_PX, delta=delta)
def test_get_text_width_long(self):
font = Font("Times New Roman", False, False, 14)
self.assertAlmostEqual(245, font.get_text_width("Электроэнцефалографический") / _EMUS_PER_PX, delta=delta)
def test_get_text_width_bold(self):
font = Font("Times New Roman", True, False, 14)
self.assertAlmostEqual(39, font.get_text_width("hello") / _EMUS_PER_PX, delta=delta)
def test_get_text_width_italic(self):
font = Font("Times New Roman", False, True, 14)
self.assertAlmostEqual(37.5, font.get_text_width("hello") / _EMUS_PER_PX, delta=delta)
def test_get_text_width_bold_italic(self):
font = Font("Times New Roman", True, True, 14)
self.assertAlmostEqual(38.5, font.get_text_width("hello") / _EMUS_PER_PX, delta=delta)
def test_get_line_height_times(self):
font = Font("Times New Roman", False, False, 14)
self.assertAlmostEqual(21.4, font.get_line_height() / _EMUS_PER_PX, delta=delta)
def test_get_line_height_times_large(self):
font = Font("Times New Roman", False, False, 50)
self.assertAlmostEqual(77, font.get_line_height() / _EMUS_PER_PX, delta=delta)
def test_get_line_height_calibri(self):
font = Font("Calibri", False, False, 14)
self.assertAlmostEqual(23, font.get_line_height() / _EMUS_PER_PX, delta=delta)
def test_get_line_height_consolas(self):
font = Font("Consolas", False, False, 20)
self.assertAlmostEqual(31, font.get_line_height() / _EMUS_PER_PX, delta=delta)
def test_get_line_height_courier(self):
font = Font("Courier New", False, False, 12)
self.assertAlmostEqual(18.3, font.get_line_height() / _EMUS_PER_PX, delta=delta)
def test_is_mono_courier(self):
font = Font("Courier New", False, False, 12)
self.assertTrue(font.is_mono)
def test_is_mono_times(self):
font = Font("Times New Roman", False, False, 12)
self.assertFalse(font.is_mono)
class TestParagraphSizer(unittest.TestCase):
def setUp(self):
self._document, self._max_height, self._max_width = _create_test_document()
def test_count_lines(self):
paragraph = self._document.add_paragraph()
paragraph.add_run("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam lacinia fringilla lectus, "
"nec euismod odio convallis sed. Nunc ac libero ultricies, condimentum neque et, "
"fermentum urna. Donec feugiat diam sed nulla rutrum, sit amet accumsan odio tempor. Sed "
"bibendum ante at orci faucibus, sed dignissim nisi finibus. Vestibulum luctus eget enim et "
"mattis. In porta convallis ipsum eget dignissim. Ut orci ante, bibendum ut lorem quis, "
"gravida molestie neque. Nulla vitae sapien sed risus gravida elementum non eu lorem. "
"Quisque ac turpis nisl.")
ps = ParagraphSizer(paragraph, None, self._max_width)
self.assertEqual(7, ps.count_lines(paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25)))
def test_count_lines2(self):
paragraph = self._document.add_paragraph()
paragraph.add_run("Ordered lists are useful when you want to present items in a specific order. This is additional text for illustration. Ordered lists are useful when you want to present items in a specific order. This is additional text for illustration. Ordered lists are useful when you want to present items in a specific order. This is additional text for illustration.")
ps = ParagraphSizer(paragraph, None, self._max_width)
self.assertEqual(4, ps.count_lines(paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25)))
def test_count_lines_short_last_line(self):
paragraph = self._document.add_paragraph()
paragraph.add_run("Nam porta urna vel turpis lobortis, nec congue sem suscipit. Mauris ac facilisis metus, "
"non fermentum turpis. Nunc erat ipsum, interdum sit amet odio ut, vulputate feugiat arcu. "
"Donec nec rhoncus metus, nec pharetra mauris. Proin magna arcu, porta vitae eleifend et, "
"scelerisque vel nunc. Ut ut neque sed libero mattis finibus quis eu tortor. Quisque "
"molestie tempus neque rutrum vestibulum. Proin dui odio, tincidunt nec lectus at, "
"mollis tristique diam. Nulla arcu ante, fringilla sit amet pretium venenatis, ultricies a "
"elit. Sed ac velit a dolor interdum sollicitudin. Donec tortor leo, finibus eu nisi id, "
"cursus luctus diam. Vestibulum ex nulla, fringilla pellentesque diam eu, rhoncus suscipit "
"ligula. Mauris vestibulum libero erat, vitae mollis orci ultricies at. Nulla maximus "
"elementum nulla at ultrices. Vestibulum pellentesque vulputate orci, quis finibus")
ps = ParagraphSizer(paragraph, None, self._max_width)
self.assertEqual(11, ps.count_lines(paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25)))
def test_count_lines_short_last_line2(self):
paragraph = self._document.add_paragraph()
paragraph.add_run("OpenAI is a leading artificial intelligence research organization, known for advancements in language models like GPT. Click the link to learn more. Hello world —")
ps = ParagraphSizer(paragraph, None, self._max_width)
self.assertEqual(3, ps.count_lines(paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25)))
def test_count_lines_long_last_line(self):
paragraph = self._document.add_paragraph()
paragraph.add_run("Donec finibus elementum lectus non ultricies. Pellentesque dictum tellus a neque rutrum "
"euismod. Fusce lobortis id est ut bibendum. Integer quis nunc convallis, maximus justo "
"fermentum, vestibulum metus. Nulla fringilla quam in purus laoreet, eu rhoncus risus "
"condimentum. Sed eget odio urna. Integer mi diam, aliquam id rhoncus vitae, lacinia quis "
"augue. Nulla ultrices velit vel urna accumsan, et feugiat nunc fringilla. Praesent feugiat "
"neque ac tellus rutrum congue. Sed congue libero congue, blandit felis ac, "
"lobortis libero. Phasellus eleifend ex vulputate odio mollis dictum.")
ps = ParagraphSizer(paragraph, None, self._max_width)
self.assertEqual(7, ps.count_lines(paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25)))
def test_count_lines_long_last_line2(self):
paragraph = self._document.add_paragraph()
paragraph.add_run('Markdown supports rendering mathematical formulas using LaTeX syntax. This allows you to include complex equations and mathematical notation in your documents.')
ps = ParagraphSizer(paragraph, None, self._max_width)
self.assertEqual(2, ps.count_lines(paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25)))
def test_count_lines_long_last_line3(self):
paragraph = self._document.add_paragraph()
paragraph.add_run("Markdown's LaTeX syntax allows you to easily write fractions and exponents. For instance, you can represent the derivative of a function InlineFormula is not supported with respect to InlineFormula is not supported using the following notation:")
ps = ParagraphSizer(paragraph, None, self._max_width)
self.assertEqual(3, ps.count_lines(paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25)))
def test_count_lines_long_word(self):
paragraph = self._document.add_paragraph()
paragraph.add_run("verylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongword")
ps = ParagraphSizer(paragraph, None, self._max_width)
self.assertEqual(3, ps.count_lines(paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25)))
def test_count_lines_long_word_2(self):
paragraph = self._document.add_paragraph()
paragraph.add_run("someword verylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongwordverylongword")
ps = ParagraphSizer(paragraph, None, self._max_width)
self.assertEqual(4, ps.count_lines(paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25)))
def test_count_lines_courier(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(""" table._cells[0]._element.append(paragraph_rendered_info.docx_element._element)""")
ps = ParagraphSizer(paragraph, None, self._max_width-LISTING_OFFSET)
self.assertEqual(3, ps.count_lines(paragraph.runs, self._max_width-LISTING_OFFSET, paragraph.style.font, 0))
def test_count_lines_courier2(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
""" continuation_paragraph = Paragraph(self.parent)""")
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(1, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0))
def test_count_lines_courier3(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
"""ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo""")
ps = ParagraphSizer(paragraph, None, self._max_width - Pt(14))
self.assertEqual(2, ps.count_lines(paragraph.runs, self._max_width - Pt(14), paragraph.style.font, 0))
def test_count_lines_courier4(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
""" def add_link(self, text: str, url: str, is_bold: bool = None, is_italic: bool = None):""")
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(2, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0))
def test_count_lines_courier5(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
""" self._docx_paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY""")
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(2, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0))
def test_count_lines_courier6(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
""" # and "Heading" in previous_rendered.docx_element.style.name\\""")
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(2, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0))
def test_count_lines_courier7(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
""" run = DocxRun(create_element("w:r"), self._docx_paragraph)""")
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(1, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0, True))
def test_count_lines_courier7(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
""" # and ((min(2, height_data.lines) - 1) * height_data.line_spacing + 1) * height_data.line_height\\""")
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(2, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0, True))
def test_count_lines_courier8(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
""" return self._docx_paragraph.paragraph_format.first_line_indent""")
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(2, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0, True))
def test_count_lines_courier9(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
""" self._docx_paragraph.paragraph_format.first_line_indent = value""")
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(2, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0, True))
def test_count_lines_courier10(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
""" if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph) else None,""")
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(3, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0, True))
def test_count_lines_courier_multiple_runs(self):
paragraph = self._document.add_paragraph(style="Code")
for run_text in ['', ' ', 'run', ' ', '=', ' ', 'DocxRun', '(', 'create_element', '(', '"', 'w:r', '"', ')', ',', ' ', 'self', '.', '_docx_paragraph', ')']:
paragraph.add_run(run_text)
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(1, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0, True))
def test_count_lines_courier_multiple_runs2(self):
paragraph = self._document.add_paragraph(style="Code")
for run_text in ['', ' ', '-', '>', ' ', 'Generator', '[', 'RenderedInfo', ' ', '|', ' ', 'Renderable', ',', ' ', 'None', ',', ' ', 'None', ']', ':']:
paragraph.add_run(run_text)
ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
self.assertEqual(1, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0, True))
# def test_count_lines_courier_multiple_runs3(self):
# paragraph = self._document.add_paragraph(style="Code")
# for run_text in ['', ' ', '-', '>', ' ', 'Generator', '[', 'RenderedInfo', ' ', '|', ' ', 'Renderable', ',', ' ', 'None', ',', ' ', 'None', ']', ':']:
# paragraph.add_run(run_text)
#
# ps = ParagraphSizer(paragraph, None, self._max_width - LISTING_OFFSET)
#
# self.assertEqual(3, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0, True))
+181
View File
@@ -0,0 +1,181 @@
"""Tests for table cell merge (^ rowspan, > colspan)."""
from __future__ import annotations
import os
from docx import Document
from docx.shared import Mm, Pt
from md2gost import package_dir
from md2gost.checker import check_table_merge
from md2gost.docx_elements import apply_cell_merge, create_table, create_table_cell, create_table_row
from md2gost.extended_markdown import markdown
from md2gost.extended_markdown.table import Table as MdTable
from md2gost.layout_tracker import LayoutState
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable_factory import RenderableFactory
SAMPLE_ROWSPAN = """\
%req Требования
| Категория | Описание |
|-----------|----------|
| Производительность | Требование 1 |
| ^ | Требование 2 |
| ^ | Требование 3 |
| Масштабируемость | Требование 4 |
| ^ | Требование 5 |
"""
SAMPLE_COLSPAN = """\
%cspan Demo
| A | B | C |
|---|---|---|
| wide | > | Z |
"""
def test_parse_rowspan_markers():
doc = markdown.parse(SAMPLE_ROWSPAN)
tables = [c for c in doc.children if isinstance(c, MdTable)]
assert len(tables) == 1
t = tables[0]
assert len(t.children) == 6
assert t.children[1].children[0].merge_v == "restart"
assert t.children[2].children[0].merge_v == "continue"
assert t.children[3].children[0].merge_v == "continue"
assert t.children[4].children[0].merge_v == "restart"
assert t.children[5].children[0].merge_v == "continue"
def test_parse_colspan_markers():
doc = markdown.parse(SAMPLE_COLSPAN)
tables = [c for c in doc.children if isinstance(c, MdTable)]
assert len(tables) == 1
t = tables[0]
data = t.children[1].children
assert data[0].merge_h == "restart"
assert data[1].merge_h == "continue"
assert data[2].merge_h == "none"
def test_apply_cell_merge_oxml():
doc = Document()
table = create_table(doc, 0, 2, doc.sections[0].page_width)
assert 'w:type="dxa"' in table._tbl.xml
row = create_table_row(table)
cell = create_table_cell(row, doc.sections[0].page_width / 2)
apply_cell_merge(cell, v_merge="restart", grid_span=2)
xml = cell._tc.xml
assert "vMerge" in xml
assert "restart" in xml
assert "gridSpan" in xml
def test_create_table_fixed_width():
doc = Document()
width = Mm(170)
table = create_table(doc, 0, 3, width)
xml = table._tbl.xml
assert 'w:type="dxa"' in xml
assert 'w:type="fixed"' in xml
assert xml.count("gridCol") == 3
# ~170 mm ≈ 9638 twips, not EMU (~6e6)
from md2gost.docx_elements import _twips
assert 9000 < _twips(width) < 11000
import re
m = re.search(r'w:tblW[^>]*w:w="(\d+)"', xml) or re.search(r'w:w="(\d+)"[^>]*w:type="dxa"', xml)
# tblW attrs order may vary
w_attr = re.search(r'<w:tblW[^/]*/>', xml)
assert w_attr
tw = int(re.search(r'w:w="(\d+)"', w_attr.group(0)).group(1))
assert 9000 < tw < 11000, f"tblW too large (EMU mistaken for twips?): {tw}"
def test_twips_from_length_sum():
"""Length+Length yields EMU int — must convert to twips, not treat as twips."""
from md2gost.docx_elements import _twips
from docx.shared import Twips as T
summed = Mm(170) + T(108) # bare EMU int
assert isinstance(summed, int)
assert 9000 < _twips(summed) < 12000
def test_factory_sets_merge_and_oxml_row():
"""Factory passes merge metadata; physical row has vMerge without full convert."""
parsed = markdown.parse(SAMPLE_ROWSPAN)
md_table = next(c for c in parsed.children if isinstance(c, MdTable))
doc = Document(os.path.join(package_dir(), "Template.docx"))
factory = RenderableFactory(doc._body)
table = factory.create(md_table, CaptionInfo("req", "Требования"))
assert table._merge[1][0][0] == "restart"
assert table._merge[2][0][0] == "continue"
for r in range(len(table._rows)):
for c in range(table._cols):
table._rows[r][c] = []
docx_table = create_table(doc, 0, table._cols, Mm(170))
layout = LayoutState(Mm(250), Mm(170))
row_el, _ = table._build_docx_row(docx_table, 1, True, layout)
docx_table._element.append(row_el._element)
row2, _ = table._build_docx_row(docx_table, 2, True, layout)
docx_table._element.append(row2._element)
xml = docx_table._tbl.xml
assert "vMerge" in xml
assert "restart" in xml
def test_checker_merge_header_error():
bad = """\
| ^ | B |
|---|---|
| 1 | 2 |
"""
issues = check_table_merge(bad)
assert any(i.id == "table.merge_header" for i in issues)
def test_checker_merge_col_error():
bad = """\
| A | B |
|---|---|
| > | 2 |
"""
issues = check_table_merge(bad)
assert any(i.id == "table.merge_col" for i in issues)
def test_checker_merge_ok():
issues = check_table_merge(SAMPLE_ROWSPAN)
assert issues == []
def test_table_continuation_modes():
from md2gost.profiles import TABLE_CONTINUATION_MODES, DEFAULT_TABLE_CONTINUATION
assert DEFAULT_TABLE_CONTINUATION == "off"
assert TABLE_CONTINUATION_MODES == ("off", "legacy", "caption", "soft")
doc = Document(os.path.join(package_dir(), "Template.docx"))
from md2gost.renderable.table import Table as RTable
from md2gost.renderable.caption import CaptionInfo
t = RTable(doc._body, 1, 2, CaptionInfo("x", "y"))
assert t._continuation_mode == "off"
t.set_continuation_mode("legacy")
assert t._continuation_mode == "legacy"
t.set_continuation_mode("caption")
assert t._continuation_mode == "caption"
t.set_continuation_mode("soft")
assert t._continuation_mode == "soft"
assert not t._should_split_fragment(Pt(1000), LayoutState(Mm(250), Mm(170)), 3)
t.set_continuation_mode("caption")
assert t._should_split_fragment(Pt(1000), LayoutState(Mm(50), Mm(170)), 3)
try:
t.set_continuation_mode("nope")
assert False
except ValueError:
pass