v0.5.2
Python application / build (push) Waiting to run

Что то сделал
This commit is contained in:
Igor20264
2026-09-08 19:37:54 +03:00
parent 1a5b35eb54
commit 510f7e7adf
90 changed files with 11720 additions and 5547 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+78
View File
@@ -0,0 +1,78 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2400" height="1700" viewBox="0 0 2400 1700">
<rect width="100%" height="100%" fill="#fff"/>
<rect x="16.0" y="16.0" width="2368.0" height="1668.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<rect x="22.4" y="22.4" width="2355.2" height="1655.2" fill="none" stroke="#000000" stroke-width="2.00"/>
<line x1="22.4" y1="214.4" x2="2377.6" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="22.4" y1="1549.6" x2="2377.6" y2="1549.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="363.9" y1="22.4" x2="363.9" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1200.0" y1="22.4" x2="1200.0" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1612.2" y1="22.4" x2="1612.2" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1953.7" y1="22.4" x2="1953.7" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1200.0" y1="66.4" x2="1612.2" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1426.7" y1="22.4" x2="1426.7" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="34.4" y="32.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">USED AT:</text>
<text x="375.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">AUTHOR:</text>
<text x="487.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">Кинзябулатов Рамиль</text>
<text x="375.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">PROJECT:</text>
<text x="503.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">Разработка универсальной модели торгового предприятия</text>
<text x="375.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NOTES:</text>
<text x="479.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">1</text>
<text x="507.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">2</text>
<text x="535.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">3</text>
<text x="563.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">4</text>
<text x="591.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">5</text>
<text x="619.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">6</text>
<text x="647.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">7</text>
<text x="675.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">8</text>
<text x="703.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">9</text>
<text x="731.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">10</text>
<text x="1210.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE:</text>
<text x="1284.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">08.02.2022</text>
<text x="1434.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">REV:</text>
<text x="1498.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">31.03.2022</text>
<rect x="1212.0" y="80.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1236.0" y="78.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">WORKING</text>
<rect x="1212.0" y="112.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1236.0" y="110.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">DRAFT</text>
<rect x="1212.0" y="144.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1236.0" y="142.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">RECOMMENDED</text>
<rect x="1212.0" y="176.4" width="16.0" height="16.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<text x="1236.0" y="174.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">PUBLICATION</text>
<text x="1624.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">READER</text>
<line x1="1612.2" y1="66.4" x2="1953.7" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="1624.2" y="74.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE</text>
<text x="1965.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">CONTEXT:</text>
<text x="2165.6" y="134.4" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="700" fill="#000000">TOP</text>
<line x1="446.3" y1="1549.6" x2="446.3" y2="1677.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1906.6" y1="1549.6" x2="1906.6" y2="1677.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="34.4" y="1557.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NODE:</text>
<text x="234.4" y="1625.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">A-0</text>
<text x="458.3" y="1557.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">TITLE:</text>
<text x="1176.4" y="1625.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="32.0" font-weight="700" fill="#000000">Распорядиться товаром</text>
<text x="1918.6" y="1557.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NUMBER:</text>
<text x="2142.1" y="1621.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">1</text>
<rect x="2341.6" y="1641.6" width="36.0" height="36.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="2359.6" y="1659.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">1</text>
<line x1="38.4" y1="870.0" x2="888.0" y2="870.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="888.0,870.0 872.0,863.3 872.0,876.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1208.0" y1="230.4" x2="1208.0" y2="732.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1208.0,732.0 1201.3,716.0 1214.7,716.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1528.0" y1="870.0" x2="2361.6" y2="870.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="2361.6,870.0 2345.6,863.3 2345.6,876.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1208.0" y1="1533.6" x2="1208.0" y2="1008.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1208.0,1008.0 1201.3,1024.0 1214.7,1024.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<rect x="406.6" y="799.9" width="79.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="446.2" y="814.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Спрос</text>
<rect x="1007.8" y="438.7" width="168.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1091.9" y="452.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Нормативная</text>
<text x="1091.9" y="479.8" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">документация</text>
<rect x="1889.0" y="799.9" width="78.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1928.1" y="814.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Товар</text>
<rect x="1056.8" y="1262.3" width="119.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1116.4" y="1276.5" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Персонал</text>
<rect x="896.0" y="740.0" width="640.0" height="276.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
<rect x="888.0" y="732.0" width="640.0" height="276.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
<polygon points="888.0,732.0 904.0,732.0 888.0,748.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
<text x="1208.0" y="864.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Распорядиться товаром</text>
<text x="1516.0" y="998.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A0</text>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+166
View File
@@ -0,0 +1,166 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2800" height="1840" viewBox="0 0 2800 1840">
<rect width="100%" height="100%" fill="#fff"/>
<rect x="16.0" y="16.0" width="2768.0" height="1808.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<rect x="22.4" y="22.4" width="2755.2" height="1795.2" fill="none" stroke="#000000" stroke-width="2.00"/>
<line x1="22.4" y1="214.4" x2="2777.6" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="22.4" y1="1689.6" x2="2777.6" y2="1689.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="421.9" y1="22.4" x2="421.9" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1400.0" y1="22.4" x2="1400.0" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1882.2" y1="22.4" x2="1882.2" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="2281.7" y1="22.4" x2="2281.7" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1400.0" y1="66.4" x2="1882.2" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1665.2" y1="22.4" x2="1665.2" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="34.4" y="32.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">USED AT:</text>
<text x="433.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">AUTHOR:</text>
<text x="545.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">Кинзябулатов Рамиль</text>
<text x="433.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">PROJECT:</text>
<text x="561.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">Разработка универсальной модели торгового предприятия</text>
<text x="433.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NOTES:</text>
<text x="537.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">1</text>
<text x="565.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">2</text>
<text x="593.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">3</text>
<text x="621.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">4</text>
<text x="649.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">5</text>
<text x="677.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">6</text>
<text x="705.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">7</text>
<text x="733.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">8</text>
<text x="761.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">9</text>
<text x="789.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">10</text>
<text x="1410.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE:</text>
<text x="1484.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">08.02.2022</text>
<text x="1673.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">REV:</text>
<text x="1737.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">31.03.2022</text>
<rect x="1412.0" y="80.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1436.0" y="78.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">WORKING</text>
<rect x="1412.0" y="112.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1436.0" y="110.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">DRAFT</text>
<rect x="1412.0" y="144.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1436.0" y="142.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">RECOMMENDED</text>
<rect x="1412.0" y="176.4" width="16.0" height="16.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<text x="1436.0" y="174.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">PUBLICATION</text>
<text x="1894.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">READER</text>
<line x1="1882.2" y1="66.4" x2="2281.7" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="1894.2" y="74.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE</text>
<text x="2293.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">CONTEXT:</text>
<rect x="2475.6" y="90.4" width="116.0" height="56.0" fill="#c4c4c4" stroke="none" stroke-width="0.00"/>
<rect x="2471.6" y="86.4" width="116.0" height="56.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
<text x="2579.6" y="136.4" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">A-0</text>
<line x1="518.3" y1="1689.6" x2="518.3" y2="1817.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="2226.6" y1="1689.6" x2="2226.6" y2="1817.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="34.4" y="1697.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NODE:</text>
<text x="270.4" y="1765.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">A0</text>
<text x="530.3" y="1697.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">TITLE:</text>
<text x="1372.4" y="1765.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="32.0" font-weight="700" fill="#000000">Распорядиться товаром</text>
<text x="2238.6" y="1697.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NUMBER:</text>
<text x="2502.1" y="1761.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">2</text>
<rect x="2741.6" y="1781.6" width="36.0" height="36.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="2759.6" y="1799.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">2</text>
<line x1="38.4" y1="652.0" x2="696.0" y2="652.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="696.0,652.0 680.0,645.3 680.0,658.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="38.4" y1="891.8" x2="1208.0" y2="891.8" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1208.0,891.8 1192.0,885.1 1192.0,898.6" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1296.0" y1="230.4" x2="1296.0" y2="278.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1296.0" y1="278.4" x2="784.0" y2="278.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="784.0" y1="278.4" x2="784.0" y2="566.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1296.0" y1="230.4" x2="1296.0" y2="854.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1296.0" y1="278.4" x2="1808.0" y2="278.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1808.0" y1="278.4" x2="1808.0" y2="1142.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="784.0,566.0 777.3,550.0 790.7,550.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<polygon points="1296.0,854.0 1289.3,838.0 1302.7,838.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<polygon points="1808.0,1142.0 1801.3,1126.0 1814.7,1126.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1008.0" y1="230.4" x2="1008.0" y2="566.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1008.0,566.0 1001.3,550.0 1014.7,550.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="2032.0" y1="230.4" x2="2032.0" y2="1142.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="2032.0,1142.0 2025.3,1126.0 2038.7,1126.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1096.0" y1="603.8" x2="1152.0" y2="603.8" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1152.0" y1="603.8" x2="1152.0" y2="988.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1152.0" y1="988.2" x2="1208.0" y2="988.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1208.0,988.2 1192.0,981.4 1192.0,994.9" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1096.0" y1="700.2" x2="1216.0" y2="700.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1216.0" y1="700.2" x2="1216.0" y2="798.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1216.0" y1="798.0" x2="1520.0" y2="798.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1520.0" y1="798.0" x2="1520.0" y2="854.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1520.0,854.0 1513.3,838.0 1526.7,838.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1608.0" y1="940.0" x2="1664.0" y2="940.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1664.0" y1="940.0" x2="1664.0" y2="1228.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1664.0" y1="1228.0" x2="1720.0" y2="1228.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1720.0,1228.0 1704.0,1221.3 1704.0,1234.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="2120.0" y1="1179.8" x2="2761.6" y2="1179.8" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="2761.6,1179.8 2745.6,1173.1 2745.6,1186.6" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="2120.0" y1="1276.2" x2="2761.6" y2="1276.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="2761.6,1276.2 2745.6,1269.4 2745.6,1282.9" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1408.0" y1="1673.6" x2="1408.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1408.0" y1="1362.0" x2="896.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="896.0" y1="1362.0" x2="896.0" y2="738.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1408.0" y1="1362.0" x2="1296.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1296.0" y1="1362.0" x2="1296.0" y2="1026.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1408.0" y1="1362.0" x2="1920.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1920.0" y1="1362.0" x2="1920.0" y2="1314.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="896.0,738.0 889.3,754.0 902.7,754.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<polygon points="1296.0,1026.0 1289.3,1042.0 1302.7,1042.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<polygon points="1920.0,1314.0 1913.3,1330.0 1926.7,1330.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1520.0" y1="1673.6" x2="1520.0" y2="1026.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1520.0,1026.0 1513.3,1042.0 1526.7,1042.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<rect x="42.4" y="627.0" width="23.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="54.1" y="637.7" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">I1</text>
<rect x="42.4" y="866.8" width="23.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="54.1" y="877.5" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">I2</text>
<rect x="1306.0" y="234.4" width="31.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1321.7" y="245.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">C1</text>
<rect x="1018.0" y="234.4" width="31.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1033.7" y="245.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">C2</text>
<rect x="2042.0" y="234.4" width="31.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="2057.7" y="245.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">C3</text>
<rect x="2724.2" y="1154.8" width="33.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="2740.9" y="1165.5" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">O1</text>
<rect x="2724.2" y="1251.2" width="33.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="2740.9" y="1261.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">O2</text>
<rect x="1418.0" y="1648.2" width="34.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1435.2" y="1658.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">M1</text>
<rect x="1530.0" y="1648.2" width="34.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1547.2" y="1658.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">M2</text>
<rect x="314.4" y="581.9" width="79.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="354.0" y="596.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Спрос</text>
<rect x="518.2" y="794.9" width="163.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="599.8" y="809.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Предложения</text>
<text x="599.8" y="836.0" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">поставщиков</text>
<rect x="1211.9" y="295.0" width="168.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1296.0" y="309.3" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Нормативная</text>
<text x="1296.0" y="336.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">документация</text>
<rect x="1040.0" y="392.6" width="195.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1137.6" y="406.8" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Ассортиментная</text>
<text x="1137.6" y="433.7" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">политика</text>
<rect x="1889.8" y="635.5" width="110.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1944.9" y="649.7" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Условия</text>
<text x="1944.9" y="676.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">поставки</text>
<rect x="1122.9" y="533.8" width="155.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1200.5" y="548.0" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">план закупок</text>
<rect x="1165.6" y="603.2" width="110.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1220.7" y="617.4" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">график</text>
<text x="1220.7" y="644.3" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">поставок</text>
<rect x="1634.9" y="869.9" width="192.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1731.0" y="884.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">товар к отгрузке</text>
<rect x="2388.9" y="1109.8" width="78.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="2428.0" y="1124.0" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Товар</text>
<rect x="2352.9" y="1206.1" width="150.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="2428.0" y="1220.3" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Акт отгрузки</text>
<rect x="1256.8" y="1505.0" width="119.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1316.4" y="1519.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Персонал</text>
<rect x="1407.8" y="1279.0" width="80.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1447.9" y="1293.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Склад</text>
<rect x="704.0" y="574.0" width="400.0" height="172.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
<rect x="696.0" y="566.0" width="400.0" height="172.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
<polygon points="696.0,566.0 712.0,566.0 696.0,582.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
<text x="896.0" y="646.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Принять и оценить спрос</text>
<text x="1084.0" y="728.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A1</text>
<rect x="1216.0" y="862.0" width="400.0" height="172.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
<rect x="1208.0" y="854.0" width="400.0" height="172.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
<polygon points="1208.0,854.0 1224.0,854.0 1208.0,870.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
<text x="1408.0" y="934.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Закупить и хранить</text>
<text x="1596.0" y="1016.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A2</text>
<rect x="1728.0" y="1150.0" width="400.0" height="172.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
<rect x="1720.0" y="1142.0" width="400.0" height="172.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
<polygon points="1720.0,1142.0 1736.0,1142.0 1720.0,1158.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
<text x="1920.0" y="1222.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Отгрузить товар</text>
<text x="2108.0" y="1304.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A3</text>
</svg>

After

Width:  |  Height:  |  Size: 24 KiB

+194
View File
@@ -0,0 +1,194 @@
"""DFD via pbauermeister/data-flow-diagram (Graphviz / Kroki)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from md2gost.diagram_renderer import (
DIAGRAM_LANGS,
configure_diagrams,
is_diagram_lang,
prepare_source,
render_diagram,
)
from md2gost.dfd import (
SAMPLE_CONTEXT,
SAMPLE_LEVEL0,
DfdParseError,
_compile_dot,
write_dfd_outputs,
)
from md2gost.extended_markdown import markdown
from md2gost.extended_markdown.caption import Caption as CapEl
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.diagram import DiagramFigure
from md2gost.renderable_factory import RenderableFactory
def test_is_diagram_lang_dfd():
assert is_diagram_lang("dfd")
assert is_diagram_lang("uml-dfd")
assert is_diagram_lang("data-flow-diagram")
assert is_diagram_lang("yourdon")
assert is_diagram_lang("uml-yourdon")
assert "dfd" in DIAGRAM_LANGS
def test_prepare_dfd_stays_native():
src, dtype = prepare_source("dfd", SAMPLE_CONTEXT)
assert dtype == "dfd"
assert "@startuml" not in src.lower()
assert "process" in src
assert "System" in src
for lang in ("uml-dfd", "data-flow-diagram", "yourdon"):
_, dtype2 = prepare_source(lang, SAMPLE_LEVEL0)
assert dtype2 == "dfd"
def test_compile_dot_empty_raises():
with pytest.raises(DfdParseError, match="пустой"):
_compile_dot(" \n\n ")
def test_compile_dot_sample(monkeypatch):
"""When the package is importable, real compile; else accept mock."""
try:
dot, is_ctx = _compile_dot(SAMPLE_CONTEXT)
except DfdParseError as exc:
if "data-flow-diagram не найден" in str(exc) or "не найден" in str(exc):
pytest.skip("data-flow-diagram not installed for this Python")
raise
assert "digraph" in dot.lower() or "graph" in dot.lower()
assert is_ctx is True
def test_strip_graphviz_tcl_libs(tmp_path):
from md2gost.dfd import GRAPHVIZ_TCL_DLL_NAMES, strip_graphviz_tcl_libs
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
(bin_dir / "dot.exe").write_bytes(b"mz")
(bin_dir / "tcl86t.dll").write_bytes(b"tcl")
(bin_dir / "gdtclft.dll").write_bytes(b"gd")
(bin_dir / "cgraph.dll").write_bytes(b"keep")
tcl_mod = tmp_path / "lib" / "graphviz" / "tcl"
tcl_mod.mkdir(parents=True)
(tcl_mod / "pkgIndex.tcl").write_text("x", encoding="utf-8")
removed = strip_graphviz_tcl_libs(tmp_path)
assert removed >= 3
assert not (bin_dir / "tcl86t.dll").exists()
assert not (bin_dir / "gdtclft.dll").exists()
assert (bin_dir / "dot.exe").exists()
assert (bin_dir / "cgraph.dll").exists()
assert not tcl_mod.exists()
for name in GRAPHVIZ_TCL_DLL_NAMES:
assert not (bin_dir / name).exists()
def test_write_dfd_outputs_uses_local_or_kroki(tmp_path):
png = tmp_path / "a.png"
svg = tmp_path / "a.svg"
fake_dot = "digraph G { a -> b; }\n"
def fake_local(dot, out_path, fmt, *, context):
Path(out_path).write_bytes(
b"\x89PNG\r\n\x1a\n" if fmt == "png" else b"<svg></svg>"
)
if fmt == "png":
# minimal valid-enough PNG header for startswith checks
Path(out_path).write_bytes(
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
+ b"\x00" * 20
)
else:
Path(out_path).write_text(
"<svg xmlns='http://www.w3.org/2000/svg'><text>Клиент</text></svg>",
encoding="utf-8",
)
return True
with patch("md2gost.dfd._compile_dot", return_value=(fake_dot, False)), \
patch("md2gost.dfd._render_dot_local", side_effect=fake_local), \
patch("md2gost.dfd._render_dot_kroki", return_value=False):
assert write_dfd_outputs(SAMPLE_LEVEL0, png, svg, scale=1)
assert png.read_bytes().startswith(b"\x89PNG")
assert "Клиент" in svg.read_text(encoding="utf-8")
def test_write_dfd_outputs_falls_back_to_kroki(tmp_path):
png = tmp_path / "a.png"
def fake_kroki(dot, out_path, fmt):
Path(out_path).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
return True
with patch("md2gost.dfd._compile_dot", return_value=("digraph G {}", False)), \
patch("md2gost.dfd._render_dot_local", return_value=False), \
patch("md2gost.dfd._render_dot_kroki", side_effect=fake_kroki):
assert write_dfd_outputs(SAMPLE_CONTEXT, png, None, scale=1)
assert png.read_bytes().startswith(b"\x89PNG")
def test_write_dfd_outputs_raises_when_no_renderer(tmp_path):
with patch("md2gost.dfd._compile_dot", return_value=("digraph G {}", False)), \
patch("md2gost.dfd._render_dot_local", return_value=False), \
patch("md2gost.dfd._render_dot_kroki", return_value=False):
with pytest.raises(DfdParseError, match="Graphviz|Kroki"):
write_dfd_outputs(SAMPLE_CONTEXT, tmp_path / "x.png", None)
def test_render_diagram_dfd_skips_jar(tmp_path):
calls = {"jar": 0}
def fake_jar(*args, **kwargs):
calls["jar"] += 1
return False
def fake_write(source, out_png, out_svg, *, scale=2.0):
Path(out_png).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
return True
configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=1)
with patch("md2gost.diagram_renderer._render_plantuml_jar", side_effect=fake_jar), \
patch("md2gost.dfd.write_dfd_outputs", side_effect=fake_write):
result = render_diagram(
"dfd", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=1,
)
assert calls["jar"] == 0
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
def test_render_diagram_dfd_bad_source(tmp_path):
configure_diagrams(fallback="off", cache_dir=str(tmp_path))
with patch("md2gost.dfd._compile_dot", side_effect=DfdParseError("пустой DFD")):
with pytest.raises(RuntimeError, match="пустой|DFD"):
render_diagram("dfd", " ", cache_dir=str(tmp_path))
def test_factory_routes_dfd_to_diagram():
from docx import Document
from md2gost import package_dir
import os
md = "%ctx Контекст DFD\n\n```dfd\n" + SAMPLE_CONTEXT + "```\n"
parsed = markdown.parse(md)
doc = Document(os.path.join(package_dir(), "Template.docx"))
factory = RenderableFactory(doc._body)
caption = None
renderables = []
for el in parsed.children:
from marko.block import BlankLine
if isinstance(el, BlankLine):
continue
if isinstance(el, CapEl):
caption = CaptionInfo(el.unique_name, el.text, el.with_listing)
continue
renderables.append(factory.create(el, caption))
caption = None
assert len(renderables) == 1
assert isinstance(renderables[0], DiagramFigure)
assert renderables[0]._lang == "dfd"
+151 -53
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import json
import pytest
from md2gost.diagram_includes import (
@@ -25,8 +26,12 @@ from md2gost.diagram_renderer import (
from md2gost.diagram_schemes import (
DiagramScheme,
apply_scheme,
bundled_schemes_path,
claim_user_scheme_if_diverged,
configure_schemes,
ensure_user_schemes,
load_schemes_from_path,
migrate_user_schemes,
prepare_with_schemes,
scheme_id_from_lang,
user_schemes_path,
@@ -83,35 +88,21 @@ def test_unknown_scheme_raises(tmp_path, monkeypatch):
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):
def test_bundled_c4_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")
assert is_diagram_lang("c4")
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"
assert scheme_id_from_lang("usecase") == "usecase"
assert scheme_id_from_lang("uml-usecase") == "usecase"
def test_ensure_user_schemes_once(tmp_path):
@@ -121,10 +112,129 @@ def test_ensure_user_schemes_once(tmp_path):
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")
text = path.read_text(encoding="utf-8")
assert "mine" in text
assert "c4" in text # migration re-adds bundled ids
assert path == user_schemes_path(tmp_path)
def test_migrate_removes_orphaned_builtin_and_adds_missing(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
path.write_text(
json.dumps(
{
"bpmn": {
"title": "BPMN 2.0",
"author": "md2gost",
"prefix": "@startuml\n",
"postfix": "\n@enduml",
"includes": ["BPMN.puml"],
},
"mine": {"title": "x", "author": "", "prefix": "", "postfix": ""},
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
result = migrate_user_schemes(tmp_path)
assert result.written
assert "bpmn" in result.removed
assert "c4" in result.added
schemes = load_schemes_from_path(path)
assert "bpmn" not in schemes
assert "mine" in schemes
assert schemes["mine"].title == "x"
bundled = load_schemes_from_path(bundled_schemes_path())
for sid in bundled:
assert sid in schemes
def test_migrate_preserves_user_override_of_builtin(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
path.write_text(
json.dumps(
{
"c4": {
"title": "My C4",
"author": "",
"docs": "custom docs",
"prefix": "@startuml\n",
"postfix": "\n@enduml",
"includes": [],
}
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
result = migrate_user_schemes(tmp_path)
schemes = load_schemes_from_path(path)
assert schemes["c4"].docs == "custom docs"
assert schemes["c4"].author == ""
assert "c4" not in result.updated
assert "usecase" in schemes # other bundled ids still added
def test_migrate_updates_app_owned_builtin(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
path.write_text(
json.dumps(
{
"c4": {
"title": "Old C4",
"author": "md2gost",
"version": "0.0.1",
"docs": "stale",
"prefix": "@startuml\n",
"postfix": "\n@enduml",
"includes": [],
}
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
result = migrate_user_schemes(tmp_path)
assert "c4" in result.updated
schemes = load_schemes_from_path(path)
bundled = load_schemes_from_path(bundled_schemes_path())
assert schemes["c4"].to_dict() == bundled["c4"].to_dict()
def test_migrate_idempotent(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
path = user_schemes_path(tmp_path)
before = path.read_text(encoding="utf-8")
mtime1 = path.stat().st_mtime_ns
result = migrate_user_schemes(tmp_path)
assert not result.written
assert not result.changed
assert path.read_text(encoding="utf-8") == before
assert path.stat().st_mtime_ns == mtime1
def test_claim_user_scheme_if_diverged():
bundled = load_schemes_from_path(bundled_schemes_path())
stock = bundled["c4"]
same = DiagramScheme.from_dict("c4", stock.to_dict())
claimed_same = claim_user_scheme_if_diverged(same, bundled=bundled)
assert claimed_same.author == "md2gost"
edited = DiagramScheme.from_dict(
"c4",
{**stock.to_dict(), "docs": "my docs"},
)
claimed = claim_user_scheme_if_diverged(edited, bundled=bundled)
assert claimed.author == ""
assert claimed.docs == "my docs"
def test_apply_scheme_no_double_start():
scheme = DiagramScheme(
id="t",
@@ -137,6 +247,18 @@ def test_apply_scheme_no_double_start():
assert "skinparam monochrome true" in out
def test_archimate_scheme_includes_stdlib():
bundled = load_schemes_from_path(bundled_schemes_path())
scheme = bundled["archimate"]
assert scheme.title == "ArchiMate 3.2"
out = apply_scheme(scheme, 'Business_Actor(a, "X")\n')
assert "!include <archimate/Archimate>" in out
assert "Business_Actor(a, \"X\")" in out
assert out.strip().lower().startswith("@startuml")
assert "@enduml" in out.lower()
assert out.lower().count("@startuml") == 1
def test_include_cache_index_no_overwrite(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
calls = []
@@ -455,39 +577,15 @@ def test_diagram_engine_status_mentions_java_or_kroki():
def test_diagram_langs():
assert "uml" in DIAGRAM_LANGS
assert "c4" in DIAGRAM_LANGS
assert "bpmn" in DIAGRAM_LANGS
assert "usecase" in DIAGRAM_LANGS
assert "idef0" in DIAGRAM_LANGS
assert "dfd" 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
assert is_diagram_lang("uml-usecase")
assert is_diagram_lang("idef0")
assert is_diagram_lang("uml-idef0")
assert is_diagram_lang("dfd")
assert is_diagram_lang("uml-dfd")
assert is_diagram_lang("data-flow-diagram")
assert is_diagram_lang("yourdon")
+228
View File
@@ -0,0 +1,228 @@
"""DOCX core properties: resolve, apply, persist in user profile."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from docx import Document
from md2gost.doc_metadata import (
CORE_PROP_MAX,
DEFAULT_AUTHOR_SOURCE,
DEFAULT_DOC_COMMENTS,
DocumentMetadata,
apply_document_metadata,
clip_core,
resolve_document_metadata,
)
from md2gost.pipeline import ConvertRequest, convert
from md2gost.user_profile import UserProfile, load_user_profile, save_user_profile
from md2gost.__main__ import build_parser, request_from_args
def test_clip_core_limit():
assert clip_core("ab") == "ab"
assert len(clip_core("x" * (CORE_PROP_MAX + 10))) == CORE_PROP_MAX
def test_metadata_from_dict_defaults_and_empty_comments():
blank = DocumentMetadata.from_dict(None)
assert blank.author_source == DEFAULT_AUTHOR_SOURCE
assert blank.comments == DEFAULT_DOC_COMMENTS
empty = DocumentMetadata.from_dict({"comments": "", "author_source": "nope"})
assert empty.comments == ""
assert empty.author_source == DEFAULT_AUTHOR_SOURCE
custom = DocumentMetadata.from_dict({"author_source": "student", "title": " Отчёт "})
assert custom.author_source == "student"
assert custom.title == "Отчёт"
def test_resolve_author_sources(monkeypatch):
monkeypatch.setattr("md2gost.doc_metadata.os_user", lambda: "win-user")
stored = DocumentMetadata(author_source="custom", author="Кастом К. К.")
os_meta = resolve_document_metadata(stored=DocumentMetadata(author_source="os"))
assert os_meta.author == "win-user"
assert os_meta.last_modified_by == "win-user"
assert os_meta.comments == DEFAULT_DOC_COMMENTS
student_meta = resolve_document_metadata(
stored=DocumentMetadata(author_source="student"),
student="Студентов С. С.",
)
assert student_meta.author == "Студентов С. С."
custom_meta = resolve_document_metadata(stored=stored)
assert custom_meta.author == "Кастом К. К."
override = resolve_document_metadata(
stored=stored,
author="С CLI",
comments="",
title="Тема работы",
last_modified_by="Редактор",
)
assert override.author == "С CLI"
assert override.comments == ""
assert override.title == "Тема работы"
assert override.last_modified_by == "Редактор"
empty_custom = resolve_document_metadata(
stored=DocumentMetadata(author_source="custom", author=""),
)
assert empty_custom.author == "win-user"
def test_apply_document_metadata_writes_core_props(tmp_path: Path):
from md2gost import package_dir
doc = Document(str(Path(package_dir()) / "Template.docx"))
stamp = datetime(2026, 9, 8, 12, 0, 0)
apply_document_metadata(
doc,
DocumentMetadata(
author="Автор А. А.",
last_modified_by="Автор А. А.",
title="Название",
subject="Тема",
keywords="гост, отчёт",
comments="",
category="учебный",
),
now=stamp,
)
out = tmp_path / "meta.docx"
doc.save(str(out))
loaded = Document(str(out))
cp = loaded.core_properties
assert cp.author == "Автор А. А."
assert cp.last_modified_by == "Автор А. А."
assert cp.title == "Название"
assert cp.subject == "Тема"
assert cp.keywords == "гост, отчёт"
assert cp.comments == ""
assert cp.category == "учебный"
assert cp.revision == 1
assert cp.created.replace(tzinfo=None) == stamp
assert cp.modified.replace(tzinfo=None) == stamp
def test_user_profile_preserves_metadata_when_saving_student(tmp_path: Path):
save_user_profile(
UserProfile(
student="А А А",
group="ГР-1",
metadata=DocumentMetadata(
author_source="student",
title="Отчёт",
comments="",
),
),
base=tmp_path,
)
loaded = load_user_profile(base=tmp_path)
assert loaded.student == "А А А"
assert loaded.metadata.author_source == "student"
assert loaded.metadata.title == "Отчёт"
assert loaded.metadata.comments == ""
loaded.student = "Б Б Б"
save_user_profile(loaded, base=tmp_path)
again = load_user_profile(base=tmp_path)
assert again.student == "Б Б Б"
assert again.group == "ГР-1"
assert again.metadata.title == "Отчёт"
assert again.metadata.comments == ""
def test_old_user_json_without_metadata_block(tmp_path: Path):
path = tmp_path / "md2gost.user.json"
path.write_text('{"student": "Иванов", "group": "ИНБО-01-00"}\n', encoding="utf-8")
loaded = load_user_profile(base=tmp_path)
assert loaded.student == "Иванов"
assert loaded.metadata.comments == DEFAULT_DOC_COMMENTS
assert loaded.metadata.author_source == DEFAULT_AUTHOR_SOURCE
def test_parser_doc_metadata_flags():
parser = build_parser()
args = parser.parse_args(
[
"report.md",
"--doc-author-from", "student",
"--doc-author", "Петров П. П.",
"--doc-title", "Курсовая",
"--doc-comments", "",
"--doc-keywords", "гост",
]
)
req = request_from_args(args)
assert req.doc_author_source == "student"
assert req.doc_author == "Петров П. П."
assert req.doc_title == "Курсовая"
assert req.doc_comments == ""
assert req.doc_keywords == "гост"
default = request_from_args(parser.parse_args(["report.md"]))
assert default.doc_author is None
assert default.doc_comments is None
def test_pipeline_applies_profile_and_cli_metadata(tmp_path: Path, monkeypatch):
from md2gost import user_profile as up
monkeypatch.setattr(up, "app_dir", lambda: tmp_path)
save_user_profile(
UserProfile(
student="Конвейев К. К.",
group="ИНБО-00-00",
metadata=DocumentMetadata(
author_source="student",
title="Из профиля",
subject="Тема профиля",
comments="штамп",
),
),
base=tmp_path,
)
md = tmp_path / "report.md"
md.write_text("# *ВВЕДЕНИЕ\n\nТекст.\n", encoding="utf-8")
out = tmp_path / "report.docx"
result = convert(
ConvertRequest(
filename=str(md),
output=str(out),
table_continuation="off",
listing_continuation="off",
open_when_done=False,
),
log=lambda _m: None,
)
assert result.ok, result.message
cp = Document(str(out)).core_properties
assert cp.author == "Конвейев К. К."
assert cp.last_modified_by == "Конвейев К. К."
assert cp.title == "Из профиля"
assert cp.subject == "Тема профиля"
assert cp.comments == "штамп"
assert cp.revision == 1
out2 = tmp_path / "report2.docx"
result2 = convert(
ConvertRequest(
filename=str(md),
output=str(out2),
table_continuation="off",
listing_continuation="off",
open_when_done=False,
doc_author="С CLI",
doc_comments="",
doc_title="С CLI название",
),
log=lambda _m: None,
)
assert result2.ok, result2.message
cp2 = Document(str(out2)).core_properties
assert cp2.author == "С CLI"
assert cp2.comments == ""
assert cp2.title == "С CLI название"
assert cp2.subject == "Тема профиля"
+422
View File
@@ -0,0 +1,422 @@
"""Parse and render IDEF0 fenced diagrams."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from PIL import Image
from md2gost.diagram_renderer import (
DIAGRAM_LANGS,
configure_diagrams,
is_diagram_lang,
prepare_source,
render_diagram,
)
from md2gost.extended_markdown import markdown
from md2gost.extended_markdown.caption import Caption as CapEl
from md2gost.idef0 import (
SAMPLE_CONTEXT,
SAMPLE_DECOMPOSITION,
Idef0ParseError,
parse_idef0,
render_idef0,
write_idef0_outputs,
_Rect,
_layout_arrow_label,
_load_font,
_polyline_backtracks,
_seg_hits_open_box,
_layout_boxes,
_assign_slots,
_box_order,
_draw_arrow,
_make_channels,
_draw_kit_form,
_Scene,
_port_point,
)
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.diagram import DiagramFigure
from md2gost.renderable_factory import RenderableFactory
def _svg_has_words(svg: str, phrase: str) -> None:
missing = [tok for tok in phrase.split() if tok and tok not in svg]
assert not missing, f"missing {missing} in SVG for {phrase!r}"
def test_is_diagram_lang_idef0():
assert is_diagram_lang("idef0")
assert is_diagram_lang("uml-idef0")
assert "idef0" in DIAGRAM_LANGS
def test_prepare_idef0_stays_native():
src, dtype = prepare_source("idef0", SAMPLE_CONTEXT)
assert dtype == "idef0"
assert "@startuml" not in src.lower()
assert "Распорядиться товаром" in src
src2, dtype2 = prepare_source("uml-idef0", "[A0] X\n<- in")
assert dtype2 == "idef0"
def test_parse_sample_context():
d = parse_idef0(SAMPLE_CONTEXT)
assert d.node == "A-0"
assert d.title == "Распорядиться товаром"
assert d.number == "1"
assert d.author == "Кинзябулатов Рамиль"
assert "торгового предприятия" in d.project
assert d.date == "08.02.2022"
assert d.rev == "31.03.2022"
assert d.status == "PUBLICATION"
assert d.context == "TOP"
assert len(d.boxes) == 1
assert d.boxes[0].id == "A0"
assert d.boxes[0].name == "Распорядиться товаром"
labels = {a.label: a for a in d.arrows}
assert set(labels) == {"Спрос", "Нормативная документация", "Товар", "Персонал"}
assert labels["Спрос"].targets[0].side == "I"
assert labels["Нормативная документация"].targets[0].side == "C"
assert labels["Товар"].sources[0].side == "O"
assert labels["Персонал"].targets[0].side == "M"
def test_parse_sample_decomposition():
d = parse_idef0(SAMPLE_DECOMPOSITION)
assert d.node == "A0"
assert [b.id for b in d.boxes] == ["A1", "A2", "A3"]
by_label = {a.label: a for a in d.arrows}
assert by_label["Нормативная документация"].icom == "C1"
assert {t.box for t in by_label["Нормативная документация"].targets} == {
"A1", "A2", "A3",
}
assert by_label["график поставок"].sources[0].box == "A1"
assert by_label["график поставок"].targets[0].box == "A2"
assert by_label["график поставок"].targets[0].side == "C"
assert by_label["план закупок"].targets[0].box == "A2"
assert by_label["товар к отгрузке"].sources[0].box == "A2"
assert by_label["Товар"].icom == "O1"
assert {t.box for t in by_label["Персонал"].targets} == {"A1", "A2", "A3"}
assert by_label["Склад"].targets[0].box == "A2"
assert by_label["Спрос"].icom == "I1"
def test_layout_boxes_cluster_is_compact_and_centered():
d = parse_idef0(SAMPLE_DECOMPOSITION)
work = _Rect(0, 0, 1400, 720)
geoms = _layout_boxes(d, work, 1.0)
boxes = [geoms[b.id].rect for b in d.boxes]
x0, x1 = min(b.x for b in boxes), max(b.r for b in boxes)
y0, y1 = min(b.y for b in boxes), max(b.b for b in boxes)
assert x1 - x0 < work.w * 0.55
assert y1 - y0 < work.h * 0.70
assert abs((x0 + x1) / 2 - work.cx) < 80
assert x0 > 80
assert work.r - x1 > 70
assert y0 > 60
assert work.b - y1 > 70
def test_output_icom_with_control_branch_also_hits_boundary():
d = parse_idef0(
"""
[A1] One
[A2] Two
A1 -> A2.C : план продаж [O1]
"""
)
arrow = d.arrows[0]
assert arrow.icom == "O1"
assert { (t.box, t.side) for t in arrow.targets } == {("A2", "C"), (None, "O")}
def test_arrow_label_sits_off_vertical_shaft():
work = _Rect(0, 0, 800, 600)
font = _load_font(12, False)
placed = _layout_arrow_label(
[(200.0, 40.0), (200.0, 320.0)],
"Нормативная документация",
font,
12.0,
1.0,
work,
[],
)
assert placed is not None
halo, attach, _connect, lines, orient = placed
assert orient == "V"
assert not halo.contains_point(*attach, pad=3)
assert abs(halo.cx - attach[0]) >= 10
assert len(lines) >= 2
def test_arrow_label_sits_above_horizontal_shaft():
work = _Rect(0, 0, 800, 600)
font = _load_font(12, False)
placed = _layout_arrow_label(
[(40.0, 200.0), (360.0, 200.0)],
"Спрос",
font,
12.0,
1.0,
work,
[],
)
assert placed is not None
halo, attach, _connect, _lines, orient = placed
assert orient == "H"
assert halo.b <= attach[1] - 4 or halo.y >= attach[1] + 4
assert not halo.contains_point(*attach, pad=2)
def test_decomposition_paths_do_not_backtrack_or_cut_boxes():
d = parse_idef0(SAMPLE_DECOMPOSITION)
s = 1.0
w, h = 1400, 920
scene = _Scene(w, h)
fonts = {
"lab": 9, "val": 11, "small": 9.5, "box": 13, "id": 10,
"arrow": 12, "node": 20, "title": 16, "top": 22,
}
work = _draw_kit_form(scene, d, _Rect(8, 8, w - 16, h - 16), s, fonts)
geoms = _layout_boxes(d, work, s)
_assign_slots(d, geoms)
order = _box_order(d)
ch = _make_channels(d, geoms, work, s)
for arrow in d.arrows:
paths = _draw_arrow(scene, arrow, geoms, work, s, 1.2, order, ch)
skip = {e.box for e in arrow.sources + arrow.targets if e.box}
for pts in paths:
assert not _polyline_backtracks(pts), arrow.label
for a, b in zip(pts, pts[1:]):
for gid, g in geoms.items():
if gid in skip:
continue
assert not _seg_hits_open_box(a, b, g.rect), (arrow.label, gid)
def test_two_ports_on_same_side_are_spread():
d = parse_idef0(
"""
[A0] Box
<- one
<- two
-> out
^ ctrl
v mech
"""
)
work = _Rect(0, 0, 800, 600)
geoms = _layout_boxes(d, work, 1.0)
_assign_slots(d, geoms)
g = geoms["A0"]
y0, y1 = _port_point(g, "I", 0, 2)[1], _port_point(g, "I", 1, 2)[1]
assert abs(y1 - y0) >= 0.5 * g.rect.h
def test_parse_keyword_and_explicit_box():
d = parse_idef0(
"""
[A0] Распорядиться товаром
A0 in Спрос
A0 control Нормативная документация
A0 out Товар
A0 mech Персонал
"""
)
assert [a.label for a in d.arrows] == [
"Спрос",
"Нормативная документация",
"Товар",
"Персонал",
]
def test_parse_decomposition_fork_join_icom_tunnel():
d = parse_idef0(
"""
node A0
title Обработать заказ
number 2
[A1] Принять заказ
[A2] Закупить
[A3] Отгрузить
A1 <- Заявка [I1]
A1 ^ Правила [C1]
A1 -> A2 : потребность
A2 -> A3 : товар на складе
A3 -> Товар [O1]
A1, A2 -> A3 : сводка
A1 -> A2, A3 : данные
(in Секрет
in) Локальный
A3 v Склад [M1]
"""
)
assert d.node == "A0"
assert [b.id for b in d.boxes] == ["A1", "A2", "A3"]
by_label = {a.label: a for a in d.arrows}
assert by_label["потребность"].sources[0].box == "A1"
assert by_label["потребность"].targets[0].box == "A2"
assert by_label["Заявка"].icom == "I1"
assert {t.box for t in by_label["данные"].targets} == {"A2", "A3"}
assert {s.box for s in by_label["сводка"].sources} == {"A1", "A2"}
secret = by_label["Секрет"]
assert secret.sources[0].box is None and secret.sources[0].tunnel
local = by_label["Локальный"]
assert local.targets[0].tunnel
def test_parse_empty_raises():
with pytest.raises(Idef0ParseError, match="пустой"):
parse_idef0(" \n # only comment\n")
def test_parse_bad_line_raises():
with pytest.raises(Idef0ParseError, match="строку"):
parse_idef0("[A0] Box\nthis is not an arrow\n")
def test_render_sample_png_and_svg(tmp_path):
d = parse_idef0(SAMPLE_CONTEXT)
png, svg = render_idef0(d, scale=1.0, want_svg=True)
assert png.startswith(b"\x89PNG")
assert svg is not None
for needle in (
"Распорядиться товаром",
"A-0",
"Спрос",
"Нормативная документация",
"Товар",
"Персонал",
"PUBLICATION",
"TOP",
"A0",
"Кинзябулатов",
):
_svg_has_words(svg, needle)
img = Image.open(__import__("io").BytesIO(png))
assert img.size[0] >= 1000
assert img.size[1] >= 700
extrema = img.convert("L").getextrema()
assert extrema[0] < 250 # has ink, not a blank page
out_png = tmp_path / "a.png"
out_svg = tmp_path / "a.svg"
assert write_idef0_outputs(SAMPLE_CONTEXT, out_png, out_svg, scale=1)
assert out_png.read_bytes().startswith(b"\x89PNG")
assert "NODE" in out_svg.read_text(encoding="utf-8")
def test_render_decomposition_does_not_crash():
src = """
node A0
title Распорядиться товаром
[A1] Принять заказ
[A2] Отгрузить
[A3] Учесть
A1 <- Заявка [I1]
A1 -> A2 : заказ
A2 -> A3 : накладная
A3 -> Товар [O1]
A1 ^ Правила [C1]
A2 v Склад [M1]
"""
png, svg = render_idef0(parse_idef0(src), scale=1.0, want_svg=True)
assert png.startswith(b"\x89PNG")
assert "Принять заказ" in (svg or "")
assert "заказ" in (svg or "")
def test_render_sample_decomposition_png_and_svg():
png, svg = render_idef0(parse_idef0(SAMPLE_DECOMPOSITION), scale=1.0, want_svg=True)
assert png.startswith(b"\x89PNG")
text = svg or ""
for needle in (
"Принять и оценить спрос",
"Закупить и хранить",
"план закупок",
"график поставок",
"Персонал",
"C1",
"A3",
):
_svg_has_words(text, needle)
assert "Purpose:" not in text
assert "Viewpoint:" not in text
assert "A4" not in text
img = Image.open(__import__("io").BytesIO(png))
assert img.size[0] >= 1400
assert img.size[1] >= 900
extrema = img.convert("L").getextrema()
assert extrema[0] < 250
def test_render_diagram_idef0_skips_jar_and_kroki(tmp_path):
calls = {"jar": 0, "kroki": 0}
def fake_jar(*args, **kwargs):
calls["jar"] += 1
return False
def fake_kroki(*args, **kwargs):
calls["kroki"] += 1
return False
configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=1)
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(
"idef0", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=1,
)
assert calls["jar"] == 0
assert calls["kroki"] == 0
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
assert result.pixel_scale == 1
def test_render_diagram_idef0_scale_and_cache(tmp_path):
configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=2)
r1 = render_diagram("idef0", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=2)
r2 = render_diagram("idef0", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=2)
assert r1.png_path == r2.png_path
assert r1.pixel_scale == 2
img = Image.open(r1.png_path)
assert img.size[0] >= 2000
def test_render_diagram_idef0_bad_source(tmp_path):
configure_diagrams(fallback="off", cache_dir=str(tmp_path))
with pytest.raises(RuntimeError, match="IDEF0"):
render_diagram("idef0", "not a diagram", cache_dir=str(tmp_path))
def test_factory_routes_idef0_to_diagram():
from docx import Document
from md2gost import package_dir
import os
md = "%ctx Контекст A-0\n\n```idef0\n" + SAMPLE_CONTEXT + "```\n"
parsed = markdown.parse(md)
doc = Document(os.path.join(package_dir(), "Template.docx"))
factory = RenderableFactory(doc._body)
caption = None
renderables = []
for el in parsed.children:
from marko.block import BlankLine
if isinstance(el, BlankLine):
continue
if isinstance(el, CapEl):
caption = CaptionInfo(el.unique_name, el.text, el.with_listing)
continue
renderables.append(factory.create(el, caption))
caption = None
assert len(renderables) == 1
assert isinstance(renderables[0], DiagramFigure)
assert renderables[0]._lang == "idef0"
+52
View File
@@ -16,6 +16,14 @@ from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.image import Image
from md2gost.renderable.table import Table
from md2gost.renderer import Renderer
from docx.oxml.ns import qn
def _section_page_start(section):
pg = section._sectPr.find(qn("w:pgNumType"))
if pg is None:
return None
return pg.get(qn("w:start"))
def test_content_size_landscape_wider():
@@ -57,6 +65,50 @@ def test_renderer_landscape_section_for_table():
assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT
assert int(doc.sections[-1].page_width) < int(doc.sections[-1].page_height)
# Landscape and return-to-portrait must not restart PAGE (no w:start).
for section in land2:
assert _section_page_start(section) is None
assert _section_page_start(doc.sections[-1]) is None
def test_landscape_sections_have_no_page_restart():
"""+landscape must not leave w:pgNumType/@w:start on album / following portrait."""
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.heading import Heading
from md2gost.renderable.paragraph import Paragraph
from md2gost.renderable.toc import ToC
h = Heading(doc._body, 1, False)
h.add_run("СОДЕРЖАНИЕ")
toc = ToC(doc._body, toc_mode="native")
para = Paragraph(doc._body)
para.add_run("Текст перед широкой таблицей.")
table = Table(doc._body, 2, 3, CaptionInfo("wide", "Карта", landscape=True))
for r in range(2):
for c in range(3):
table.add_paragraph_to_cell(r, c).add_run(f"{r}{c}")
after = Paragraph(doc._body)
after.add_run("Текст после альбома.")
Renderer(doc, skip_numbering=True).process([h, toc, para, table, after])
land = [s for s in doc.sections if s.orientation == WD_ORIENT.LANDSCAPE]
assert land
for section in land:
assert _section_page_start(section) is None, "landscape restarted PAGE"
# Body portrait after landscape also continuous.
assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT
assert _section_page_start(doc.sections[-1]) is None
# Non-first sections must not restart (first may still carry template start).
for section in list(doc.sections)[1:]:
assert _section_page_start(section) is None
def test_parser_propagates_landscape_to_table():
doc = Document(os.path.join(package_dir(), "Template.docx"))
+49
View File
@@ -201,6 +201,15 @@ def test_preprocess_skips_uml_and_mermaid_fences():
"```\n\n"
"```uml-c4context\n"
'Person(user, "Заявитель")\n'
"```\n\n"
"```idef0\n"
'title "Выручка"\n'
"[A0] Процесс\n"
"```\n\n"
"```dfd\n"
'entity User\n'
'process App\n'
'User --> App "request"\n'
"```\n"
)
out = preprocess_markdown(md)
@@ -209,6 +218,10 @@ def test_preprocess_skips_uml_and_mermaid_fences():
assert 'title "Выручка"' in out
assert 'Person(user, "Заявитель")' in out
assert "«Акционеры»" not in out
assert "[A0] Процесс" in out
assert "entity User" in out
assert 'User --> App "request"' in out
assert "«request»" not in out
def test_formula_refs():
@@ -247,6 +260,42 @@ def test_biblio_entries_from_concatenated_paragraph():
assert flat[0].key == "1"
def test_fold_bibliography_drops_citation_mangled_residue():
"""Citation linking turns [5]: into []: in .text — must still be consumed."""
import docx
from md2gost.biblio_processor import (
fold_bibliography,
is_biblio_source_paragraph,
)
from md2gost.bibliography_renderable import Bibliography
from md2gost.renderable.heading import Heading
from md2gost.renderable.paragraph import Paragraph
assert is_biblio_source_paragraph("[1]: Книга. — М., 2024.")
assert is_biblio_source_paragraph("[]: Книга. — М., 2024.")
assert is_biblio_source_paragraph("[]: https://example.com/a")
assert not is_biblio_source_paragraph("Обычный абзац без источника.")
doc = docx.Document(r"md2gost/Template.docx")
doc._body.clear_content()
h = Heading(doc._body, 1, numbered=False)
h.add_run("СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ")
residue = Paragraph(doc._body)
residue.add_run("[]: Остаток после гиперссылки на цитату.")
prose = Paragraph(doc._body)
prose.add_run("Это уже не библиография.")
md = """# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
[1]: Книга. — М., 2024.
"""
out = fold_bibliography([h, residue, prose], doc._body, raw_markdown=md)
types = [type(x).__name__ for x in out]
assert types == ["Heading", "Bibliography", "Paragraph"]
assert isinstance(out[1], Bibliography)
assert out[2] is prose
def test_emdash_to_hyphen_preprocess():
from md2gost.profiles import preprocess_markdown, set_emdash_to_hyphen, dash_separator
out = preprocess_markdown("Цель — тест. Слово - слово.", emdash_to_hyphen=True)
+113 -1
View File
@@ -1,9 +1,28 @@
"""Page number start / offset helpers."""
from pathlib import Path
from zipfile import ZipFile
from docx import Document
from docx.oxml.ns import qn
from lxml import etree
from md2gost.page_geometry import apply_page_number_start, set_section_page_start
from md2gost.page_geometry import (
apply_page_number_start,
apply_section_geometry,
clear_section_page_start,
patch_docx_page_starts,
set_section_page_start,
)
_W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
def _pg_start(section):
pg = section._sectPr.find(qn("w:pgNumType"))
if pg is None:
return None
return pg.get(qn("w:start"))
def test_set_section_page_start():
@@ -33,3 +52,96 @@ def test_apply_page_number_start_none_strips():
apply_page_number_start(doc, None)
pg = doc.sections[0]._sectPr.find(qn("w:pgNumType"))
assert pg is None or pg.get(qn("w:start")) is None
def test_apply_page_number_start_none_strips_all_four_sections():
doc = Document()
for _ in range(3):
doc.add_section()
for section in doc.sections:
set_section_page_start(section, 1)
assert all(_pg_start(s) == "1" for s in doc.sections)
apply_page_number_start(doc, None)
assert all(_pg_start(s) is None for s in doc.sections)
def test_apply_page_number_start_body_only_with_front():
doc = Document()
for _ in range(3):
doc.add_section() # front + body + landscape + portrait = 4
for section in doc.sections:
set_section_page_start(section, 1)
apply_page_number_start(doc, 1, front_sections=1)
assert _pg_start(doc.sections[0]) is None
assert _pg_start(doc.sections[1]) == "1"
assert _pg_start(doc.sections[2]) is None
assert _pg_start(doc.sections[3]) is None
def test_apply_section_geometry_strips_copied_start():
doc = Document()
set_section_page_start(doc.sections[0], 1)
apply_section_geometry(doc.sections[0], landscape=True)
assert _pg_start(doc.sections[0]) is None
def test_clear_section_page_start_removes_empty_element():
doc = Document()
set_section_page_start(doc.sections[0], 1)
clear_section_page_start(doc.sections[0])
assert doc.sections[0]._sectPr.find(qn("w:pgNumType")) is None
def test_patch_docx_page_starts_strips_and_restores(tmp_path: Path):
doc = Document()
for _ in range(3):
doc.add_section()
for section in doc.sections:
set_section_page_start(section, 1)
path = tmp_path / "pages.docx"
doc.save(path)
patch_docx_page_starts(path, None)
with ZipFile(path) as z:
root = etree.fromstring(z.read("word/document.xml"))
starts = [
el.get(f"{_W}start")
for el in root.findall(f".//{_W}pgNumType")
]
assert all(s is None for s in starts)
# Re-seed starts then restore body-only restart.
doc2 = Document(str(path))
for section in doc2.sections:
set_section_page_start(section, 1)
doc2.save(path)
patch_docx_page_starts(path, 2, front_sections=1)
with ZipFile(path) as z:
root = etree.fromstring(z.read("word/document.xml"))
body = root.find(f"{_W}body")
sects = []
for child in body:
if child.tag == f"{_W}sectPr":
sects.append(child)
continue
if child.tag != f"{_W}p":
continue
p_pr = child.find(f"{_W}pPr")
if p_pr is None:
continue
sect = p_pr.find(f"{_W}sectPr")
if sect is not None:
sects.append(sect)
assert len(sects) == 4
vals = [
(s.find(f"{_W}pgNumType").get(f"{_W}start")
if s.find(f"{_W}pgNumType") is not None else None)
for s in sects
]
assert vals[0] is None
assert vals[1] == "2"
assert vals[2] is None
assert vals[3] is None
+1
View File
@@ -156,6 +156,7 @@ def test_prompt_catalog_loads():
assert "generate-md.md" in names
assert "generate-mirea-report.md" in names
assert "generate-pis-custom-report.md" in names
assert "emulate-student.md" in names
assert all(text.strip() for _n, _t, text in catalog)
+291
View File
@@ -0,0 +1,291 @@
"""Tests for title page generation (```title, info_conv.yaml, template fill)."""
from __future__ import annotations
from pathlib import Path
from docx import Document
from md2gost.title_page import (
TitleMeta,
build_title_docx,
load_info_conv,
merge_title_meta,
parse_and_strip_title_fence,
parse_kv_text,
should_generate_title,
)
from md2gost.user_profile import UserProfile, load_user_profile, save_user_profile
def test_parse_title_fence_and_strip():
md = (
"```title\n"
"number: 3\n"
"year: 2025\n"
"discipline: Тестовая дисциплина\n"
"```\n"
"\n"
"# *ВВЕДЕНИЕ\n"
"\n"
"Text.\n"
)
fields, body = parse_and_strip_title_fence(md)
assert fields["number"] == "3"
assert fields["year"] == "2025"
assert fields["discipline"] == "Тестовая дисциплина"
assert "# *ВВЕДЕНИЕ" in body
assert "```title" not in body
def test_parse_kv_russian_aliases_and_typos():
raw = (
"Инститиут: ИИТ\n"
"Кафедра: ЦТ\n"
"Дисциплина: ПАЦО\n"
"Преподователь: Иванов И. И.\n"
"Должность: старший преподаватель\n"
)
y = parse_kv_text(raw)
assert y["institute"] == "ИИТ"
assert y["department"] == "ЦТ"
assert y["discipline"] == "ПАЦО"
assert y["teacher"] == "Иванов И. И."
assert y["teacher_position"] == "старший преподаватель"
def test_merge_fence_overrides_yaml():
yaml_f = {"institute": "ИИТ", "discipline": "Из yaml"}
fence = {"discipline": "Из fence", "number": "2"}
meta = merge_title_meta(
yaml_fields=yaml_f,
fence_fields=fence,
student="Петров П. П.",
group="ИНБО-01-20",
)
assert meta.institute == "ИИТ"
assert meta.discipline == "Из fence"
assert meta.number == "2"
assert meta.student == "Петров П. П."
assert meta.group == "ИНБО-01-20"
# student/group ignored from yaml/fence
meta2 = merge_title_meta(
yaml_fields={"student": "X", "group": "Y", "institute": "Z"},
student="A",
group="B",
)
assert meta2.student == "A" and meta2.group == "B"
assert meta2.institute == "Z"
def test_load_info_conv(tmp_path: Path):
p = tmp_path / "info_conv.yaml"
p.write_text(
"Институт: Институт информационных технологий (ИИТ)\n"
"Кафедра: Кафедра ЦТ\n"
"Преподаватель: Сидоров\n"
"Должность: ассистент\n",
encoding="utf-8",
)
data = load_info_conv(tmp_path)
assert "ИИТ" in data["institute"]
assert data["teacher"] == "Сидоров"
def test_should_generate_title(tmp_path: Path):
assert should_generate_title(
explicit_title="ready.docx",
fence_fields={"number": "1"},
md_dir=tmp_path,
enabled=True,
) is False
assert should_generate_title(
explicit_title=None,
fence_fields={"number": "1"},
md_dir=tmp_path,
enabled=False,
) is False
assert should_generate_title(
explicit_title=None,
fence_fields={"number": "1"},
md_dir=tmp_path,
enabled=True,
) is True
assert should_generate_title(
explicit_title=None,
fence_fields={},
md_dir=tmp_path,
enabled=True,
) is False
(tmp_path / "info_conv.yaml").write_text("Институт: X\n", encoding="utf-8")
assert should_generate_title(
explicit_title=None,
fence_fields={},
md_dir=tmp_path,
enabled=True,
) is True
assert should_generate_title(
explicit_title=None,
fence_fields={},
md_dir=tmp_path,
enabled=False,
) is False
def test_user_profile_roundtrip(tmp_path: Path):
save_user_profile(UserProfile(student="А А А", group="ГР-1"), base=tmp_path)
loaded = load_user_profile(base=tmp_path)
assert loaded.student == "А А А"
assert loaded.group == "ГР-1"
assert loaded.is_complete()
def test_build_title_docx_fills_red_slots(tmp_path: Path):
meta = TitleMeta(
institute="Институт ИИТ",
department="Кафедра ЦТ",
discipline="Дисциплина X",
number="7",
group="ИНБО-99-99",
student="Тестов Тест Тестович",
teacher_position="доцент, кафедра ЦТ",
teacher="Преподов П. П.",
year="2024",
)
out = tmp_path / "title.docx"
build_title_docx(meta, out)
doc = Document(str(out))
texts = [p.text for p in doc.paragraphs]
assert "Институт ИИТ" in texts[0]
assert "Кафедра ЦТ" in texts[1]
assert "Дисциплина X" in texts[5]
assert "7" in texts[8]
assert "ИНБО-99-99" in texts[13]
assert "Тестов Тест Тестович" in texts[15]
assert "доцент" in texts[19]
assert "Преподов" in texts[21]
assert "2024" in texts[28]
# prefixes kept
assert texts[13].startswith("студент группы")
assert "по дисциплине" in texts[5]
assert "Москва" in texts[28]
def test_pipeline_generates_title(tmp_path: Path, monkeypatch):
from md2gost.pipeline import ConvertRequest, convert
from md2gost import user_profile as up
monkeypatch.setattr(up, "app_dir", lambda: tmp_path)
save_user_profile(
UserProfile(student="Конвейев К. К.", group="ИНБО-00-00"),
base=tmp_path,
)
(tmp_path / "info_conv.yaml").write_text(
"Институт: ИИТ тест\n"
"Кафедра: Каф тест\n"
"Дисциплина: Дисц тест\n"
"Преподаватель: Преп П. П.\n"
"Должность: ст. преподаватель\n",
encoding="utf-8",
)
md = tmp_path / "report.md"
md.write_text(
"```title\n"
"number: 2\n"
"year: 2026\n"
"```\n"
"\n"
"# *СОДЕРЖАНИЕ\n"
"\n"
"[TOC]\n"
"\n"
"# *ВВЕДЕНИЕ\n"
"\n"
"Текст введения.\n"
"\n"
"# *ЗАКЛЮЧЕНИЕ\n"
"\n"
"Вывод.\n"
"\n"
"# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ\n"
"\n"
"[1]: Книга. — М., 2024.\n",
encoding="utf-8",
)
out = tmp_path / "report.docx"
result = convert(
ConvertRequest(
filename=str(md),
output=str(out),
table_continuation="off",
listing_continuation="off",
auto_title=True,
open_when_done=False,
),
log=lambda _m: None,
)
assert result.ok, result.message
assert out.is_file()
doc = Document(str(out))
blob = "\n".join(p.text for p in doc.paragraphs)
assert "Конвейев" in blob
assert "ИНБО-00-00" in blob
assert "ИИТ тест" in blob
assert "Дисц тест" in blob
assert "Преп П. П." in blob
def test_pipeline_skips_title_by_default(tmp_path: Path, monkeypatch):
from md2gost.pipeline import ConvertRequest, convert
from md2gost import user_profile as up
monkeypatch.setattr(up, "app_dir", lambda: tmp_path)
save_user_profile(
UserProfile(student="Конвейев К. К.", group="ИНБО-00-00"),
base=tmp_path,
)
(tmp_path / "info_conv.yaml").write_text(
"Институт: ИИТ тест\n"
"Кафедра: Каф тест\n"
"Дисциплина: Дисц тест\n"
"Преподаватель: Преп П. П.\n"
"Должность: ст. преподаватель\n",
encoding="utf-8",
)
md = tmp_path / "report.md"
md.write_text(
"```title\n"
"number: 2\n"
"year: 2026\n"
"```\n"
"\n"
"# *ВВЕДЕНИЕ\n"
"\n"
"Текст введения.\n"
"\n"
"# *ЗАКЛЮЧЕНИЕ\n"
"\n"
"Вывод.\n"
"\n"
"# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ\n"
"\n"
"[1]: Книга. — М., 2024.\n",
encoding="utf-8",
)
out = tmp_path / "report.docx"
result = convert(
ConvertRequest(
filename=str(md),
output=str(out),
table_continuation="off",
listing_continuation="off",
open_when_done=False,
),
log=lambda _m: None,
)
assert result.ok, result.message
doc = Document(str(out))
blob = "\n".join(p.text for p in doc.paragraphs)
assert "Конвейев" not in blob
assert "ИИТ тест" not in blob
assert "Текст введения" in blob
+378
View File
@@ -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
+30
View File
@@ -13,7 +13,10 @@ from md2gost.word_fix import (
continuation_label,
find_page_break_row,
fix_continuations,
is_caption_orphaned,
is_orphan_header_break,
parse_caption_text,
split_vmerge_spans_at_break,
)
@@ -54,6 +57,33 @@ def test_find_page_break_row():
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
# typical vMerge table pages after cell-based detection
assert find_page_break_row([14, 14, 14, 14, 14, 14, 15, 15]) == 7
def test_orphan_header_and_caption_guards():
"""Method guide: no title-only / title+column-headers alone at page end."""
# Split would leave only heading row on previous page
assert is_orphan_header_break(2, had_header=True) is True
assert is_orphan_header_break(2, had_header=False) is False
assert is_orphan_header_break(3, had_header=True) is False
assert is_orphan_header_break(None, had_header=True) is False
# Caption on earlier page than first table row
assert is_caption_orphaned(5, 6) is True
assert is_caption_orphaned(5, 5) is False
assert is_caption_orphaned(None, 6) is False
def test_split_vmerge_spans_at_break():
# (row, col, span, text) — merge rows 2..6 (span 5), break at row 7 → all in first
spans = [(2, 1, 5, "A"), (7, 1, 2, "B")]
first, cont = split_vmerge_spans_at_break(spans, 7)
assert first == [(2, 1, 5, "A")]
assert cont == [(1, 1, 2, "B")]
# merge crosses break: rows 5..8, break at 7
first, cont = split_vmerge_spans_at_break([(5, 1, 4, "X")], 7)
assert first == [(5, 1, 2, "X")]
assert cont == [(1, 1, 2, "X")]
def test_continuation_label():