v0.5.3
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

- Мелкие исправления увеличивающие работу конвертера
This commit is contained in:
Igor20264
2026-09-10 10:50:15 +03:00
parent 510f7e7adf
commit e6b58efeb3
95 changed files with 7165 additions and 8771 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

-78
View File
@@ -1,78 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

-166
View File
@@ -1,166 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 24 KiB

+59
View File
@@ -112,6 +112,7 @@ def test_write_dfd_outputs_uses_local_or_kroki(tmp_path):
return True
with patch("md2gost.dfd._compile_dot", return_value=(fake_dot, False)), \
patch("md2gost.dfd._render_dot_local_combined", return_value=set()), \
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)
@@ -119,6 +120,64 @@ def test_write_dfd_outputs_uses_local_or_kroki(tmp_path):
assert "Клиент" in svg.read_text(encoding="utf-8")
def test_write_dfd_outputs_one_graphviz_run_for_both_formats(tmp_path):
"""PNG + SVG come from a single Graphviz process; no per-format retry."""
png = tmp_path / "a.png"
svg = tmp_path / "a.svg"
single = []
def fake_combined(dot, targets, *, context):
for out_path, fmt in targets:
Path(out_path).write_bytes(
b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 if fmt == "png" else b"<svg/>"
)
return {fmt for _, fmt in targets}
with patch("md2gost.dfd._compile_dot", return_value=("digraph G {}", False)), \
patch("md2gost.dfd._render_dot_local_combined", side_effect=fake_combined), \
patch("md2gost.dfd._render_dot_local", side_effect=lambda *a, **k: single.append(a) or True), \
patch("md2gost.dfd._render_dot_kroki", return_value=False):
assert write_dfd_outputs(SAMPLE_LEVEL0, png, svg, scale=1)
assert single == []
assert png.read_bytes().startswith(b"\x89PNG")
assert svg.read_bytes().startswith(b"<svg")
def test_write_dfd_outputs_retries_format_missed_by_combined(tmp_path):
"""If the combined run only produced PNG, SVG still goes through the old path."""
png = tmp_path / "a.png"
svg = tmp_path / "a.svg"
retried = []
def fake_combined(dot, targets, *, context):
for out_path, fmt in targets:
if fmt == "png":
Path(out_path).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
return {"png"}
def fake_local(dot, out_path, fmt, *, context):
retried.append(fmt)
Path(out_path).write_bytes(b"<svg/>")
return True
with patch("md2gost.dfd._compile_dot", return_value=("digraph G {}", False)), \
patch("md2gost.dfd._render_dot_local_combined", side_effect=fake_combined), \
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 retried == ["svg"]
assert svg.read_bytes().startswith(b"<svg")
def test_render_dot_local_combined_single_target_is_noop():
from md2gost.dfd import _render_dot_local_combined
with patch("md2gost.dfd.resolve_graphviz_engine") as resolve:
assert _render_dot_local_combined("digraph G {}", [(Path("a.png"), "png")],
context=False) == set()
resolve.assert_not_called()
def test_write_dfd_outputs_falls_back_to_kroki(tmp_path):
png = tmp_path / "a.png"
+223
View File
@@ -16,11 +16,18 @@ from md2gost.diagram_includes import (
)
from md2gost.diagram_renderer import (
DIAGRAM_LANGS,
PLANTUML_CACHE_PREFIX,
PLANTUML_VERSION,
_cache_paths,
_PumlJob,
_plantuml_run,
configure_diagrams,
diagram_engine_status,
is_diagram_lang,
prepare_source,
register_pending_diagram,
render_diagram,
reset_diagram_batch,
resolve_plantuml_jar,
)
from md2gost.diagram_schemes import (
@@ -558,6 +565,222 @@ def test_attach_svg_blip():
assert len(image_rels) >= 2
class _FakeProc:
returncode = 0
stdout = ""
stderr = ""
def _fake_java(*, out_stem: str = "src", fail: bool = False):
"""Stand-in for `java -jar plantuml.jar`: writes what the real jar would."""
calls: list[list[str]] = []
def run(cmd, **kwargs):
calls.append(list(cmd))
if fail:
proc = _FakeProc()
proc.returncode = 1
proc.stderr = "boom"
return proc
fmt = next(a[2:] for a in cmd if a.startswith("-t"))
payload = (
b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 if fmt == "png"
else b"<svg xmlns='http://www.w3.org/2000/svg'/>"
)
for arg in cmd:
if not str(arg).endswith(".puml"):
continue
out_dir = Path(arg).parent / "out"
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / f"{out_stem}.{fmt}").write_bytes(payload)
return _FakeProc()
return run, calls
def _dummy_jar(tmp_path: Path) -> Path:
jar = tmp_path / "plantuml.jar"
jar.write_bytes(b"PK" + b"\x00" * 2000)
return jar
def test_plantuml_run_batches_sources_into_one_jvm(tmp_path):
jar = _dummy_jar(tmp_path)
jobs = [_PumlJob(f"@startuml\nA{i} -> B{i}\n@enduml", tmp_path / f"{i}.png")
for i in range(3)]
run, calls = _fake_java()
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
done = _plantuml_run(jobs, str(jar), "png")
assert done == [True, True, True]
assert len(calls) == 1
assert sum(1 for a in calls[0] if str(a).endswith(".puml")) == 3
for job in jobs:
assert job.out_path.read_bytes().startswith(b"\x89PNG")
def test_plantuml_run_keeps_named_diagrams_apart(tmp_path):
"""`@startuml Name` renames the output file — each source needs its own -o dir."""
jar = _dummy_jar(tmp_path)
jobs = [_PumlJob("@startuml Name\nA -> B\n@enduml", tmp_path / f"{i}.png")
for i in range(2)]
run, calls = _fake_java(out_stem="Name")
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
done = _plantuml_run(jobs, str(jar), "png")
assert done == [True, True]
assert calls[0].count("-o") == 1
src_dirs = {Path(a).parent for a in calls[0] if str(a).endswith(".puml")}
assert len(src_dirs) == 2
assert all(job.out_path.is_file() for job in jobs)
def test_plantuml_run_failure_reports_every_job(tmp_path):
jar = _dummy_jar(tmp_path)
jobs = [_PumlJob("@startuml\nA -> B\n@enduml", tmp_path / f"{i}.png")
for i in range(2)]
run, _calls = _fake_java(fail=True)
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
done = _plantuml_run(jobs, str(jar), "png")
assert done == [False, False]
assert not any(job.out_path.exists() for job in jobs)
def test_plantuml_run_without_java_never_spawns(tmp_path):
jar = _dummy_jar(tmp_path)
run, calls = _fake_java()
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=False):
done = _plantuml_run([_PumlJob("@startuml\nA -> B\n@enduml", tmp_path / "a.png")],
str(jar), "png")
assert done == [False]
assert calls == []
def test_batch_prepass_draws_whole_document_in_one_jvm(tmp_path):
jar = _dummy_jar(tmp_path)
configure_diagrams(
fallback="off", cache_dir=str(tmp_path), plantuml_jar=str(jar), diagram_scale=1,
)
register_pending_diagram("uml", "A -> B")
register_pending_diagram("uml", "C -> D")
run, calls = _fake_java()
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
first = render_diagram("uml", "A -> B")
second = render_diagram("uml", "C -> D")
assert len(calls) == 1
assert first.png_path != second.png_path
for result in (first, second):
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
def test_batch_prepass_svg_mode_uses_two_jvms_not_four(tmp_path):
jar = _dummy_jar(tmp_path)
configure_diagrams(
fallback="off", cache_dir=str(tmp_path), plantuml_jar=str(jar),
diagram_scale=1, diagram_format="svg",
)
register_pending_diagram("uml", "A -> B")
register_pending_diagram("uml", "C -> D")
run, calls = _fake_java()
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
first = render_diagram("uml", "A -> B")
second = render_diagram("uml", "C -> D")
assert sorted(next(a for a in c if a.startswith("-t")) for c in calls) == \
["-tpng", "-tsvg"]
assert first.svg_path and second.svg_path
assert Path(first.svg_path).read_bytes().startswith(b"<svg")
def test_batch_prepass_skips_cached_diagrams(tmp_path):
jar = _dummy_jar(tmp_path)
configure_diagrams(
fallback="off", cache_dir=str(tmp_path), plantuml_jar=str(jar), diagram_scale=1,
)
cached_src, _ = prepare_source("uml", "A -> B")
cached_png, _ = _cache_paths(
cached_src, str(tmp_path), cache_prefix=PLANTUML_CACHE_PREFIX,
)
cached_png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"cached")
register_pending_diagram("uml", "A -> B")
register_pending_diagram("uml", "C -> D")
run, calls = _fake_java()
with patch("md2gost.diagram_renderer.subprocess.run", side_effect=run), \
patch("md2gost.diagram_renderer._java_available", return_value=True):
hit = render_diagram("uml", "A -> B")
render_diagram("uml", "C -> D")
assert len(calls) == 1
assert sum(1 for a in calls[0] if str(a).endswith(".puml")) == 1
assert Path(hit.png_path).read_bytes().endswith(b"cached")
def test_batch_prepass_not_triggered_for_explicit_overrides(tmp_path):
configure_diagrams(fallback="remote", cache_dir=str(tmp_path))
register_pending_diagram("uml", "A -> B")
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
out_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 20)
return True
with patch("md2gost.diagram_renderer._flush_plantuml_batch") as flush, \
patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
render_diagram("uml", "A -> B", cache_dir=str(tmp_path))
flush.assert_not_called()
reset_diagram_batch()
def test_diagram_figure_registers_for_batch():
from docx import Document
from md2gost import package_dir
import md2gost.diagram_renderer as dr
import os
reset_diagram_batch()
doc = Document(os.path.join(package_dir(), "Template.docx"))
DiagramFigure(doc._body, "uml", "A -> B")
DiagramFigure(doc._body, "uml", "A -> B")
DiagramFigure(doc._body, "mermaid", "flowchart LR\nA-->B")
assert dr._PENDING == [("uml", "A -> B"), ("mermaid", "flowchart LR\nA-->B")]
reset_diagram_batch()
assert dr._PENDING == []
def test_plantuml_cache_key_carries_jar_version(tmp_path):
assert PLANTUML_VERSION in PLANTUML_CACHE_PREFIX
prepared, dtype = prepare_source("uml", "A -> B")
assert dtype == "plantuml"
versioned, _ = _cache_paths(
prepared, str(tmp_path), cache_prefix=PLANTUML_CACHE_PREFIX,
)
legacy, _ = _cache_paths(prepared, str(tmp_path))
assert versioned != legacy
def test_stale_unversioned_cache_is_a_miss_not_a_crash(tmp_path):
"""Files cached before the jar version entered the key must be re-rendered."""
configure_diagrams(fallback="remote", cache_dir=str(tmp_path), diagram_scale=1)
prepared, _ = prepare_source("uml", "A -> B")
legacy, _ = _cache_paths(prepared, str(tmp_path))
legacy.write_bytes(b"\x89PNG\r\n\x1a\n" + b"stale")
def fake_kroki(source, diagram_type, base_url, out_path, fmt="png"):
out_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"fresh")
return True
with patch("md2gost.diagram_renderer._render_plantuml_jar", return_value=False), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram(
"uml", "A -> B", cache_dir=str(tmp_path), diagram_scale=1,
)
assert Path(result.png_path) != legacy
assert Path(result.png_path).read_bytes().endswith(b"fresh")
assert legacy.read_bytes().endswith(b"stale")
def test_resolve_plantuml_jar_explicit(tmp_path):
jar = tmp_path / "plantuml.jar"
jar.write_bytes(b"PK" + b"\x00" * 2000)
+82
View File
@@ -0,0 +1,82 @@
"""Пустой заголовок: конвертер не падает, но и не молчит.
Методичка МИРЭА (`_method_extract.txt`, строка 156): «…разделы, пункты …
имеют заголовки, четко и кратко отражающие их содержание». Заголовок без
текста — нарушение, а не валидный ввод: он даст в документе пронумерованный,
но безымянный раздел, который попадёт в автособираемое содержание. Значит,
парсер обязан такую строку пережить, а checker — сообщить о ней.
"""
import unittest
from md2gost.checker import check_markdown
from md2gost.extended_markdown.heading import Heading
MD_EMPTY_HEADING = """# *СОДЕРЖАНИЕ
[TOC]
#
Текст раздела без заголовка.
"""
MD_OK_HEADINGS = """# *СОДЕРЖАНИЕ
[TOC]
# Анализ предметной области
Текст раздела.
"""
def _heading(source: str) -> Heading:
match = Heading.pattern.match(source)
assert match is not None, f"pattern did not match {source!r}"
return Heading(match)
class TestEmptyHeadingParsing(unittest.TestCase):
def test_bare_hash_does_not_raise(self):
heading = _heading("#\n")
self.assertEqual("", heading.inline_body)
self.assertTrue(heading.numbered)
self.assertEqual(1, heading.level)
def test_bare_hashes_of_deeper_level(self):
heading = _heading("###\n")
self.assertEqual("", heading.inline_body)
self.assertTrue(heading.numbered)
self.assertEqual(3, heading.level)
def test_asterisk_without_text_is_unnumbered(self):
heading = _heading("# *\n")
self.assertEqual("", heading.inline_body)
self.assertFalse(heading.numbered)
def test_normal_headings_are_unaffected(self):
numbered = _heading("# Анализ предметной области\n")
self.assertEqual("Анализ предметной области", numbered.inline_body)
self.assertTrue(numbered.numbered)
unnumbered = _heading("## *ВВЕДЕНИЕ\n")
self.assertEqual("ВВЕДЕНИЕ", unnumbered.inline_body)
self.assertFalse(unnumbered.numbered)
class TestEmptyHeadingChecker(unittest.TestCase):
def test_empty_heading_is_reported(self):
issues = check_markdown(MD_EMPTY_HEADING, "coursework")
found = [i for i in issues if i.id == "heading.empty"]
self.assertEqual(1, len(found))
self.assertEqual("warning", found[0].severity)
self.assertEqual(4, found[0].line)
def test_normal_headings_produce_no_issue(self):
issues = check_markdown(MD_OK_HEADINGS, "coursework")
self.assertEqual([], [i for i in issues if i.id == "heading.empty"])
+62
View File
@@ -0,0 +1,62 @@
"""The mml2omml stylesheet is now compiled once and shared between formulas.
These tests pin the two properties that makes safe: the output must match a
freshly compiled transform, and reusing the compiled object must not let one
formula affect the next.
"""
import os
import unittest
import latex2mathml.converter
from lxml import etree
from md2gost import package_dir
from md2gost.latex_math import latex_to_omml
_FORMULAS = [
"x^2 + y^2 = z^2",
r"\frac{a}{b}",
r"\frac{a + b}{c + d}",
r"\sum_{i=1}^{n} i",
r"\sqrt{x+1}",
r"\int_0^1 f(x)dx",
r"\alpha \beta \gamma",
r"E = mc^2",
]
def _freshly_compiled(latex_equation: str) -> bytes:
"""Pre-optimization behaviour: parse and compile the stylesheet per call."""
mathml = latex2mathml.converter.convert(latex_equation)
tree = etree.fromstring(mathml)
transform = etree.XSLT(etree.parse(os.path.join(package_dir(), "mml2omml")))
return etree.tostring(transform(tree).getroot())
class TestLatexToOmml(unittest.TestCase):
def test_matches_freshly_compiled_transform(self):
for formula in _FORMULAS:
with self.subTest(formula=formula):
self.assertEqual(_freshly_compiled(formula),
etree.tostring(latex_to_omml(formula)))
def test_repeated_calls_are_identical(self):
for formula in _FORMULAS:
with self.subTest(formula=formula):
outputs = {etree.tostring(latex_to_omml(formula)) for _ in range(5)}
self.assertEqual(1, len(outputs))
def test_interleaved_formulas_do_not_interfere(self):
expected = {f: etree.tostring(latex_to_omml(f)) for f in _FORMULAS}
for _ in range(3):
for formula in reversed(_FORMULAS):
self.assertEqual(expected[formula], etree.tostring(latex_to_omml(formula)))
def test_invalid_formula_still_raises_value_error(self):
with self.assertRaises(ValueError):
latex_to_omml(r"\frac{")
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""Дефис внутри гиперссылки: текст не должен дублироваться.
Методичка МИРЭА (`_method_extract.txt`, строки 373-376): знак дефиса «-» не
отделяется пробелами и используется в сложных словах (во-первых,
физико-математический, кто-либо) и в интервалах (100-150 Мб). Поэтому слово не
должно рваться по дефису при переносе строки — вместо обычного дефиса в текст
пишется отдельный элемент `w:noBreakHyphen`, как это уже делает
`Paragraph.add_run`.
"""
import os
import unittest
import docx
from docx.oxml.ns import qn
from md2gost import package_dir
from md2gost.renderable.paragraph import Link, Paragraph
from md2gost.styles import apply_mirea_styles
def _texts(element) -> list[str]:
return [t.text or "" for t in element.iter(qn("w:t"))]
class TestLinkHyphen(unittest.TestCase):
def setUp(self) -> None:
self._document = docx.Document(os.path.join(package_dir(), "Template.docx"))
self._document._body.clear_content()
apply_mirea_styles(self._document)
def _hyperlink(self, text: str):
paragraph = Paragraph(self._document._body)
link = Link("https://example.com", paragraph._docx_paragraph)
link.add_run(text)
return link.element
def test_compound_word_is_split_around_no_break_hyphen(self):
hyperlink = self._hyperlink("во-первых")
runs = hyperlink.findall(qn("w:r"))
self.assertEqual(3, len(runs))
self.assertEqual(
1, len(hyperlink.findall(f'{qn("w:r")}/{qn("w:noBreakHyphen")}'))
)
self.assertEqual("вопервых", "".join(_texts(hyperlink)))
for run in runs:
self.assertNotIn("во-первых", "".join(_texts(run)))
def test_interval_is_split_around_no_break_hyphen(self):
hyperlink = self._hyperlink("100-150")
self.assertEqual(["100", "150"], [t for t in _texts(hyperlink) if t])
def test_several_hyphens_produce_one_run_per_part(self):
hyperlink = self._hyperlink("физико-математический-факультет")
self.assertEqual(5, len(hyperlink.findall(qn("w:r"))))
self.assertEqual(
"физикоматематическийфакультет", "".join(_texts(hyperlink))
)
def test_text_without_hyphen_stays_one_run(self):
hyperlink = self._hyperlink("пример")
self.assertEqual(1, len(hyperlink.findall(qn("w:r"))))
self.assertEqual("пример", "".join(_texts(hyperlink)))
+163
View File
@@ -0,0 +1,163 @@
"""Оформление листинга и флаг подсветки синтаксиса.
Методичка МИРЭА, табл. 4.1 (`_method_extract.txt`, строки 716-737), блок
«Содержание листинга»: шрифт Courier New, начертание Обычный, размер 10 пт,
цвет текста Авто (чёрный), выравнивание по левому краю, отступы слева/справа
0 см, первая строка 0 см, интервал перед и после 0 мм, междустрочный
одинарный.
Отсюда два независимых требования:
* с выключенной подсветкой (дефолт) содержимое листинга не должно нести
никакого прямого форматирования на уровне run'а;
* если пользователь всё же включил `--syntax-highlighting`, флаг обязан
работать честно — курсив из стиля Pygments должен доезжать до XML, а checker
обязан предупредить об отклонении от табл. 4.1.
"""
import os
import unittest
from contextlib import contextmanager
import docx
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from docx.oxml.ns import qn
from docx.shared import Pt
from md2gost import package_dir
from md2gost.checker import check_markdown
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.listing import Listing
from md2gost.styles import apply_mirea_styles
CODE = '# комментарий\nprint("hello")\n'
MD_WITH_LISTING = """# *СОДЕРЖАНИЕ
[TOC]
# Раздел
%code1 Пример кода
```python
print(1)
```
См. @Листинг:code1.
"""
@contextmanager
def _syntax_highlighting(enabled: bool):
previous = os.environ.get("SYNTAX_HIGHLIGHTING")
if enabled:
os.environ["SYNTAX_HIGHLIGHTING"] = "1"
else:
os.environ.pop("SYNTAX_HIGHLIGHTING", None)
try:
yield
finally:
if previous is None:
os.environ.pop("SYNTAX_HIGHLIGHTING", None)
else:
os.environ["SYNTAX_HIGHLIGHTING"] = previous
class _ListingTestCase(unittest.TestCase):
def setUp(self) -> None:
self._document = docx.Document(os.path.join(package_dir(), "Template.docx"))
self._document._body.clear_content()
apply_mirea_styles(self._document)
def _listing(self, highlighting: bool) -> Listing:
listing = Listing(self._document._body, "python", CaptionInfo("code1", "Пример"))
with _syntax_highlighting(highlighting):
listing.set_text(CODE)
return listing
@staticmethod
def _runs(listing: Listing):
for paragraph in listing.paragraphs:
for run in paragraph._docx_paragraph.runs:
yield run
class TestListingWithoutHighlighting(_ListingTestCase):
"""Табл. 4.1: содержимое листинга — Обычный, чёрный, Courier New 10 пт."""
def test_runs_carry_no_direct_formatting(self):
listing = self._listing(highlighting=False)
runs = list(self._runs(listing))
self.assertTrue(runs)
for run in runs:
rPr = run._element.find(qn("w:rPr"))
if rPr is None:
continue
self.assertIsNone(rPr.find(qn("w:b")), run.text)
self.assertIsNone(rPr.find(qn("w:i")), run.text)
self.assertIsNone(rPr.find(qn("w:color")), run.text)
def test_code_style_matches_table_4_1(self):
style = self._document.styles["Code"]
self.assertEqual("Courier New", style.font.name)
self.assertEqual(Pt(10), style.font.size)
self.assertFalse(style.font.bold)
self.assertFalse(style.font.italic)
pf = style.paragraph_format
self.assertEqual(WD_ALIGN_PARAGRAPH.LEFT, pf.alignment)
self.assertAlmostEqual(0, pf.first_line_indent.cm, places=3)
self.assertAlmostEqual(0, pf.left_indent.cm, places=3)
self.assertAlmostEqual(0, pf.space_before.mm, places=3)
self.assertAlmostEqual(0, pf.space_after.mm, places=3)
self.assertEqual(WD_LINE_SPACING.SINGLE, pf.line_spacing_rule)
class TestListingWithHighlighting(_ListingTestCase):
"""Включённый флаг обязан реально давать курсив там, где его задаёт Pygments."""
def test_italic_token_reaches_xml(self):
listing = self._listing(highlighting=True)
italic_runs = [run for run in self._runs(listing) if run.italic]
self.assertTrue(
italic_runs,
"ни один run не получил курсив, хотя стиль Pygments задаёт italic "
"для Token.Comment",
)
self.assertTrue(
any("комментарий" in run.text for run in italic_runs),
[run.text for run in italic_runs],
)
class TestSyntaxHighlightingChecker(unittest.TestCase):
"""Подсветка противоречит табл. 4.1 — checker обязан об этом сказать."""
def test_no_issue_when_flag_is_off(self):
issues = check_markdown(MD_WITH_LISTING, "coursework")
self.assertEqual(
[], [i for i in issues if i.id == "listing.syntax_highlighting"]
)
def test_warning_when_flag_is_on(self):
issues = check_markdown(
MD_WITH_LISTING, "coursework", syntax_highlighting=True
)
found = [i for i in issues if i.id == "listing.syntax_highlighting"]
self.assertEqual(1, len(found))
self.assertEqual("warning", found[0].severity)
def test_error_under_strict(self):
issues = check_markdown(
MD_WITH_LISTING, "coursework", syntax_highlighting=True, strict=True
)
found = [i for i in issues if i.id == "listing.syntax_highlighting"]
self.assertEqual(1, len(found))
self.assertEqual("error", found[0].severity)
+1 -1
View File
@@ -226,7 +226,7 @@ class TestParagraphSizer(unittest.TestCase):
self.assertEqual(1, ps.count_lines(paragraph.runs, self._max_width - LISTING_OFFSET, paragraph.style.font, 0, True))
def test_count_lines_courier7(self):
def test_count_lines_courier7_2(self):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(
""" # and ((min(2, height_data.lines) - 1) * height_data.line_spacing + 1) * height_data.line_height\\""")
+462
View File
@@ -0,0 +1,462 @@
"""Equivalence tests for the ParagraphSizer hot-path optimizations.
`_LegacyFont` and `_legacy_count_lines` are verbatim copies of the
pre-optimization implementations. Every test here asserts that the current
implementation produces bit-identical results, so the caching and the
per-word (instead of per-character) width measurement cannot silently move
line breaks.
"""
import unittest
from functools import cached_property
from math import ceil
from docx.oxml import OxmlElement
from docx.shared import Cm, Pt
from docx.text.font import Font as DocxFont
from docx.text.run import Run
from freetype import Face
from PIL import Image, ImageDraw, ImageFont
from md2gost.renderable.find_font import find_font
from md2gost.renderable.listing import LISTING_OFFSET
from md2gost.renderable.paragraph_sizer import (
Font,
ParagraphSizer,
_get_font,
_merge_objects,
measure_paragraph,
)
from . import _create_test_document
class _LegacyFont:
"""Verbatim copy of Font before the metrics snapshot was introduced."""
def __init__(self, name: str, bold: bool, italic: bool, size_pt: int):
path = find_font(name, bold, italic)
self._freetypefont = ImageFont.truetype(path, size_pt)
self._draw = ImageDraw.Draw(Image.new("RGB", (1000, 1000)))
self._face = Face(path)
self._face.set_char_size(int(size_pt * 64))
def get_text_width(self, text: str):
if not self.is_mono:
bbox = self._draw.textbbox((0, 0), text, self._freetypefont)
return Pt(bbox[2] - bbox[0])
else:
return Pt(len(text) * self._face.glyph.advance.x / 64)
def get_line_height(self):
if "Times" in str(self._face.family_name) and self._freetypefont.size == 14:
return Pt(16.05)
if "Courier" in str(self._face.family_name) and self._freetypefont.size == 12:
return Pt(13.61)
else:
return Pt(self._face.size.height / 64)
@cached_property
def is_mono(self):
self._face.load_char("i")
i_width = self._face.glyph.advance.x
self._face.load_char("m")
return i_width == self._face.glyph.advance.x
def _legacy_count_lines(runs, max_width, docx_font: DocxFont, first_line_indent, is_mono: bool = False):
"""Verbatim copy of ParagraphSizer.count_lines before the optimization."""
lines = 1
line_width = first_line_indent
space_width = _LegacyFont(
docx_font.name, docx_font.bold, docx_font.italic, docx_font.size.pt).get_text_width(" ")
if not is_mono:
space_width *= 0.81
word_part = ""
word_parts_widths = [0]
spaces = 0
for i, run in enumerate(runs):
if word_part:
word_part = ""
word_parts_widths.append(0)
run_docx_font = _merge_objects(docx_font, run.font)
font = _LegacyFont(run_docx_font.name, run_docx_font.bold,
run_docx_font.italic, run_docx_font.size.pt)
run_text = run.text
if run_text == "" and run._element.xpath("w:noBreakHyphen"):
run_text = "-"
if i == len(runs) - 1:
run_text += " "
for c in run_text:
if c == " ":
if any(word_parts_widths):
width = spaces * space_width + sum(word_parts_widths)
if width <= max_width - line_width:
line_width += width
elif width > max_width - first_line_indent:
if lines == 1 and line_width == first_line_indent and not spaces:
lines += ceil((width - (max_width - first_line_indent)) / max_width)
line_width = (width - (max_width - first_line_indent)) % max_width
else:
lines += ceil(width / max_width)
line_width = width % max_width
else:
lines += 1
line_width = sum(word_parts_widths)
word_part = ""
word_parts_widths = [0]
spaces = 1
else:
spaces += 1
else:
word_part += c
word_parts_widths[-1] = font.get_text_width(word_part)
return int(lines)
_BODY = (
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam lacinia fringilla lectus, "
"nec euismod odio convallis sed. Nunc ac libero ultricies, condimentum neque et, "
"fermentum urna. Donec feugiat diam sed nulla rutrum, sit amet accumsan odio tempor."
)
# (name, list of run texts). Single-element lists are single-run paragraphs.
_SINGLE_RUN_CASES = [
("body", _BODY),
("empty", ""),
("one_space", " "),
("only_spaces", " "),
("one_word", "word"),
("leading_spaces", " leading spaces after indent"),
("trailing_spaces", "trailing spaces before end "),
("double_spaces", "double spaces between words"),
("long_word", "verylongword" * 13),
("word_then_long_word", "someword " + "verylongword" * 13),
("long_word_then_word", "verylongword" * 13 + " someword"),
("cyrillic", "Электроэнцефалографический анализ показал устойчивую динамику показателей."),
("cyrillic_long", "Электроэнцефалографический " * 8),
("punctuation_only", ". , ; : ! ?"),
("em_dash_tail", "OpenAI is a leading research organization. Hello world —"),
("code_line", " table._cells[0]._element.append(paragraph_rendered_info.docx_element._element)"),
("code_short", " continuation_paragraph = Paragraph(self.parent)"),
("code_ooo", "ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo"),
("code_comment", " # and ((min(2, height_data.lines) - 1) * height_data.line_spacing + 1)\\"),
("tabs_as_spaces", " def add_link(self, text: str, url: str, is_bold: bool = None):"),
]
_MULTI_RUN_CASES = [
("split_mid_word", ["verylong", "wordthat", "spansruns and then more text follows here"]),
("split_mid_word_long", ["verylongword" * 6, "verylongword" * 7]),
("empty_runs_interleaved", ["", "hello", "", " ", "", "world", ""]),
("space_owned_by_own_run", ["hello", " ", "world", " ", "again"]),
("code_tokens", ["", " ", "run", " ", "=", " ", "DocxRun", "(", "create_element",
"(", '"', "w:r", '"', ")", ",", " ", "self", ".", "_docx_paragraph", ")"]),
("code_tokens2", ["", " ", "-", ">", " ", "Generator", "[", "RenderedInfo", " ",
"|", " ", "Renderable", ",", " ", "None", ",", " ", "None", "]", ":"]),
("trailing_empty_run", ["some words here", ""]),
("trailing_space_run", ["some words here", " "]),
("all_empty", ["", "", ""]),
("word_ends_exactly_at_run_end", ["complete", " ", "words", " ", "only"]),
("many_fragments_one_word", list("supercalifragilisticexpialidocious")),
("long_text_split_in_three", [_BODY[:90], _BODY[90:180], _BODY[180:]]),
]
# (bold, italic) per run, cycled - exercises the per-run font merge path
_STYLE_CYCLE = [(None, None), (True, None), (None, True), (True, True)]
class TestCountLinesEquivalence(unittest.TestCase):
"""New count_lines must return exactly what the legacy implementation returned."""
def setUp(self):
self._document, self._max_height, self._max_width = _create_test_document()
def _assert_same(self, paragraph, max_width, first_line_indent, is_mono):
sizer = ParagraphSizer(paragraph, None, max_width)
runs = paragraph.runs
docx_font = paragraph.style.font
expected = _legacy_count_lines(runs, max_width, docx_font, first_line_indent, is_mono)
actual = sizer.count_lines(runs, max_width, docx_font, first_line_indent, is_mono)
self.assertEqual(expected, actual)
def test_single_run_normal_style(self):
for name, text in _SINGLE_RUN_CASES:
for indent in (0, Cm(1.25)):
with self.subTest(case=name, indent=indent):
paragraph = self._document.add_paragraph()
paragraph.add_run(text)
self._assert_same(paragraph, self._max_width, indent, False)
def test_single_run_code_style(self):
max_width = self._max_width - LISTING_OFFSET
for name, text in _SINGLE_RUN_CASES:
for is_mono in (False, True):
with self.subTest(case=name, is_mono=is_mono):
paragraph = self._document.add_paragraph(style="Code")
paragraph.add_run(text)
self._assert_same(paragraph, max_width, 0, is_mono)
def test_single_run_narrow_width(self):
"""Narrow columns push almost every word onto its own line."""
for name, text in _SINGLE_RUN_CASES:
with self.subTest(case=name):
paragraph = self._document.add_paragraph()
paragraph.add_run(text)
self._assert_same(paragraph, Cm(3), 0, False)
def test_multi_run_normal_style(self):
for name, texts in _MULTI_RUN_CASES:
for indent in (0, Cm(1.25)):
with self.subTest(case=name, indent=indent):
paragraph = self._document.add_paragraph()
for text in texts:
paragraph.add_run(text)
self._assert_same(paragraph, self._max_width, indent, False)
def test_multi_run_code_style(self):
max_width = self._max_width - LISTING_OFFSET
for name, texts in _MULTI_RUN_CASES:
with self.subTest(case=name):
paragraph = self._document.add_paragraph(style="Code")
for text in texts:
paragraph.add_run(text)
self._assert_same(paragraph, max_width, 0, True)
def test_multi_run_mixed_bold_italic(self):
"""Different fonts per run: word fragments must be measured with their own font."""
for name, texts in _MULTI_RUN_CASES:
with self.subTest(case=name):
paragraph = self._document.add_paragraph()
for i, text in enumerate(texts):
run = paragraph.add_run(text)
run.font.bold, run.font.italic = _STYLE_CYCLE[i % len(_STYLE_CYCLE)]
self._assert_same(paragraph, self._max_width, Cm(1.25), False)
def test_no_break_hyphen_runs(self):
"""An empty run carrying w:noBreakHyphen is measured as a literal '-'."""
for texts in (["во", "", "первых"],
["физико", "", "математический анализ данных"],
["", "", ""],
["интервал 100", "", "150 Мб и далее по тексту"]):
with self.subTest(texts=texts):
paragraph = self._document.add_paragraph()
for text in texts:
run = paragraph.add_run(text)
if text == "":
run._element.append(OxmlElement("w:noBreakHyphen"))
self._assert_same(paragraph, self._max_width, Cm(1.25), False)
def test_widths_measured_once_per_word(self):
"""Guards the optimization itself: no per-character re-measurement."""
paragraph = self._document.add_paragraph()
text = "alpha bravo charlie delta echo foxtrot"
paragraph.add_run(text)
calls = []
original = Font.get_text_width
def counting(self, value):
calls.append(value)
return original(self, value)
Font.get_text_width = counting
try:
ParagraphSizer(paragraph, None, self._max_width).count_lines(
paragraph.runs, self._max_width, paragraph.style.font, Cm(1.25))
finally:
Font.get_text_width = original
words = text.split(" ")
# one call for the space width, then one per whole word
self.assertEqual([" "] + words, calls)
self.assertLess(len(calls), len(text.replace(" ", "")))
class TestCalculateHeightEquivalence(unittest.TestCase):
"""Full calculate_height results for a representative document must not move."""
def setUp(self):
self._document, self._max_height, self._max_width = _create_test_document()
def _result_tuple(self, paragraph, max_width):
result = ParagraphSizer(paragraph, None, max_width).calculate_height()
return (result.lines, int(result.line_height), result.line_spacing,
int(result.before), int(result.after), int(result.base), int(result.full))
def test_repeated_calls_are_stable(self):
"""Shared Font instances must not accumulate state across calls."""
paragraph = self._document.add_paragraph()
paragraph.add_run(_BODY)
first = self._result_tuple(paragraph, self._max_width)
for _ in range(5):
self.assertEqual(first, self._result_tuple(paragraph, self._max_width))
def test_interleaved_fonts_do_not_interfere(self):
"""Alternating between cached fonts must not change either one's output."""
body = self._document.add_paragraph()
body.add_run(_BODY)
code = self._document.add_paragraph(style="Code")
code.add_run(" table._cells[0]._element.append(rendered_info.docx_element._element)")
body_first = self._result_tuple(body, self._max_width)
code_first = self._result_tuple(code, self._max_width - LISTING_OFFSET)
for _ in range(3):
self.assertEqual(code_first, self._result_tuple(code, self._max_width - LISTING_OFFSET))
self.assertEqual(body_first, self._result_tuple(body, self._max_width))
class TestFontCache(unittest.TestCase):
def test_same_descriptor_returns_same_instance(self):
self.assertIs(_get_font("Times New Roman", False, False, 14),
_get_font("Times New Roman", False, False, 14))
def test_different_descriptors_return_different_instances(self):
base = _get_font("Times New Roman", False, False, 14)
self.assertIsNot(base, _get_font("Times New Roman", True, False, 14))
self.assertIsNot(base, _get_font("Times New Roman", False, True, 14))
self.assertIsNot(base, _get_font("Times New Roman", False, False, 12))
self.assertIsNot(base, _get_font("Courier New", False, False, 14))
def test_cached_font_matches_fresh_font(self):
descriptors = [
("Times New Roman", False, False, 14),
("Times New Roman", True, False, 14),
("Times New Roman", False, True, 14),
("Times New Roman", True, True, 14),
("Courier New", False, False, 12),
("Calibri", False, False, 14),
]
samples = ["", " ", "i", "m", "hello", "in", "Электроэнцефалографический",
" ", "a b c", "verylongword" * 5, "()[]{}", "0123456789"]
for descriptor in descriptors:
legacy = _LegacyFont(*descriptor)
cached = _get_font(*descriptor)
with self.subTest(descriptor=descriptor):
self.assertEqual(legacy.is_mono, cached.is_mono)
self.assertEqual(legacy.get_line_height(), cached.get_line_height())
for sample in samples:
self.assertEqual(legacy.get_text_width(sample),
cached.get_text_width(sample),
msg=f"{descriptor} {sample!r}")
def test_mono_width_is_independent_of_glyph_slot(self):
"""get_text_width must not depend on whichever glyph was loaded last."""
font = _get_font("Courier New", False, False, 12)
self.assertTrue(font.is_mono)
expected = font.get_text_width("hello world")
for char in "iMW._@":
font._face.load_char(char)
self.assertEqual(expected, font.get_text_width("hello world"))
def test_font_holds_no_per_call_state(self):
"""Repeated and interleaved measurements return identical values."""
font = _get_font("Times New Roman", False, False, 14)
other = _get_font("Courier New", False, False, 12)
widths = {text: font.get_text_width(text) for text in ("a", "ab", "abc", "hello world")}
for _ in range(3):
other.get_text_width("hello world")
for text, width in widths.items():
self.assertEqual(width, font.get_text_width(text))
def test_public_font_constructor_still_works(self):
"""Font itself stays constructible - existing tests instantiate it directly."""
font = Font("Times New Roman", False, False, 14)
self.assertFalse(font.is_mono)
self.assertGreater(font.get_text_width("hello"), 0)
class TestMeasureParagraphCache(unittest.TestCase):
"""measure_paragraph() decides page breaks, so a hit must equal a fresh run."""
def setUp(self):
self._document, self._max_height, self._max_width = _create_test_document()
def _fresh(self, paragraph, previous, max_width):
return ParagraphSizer(paragraph, previous, max_width).calculate_height()
def test_hit_equals_fresh_measurement(self):
cache = {}
paragraph = self._document.add_paragraph()
paragraph.add_run(_BODY)
first = measure_paragraph(cache, paragraph, None, self._max_width)
for _ in range(3):
self.assertEqual(self._fresh(paragraph, None, self._max_width), first)
self.assertEqual(first, measure_paragraph(cache, paragraph, None, self._max_width))
def test_edited_text_is_remeasured(self):
cache = {}
paragraph = self._document.add_paragraph()
run = paragraph.add_run("short")
short = measure_paragraph(cache, paragraph, None, self._max_width)
run.text = _BODY
long = measure_paragraph(cache, paragraph, None, self._max_width)
self.assertGreater(long.lines, short.lines)
self.assertEqual(self._fresh(paragraph, None, self._max_width), long)
def test_edited_spacing_is_remeasured(self):
cache = {}
paragraph = self._document.add_paragraph()
paragraph.add_run(_BODY)
before = measure_paragraph(cache, paragraph, None, self._max_width)
paragraph.paragraph_format.space_before = Pt(12)
after = measure_paragraph(cache, paragraph, None, self._max_width)
self.assertNotEqual(int(before.before), int(after.before))
self.assertEqual(self._fresh(paragraph, None, self._max_width), after)
def test_width_is_part_of_the_key(self):
cache = {}
paragraph = self._document.add_paragraph()
paragraph.add_run(_BODY)
wide = measure_paragraph(cache, paragraph, None, self._max_width)
narrow = measure_paragraph(cache, paragraph, None, self._max_width // 2)
self.assertGreater(narrow.lines, wide.lines)
def test_predecessor_spacing_is_part_of_the_key(self):
cache = {}
previous = self._document.add_paragraph()
previous.add_run("previous")
paragraph = self._document.add_paragraph()
paragraph.paragraph_format.space_before = Pt(12)
paragraph.add_run(_BODY)
alone = measure_paragraph(cache, paragraph, None, self._max_width)
collapsed = measure_paragraph(cache, paragraph, previous, self._max_width)
self.assertNotEqual(int(alone.before), int(collapsed.before))
self.assertEqual(self._fresh(paragraph, previous, self._max_width), collapsed)
def test_result_is_a_private_copy(self):
"""Paragraph.render() zeroes .before on a new page; the cache must not keep that."""
cache = {}
paragraph = self._document.add_paragraph()
paragraph.paragraph_format.space_before = Pt(12)
paragraph.add_run(_BODY)
first = measure_paragraph(cache, paragraph, None, self._max_width)
self.assertGreater(int(first.before), 0)
first.before = 0
second = measure_paragraph(cache, paragraph, None, self._max_width)
self.assertGreater(int(second.before), 0)
def test_attaching_the_predecessor_still_hits(self):
"""A figure is measured detached, then again after the Renderer attaches it."""
cache = {}
previous = self._document.add_paragraph()
previous.add_run("previous")
detached = self._document.add_paragraph()
detached.add_run(_BODY)
measure_paragraph(cache, detached, previous, self._max_width)
size = len(cache)
self._document._body._element.append(previous._p)
measure_paragraph(cache, detached, previous, self._max_width)
self.assertEqual(size, len(cache))
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -3,6 +3,8 @@
import os
import sys
import pytest
from md2gost.dnd import first_markdown, normalize_drop_paths, parse_tkdnd_files
from md2gost.pipeline import (
ConvertRequest,
@@ -80,9 +82,8 @@ def test_parser_gui_and_optional_file():
assert req.filename == "report.md"
@pytest.mark.skipif(sys.platform != "win32", reason="win32-only drag&drop API")
def test_win32_drop_api_pointer_width():
if sys.platform != "win32":
return
import ctypes
from md2gost.dnd import _win32_drop_api
api = _win32_drop_api()
@@ -91,9 +92,8 @@ def test_win32_drop_api_pointer_width():
api.CallWindowProc.argtypes[0](huge) # must not OverflowError
@pytest.mark.skipif(sys.platform != "win32", reason="win32-only drag&drop API")
def test_win32_drop_hook_survives_window_messages():
if sys.platform != "win32":
return
import tkinter as tk
from md2gost.dnd import enable_file_drop
root = tk.Tk()