Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -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 |
@@ -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 |
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 == "Тема профиля"
|
||||
@@ -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"
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user