Update 0.4.0
Python application / build (push) Has been cancelled

- Add\Rework UI
- Add Split Table and Listing
- Add Support Customazeble schems
This commit is contained in:
Igor20264
2026-09-04 22:28:39 +03:00
parent 516abe7b83
commit b38661f588
70 changed files with 69532 additions and 413 deletions
+398 -13
View File
@@ -1,16 +1,35 @@
"""Tests for diagram renderer and factory routing."""
"""Tests for diagram renderer, schemes, and include cache."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from md2gost.diagram_includes import (
include_cache_index_path,
reset_include_cache,
resolve_url,
rewrite_http_includes_in_source,
)
from md2gost.diagram_renderer import (
DIAGRAM_LANGS,
configure_diagrams,
diagram_engine_status,
is_diagram_lang,
prepare_source,
render_diagram,
configure_diagrams,
resolve_plantuml_jar,
)
from md2gost.diagram_schemes import (
DiagramScheme,
apply_scheme,
configure_schemes,
ensure_user_schemes,
prepare_with_schemes,
scheme_id_from_lang,
user_schemes_path,
)
from md2gost.extended_markdown import markdown
from md2gost.extended_markdown.caption import Caption as CapEl
@@ -26,16 +45,148 @@ def test_prepare_uml_wraps_startuml():
assert dtype == "plantuml"
def test_prepare_c4_adds_include():
src, dtype = prepare_source("c4", "Person(user, \"User\")")
assert "!include" in src
def test_prepare_c4_uses_scheme(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
configure_schemes(base=tmp_path)
def fake_resolve(ref, *, base=None, schemes_dir=None):
return str(tmp_path / "C4_Container.puml")
with patch("md2gost.diagram_schemes.resolve_include_ref", side_effect=fake_resolve):
src, dtype = prepare_with_schemes("c4", 'Person(user, "U")', base=tmp_path)
assert dtype == "plantuml"
assert "Person(user" in src
assert "@startuml" in src.lower()
assert "!include" in src
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_prepare_uml_c4_alias(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
configure_schemes(base=tmp_path)
def fake_resolve(ref, *, base=None, schemes_dir=None):
return str(tmp_path / "stub.puml")
with patch("md2gost.diagram_schemes.resolve_include_ref", side_effect=fake_resolve):
src, dtype = prepare_with_schemes("uml-c4", 'Person(user, "U")', base=tmp_path)
assert dtype == "plantuml"
assert "Person(user" in src
def test_unknown_scheme_raises(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
configure_schemes(base=tmp_path)
with pytest.raises(ValueError, match="не найдена"):
prepare_with_schemes("uml-nosuch", "A -> B", base=tmp_path)
def test_bpmn_scheme_prepare(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
configure_schemes(base=tmp_path)
assert is_diagram_lang("bpmn")
assert is_diagram_lang("uml-bpmn")
src, dtype = prepare_with_schemes("bpmn", "Start(s)\nEnd(e)\nFlow(s, e)", base=tmp_path)
assert dtype == "plantuml"
assert "Start(s)" in src
assert "!include" in src
assert "BPMN.puml" in src.replace("\\", "/")
assert src.lower().count("@startuml") == 1
def test_bundled_bpmn_survives_user_file_without_it(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = ensure_user_schemes(tmp_path)
path.write_text('{"mine":{"title":"x","prefix":"","postfix":""}}\n', encoding="utf-8")
configure_schemes(base=tmp_path)
assert is_diagram_lang("mine")
assert is_diagram_lang("bpmn")
def test_scheme_id_from_lang():
assert scheme_id_from_lang("uml") is None
assert scheme_id_from_lang("uml-c4") == "c4"
assert scheme_id_from_lang("c4") == "c4"
assert scheme_id_from_lang("bpmn") == "bpmn"
assert scheme_id_from_lang("uml-bpmn") == "bpmn"
def test_ensure_user_schemes_once(tmp_path):
path = ensure_user_schemes(tmp_path)
assert path.is_file()
assert "c4" in path.read_text(encoding="utf-8")
path.write_text('{"mine":{"title":"x","prefix":"","postfix":""}}\n', encoding="utf-8")
again = ensure_user_schemes(tmp_path)
assert again == path
assert "mine" in path.read_text(encoding="utf-8")
assert path == user_schemes_path(tmp_path)
def test_apply_scheme_no_double_start():
scheme = DiagramScheme(
id="t",
prefix="@startuml\nskinparam monochrome true\n",
postfix="\n@enduml",
includes=[],
)
out = apply_scheme(scheme, "@startuml\nA -> B\n@enduml")
assert out.lower().count("@startuml") == 1
assert "skinparam monochrome true" in out
def test_include_cache_index_no_overwrite(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
calls = []
def fake_get(url, timeout=60):
calls.append(url)
class Resp:
content = f"' from {url}\n".encode("utf-8")
def raise_for_status(self):
return None
return Resp()
with patch("md2gost.diagram_includes.requests.get", side_effect=fake_get):
p1 = resolve_url("https://example.com/a.puml", base=tmp_path)
p2 = resolve_url("https://example.com/b.puml", base=tmp_path)
assert p1 != p2
assert p1.is_file() and p2.is_file()
content1 = p1.read_bytes()
# second resolve same URL — no new download
p1b = resolve_url("https://example.com/a.puml", base=tmp_path)
assert p1b == p1
assert p1.read_bytes() == content1
assert calls.count("https://example.com/a.puml") == 1
assert include_cache_index_path(tmp_path).is_file()
removed = reset_include_cache(tmp_path)
assert removed >= 2
assert not include_cache_index_path(tmp_path).is_file()
def test_rewrite_http_in_source(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
def fake_get(url, timeout=60):
class Resp:
content = b"rectangle x\n"
def raise_for_status(self):
return None
return Resp()
with patch("md2gost.diagram_includes.requests.get", side_effect=fake_get):
src = "@startuml\n!include https://example.com/x.puml\nA->B\n@enduml"
out = rewrite_http_includes_in_source(src, base=tmp_path)
assert "https://example.com" not in out
assert "!include " in out
def test_caption_plus_listing():
@@ -47,6 +198,45 @@ def test_caption_plus_listing():
assert caps[0].unique_name == "fig"
def test_caption_plus_landscape():
doc = markdown.parse("%wide Big scheme +landscape\n\n```uml\nA->B\n```\n")
caps = [c for c in doc.children if isinstance(c, CapEl)]
assert caps[0].landscape is True
assert caps[0].with_listing is False
assert caps[0].text == "Big scheme"
doc2 = markdown.parse("%t1 Широкая +listing +landscape\n\n|a|b|\n|-|-|\n|1|2|\n")
caps2 = [c for c in doc2.children if isinstance(c, CapEl)]
assert caps2[0].landscape is True
assert caps2[0].with_listing is True
assert caps2[0].text == "Широкая"
def test_caption_interrupts_paragraph():
"""% line right after text (no blank line) must still be Caption, not plain text."""
from marko.block import FencedCode, Paragraph
doc = markdown.parse(
"Пишите так:\n"
"%testuml test +landscape\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].landscape is True
assert caps[0].unique_name == "testuml"
joined = ""
for p in doc.children:
if not isinstance(p, Paragraph):
continue
for ch in getattr(p, "children", []) or []:
if hasattr(ch, "children") and isinstance(ch.children, str):
joined += ch.children
assert "%testuml" not in joined
assert any(isinstance(c, FencedCode) and c.lang == "uml" for c in doc.children)
def test_factory_routes_uml_to_diagram():
from docx import Document
from md2gost import package_dir
@@ -77,8 +267,8 @@ def test_factory_routes_uml_to_diagram():
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)
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
out_path.write_bytes(png if fmt == "png" else b"<svg xmlns='http://www.w3.org/2000/svg'/>")
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path))
@@ -86,8 +276,8 @@ def test_render_diagram_uses_cache(tmp_path):
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")
assert path1.png_path == path2.png_path
assert open(path1.png_path, "rb").read().startswith(b"\x89PNG")
def test_render_diagram_off_raises(tmp_path):
@@ -98,7 +288,202 @@ def test_render_diagram_off_raises(tmp_path):
render_diagram("uml", "A -> B", fallback="off", cache_dir=str(tmp_path))
def test_prepare_mermaid_no_startuml():
src, dtype = prepare_source("mermaid", "flowchart LR\nA-->B")
assert "@startuml" not in src.lower()
assert dtype == "mermaid"
assert "flowchart LR" in src
src2, dtype2 = prepare_source("mmd", "sequenceDiagram\nA->>B: hi")
assert dtype2 == "mermaid"
assert "@startuml" not in src2.lower()
def test_plantuml_scale_injected_into_render_cache(tmp_path):
from md2gost.diagram_renderer import _ensure_plantuml_png_scale
boosted = _ensure_plantuml_png_scale("@startuml\nA -> B\n@enduml")
assert "scale 2" in boosted
# author override kept
custom = _ensure_plantuml_png_scale("@startuml\nscale 1.5\nA -> B\n@enduml")
assert custom.count("scale") == 1
assert "scale 1.5" in custom
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
seen = []
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
seen.append(source)
out_path.write_bytes(png if fmt == "png" else b"<svg/>")
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_scale=3)
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram("uml", "A -> B", cache_dir=str(tmp_path))
assert seen and "scale 3" in seen[0]
assert result.pixel_scale == 3
def test_diagram_scale_one_skips_inject(tmp_path):
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
seen = []
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
seen.append(source)
out_path.write_bytes(png)
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_scale=1)
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram("uml", "A -> B", cache_dir=str(tmp_path), diagram_scale=1)
assert seen and "scale " not in seen[0].split("@startuml", 1)[-1].split("A -> B")[0]
assert result.pixel_scale == 1
def test_is_diagram_lang_mermaid():
assert is_diagram_lang("mermaid")
assert is_diagram_lang("mmd")
assert "mermaid" in DIAGRAM_LANGS
assert "mmd" in DIAGRAM_LANGS
def test_render_mermaid_skips_jar(tmp_path):
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
calls = {"jar": 0, "kroki": []}
def fake_jar(*args, **kwargs):
calls["jar"] += 1
return False
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
calls["kroki"].append((diagram_type, fmt, base_url))
if fmt == "png":
out_path.write_bytes(png)
else:
out_path.write_bytes(b"<svg xmlns='http://www.w3.org/2000/svg'/>")
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path))
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("mermaid", "flowchart LR\nA-->B", cache_dir=str(tmp_path))
assert calls["jar"] == 0
assert any(t == "mermaid" and f == "png" for t, f, _ in calls["kroki"])
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
assert result.svg_path is None
def test_render_diagram_svg_asks_both_formats(tmp_path):
png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 20
svg = b'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
fmts = []
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
fmts.append(fmt)
out_path.write_bytes(png if fmt == "png" else svg)
return True
configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_format="svg")
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram(
"uml", "A -> B", cache_dir=str(tmp_path), diagram_format="svg",
)
assert "png" in fmts and "svg" in fmts
assert result.svg_path is not None
assert Path(result.svg_path).read_bytes().startswith(b"<svg")
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
def test_attach_svg_blip():
from docx import Document
from md2gost.docx_svg import attach_svg_blip, SVG_BLIP_URI
doc = Document()
run = doc.add_paragraph().add_run()
# minimal valid-ish PNG (1x1)
from io import BytesIO
from PIL import Image as PILImage
buf = BytesIO()
PILImage.new("RGB", (8, 8), color=(255, 0, 0)).save(buf, format="PNG")
buf.seek(0)
picture = run.add_picture(buf)
import tempfile
with tempfile.TemporaryDirectory() as tmp:
svg = Path(tmp) / "d.svg"
svg.write_text(
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">'
'<rect width="10" height="10"/></svg>',
encoding="utf-8",
)
assert attach_svg_blip(run, picture, svg)
xml = picture._inline.xml
assert "svgBlip" in xml
assert SVG_BLIP_URI in xml
# two image relationships on the document part
image_rels = [
r for r in doc.part.rels.values()
if "image" in (r.reltype or "")
]
assert len(image_rels) >= 2
def test_resolve_plantuml_jar_explicit(tmp_path):
jar = tmp_path / "plantuml.jar"
jar.write_bytes(b"PK" + b"\x00" * 2000)
assert resolve_plantuml_jar(str(jar), download=False) == str(jar)
missing = tmp_path / "nope.jar"
assert resolve_plantuml_jar(str(missing), download=False) is None or (
resolve_plantuml_jar(str(missing), download=False) != str(missing)
)
def test_diagram_engine_status_mentions_java_or_kroki():
text = diagram_engine_status()
assert "Java" in text
assert "PlantUML" in text
def test_diagram_langs():
assert "uml" in DIAGRAM_LANGS
assert "c4" in DIAGRAM_LANGS
assert "bpmn" in DIAGRAM_LANGS
assert is_diagram_lang("uml")
assert is_diagram_lang("uml-c4")
assert is_diagram_lang("uml-bpmn")
def test_render_bpmn_with_jar(tmp_path, monkeypatch):
import shutil
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
configure_schemes(base=tmp_path)
jar = resolve_plantuml_jar(download=False)
if not shutil.which("java") or not jar:
pytest.skip("нужны Java и plantuml.jar")
configure_diagrams(fallback="off", cache_dir=str(tmp_path), plantuml_jar=jar)
body = "\n".join(
[
"Start(s)",
"StartMessage(sm, \"msg\")",
"UserTask(t, \"Шаг\")",
"XOR(gw)",
"AND(p)",
"OR(o)",
"End(e)",
"EndTerminate(et)",
"Flow(s, t)",
"Flow(t, gw)",
"CondFlow(gw, e, \"да\")",
"DefaultFlow(gw, et)",
]
)
result = render_diagram("bpmn", body, cache_dir=str(tmp_path), fallback="off")
data = Path(result.png_path).read_bytes()
assert data.startswith(b"\x89PNG")
assert len(data) > 500
+200
View File
@@ -0,0 +1,200 @@
"""Tests for landscape section around wide figures/tables."""
from __future__ import annotations
import os
from pathlib import Path
from docx import Document
from docx.enum.section import WD_ORIENT
from PIL import Image as PILImage
from md2gost import package_dir
from md2gost.page_geometry import content_size
from md2gost.parser_ import Parser
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.image import Image
from md2gost.renderable.table import Table
from md2gost.renderer import Renderer
def test_content_size_landscape_wider():
h_p, w_p = content_size(landscape=False)
h_l, w_l = content_size(landscape=True)
assert w_l > w_p
assert h_l < h_p
def test_renderer_landscape_section_for_table():
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)
renderer = Renderer(doc, skip_numbering=False)
table = Table(doc._body, 2, 4, CaptionInfo("t1", "Wide", landscape=True))
for r in range(2):
for c in range(4):
table.add_paragraph_to_cell(r, c).add_run(f"{r},{c}")
before = len(doc.sections)
renderer.process([table])
after = len(doc.sections)
assert after >= before + 2
land = [s for s in doc.sections if s.orientation == WD_ORIENT.LANDSCAPE]
assert land, "no landscape section"
assert int(land[0].page_width) > int(land[0].page_height)
from md2gost.styles import apply_document_styles
apply_document_styles(doc, "mirea")
land2 = [s for s in doc.sections if s.orientation == WD_ORIENT.LANDSCAPE]
assert land2
assert int(land2[0].page_width) > int(land2[0].page_height)
assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT
assert int(doc.sections[-1].page_width) < int(doc.sections[-1].page_height)
def test_parser_propagates_landscape_to_table():
doc = Document(os.path.join(package_dir(), "Template.docx"))
md = "%t1 Широкая +landscape\n\n|A|B|C|\n|---|---|---|\n|1|2|3|\n"
renderables = list(Parser(doc, md).parse())
assert len(renderables) == 1
assert isinstance(renderables[0], Table)
assert renderables[0].landscape is True
def test_landscape_image_not_queued_for_soft_pagebreak(tmp_path, monkeypatch):
"""Portrait fit-check must not queue +landscape images (empty page before figure)."""
monkeypatch.chdir(tmp_path)
png = tmp_path / "wide.png"
# Wide image that is tall when forced into portrait width
PILImage.new("RGB", (2000, 1400), color=(0, 128, 255)).save(png)
os.environ["WORKING_DIR"] = str(tmp_path)
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)
from md2gost.renderable.paragraph import Paragraph as RParagraph
from md2gost.sub_renderable import SubRenderable
para = RParagraph(doc._body)
para.add_run("Before text that fills some space. " * 20)
img = Image(
doc._body,
str(png),
CaptionInfo("deploy", "Диаграмма", landscape=True),
)
# Mimic factory: image attached to paragraph
para._images.append(img)
from md2gost.layout_tracker import LayoutState
from md2gost.page_geometry import content_size
from docx.shared import Length
max_h, max_w = content_size(landscape=False)
state = LayoutState(max_h, max_w)
# Pretend most of the page is already used so a portrait fit-check would fail
state.add_height(Length(int(max_h) - 100000))
infos = list(para.render(None, state))
subs = [i for i in infos if isinstance(i, SubRenderable)]
assert len(subs) == 1
assert subs[0].renderable is img
assert subs[0].add_to_new_page is False
def test_landscape_places_figure_inside_landscape_section(tmp_path, monkeypatch):
"""Content must sit BEFORE body sectPr or Word ignores landscape."""
monkeypatch.chdir(tmp_path)
png = tmp_path / "x.png"
PILImage.new("RGB", (40, 20), color=(0, 128, 255)).save(png)
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)
os.environ["WORKING_DIR"] = str(tmp_path)
img = Image(
doc._body,
str(png),
CaptionInfo("fig", "wide", landscape=True),
)
renderer = Renderer(doc, skip_numbering=False)
renderer.process([img])
A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
section_idx = 0
image_section = None
for child in doc.element.body:
tag = child.tag.split("}")[-1]
if child.findall(f".//{A}blip"):
image_section = section_idx
sect = None
if tag == "sectPr":
sect = child
elif tag == "p":
pPr = child.find(f"{W}pPr")
if pPr is not None:
sect = pPr.find(f"{W}sectPr")
if sect is not None:
section_idx += 1
assert any(s.orientation == WD_ORIENT.LANDSCAPE for s in doc.sections)
assert image_section is not None
assert image_section == 1
def test_landscape_listing_deferred_after_section(tmp_path, monkeypatch):
"""+landscape +listing → listing must not sit in the landscape section."""
from unittest.mock import patch
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.diagram import DiagramFigure
from md2gost.renderer import Renderer
from md2gost.styles import apply_mirea_styles
monkeypatch.chdir(tmp_path)
png = tmp_path / "d.png"
PILImage.new("RGB", (40, 20), color=(0, 128, 255)).save(png)
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)
apply_mirea_styles(doc)
class FakeResult:
png_path = str(png)
svg_path = None
pixel_scale = 1.0
fig = DiagramFigure(
doc._body,
"uml",
"@startuml\nA->B\n@enduml",
CaptionInfo("d1", "Схема", with_listing=True, landscape=True),
with_listing=True,
)
assert fig.listing is not None
renderer = Renderer(doc, skip_numbering=False)
with patch("md2gost.renderable.diagram.render_diagram", return_value=FakeResult()):
renderer.process([fig])
assert any(s.orientation == WD_ORIENT.LANDSCAPE for s in doc.sections)
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}"
+161
View File
@@ -378,3 +378,164 @@ def test_apid_biblio_min_seven():
assert any(i.id == "biblio.count" for i in issues)
def test_special_heading_alignment_soderzhanie_vs_vvedenie():
import docx
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Cm
from md2gost.renderable.heading import Heading
from md2gost.renderer import Renderer
from md2gost.styles import apply_mirea_styles
doc = docx.Document(r"md2gost/Template.docx")
doc._body.clear_content()
apply_mirea_styles(doc)
renderer = Renderer(doc, skip_numbering=True)
h_toc = Heading(doc._body, 1, False)
h_toc.add_run("СОДЕРЖАНИЕ")
renderer._handle_heading(h_toc)
assert h_toc._docx_paragraph.alignment == WD_ALIGN_PARAGRAPH.CENTER
assert abs(h_toc._docx_paragraph.paragraph_format.left_indent.cm - 0) < 0.01
h_intro = Heading(doc._body, 1, False)
h_intro.add_run("ВВЕДЕНИЕ")
renderer._handle_heading(h_intro)
# Left like Heading 1 (style indent 1.25), not forced center
assert h_intro._docx_paragraph.alignment != WD_ALIGN_PARAGRAPH.CENTER
def test_appendix_item_becomes_h3_plus_title():
import docx
from docx.enum.text import WD_ALIGN_PARAGRAPH
from md2gost.renderable.heading import Heading
from md2gost.renderer import Renderer
from md2gost.styles import apply_mirea_styles
doc = docx.Document(r"md2gost/Template.docx")
doc._body.clear_content()
apply_mirea_styles(doc)
renderer = Renderer(doc, skip_numbering=True)
h = Heading(doc._body, 2, True)
h.add_run("Приложение А Листинг модуля")
renderer._handle_heading(h)
assert h.style.name == "Heading 3"
assert h.text == "Приложение А"
assert h._docx_paragraph.alignment == WD_ALIGN_PARAGRAPH.CENTER
assert len(renderer._after_current) == 1
title_p = renderer._after_current[0]
assert "Листинг модуля" in title_p._docx_paragraph.text
assert title_p._docx_paragraph.alignment == WD_ALIGN_PARAGRAPH.CENTER
def test_caption_table_keep_with_next_and_toc_styles():
import docx
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from md2gost.styles import apply_mirea_styles
doc = docx.Document(r"md2gost/Template.docx")
apply_mirea_styles(doc)
assert doc.styles["Caption Table"].paragraph_format.keep_with_next is True
assert doc.styles["Table Text"].paragraph_format.alignment == WD_ALIGN_PARAGRAPH.LEFT
bh = doc.styles["Bibliography Heading"]
assert abs(bh.paragraph_format.left_indent.cm - 1.25) < 0.01
toc1 = doc.styles["toc 1"]
assert toc1.font.all_caps is True
assert toc1.font.bold is False
assert toc1.paragraph_format.line_spacing_rule == WD_LINE_SPACING.ONE_POINT_FIVE
def test_emdash_default_false_in_pipeline():
from md2gost.pipeline import ConvertRequest
assert ConvertRequest().emdash_to_hyphen is False
def test_object_ref_unused_warning():
text = SAMPLE_OK.replace("@Рисунок:arch", "схема")
issues = check_markdown(text, "coursework")
assert any(i.id == "ref.unused" for i in issues)
def test_table_continuation_warning():
issues = check_markdown(SAMPLE_OK, "coursework", table_continuation="off")
assert any(i.id == "table.continuation" for i in issues)
issues2 = check_markdown(SAMPLE_OK, "coursework", table_continuation="caption")
assert not any(i.id == "table.continuation" for i in issues2)
issues3 = check_markdown(SAMPLE_OK, "coursework", table_continuation="word")
assert not any(i.id == "table.continuation" for i in issues3)
def test_appendix_list_warning():
text = SAMPLE_OK + "\n## Приложение Б Ещё\n\nТекст.\n"
# No list between ПРИЛОЖЕНИЯ and first appendix
issues = check_markdown(text, "coursework")
assert any(i.id == "appendix.toc" for i in issues)
def test_vkr_biblio_per_section():
text = """
# *СОДЕРЖАНИЕ
[TOC]
# *ВВЕДЕНИЕ
Текст.
# 1 Раздел
См. [1.1].
# *ЗАКЛЮЧЕНИЕ
Выводы.
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
## Нормативные
[1.1]: ГОСТ 7.32-2017. — М., 2022.
## Научные
[2.1]: Иванов И. И. Книга. — М., 2023.
# *ПРИЛОЖЕНИЯ
## Приложение А Графический материал
Слайды.
"""
issues = check_markdown(text, "vkr")
assert any(i.id == "biblio.count" for i in issues)
def test_page_fill_evaluate_heuristic():
from md2gost.page_fill_check import PageMetric, evaluate_page_fill
pages = [
PageMetric(1, 0.4, "1 Анализ", "1 Анализ"),
PageMetric(2, 0.9, "1 Анализ", "2 Проект", is_last_doc_page=False),
PageMetric(3, 0.5, "2 Проект", None, is_last_doc_page=True),
]
issues = evaluate_page_fill(pages)
assert len(issues) == 1
assert issues[0].page_index == 1
assert "heuristic" in issues[0].message
# Last page of section — no issue
pages2 = [
PageMetric(1, 0.3, "1 Анализ", "2 Проект"),
PageMetric(2, 0.9, "2 Проект", None, is_last_doc_page=True),
]
assert evaluate_page_fill(pages2) == []
# Landscape skipped
pages3 = [
PageMetric(1, 0.2, "1 Анализ", "1 Анализ", is_landscape=True),
PageMetric(2, 0.9, "1 Анализ", None, is_last_doc_page=True),
]
assert evaluate_page_fill(pages3) == []
def test_landscape_valign_center():
from docx import Document
from docx.oxml.ns import qn
from md2gost import package_dir
import os
from md2gost.page_geometry import apply_section_geometry
doc = Document(os.path.join(package_dir(), "Template.docx"))
section = doc.sections[0]
apply_section_geometry(section, landscape=True)
valign = section._sectPr.find(qn("w:vAlign"))
assert valign is not None
assert valign.get(qn("w:val")) == "center"
apply_section_geometry(section, landscape=False)
assert section._sectPr.find(qn("w:vAlign")) is None
+186
View File
@@ -0,0 +1,186 @@
"""CLI/GUI shared pipeline helpers."""
import os
import sys
from md2gost.dnd import first_markdown, normalize_drop_paths, parse_tkdnd_files
from md2gost.pipeline import ConvertRequest, convert, default_output_path, should_launch_gui
from md2gost.__main__ import build_parser, request_from_args
def test_should_launch_gui():
assert should_launch_gui(None, False) is True
assert should_launch_gui("", False) is True
assert should_launch_gui("a.md", True) is True
assert should_launch_gui("a.md", False) is False
def test_default_output_path():
path = default_output_path(r"C:\docs\report.md")
assert path.endswith("report.docx")
def test_parse_tkdnd_files():
raw = r"{C:\My Files\a.md} C:\tmp\b.md"
assert parse_tkdnd_files(raw) == [r"C:\My Files\a.md", r"C:\tmp\b.md"]
def test_normalize_and_first_markdown(tmp_path):
md = tmp_path / "note.md"
png = tmp_path / "pic.png"
md.write_text("# hi\n", encoding="utf-8")
png.write_text("x", encoding="utf-8")
paths = normalize_drop_paths([str(png), str(md)])
assert first_markdown(paths) == os.path.normpath(str(md))
def test_convert_rejects_non_md(tmp_path):
txt = tmp_path / "file.txt"
txt.write_text("nope", encoding="utf-8")
result = convert(ConvertRequest(filename=str(txt)))
assert result.ok is False
assert result.exit_code == 1
def test_convert_missing_file():
result = convert(ConvertRequest(filename="definitely-missing.md"))
assert result.ok is False
assert result.exit_code == 2
def test_parser_gui_and_optional_file():
parser = build_parser()
args = parser.parse_args(["--gui", "report.md", "--type", "PIS_custom"])
assert args.gui is True
assert args.filename == "report.md"
req = request_from_args(args)
assert req.doc_type == "PIS_custom"
assert req.filename == "report.md"
def test_win32_drop_api_pointer_width():
if sys.platform != "win32":
return
import ctypes
from md2gost.dnd import _win32_drop_api
api = _win32_drop_api()
assert api.CallWindowProc.argtypes[0] is ctypes.c_void_p
huge = (1 << 40) | 0x1234
api.CallWindowProc.argtypes[0](huge) # must not OverflowError
def test_win32_drop_hook_survives_window_messages():
if sys.platform != "win32":
return
import tkinter as tk
from md2gost.dnd import enable_file_drop
root = tk.Tk()
try:
root.geometry("240x160+40+40")
root.update()
kind = enable_file_drop(root, lambda _paths: None)
root.update()
root.event_generate("<Motion>", x=12, y=12)
root.update()
assert kind in {"win32", "tkdnd", "none"}
finally:
root.destroy()
def test_argv_needs_console():
from md2gost.__main__ import _argv_needs_console
assert _argv_needs_console(["md2gost"]) is False
assert _argv_needs_console(["md2gost", "--gui"]) is False
assert _argv_needs_console(["md2gost", "--help"]) is True
assert _argv_needs_console(["md2gost", "report.md"]) is True
assert _argv_needs_console(["md2gost", "--gui", "report.md"]) is False
def test_gui_module_imports():
from md2gost import gui
assert hasattr(gui, "run_gui")
assert hasattr(gui, "main")
def test_hr_pagebreak_becomes_page_break():
import os
import docx
from md2gost import package_dir
from md2gost.parser_ import Parser
from md2gost.renderable.page_break import PageBreak
doc = docx.Document(os.path.join(package_dir(), "Template.docx"))
doc._body.clear_content()
text = "До разрыва.\n\n---\n\nПосле разрыва.\n"
items = list(Parser(doc, text, hr_pagebreak=True).parse())
assert any(isinstance(x, PageBreak) for x in items)
doc2 = docx.Document(os.path.join(package_dir(), "Template.docx"))
doc2._body.clear_content()
items_off = list(Parser(doc2, text, hr_pagebreak=False).parse())
assert not any(isinstance(x, PageBreak) for x in items_off)
texts = []
for item in items_off:
para = getattr(item, "_docx_paragraph", None)
if para is not None:
texts.append(para.text)
assert not any("ThematicBreak" in t for t in texts)
def test_prompt_catalog_loads():
from md2gost.help_content import load_prompt_catalog
catalog = load_prompt_catalog()
names = {name for name, _title, text in catalog}
assert "generate-mirea-report.md" in names
assert "generate-pis-custom-report.md" in names
assert all(text.strip() for _n, _t, text in catalog)
def test_listing_continuation_mode():
import os
import docx
from md2gost import package_dir
from md2gost.profiles import DEFAULT_LISTING_CONTINUATION
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.listing import Listing
doc = docx.Document(os.path.join(package_dir(), "Template.docx"))
doc._body.clear_content()
listing = Listing(doc._body, "python", CaptionInfo("c1", "Код"))
listing.set_text("print(1)\n")
assert listing._continuation_mode == DEFAULT_LISTING_CONTINUATION
listing.set_continuation_mode("caption")
assert listing._continuation_mode == "caption"
try:
listing.set_continuation_mode("nope")
raise AssertionError("expected ValueError")
except ValueError:
pass
def test_parser_listing_continuation_flag():
parser = build_parser()
args = parser.parse_args(["report.md", "--listing-continuation", "legacy"])
req = request_from_args(args)
assert req.listing_continuation == "legacy"
args2 = parser.parse_args(["report.md"])
assert request_from_args(args2).listing_continuation == "word"
def test_parser_hr_pagebreak_flag():
parser = build_parser()
args = parser.parse_args(["report.md", "--no-hr-pagebreak"])
req = request_from_args(args)
assert req.hr_pagebreak is False
args2 = parser.parse_args(["report.md"])
assert request_from_args(args2).hr_pagebreak is False
assert request_from_args(args2).doc_type == "practice"
args3 = parser.parse_args(["report.md", "--hr-pagebreak"])
assert request_from_args(args3).hr_pagebreak is True
def test_parser_no_file_is_ok():
parser = build_parser()
args = parser.parse_args([])
assert args.filename is None
assert args.gui is False
+3 -3
View File
@@ -157,14 +157,14 @@ def test_checker_merge_ok():
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")
assert DEFAULT_TABLE_CONTINUATION == "word"
assert TABLE_CONTINUATION_MODES == ("off", "legacy", "caption", "soft", "word")
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"
assert t._continuation_mode == "word"
t.set_continuation_mode("legacy")
assert t._continuation_mode == "legacy"
t.set_continuation_mode("caption")
+110
View File
@@ -0,0 +1,110 @@
"""Tests for Word COM table/listing continuation post-process (pure + optional COM)."""
from __future__ import annotations
import os
import sys
import tempfile
import pytest
from md2gost.profiles import LISTING_CONTINUATION_MODES, TABLE_CONTINUATION_MODES
from md2gost.word_fix import (
continuation_label,
find_page_break_row,
fix_continuations,
parse_caption_text,
)
def test_word_mode_in_profiles():
assert "word" in TABLE_CONTINUATION_MODES
assert "word" in LISTING_CONTINUATION_MODES
@pytest.mark.parametrize(
"text, kind, number, cont",
[
("Таблица 2.1 — Название", "table", "2.1", False),
("Таблица 1", "table", "1", False),
("Продолжение Таблицы 2.1", "table", "2.1", True),
("Листинг 3 — Код", "listing", "3", False),
("Продолжение Листинга 3", "listing", "3", True),
("Таблицы 1.2", "table", "1.2", False),
],
)
def test_parse_caption_text(text, kind, number, cont):
info = parse_caption_text(text)
assert info is not None
assert info.kind == kind
assert info.number == number
assert info.is_continuation is cont
def test_parse_caption_rejects_noise():
assert parse_caption_text("") is None
assert parse_caption_text("Рисунок 1 — x") is None
assert parse_caption_text("просто текст") is None
def test_find_page_break_row():
assert find_page_break_row([]) is None
assert find_page_break_row([1]) is None
assert find_page_break_row([1, 1, 1]) is None
assert find_page_break_row([1, 1, 2, 2]) == 3
assert find_page_break_row([2, 2, 3]) == 3
assert find_page_break_row([1, 2]) == 2
def test_continuation_label():
assert continuation_label("table", "2.1") == "Продолжение Таблицы 2.1"
assert continuation_label("listing", "4") == "Продолжение Листинга 4"
def test_word_paged_modes_include_word():
from md2gost.renderable.table import _WORD_PAGED_MODES as t
from md2gost.renderable.listing import _WORD_PAGED_MODES as L
assert "word" in t
assert "word" in L
def test_fix_continuations_missing_file():
r = fix_continuations(os.path.join(tempfile.gettempdir(), "md2gost-no-such.docx"))
assert r.ok is False
assert "не найден" in r.message.lower() or "Файл" in r.message
@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):
"""Build a tall table DOCX via python-docx, then run Word fix (opt-in)."""
try:
import win32com.client # noqa: F401
except ImportError:
pytest.skip("pywin32 not installed")
from docx import Document
from docx.shared import Pt
path = tmp_path / "tall_table.docx"
doc = Document()
p = doc.add_paragraph("Таблица 1 — Длинная")
try:
p.style = "Caption"
except KeyError:
pass
table = doc.add_table(rows=1, cols=2)
table.rows[0].cells[0].text = "A"
table.rows[0].cells[1].text = "B"
for i in range(80):
row = table.add_row()
row.cells[0].text = f"row {i}"
row.cells[1].text = "x" * 20
for cell in row.cells:
for para in cell.paragraphs:
para.paragraph_format.space_after = Pt(6)
doc.save(str(path))
result = fix_continuations(str(path), tables=True, listings=False)
assert result.ok, result.message
assert path.is_file()