- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
This commit is contained in:
+398
-13
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user