@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
%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.
|
||||
|
||||

|
||||
|
||||
# Практическая работа №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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user