v0.6.0 BigUpdate
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

This commit is contained in:
Igor20264
2026-10-20 11:38:06 +03:00
parent daa84f8bce
commit 8223e0ac14
77 changed files with 12158 additions and 117 deletions
+202
View File
@@ -0,0 +1,202 @@
"""DFD Гейна–Сарсона: локальный Pillow, без Graphviz."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from md2gost.dfd_gs import (
SAMPLE,
DfdGsParseError,
_arrange,
parse_dfd_gs,
render_dfd_gs,
)
from md2gost.diagram_renderer import (
DIAGRAM_LANGS,
configure_diagrams,
is_diagram_lang,
prepare_source,
render_diagram,
)
def test_fence_aliases_do_not_steal_yourdon():
assert is_diagram_lang("dfd-gs")
assert is_diagram_lang("uml-dfd-gs")
assert is_diagram_lang("gane-sarson")
assert is_diagram_lang("uml-gane-sarson")
assert "dfd-gs" in DIAGRAM_LANGS
assert prepare_source("dfd-gs", SAMPLE)[1] == "dfd-gs"
assert prepare_source("gane-sarson", SAMPLE)[1] == "dfd-gs"
assert prepare_source("uml-dfd-gs", SAMPLE)[1] == "dfd-gs"
assert prepare_source("dfd", "entity A Клиент\n")[1] == "dfd"
assert prepare_source("uml-dfd", "entity A Клиент\n")[1] == "dfd"
assert prepare_source("yourdon", "entity A Клиент\n")[1] == "dfd"
def test_badges_and_colon_label():
diagram = parse_dfd_gs(
"""
entity A Художник
entity B Заказчик
process Accept Принять заявку
process 3.1 Проверить срок
store Orders Заказы
store D4 Акты
A --> Accept : файлы работы
"""
)
by_id = {node.id: node for node in diagram.nodes}
assert by_id["A"].badge == "1"
assert by_id["B"].badge == "2"
assert by_id["Accept"].badge == "1"
assert by_id["3.1"].badge == "3.1"
assert by_id["Orders"].badge == "D1"
assert by_id["D4"].badge == "D4"
assert diagram.flows[0].label == "файлы работы"
assert diagram.form == "kit"
assert diagram.title == ""
def test_store_code_skips_taken_numbers():
diagram = parse_dfd_gs(
"""
store D1 Заказы
store Orders Журнал
"""
)
badges = {node.id: node.badge for node in diagram.nodes}
assert badges == {"D1": "D1", "Orders": "D2"}
def test_parse_errors():
with pytest.raises(DfdGsParseError, match="пустой"):
parse_dfd_gs(" \n\n")
with pytest.raises(DfdGsParseError, match="без узлов"):
parse_dfd_gs("# только комментарий\n")
with pytest.raises(DfdGsParseError, match="без узлов"):
parse_dfd_gs("title Только шапка\n")
with pytest.raises(DfdGsParseError, match="повтор"):
parse_dfd_gs("entity A Один\nprocess A Два\n")
with pytest.raises(DfdGsParseError, match="неизвестный узел"):
parse_dfd_gs("entity A Один\nA --> B поток\n")
with pytest.raises(DfdGsParseError, match="в себя"):
parse_dfd_gs("entity A Один\nA --> A петля\n")
with pytest.raises(DfdGsParseError, match="form"):
parse_dfd_gs("form fancy\nentity A Один\n")
with pytest.raises(DfdGsParseError, match="нет подписи"):
parse_dfd_gs("process 1\n")
def test_columns_and_straight_arrow():
diagram = parse_dfd_gs(
"""
entity A Художник
process P Принять
store S Файлы
A --> P заявка
P --> S файл
"""
)
layout = _arrange(diagram, 1)
assert layout.placed["A"].x < layout.placed["P"].x < layout.placed["S"].x
straight = _arrange(
parse_dfd_gs("entity A Художник\nprocess P Принять\nA --> P заявка\n"),
1,
)
pts = straight.routes[0].pts
assert len(pts) == 2
assert abs(pts[0][1] - pts[1][1]) < 1
def test_opposite_flows_do_not_share_a_port():
diagram = parse_dfd_gs(
"""
process P Принять
store S Заказы
P --> S статус
S --> P обновление
"""
)
layout = _arrange(diagram, 1)
y1 = layout.routes[0].pts[0][1]
y2 = layout.routes[1].pts[0][1]
assert abs(y1 - y2) > 10
def test_stacked_processes_link_vertically():
diagram = parse_dfd_gs(
"""
process A Один
process B Два
A --> B дальше
"""
)
pts = _arrange(diagram, 1).routes[0].pts
assert len(pts) == 2
assert abs(pts[0][0] - pts[1][0]) < 1
assert pts[0][1] < pts[1][1]
def test_skipped_process_is_an_elbow():
diagram = parse_dfd_gs(
"""
process A Один
process B Два
process C Три
A --> C дальше
"""
)
pts = _arrange(diagram, 1).routes[0].pts
assert len(pts) >= 3
def test_render_png_and_svg_without_graphviz():
png, svg = render_dfd_gs(parse_dfd_gs(SAMPLE), scale=1)
assert png.startswith(b"\x89PNG")
assert svg is not None
assert "USED AT" in svg
assert "Тарасов" in svg
assert "A44" in svg
assert "rx=" in svg
assert "Художник" in svg
assert "rotate(-90" in svg
assert "D2" in svg
plain = parse_dfd_gs("form plain\nentity A Художник\nprocess P Принять\nA --> P заявка\n")
_png, svg_plain = render_dfd_gs(plain, scale=1)
assert "USED AT" not in (svg_plain or "")
assert "rx=" in (svg_plain or "")
def test_render_diagram_skips_jar_and_kroki(tmp_path):
calls = {"jar": 0, "kroki": 0}
def fake_jar(*_args, **_kwargs):
calls["jar"] += 1
return False
def fake_kroki(*_args, **_kwargs):
calls["kroki"] += 1
return False
configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=1, diagram_format="svg")
with patch("md2gost.diagram_renderer._render_plantuml_jar", side_effect=fake_jar), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram(
"dfd-gs", SAMPLE, cache_dir=str(tmp_path), diagram_scale=1, diagram_format="svg",
)
assert calls["jar"] == 0
assert calls["kroki"] == 0
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
assert result.svg_path and Path(result.svg_path).read_text(encoding="utf-8").startswith("<svg")
assert result.pixel_scale == 1
def test_render_diagram_bad_source(tmp_path):
configure_diagrams(fallback="off", cache_dir=str(tmp_path))
with pytest.raises(RuntimeError, match="пустой"):
render_diagram("gane-sarson", " ", cache_dir=str(tmp_path))
+61
View File
@@ -0,0 +1,61 @@
"""Output path when the GUI switches between MD → DOCX and DOCX → MD."""
import os
from md2gost.gui import (
coerce_docx_output,
import_output_path,
next_import_output,
next_markdown_output,
output_tracks_source,
)
from md2gost.pipeline import default_output_path
def test_import_markdown_counts_as_automatic(tmp_path):
docx = tmp_path / "шаблон таблицы.docx"
imported = import_output_path(str(docx))
assert imported.endswith(".md")
assert output_tracks_source(str(docx), imported)
# default_output_path appends .docx and must not be the import target
assert not output_tracks_source(str(docx), default_output_path(str(docx)))
def test_markdown_after_import_uses_docx(tmp_path):
"""MD → DOCX, then DOCX → MD, then another .md must not keep the .md output."""
report = tmp_path / "1исп.md"
imported = import_output_path(str(tmp_path / "шаблон таблицы.docx"))
out, auto = next_markdown_output(str(report), imported, auto=True)
assert out == default_output_path(str(report))
assert auto is True
# Old GUI cleared the auto flag after import, so the .md path stuck.
out, auto = next_markdown_output(str(report), imported, auto=False)
assert out == default_output_path(str(report))
assert out.lower().endswith(".docx")
assert auto is True
def test_custom_docx_is_kept_when_switching_markdown(tmp_path):
other = tmp_path / "other.md"
custom = str(tmp_path / "pinned.docx")
out, auto = next_markdown_output(str(other), custom, auto=False)
assert out == custom
assert auto is False
def test_import_replaces_previous_docx_target(tmp_path):
src = tmp_path / "шаблон таблицы.docx"
previous = str(tmp_path / "report.docx")
out, auto = next_import_output(str(src), previous, auto=False)
assert os.path.normcase(out) == os.path.normcase(import_output_path(str(src)))
assert auto is True
def test_convert_does_not_reuse_import_markdown(tmp_path):
report = tmp_path / "1исп.md"
stale = str(tmp_path / "шаблон таблицы.md")
assert coerce_docx_output(str(report), stale) == default_output_path(str(report))
keep = str(tmp_path / "keep.docx")
assert coerce_docx_output(str(report), keep) == keep
+118
View File
@@ -330,3 +330,121 @@ def test_landscape_listing_deferred_after_section(tmp_path, monkeypatch):
texts = [p.text for p in doc.paragraphs]
listing_caps = [t for t in texts if t.startswith("Листинг")]
assert listing_caps, f"no listing caption in {texts!r}"
def _empty_section_break_pairs(doc) -> int:
"""Pairs of next-page breaks with no body content between them (a blank page)."""
children = list(doc.element.body)
pairs = 0
prev_empty_break = False
for child in children:
if child.tag == f"{W_NS}sectPr":
continue
is_break = _is_break_paragraph(child)
if is_break:
if prev_empty_break:
pairs += 1
prev_empty_break = True
continue
prev_empty_break = False
return pairs
def _fresh_doc():
doc = Document(os.path.join(package_dir(), "Template.docx"))
body = doc._body._element
for child in list(body):
if child.tag.endswith("}sectPr"):
continue
body.remove(child)
return doc
def _small_table(doc, name: str, *, landscape: bool) -> Table:
table = Table(doc._body, 2, 2, CaptionInfo(name, name, landscape=landscape))
for r in range(2):
for c in range(2):
table.add_paragraph_to_cell(r, c).add_run(f"{name}{r}{c}")
return table
def test_consecutive_landscape_tables_share_section():
"""Two +landscape tables in a row must not put a blank portrait page between them."""
doc = _fresh_doc()
Renderer(doc, skip_numbering=True).process([
_small_table(doc, "A", landscape=True),
_small_table(doc, "B", landscape=True),
])
assert _empty_section_break_pairs(doc) == 0
assert [s.orientation for s in doc.sections] == [
WD_ORIENT.PORTRAIT,
WD_ORIENT.LANDSCAPE,
WD_ORIENT.PORTRAIT,
]
assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT
def test_landscape_then_text_then_landscape_returns_to_portrait():
doc = _fresh_doc()
from md2gost.renderable.paragraph import Paragraph
middle = Paragraph(doc._body)
middle.add_run("Между таблицами.")
Renderer(doc, skip_numbering=True).process([
_small_table(doc, "A", landscape=True),
middle,
_small_table(doc, "B", landscape=True),
])
assert _empty_section_break_pairs(doc) == 0
assert [s.orientation for s in doc.sections] == [
WD_ORIENT.PORTRAIT,
WD_ORIENT.LANDSCAPE,
WD_ORIENT.PORTRAIT,
WD_ORIENT.LANDSCAPE,
WD_ORIENT.PORTRAIT,
]
def test_consecutive_landscape_photos_share_section(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
png = tmp_path / "photo.png"
PILImage.new("RGB", (80, 40), color=(0, 128, 255)).save(png)
os.environ["WORKING_DIR"] = str(tmp_path)
from md2gost.renderable.paragraph import Paragraph
doc = _fresh_doc()
first = Paragraph(doc._body)
first.add_image(str(png), CaptionInfo("p1", "Фото 1", landscape=True))
second = Paragraph(doc._body)
second.add_image(str(png), CaptionInfo("p2", "Фото 2", landscape=True))
Renderer(doc, skip_numbering=True).process([first, second])
assert _empty_section_break_pairs(doc) == 0
assert [s.orientation for s in doc.sections] == [
WD_ORIENT.PORTRAIT,
WD_ORIENT.LANDSCAPE,
WD_ORIENT.PORTRAIT,
]
def test_second_landscape_photo_is_not_shrunk_into_leftover(tmp_path, monkeypatch):
"""A follower that does not fit the rest of the page keeps its page size."""
monkeypatch.chdir(tmp_path)
png = tmp_path / "tall.png"
PILImage.new("RGB", (2000, 2000), color=(0, 128, 255)).save(png)
os.environ["WORKING_DIR"] = str(tmp_path)
doc = _fresh_doc()
first = Image(doc._body, str(png), CaptionInfo("p1", "Фото 1", landscape=True))
second = Image(doc._body, str(png), CaptionInfo("p2", "Фото 2", landscape=True))
Renderer(doc, skip_numbering=True).process([first, second])
A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
heights = []
for child in doc.element.body:
ext = child.find(f".//{A}ext")
if ext is not None:
heights.append(int(ext.get("cy")))
assert len(heights) == 2
assert heights[1] > heights[0] * 0.9
+51 -1
View File
@@ -17,6 +17,7 @@ from md2gost.office import (
fix_continuations_with_engine,
resolve_office_engine,
)
from md2gost.office.detect import clear_office_engine_cache
from md2gost.office import uno_fix
from md2gost.office import uno_profile
from md2gost.office.uno_cli import soffice_cli_path
@@ -49,6 +50,13 @@ def test_parser_office_engine_libre():
assert req.office_engine == "libre"
@pytest.fixture(autouse=True)
def _clear_office_cache():
clear_office_engine_cache()
yield
clear_office_engine_cache()
@patch("md2gost.office.detect._detect_word")
@patch("md2gost.office.detect._find_soffice_install")
def test_resolve_auto_prefers_word(mock_lo, mock_word):
@@ -112,6 +120,29 @@ def test_detect_libre_from_custom_path(tmp_path):
assert install.python_path == str(python_bin)
@patch("md2gost.office.detect._word_com_available", return_value=True)
def test_detect_word_without_starting_com(_mock_avail):
install = detect_office_install("word")
assert install is not None
assert install.engine == "word"
@patch("md2gost.office.detect._detect_word")
@patch("md2gost.office.detect._find_soffice_install")
def test_resolve_auto_cached_on_second_call(mock_lo, mock_word):
mock_word.return_value = OfficeInstall(engine="word", label="Microsoft Word")
mock_lo.return_value = OfficeInstall(
engine="libre",
label="LibreOffice",
soffice_path=r"C:\LibreOffice\program\soffice.exe",
python_path=r"C:\LibreOffice\program\python.exe",
)
first = resolve_office_engine("auto")
second = resolve_office_engine("auto")
assert first == second == ("word", mock_word.return_value)
mock_word.assert_called_once()
@patch("md2gost.office.dispatch.fix_continuations")
@patch("md2gost.office.dispatch.resolve_office_engine")
def test_dispatch_word_backend(mock_resolve, mock_word_fix):
@@ -122,6 +153,21 @@ def test_dispatch_word_backend(mock_resolve, mock_word_fix):
mock_word_fix.assert_called_once()
@patch("md2gost.office.dispatch.fix_continuations")
@patch("md2gost.office.dispatch.resolve_office_engine")
def test_dispatch_uses_preresolved_install(mock_resolve, mock_word_fix):
install = OfficeInstall(engine="word", label="Microsoft Word")
mock_word_fix.return_value = FixResult(True, splits=1, message="ok")
result = fix_continuations_with_engine(
"report.docx",
engine="word",
install=install,
)
assert result.ok is True
mock_resolve.assert_not_called()
mock_word_fix.assert_called_once()
@patch("md2gost.office.dispatch.fix_continuations_uno")
@patch("md2gost.office.dispatch.resolve_office_engine")
def test_dispatch_libre_backend(mock_resolve, mock_uno):
@@ -199,7 +245,9 @@ def test_uno_call_method_or_attribute():
assert uno_call(_DocAttr(), "Text") == "attr"
def test_uno_worker_env_includes_program_dir():
def test_uno_worker_env_includes_program_dir(monkeypatch):
monkeypatch.setenv("PYTHONHOME", r"C:\Python310")
monkeypatch.setenv("PYTHONPATH", r"C:\Python310\Lib")
install = OfficeInstall(
engine="openoffice",
label="OpenOffice",
@@ -209,6 +257,8 @@ def test_uno_worker_env_includes_program_dir():
worker_dir = Path(r"C:\temp\worker")
with patch.object(uno_fix, "_office_program_dir", return_value=Path(r"C:\OpenOffice 4\program")):
env, cwd = uno_fix._uno_worker_env(worker_dir, install)
assert "PYTHONHOME" not in env
assert r"C:\Python310\Lib" not in env["PYTHONPATH"]
assert r"C:\OpenOffice 4\program" in env["PYTHONPATH"]
assert str(worker_dir) in env["PYTHONPATH"]
assert cwd == Path(r"C:\OpenOffice 4\program")
+44
View File
@@ -62,6 +62,39 @@ def test_parse_colspan_markers():
assert data[2].merge_h == "none"
SAMPLE_LT_HEADER = """\
| Ситуация | < | < | < |
|---|---|---|---|
| Шаг | a | b | c |
"""
SAMPLE_LT_BODY = """\
| A | B | C |
|---|---|---|
| wide | < | Z |
"""
def test_parse_lt_colspan_in_header():
doc = markdown.parse(SAMPLE_LT_HEADER)
t = next(c for c in doc.children if isinstance(c, MdTable))
header = t.children[0].children
assert header[0].header is True
assert header[0].merge_h == "restart"
assert [c.merge_h for c in header[1:]] == ["continue", "continue", "continue"]
body = t.children[1].children
assert [c.merge_h for c in body] == ["none", "none", "none", "none"]
def test_parse_lt_colspan_in_body():
doc = markdown.parse(SAMPLE_LT_BODY)
t = next(c for c in doc.children if isinstance(c, MdTable))
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)
@@ -153,6 +186,17 @@ def test_checker_merge_col_error():
def test_checker_merge_ok():
issues = check_table_merge(SAMPLE_ROWSPAN)
assert issues == []
assert check_table_merge(SAMPLE_LT_HEADER) == []
def test_checker_lt_first_column():
bad = """\
| A | B |
|---|---|
| < | 2 |
"""
issues = check_table_merge(bad)
assert any(i.id == "table.merge_col" for i in issues)
def test_table_continuation_modes():
+127
View File
@@ -14,8 +14,11 @@ from md2gost.word_fix import (
find_page_break_row,
fix_continuations,
is_caption_orphaned,
continuation_pass_limit,
is_orphan_header_break,
locate_break_row,
parse_caption_text,
resume_page_before_cut,
split_vmerge_spans_at_break,
)
@@ -61,6 +64,68 @@ def test_find_page_break_row():
assert find_page_break_row([14, 14, 14, 14, 14, 14, 15, 15]) == 7
def test_resume_page_before_cut():
assert resume_page_before_cut(1) == 1
assert resume_page_before_cut(2) == 1
assert resume_page_before_cut(15) == 14
def test_locate_break_row_matches_full_scan_from_page_1():
samples = [
[1, 1, 2, 2],
[2, 2, 3],
[1, 2],
[14, 14, 14, 14, 14, 14, 15, 15],
[1, 1, 1],
[8] * 30 + [9] * 10 + [12] * 5,
]
for pages in samples:
def page_at(row, pages=pages):
return pages[row - 1]
found, cut = locate_break_row(len(pages), page_at, 1)
assert found == find_page_break_row(pages)
if found:
assert cut == pages[found - 1]
def test_locate_break_row_starts_at_page_before_cut():
"""A long table is not read from row 1 when the window starts near the cut."""
pages = [10] * 80 + [14] * 8 + [15] * 120
probes: list[int] = []
def page_at(row):
probes.append(row)
return pages[row - 1]
found, cut = locate_break_row(len(pages), page_at, min_page=14)
assert found == 81
assert cut == 14
assert len(probes) < 40
continuation = [15] * 12 + [16] * 200
cont_probes: list[int] = []
def continuation_at(row):
cont_probes.append(row)
return continuation[row - 1]
found, cut = locate_break_row(
len(continuation),
continuation_at,
min_page=resume_page_before_cut(15),
)
assert (found, cut) == (13, 16)
assert len(cont_probes) < 30
def test_continuation_pass_limit():
assert continuation_pass_limit(0, 0) == 10
assert continuation_pass_limit(3, 0) == 13
assert continuation_pass_limit(3, 1) == 14
assert continuation_pass_limit(3, 5) == 18
def test_orphan_header_and_caption_guards():
"""Method guide: no title-only / title+column-headers alone at page end."""
# Split would leave only heading row on previous page
@@ -104,6 +169,68 @@ def test_fix_continuations_missing_file():
assert "не найден" in r.message.lower() or "Файл" in r.message
def test_word_com_apartment_pairs_init(monkeypatch):
import types
calls = []
fake = types.ModuleType("pythoncom")
fake.CoInitialize = lambda: calls.append("init")
fake.CoUninitialize = lambda: calls.append("uninit")
monkeypatch.setitem(sys.modules, "pythoncom", fake)
from md2gost.word_fix import word_com_apartment
with word_com_apartment():
calls.append("body")
assert calls == ["init", "body", "uninit"]
def test_word_com_apartment_skips_uninit_when_init_fails(monkeypatch):
import types
calls = []
fake = types.ModuleType("pythoncom")
def boom():
raise RuntimeError("coinit")
fake.CoInitialize = boom
fake.CoUninitialize = lambda: calls.append("uninit")
monkeypatch.setitem(sys.modules, "pythoncom", fake)
from md2gost.word_fix import word_com_apartment
with pytest.raises(RuntimeError, match="coinit"):
with word_com_apartment():
calls.append("body")
assert calls == []
@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
def test_second_thread_can_dispatch_com():
"""A fresh thread must call CoInitialize itself; module import does it once."""
try:
import win32com.client # noqa: F401
except ImportError:
pytest.skip("pywin32 not installed")
import threading
from md2gost.word_fix import word_com_apartment
errors: list[BaseException] = []
def work():
try:
with word_com_apartment():
win32com.client.Dispatch("Scripting.Dictionary")
except Exception as exc:
errors.append(exc)
for _ in range(2):
thread = threading.Thread(target=work)
thread.start()
thread.join()
assert errors == []
@pytest.mark.skipif(sys.platform != "win32", reason="Windows only")
@pytest.mark.skipif(os.environ.get("MD2GOST_TEST_WORD") != "1", reason="set MD2GOST_TEST_WORD=1 to run Word COM smoke")
def test_fix_continuations_com_smoke(tmp_path):