@@ -0,0 +1,378 @@
|
||||
"""Tests for word2md (DOCX → md2gost Markdown)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
|
||||
from word2md.captions import caption_unique_id, parse_any_caption
|
||||
from word2md.classify import classify_heading_text, is_body_start_heading, is_special_title
|
||||
from word2md.emit import emit_markdown
|
||||
from word2md.pipeline import ImportRequest, convert_docx
|
||||
from word2md.refs import apply_reference_pass, rewrite_text
|
||||
from word2md.blocks import CaptionBlock, HeadingBlock, ParagraphBlock, InlineSpan, TableBlock, ImageBlock
|
||||
from word2md.walker import postprocess_blocks
|
||||
from word2md.tables import table_to_grid
|
||||
|
||||
|
||||
def test_parse_captions():
|
||||
fig = parse_any_caption("Рисунок 1.1 — Архитектура")
|
||||
assert fig is not None
|
||||
assert fig.kind == "figure"
|
||||
assert fig.number == "1.1"
|
||||
assert fig.text == "Архитектура"
|
||||
|
||||
tbl = parse_any_caption("Таблица 2 — Сравнение")
|
||||
assert tbl is not None and tbl.kind == "table" and tbl.number == "2"
|
||||
|
||||
cont = parse_any_caption("Продолжение Таблицы 2.1")
|
||||
assert cont is not None and cont.is_continuation and cont.kind == "table"
|
||||
|
||||
lst = parse_any_caption("Листинг 1 — Код")
|
||||
assert lst is not None and lst.kind == "listing"
|
||||
assert caption_unique_id("figure", "1.1") == "fig1_1"
|
||||
|
||||
|
||||
def test_special_headings():
|
||||
assert is_special_title("ВВЕДЕНИЕ")
|
||||
assert is_special_title("Список использованных источников")
|
||||
level, title, numbered = classify_heading_text("ВВЕДЕНИЕ", 1)
|
||||
assert level == 1 and not numbered and title == "ВВЕДЕНИЕ"
|
||||
assert is_body_start_heading("ВВЕДЕНИЕ", "Heading 1")
|
||||
|
||||
|
||||
def test_rewrite_refs():
|
||||
maps = {
|
||||
"figure": {"1.1": "fig1_1", "3.1": "fig3_1"},
|
||||
"table": {"2": "tbl2", "1.1": "tbl1_1"},
|
||||
"listing": {},
|
||||
"equation": {"1": "eq1"},
|
||||
}
|
||||
text = "См. Рисунок 1.1 и табл. 2, а также [1]."
|
||||
out = rewrite_text(text, maps)
|
||||
assert "@Рисунок:fig1_1" in out
|
||||
assert "@Таблица:tbl2" in out
|
||||
assert "[1]" in out
|
||||
# trailing sentence period must not stick to the number
|
||||
assert rewrite_text("сведены в Таблице 1.1.", maps) == "сведены в @Таблица:tbl1_1."
|
||||
assert rewrite_text("показан на Рисунке 3.1.", maps) == "показан на @Рисунок:fig3_1."
|
||||
|
||||
|
||||
def test_emit_special_and_table():
|
||||
blocks = [
|
||||
HeadingBlock(1, "СОДЕРЖАНИЕ", numbered=False),
|
||||
HeadingBlock(1, "ВВЕДЕНИЕ", numbered=False),
|
||||
ParagraphBlock(spans=[InlineSpan("Текст со ссылкой на Рисунок 1.")]),
|
||||
CaptionBlock(kind="table", number="1", text="Сравнение", unique_id="tbl1"),
|
||||
TableBlock(rows=[["A", "B"], ["1", "2"]], caption_id="tbl1", caption_text="Сравнение"),
|
||||
]
|
||||
blocks = apply_reference_pass(blocks)
|
||||
# no figure map → leave as is for figure
|
||||
md = emit_markdown(blocks)
|
||||
assert "# *СОДЕРЖАНИЕ" in md
|
||||
assert "# *ВВЕДЕНИЕ" in md
|
||||
assert "%tbl1 Сравнение" in md
|
||||
assert "| A | B |" in md
|
||||
|
||||
|
||||
def test_escape_literal_asterisk():
|
||||
"""DOCX stores bare '*'; MD footnotes need '\\*' so Marko won't eat them."""
|
||||
from word2md.emit import escape_md_inline
|
||||
|
||||
assert escape_md_inline("* Для 2025") == "\\* Для 2025"
|
||||
assert escape_md_inline("1,50*") == "1,50\\*"
|
||||
assert escape_md_inline("@Рисунок:fig1_1") == "@Рисунок:fig1_1"
|
||||
|
||||
md = emit_markdown(
|
||||
[
|
||||
ParagraphBlock(spans=[InlineSpan("* Для 2025 года приведён документооборот.")]),
|
||||
TableBlock(rows=[["Показатель", "2025"], ["ЭДО", "1,50*"]]),
|
||||
]
|
||||
)
|
||||
assert "\\* Для 2025 года" in md
|
||||
assert "1,50\\*" in md
|
||||
# dialect heading star stays structural
|
||||
assert "# *ВВЕДЕНИЕ" == emit_markdown(
|
||||
[HeadingBlock(1, "ВВЕДЕНИЕ", numbered=False)]
|
||||
).strip()
|
||||
|
||||
|
||||
def test_postprocess_continuation_tables():
|
||||
blocks = [
|
||||
CaptionBlock(kind="table", number="1", text="T", unique_id="tbl1"),
|
||||
TableBlock(rows=[["A"], ["1"]], caption_id="tbl1"),
|
||||
CaptionBlock(kind="table", number="1", is_continuation=True, unique_id="tbl1"),
|
||||
TableBlock(rows=[["2"]]),
|
||||
]
|
||||
out = postprocess_blocks(blocks)
|
||||
tables = [b for b in out if isinstance(b, TableBlock)]
|
||||
assert len(tables) == 1
|
||||
assert tables[0].rows == [["A"], ["1"], ["2"]]
|
||||
|
||||
|
||||
def test_postprocess_skips_repeated_header():
|
||||
blocks = [
|
||||
CaptionBlock(kind="table", number="1", text="T", unique_id="tbl1"),
|
||||
TableBlock(rows=[["H1", "H2"], ["1", "a"]], caption_id="tbl1"),
|
||||
CaptionBlock(kind="table", number="1", is_continuation=True, unique_id="tbl1"),
|
||||
TableBlock(rows=[["H1", "H2"], ["2", "b"]]),
|
||||
]
|
||||
out = postprocess_blocks(blocks)
|
||||
tables = [b for b in out if isinstance(b, TableBlock)]
|
||||
assert len(tables) == 1
|
||||
assert tables[0].rows == [["H1", "H2"], ["1", "a"], ["2", "b"]]
|
||||
|
||||
|
||||
def test_fold_figure_caption_under_image():
|
||||
"""GOST: picture then «Рисунок N — …» → one image with %id in title."""
|
||||
blocks = [
|
||||
ParagraphBlock(spans=[InlineSpan("См. Рисунок 1.1.")]),
|
||||
ImageBlock(rel_path="media/a.png"),
|
||||
CaptionBlock(kind="figure", number="1.1", text="Схема", unique_id="fig1_1"),
|
||||
]
|
||||
out = postprocess_blocks(blocks)
|
||||
assert len([b for b in out if isinstance(b, CaptionBlock)]) == 0
|
||||
imgs = [b for b in out if isinstance(b, ImageBlock)]
|
||||
assert len(imgs) == 1
|
||||
assert imgs[0].caption_id == "fig1_1"
|
||||
assert imgs[0].caption_text == "Схема"
|
||||
out = apply_reference_pass(out)
|
||||
md = emit_markdown(out)
|
||||
assert "@Рисунок:fig1_1" in md
|
||||
img_line = [l for l in md.splitlines() if l.startswith("![")][0]
|
||||
assert "%fig1_1" in img_line
|
||||
|
||||
|
||||
def test_sequential_figures_keep_distinct_ids():
|
||||
"""Caption under fig N must not leak onto figure N+1 via pending."""
|
||||
blocks = [
|
||||
ImageBlock(rel_path="a.png"),
|
||||
CaptionBlock(kind="figure", number="5.3", text="Архитектура", unique_id="fig5_3"),
|
||||
ImageBlock(rel_path="b.png"),
|
||||
CaptionBlock(kind="figure", number="5.2", text="DFD", unique_id="fig5_2"),
|
||||
]
|
||||
out = postprocess_blocks(blocks)
|
||||
imgs = [b for b in out if isinstance(b, ImageBlock)]
|
||||
assert [i.caption_id for i in imgs] == ["fig5_3", "fig5_2"]
|
||||
|
||||
|
||||
def test_docx_continuation_tables_merge(tmp_path: Path):
|
||||
"""«Продолжение Таблицы N» + fragment must become one markdown table."""
|
||||
doc = Document()
|
||||
_ensure_styles(doc)
|
||||
h = doc.add_paragraph("ВВЕДЕНИЕ")
|
||||
try:
|
||||
h.style = "Heading 1"
|
||||
except KeyError:
|
||||
pass
|
||||
cap = doc.add_paragraph("Таблица 1 — Демо")
|
||||
try:
|
||||
cap.style = "Название таблицы"
|
||||
except KeyError:
|
||||
pass
|
||||
t1 = doc.add_table(rows=3, cols=2)
|
||||
t1.cell(0, 0).text = "A"
|
||||
t1.cell(0, 1).text = "B"
|
||||
t1.cell(1, 0).text = "1"
|
||||
t1.cell(1, 1).text = "x"
|
||||
t1.cell(2, 0).text = "2"
|
||||
t1.cell(2, 1).text = "y"
|
||||
cont = doc.add_paragraph("Продолжение Таблицы 1")
|
||||
try:
|
||||
cont.style = "Название таблицы"
|
||||
except KeyError:
|
||||
pass
|
||||
t2 = doc.add_table(rows=2, cols=2)
|
||||
t2.cell(0, 0).text = "3"
|
||||
t2.cell(0, 1).text = "z"
|
||||
t2.cell(1, 0).text = "4"
|
||||
t2.cell(1, 1).text = "w"
|
||||
path = tmp_path / "cont.docx"
|
||||
doc.save(path)
|
||||
out = tmp_path / "cont.md"
|
||||
result = convert_docx(ImportRequest(filename=str(path), output=str(out)))
|
||||
assert result.ok, result.message
|
||||
md = out.read_text(encoding="utf-8")
|
||||
assert md.count("%tbl1") == 1
|
||||
assert "Продолжение" not in md
|
||||
# one header separator only
|
||||
assert md.count("| --- | --- |") == 1 or md.count("| --- | --- |") == 1
|
||||
assert "| 3 | z |" in md
|
||||
assert "| 4 | w |" in md
|
||||
assert "| 1 | x |" in md
|
||||
|
||||
|
||||
def _ensure_styles(doc: Document) -> None:
|
||||
from docx.enum.style import WD_STYLE_TYPE
|
||||
|
||||
for name, base in (
|
||||
("Caption Figure", "Caption"),
|
||||
("Название таблицы", "Caption"),
|
||||
("Caption Listing", "Caption"),
|
||||
("Code", "Normal"),
|
||||
("Bibliography", "Normal"),
|
||||
):
|
||||
try:
|
||||
doc.styles[name]
|
||||
except KeyError:
|
||||
try:
|
||||
st = doc.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH)
|
||||
try:
|
||||
st.base_style = doc.styles[base]
|
||||
except KeyError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_convert_minimal_docx(tmp_path: Path):
|
||||
doc = Document()
|
||||
_ensure_styles(doc)
|
||||
doc.add_heading("ВВЕДЕНИЕ", level=1)
|
||||
# unnumbered look: we'll rely on special title text
|
||||
p = doc.paragraphs[-1]
|
||||
p.text = "ВВЕДЕНИЕ"
|
||||
try:
|
||||
p.style = doc.styles["Heading 1"]
|
||||
except KeyError:
|
||||
pass
|
||||
doc.add_paragraph("Абзац с жирным текстом и ссылкой [1].")
|
||||
cap = doc.add_paragraph("Таблица 1 — Демо")
|
||||
try:
|
||||
cap.style = "Название таблицы"
|
||||
except KeyError:
|
||||
pass
|
||||
table = doc.add_table(rows=2, cols=2)
|
||||
table.cell(0, 0).text = "A"
|
||||
table.cell(0, 1).text = "B"
|
||||
table.cell(1, 0).text = "1"
|
||||
table.cell(1, 1).text = "2"
|
||||
doc.add_heading("ЗАКЛЮЧЕНИЕ", level=1)
|
||||
doc.paragraphs[-1].text = "ЗАКЛЮЧЕНИЕ"
|
||||
doc.add_heading("СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", level=1)
|
||||
doc.paragraphs[-1].text = "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"
|
||||
bib = doc.add_paragraph("[1]: Иванов И. И. Книга. — М., 2023.")
|
||||
try:
|
||||
bib.style = "Bibliography"
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
docx_path = tmp_path / "mini.docx"
|
||||
doc.save(docx_path)
|
||||
|
||||
result = convert_docx(ImportRequest(filename=str(docx_path), output=str(tmp_path / "mini.md")))
|
||||
assert result.ok, result.message
|
||||
md = Path(result.output_path).read_text(encoding="utf-8")
|
||||
assert "# *ВВЕДЕНИЕ" in md
|
||||
assert "%tbl1" in md
|
||||
assert "| A | B |" in md
|
||||
assert "[1]:" in md
|
||||
assert "# *ЗАКЛЮЧЕНИЕ" in md
|
||||
|
||||
|
||||
def test_roundtrip_structure(tmp_path: Path):
|
||||
"""MD → DOCX (md2gost) → MD (word2md): keep headings / table / biblio shape."""
|
||||
from md2gost.pipeline import ConvertRequest, convert
|
||||
|
||||
md_src = tmp_path / "sample.md"
|
||||
md_src.write_text(
|
||||
"""# *ВВЕДЕНИЕ
|
||||
|
||||
Текст введения со ссылкой [1].
|
||||
|
||||
%cmp Сравнение
|
||||
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
См. Таблица 1 — будет заменена после нумерации.
|
||||
|
||||
# *ЗАКЛЮЧЕНИЕ
|
||||
|
||||
Итог.
|
||||
|
||||
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
|
||||
|
||||
[1]: Источник один. — М., 2023.
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
docx_out = tmp_path / "sample.docx"
|
||||
conv = convert(
|
||||
ConvertRequest(
|
||||
filename=str(md_src),
|
||||
output=str(docx_out),
|
||||
table_continuation="off",
|
||||
listing_continuation="off",
|
||||
check=False,
|
||||
)
|
||||
)
|
||||
assert conv.ok, conv.message
|
||||
|
||||
md2 = tmp_path / "back.md"
|
||||
imp = convert_docx(ImportRequest(filename=str(docx_out), output=str(md2)))
|
||||
assert imp.ok, imp.message
|
||||
text = md2.read_text(encoding="utf-8")
|
||||
assert "# *ВВЕДЕНИЕ" in text
|
||||
assert "# *ЗАКЛЮЧЕНИЕ" in text
|
||||
assert "| A | B |" in text or "| A |" in text
|
||||
assert "[1]" in text
|
||||
assert "%tbl" in text or "Таблица" in text or "|" in text
|
||||
|
||||
|
||||
def test_table_grid_merge_markers():
|
||||
doc = Document()
|
||||
table = doc.add_table(rows=2, cols=2)
|
||||
table.cell(0, 0).text = "X"
|
||||
table.cell(0, 1).text = "Y"
|
||||
table.cell(1, 0).text = "Z"
|
||||
table.cell(1, 1).text = "W"
|
||||
# merge horizontally first row
|
||||
table.cell(0, 0).merge(table.cell(0, 1))
|
||||
rows, merges = table_to_grid(table)
|
||||
assert len(rows) == 2
|
||||
# first row should have continue marker for span
|
||||
assert any(cell == ">" for cell in rows[0]) or merges[0][1][1] == "continue"
|
||||
|
||||
|
||||
def test_nobreak_hyphen_preserved(tmp_path: Path):
|
||||
"""md2gost writes '-' as w:noBreakHyphen; word2md must restore ASCII hyphen."""
|
||||
from md2gost.pipeline import ConvertRequest, convert
|
||||
from md2gost.util import create_element
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
md_src = tmp_path / "hyphen.md"
|
||||
md_src.write_text(
|
||||
"# *ВВЕДЕНИЕ\n\n"
|
||||
"Во-первых, ИТ-компания и 63-ФЗ. Организационно-правовая форма.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
docx_out = tmp_path / "hyphen.docx"
|
||||
conv = convert(
|
||||
ConvertRequest(
|
||||
filename=str(md_src),
|
||||
output=str(docx_out),
|
||||
table_continuation="off",
|
||||
listing_continuation="off",
|
||||
)
|
||||
)
|
||||
assert conv.ok, conv.message
|
||||
|
||||
# Sanity: DOCX really has noBreakHyphen
|
||||
doc = Document(str(docx_out))
|
||||
xml = doc.element.body.xml
|
||||
assert "noBreakHyphen" in xml
|
||||
# python-docx .text drops them
|
||||
joined = " ".join(p.text or "" for p in doc.paragraphs)
|
||||
assert "Вопервых" in joined.replace(" ", "") or "Во-первых" not in joined
|
||||
|
||||
md2 = tmp_path / "back.md"
|
||||
imp = convert_docx(ImportRequest(filename=str(docx_out), output=str(md2)))
|
||||
assert imp.ok, imp.message
|
||||
text = md2.read_text(encoding="utf-8")
|
||||
assert "Во-первых" in text
|
||||
assert "ИТ-компания" in text
|
||||
assert "63-ФЗ" in text
|
||||
assert "Организационно-правовая" in text
|
||||
Reference in New Issue
Block a user